mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
add: more commits
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user