add: more commits

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-03 16:21:27 +08:00
parent 17326b2c2e
commit 7e964f2432
112 changed files with 9160 additions and 3461 deletions
+102 -4
View File
@@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useState } from "react";
import { createContext, useCallback, useContext, useRef, useState } from "react";
import api from "@/utils/api.util";
import { toast } from "sonner";
@@ -20,6 +20,16 @@ const PAGINATION_INIT = {
hasNextPage: false,
};
// No Redis yet — these caches are plain in-memory (per browser tab, cleared on
// refresh) to absorb the repeated open/close traffic pickers like
// AssetPickerSheet generate against Postgres and the media-token endpoint.
const LIST_CACHE_TTL_MS = 20_000; // short: just enough to survive rapid open/close flapping
const MEDIA_TOKEN_TTL_MS = 30 * 60 * 1000; // mirrors TOKEN_TTL_SEC in media.controller.js
const MEDIA_TOKEN_REFRESH_MARGIN_MS = 2 * 60 * 1000; // re-mint a bit before real expiry
const cacheKeyFor = (scope, { page, limit, filters, sort }) =>
`${scope}:${JSON.stringify({ page, limit, filters, sort })}`;
export function AssetsProvider({ children }) {
const [assets, setAssets] = useState([]);
const [attributes, setAttributes] = useState([]);
@@ -27,6 +37,32 @@ export function AssetsProvider({ children }) {
const [selectedAsset, setSelectedAsset] = useState(null);
const [loading, setLoading] = useState(false);
// { [asset_id]: { token, thumbnail_url, issuedAt } } — shared across every
// picker instance so tokens survive sheet open/close for their full TTL.
const [mediaTokens, setMediaTokens] = useState({});
const mediaTokensRef = useRef({});
const listCacheRef = useRef(new Map());
const invalidateListCache = () => listCacheRef.current.clear();
// Seeds mediaTokens from stream_token/thumbnail_url fields the backend now
// embeds directly in S3 rows of GET /admin/assets — so getMediaTokens (called
// right after fetchAssets by pickers/tables) finds them already cached and
// skips the batch round-trip instead of re-requesting tokens it just got.
const seedMediaTokensFromRows = (rows = []) => {
const issuedAt = Date.now();
const next = {};
for (const row of rows) {
if (row.stream_token) {
next[String(row.asset_id)] = { token: row.stream_token, thumbnail_url: row.thumbnail_url ?? null, issuedAt };
}
}
if (Object.keys(next).length) {
mediaTokensRef.current = { ...mediaTokensRef.current, ...next };
setMediaTokens(mediaTokensRef.current);
}
};
const request = useCallback(async (fn) => {
setLoading(true);
try {
@@ -41,9 +77,22 @@ export function AssetsProvider({ children }) {
}, []);
// ─── GET /api/admin/assets ────────────────────────────────────────────────
// Cached per (page, limit, filters, sort) for LIST_CACHE_TTL_MS so toggling
// a picker like AssetPickerSheet open/closed doesn't re-hit Postgres for the
// same query within the TTL window. Pass force: true to bypass the cache.
const fetchAssets = useCallback(
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
request(async () => {
({ page = 1, limit = 10, filters = [], sort = [], force = false } = {}) => {
const key = cacheKeyFor("assets", { page, limit, filters, sort });
const cached = listCacheRef.current.get(key);
if (!force && cached && Date.now() - cached.fetchedAt < LIST_CACHE_TTL_MS) {
setAssets(cached.assets);
setPagination(cached.pagination);
setAttributes(cached.attributes);
seedMediaTokensFromRows(cached.assets);
return Promise.resolve(cached.raw);
}
return request(async () => {
const { data } = await api.get("/admin/assets", {
params: {
page, limit,
@@ -57,12 +106,53 @@ export function AssetsProvider({ children }) {
setAssets(result?.data ?? []);
setPagination(result?.pagination ?? PAGINATION_INIT);
setAttributes(result.attributes);
seedMediaTokensFromRows(result?.data);
listCacheRef.current.set(key, {
assets: result?.data ?? [],
pagination: result?.pagination ?? PAGINATION_INIT,
attributes: result.attributes,
raw: data.data,
fetchedAt: Date.now(),
});
return data.data;
}),
});
},
[request]
);
// ─── POST /api/admin/media/tokens (batch) ────────────────────────────────
// Skips any asset_id whose cached token is still within its TTL (minus a
// safety margin) instead of re-minting a fresh JWT/presigned URL every time
// a picker reopens. Shared across all picker instances via context state.
const getMediaTokens = useCallback((assetIds = []) => {
const now = Date.now();
const missing = assetIds
.map(String)
.filter((id) => {
const cached = mediaTokensRef.current[id];
return !cached || (now - cached.issuedAt) > (MEDIA_TOKEN_TTL_MS - MEDIA_TOKEN_REFRESH_MARGIN_MS);
});
if (!missing.length) return Promise.resolve(mediaTokensRef.current);
return api.post("/admin/media/tokens", { asset_ids: missing }).then(({ data }) => {
const tokens = data.data?.tokens ?? {};
const thumbnails = data.data?.thumbnails ?? {};
const issuedAt = Date.now();
const next = {};
for (const [id, token] of Object.entries(tokens)) {
next[id] = { token, thumbnail_url: thumbnails[id] ?? null, issuedAt };
}
mediaTokensRef.current = { ...mediaTokensRef.current, ...next };
setMediaTokens(mediaTokensRef.current);
return mediaTokensRef.current;
});
}, []);
// ─── GET /api/admin/assets/:assetId ───────────────────────────────────────
const fetchAsset = useCallback(
(assetId) =>
@@ -110,6 +200,7 @@ export function AssetsProvider({ children }) {
const asset = res.data?.data?.data ?? null;
if (asset) {
setAssets((prev) => [asset, ...prev]);
invalidateListCache();
toast.success("Asset uploaded successfully.");
}
return res.data;
@@ -135,6 +226,7 @@ export function AssetsProvider({ children }) {
if (asset) {
setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a)));
setSelectedAsset(asset);
invalidateListCache();
toast.success("Asset updated successfully.");
}
return res.data;
@@ -151,6 +243,7 @@ export function AssetsProvider({ children }) {
});
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev));
invalidateListCache();
toast.success("Asset archived.");
return res.data;
}),
@@ -165,6 +258,7 @@ export function AssetsProvider({ children }) {
data: { ids, deletedBy },
});
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
invalidateListCache();
toast.success(`${ids.length} asset(s) archived.`);
return res.data;
}),
@@ -179,6 +273,7 @@ export function AssetsProvider({ children }) {
const asset = res.data?.data?.data ?? null;
if (asset) {
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
invalidateListCache();
toast.success("Asset restored.");
}
return res.data;
@@ -192,6 +287,7 @@ export function AssetsProvider({ children }) {
request(async () => {
const res = await api.patch("/admin/assets/bulk-restore", { ids });
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
invalidateListCache();
toast.success(`${ids.length} asset(s) restored.`);
return res.data;
}),
@@ -215,6 +311,8 @@ export function AssetsProvider({ children }) {
pagination,
selectedAsset,
loading,
mediaTokens,
getMediaTokens,
setPagination,
setSelectedAsset,
fetchAssets,