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
+40 -45
View File
@@ -9,7 +9,6 @@ import { Spinner } from "@/components/ui/spinner";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useAssets } from "@/contexts/AdminAssetsContext";
import api from "@/utils/api.util";
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
const DEBOUNCE_MS = 400;
@@ -73,23 +72,34 @@ function EmptyState({ fileType }) {
// ─── Main Sheet ───────────────────────────────────────────────────────────────
export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
if (!open) return null;
// NOTE: previously this returned null before any hooks ran when `open` was
// false. Since the parent renders this component unconditionally (only the
// `open` prop toggles), that meant React remounted every hook from scratch
// on each open — wiping local state and forcing a full refetch every time,
// plus skipping the <Sheet> close transition. Visibility is controlled by
// <Sheet open={open}> below instead, so state (and the caches in
// AdminAssetsContext) survive across open/close toggles.
const { fetchAssets, assets, pagination, loading } = useAssets();
const { fetchAssets, assets, pagination, loading, mediaTokens, getMediaTokens } = useAssets();
const [search, setSearch] = useState("");
const [activeExts, setActiveExts] = useState(new Set());
const [page, setPage] = useState(1);
const [selected, setSelected] = useState(null);
const [filterOpen, setFilterOpen] = useState(false);
// { [asset_id]: streamUrl } — resolved once per asset list via batch token request
const [streamUrls, setStreamUrls] = useState({});
const debounceRef = useRef(null);
const isFirstSearchRun = useRef(true);
const LIMIT = 12;
const extOptions = EXT_OPTIONS[fileType] ?? [];
const resolveStreamSrc = useCallback((assetId) => {
const entry = mediaTokens[String(assetId)];
if (!entry) return null;
return entry.thumbnail_url ?? `${STREAM_BASE}/${entry.token}`;
}, [mediaTokens]);
// ── Build and fire fetch ──────────────────────────────────────────────────
const doFetch = useCallback((searchVal, extSet, pg) => {
const filters = [
@@ -100,8 +110,19 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
fetchAssets({ page: pg, limit: LIMIT, filters });
}, [fileType, fetchAssets]);
// ── Auto-search: debounce on search input change ──────────────────────────
// ── Immediate fetch: on open, or when filters/page change while open ──────
// (fetchAssets itself is TTL-cached in AdminAssetsContext, so reopening
// with the same query within the cache window costs no network round-trip.)
useEffect(() => {
if (!open) return;
doFetch(search, activeExts, page);
}, [open, activeExts, page]);
// ── Debounced fetch: only when the user edits the search box ──────────────
// Deliberately does NOT depend on `open` — otherwise this and the effect
// above both fire on every sheet open, doubling the request.
useEffect(() => {
if (isFirstSearchRun.current) { isFirstSearchRun.current = false; return; }
if (!open) return;
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
@@ -109,51 +130,26 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
doFetch(search, activeExts, 1);
}, DEBOUNCE_MS);
return () => clearTimeout(debounceRef.current);
}, [search, open]);
// ── Immediate fetch on ext filter or page change ──────────────────────────
useEffect(() => {
if (!open) return;
doFetch(search, activeExts, page);
}, [activeExts, page, open]);
}, [search]);
// ── Batch token fetch after assets load ───────────────────────────────────
// One request for all S3 assets on the current page instead of N per-card requests.
// This eliminates the thundering-herd / auth-refresh race that caused some cards to
// silently show "No preview" after a page reload (multiple 401s queuing simultaneously
// while the interceptor refreshes, some dropping if cancelled mid-flight).
// One request for all S3 assets on the current page instead of N per-card
// requests. getMediaTokens (AdminAssetsContext) already skips any asset_id
// whose cached token is still valid, so reopening the sheet within the
// token's ~30min TTL issues no request at all for previously-seen assets.
useEffect(() => {
if (!assets.length) return;
if (!open || !assets.length) return;
// Only request tokens for S3 assets we don't already have a URL for —
// this prevents duplicate POST /tokens when the assets list triggers
// this effect more than once per sheet open (e.g., two fetch effects
// both reacting to open mounting, producing two assets updates).
const s3Ids = assets
.filter((a) => a.storage_provider === "s3" && !streamUrls[String(a.asset_id)])
.filter((a) => a.storage_provider === "s3")
.map((a) => a.asset_id);
if (!s3Ids.length) return;
let cancelled = false;
api.post("/admin/media/tokens", { asset_ids: s3Ids })
.then(({ data }) => {
if (cancelled) return;
const tokens = data.data?.tokens ?? {};
const thumbnails = data.data?.thumbnails ?? {};
const urls = {};
for (const [id, token] of Object.entries(tokens)) {
// Prefer presigned thumbnail URL (faster, direct); fall back to stream proxy
urls[id] = thumbnails[id] ?? `${STREAM_BASE}/${token}`;
}
setStreamUrls((prev) => ({ ...prev, ...urls }));
})
.catch((err) => {
console.warn("[AssetPickerSheet] batch token fetch failed:", err?.response?.status, err?.message);
});
return () => { cancelled = true; };
}, [assets]);
getMediaTokens(s3Ids).catch((err) => {
console.warn("[AssetPickerSheet] batch token fetch failed:", err?.response?.status, err?.message);
});
}, [assets, open, getMediaTokens]);
// ── Reset on close ────────────────────────────────────────────────────────
useEffect(() => {
@@ -163,7 +159,6 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
setPage(1);
setSelected(null);
setFilterOpen(false);
setStreamUrls({});
}
}, [open]);
@@ -187,7 +182,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
// (e.g. badge image picker) can use the authenticated URL directly
// rather than falling back to asset.file_url which is a private CDN
// key that the browser cannot load without S3 credentials.
const resolvedUrl = streamUrls[String(asset.asset_id)]
const resolvedUrl = resolveStreamSrc(asset.asset_id)
?? asset.thumbnail_url
?? asset.file_url
?? null;
@@ -304,7 +299,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
<AssetCard
key={asset.asset_id}
asset={asset}
streamSrc={streamUrls[String(asset.asset_id)] ?? null}
streamSrc={resolveStreamSrc(asset.asset_id)}
selected={selected === asset.asset_id}
onSelect={handleSelect}
/>