import { useEffect, useState, useCallback, useRef } from "react"; import { Search, CheckCircle2, SlidersHorizontal, X } from "lucide-react"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Spinner } from "@/components/ui/spinner"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useAssets } from "@/contexts/AdminAssetsContext"; import { formatPlayerTime } from "@/utils/format.util"; const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`; const DEBOUNCE_MS = 400; const EXT_OPTIONS = { image: ["svg", "png", "jpg", "jpeg", "webp", "gif"], video: ["mp4", "mov", "webm", "avi"], audio: ["mp3", "wav", "ogg", "m4a"], document: ["pdf", "docx", "xlsx", "pptx"], }; // ─── Asset Card ─────────────────────────────────────────────────────────────── // streamSrc is resolved at the sheet level (batch token request) — no per-card fetch. function AssetCard({ asset, streamSrc, selected, onSelect }) { const directThumb = asset.thumbnail_url ?? asset.file_url; const thumb = streamSrc ?? directThumb; // duration comes straight off the asset row (ffprobe-derived at upload) — // shows regardless of whether a thumbnail image loaded, since a missing // preview shouldn't also mean losing the one other useful signal at a glance. const showDuration = (asset.file_type === "video" || asset.file_type === "audio") && !!asset.duration; return ( ); } function EmptyState({ fileType }) { return (

No {fileType} files found.

); } // ─── Main Sheet ─────────────────────────────────────────────────────────────── // allowedExtensions optionally narrows a fileType's picker to a subset of // EXT_OPTIONS (e.g. the Document Import block only wants pdf/pptx out of the // full document set). Omit it and behavior is unchanged from every existing // caller — the extension filter stays purely an opt-in user-facing toggle. export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allowedExtensions }) { // 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 close transition. Visibility is controlled by // below instead, so state (and the caches in // AdminAssetsContext) survive across open/close toggles. 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); const debounceRef = useRef(null); const isFirstSearchRun = useRef(true); const LIMIT = 12; const extOptions = allowedExtensions ?? 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) => { // No chips picked: fall back to allowedExtensions (if the caller // passed one) as a mandatory whitelist, otherwise no extension // filter at all — matches every pre-existing caller's behavior. const extFilterValue = extSet.size > 0 ? [...extSet] : (allowedExtensions ?? null); const filters = [ ...(fileType ? [{ id: "file_type", value: [fileType] }] : []), ...(searchVal.trim() ? [{ id: "display_name", value: [searchVal.trim()] }] : []), ...(extFilterValue?.length ? [{ id: "extension", value: extFilterValue }] : []), ]; fetchAssets({ page: pg, limit: LIMIT, filters }); }, [fileType, fetchAssets, allowedExtensions]); // ── 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(() => { setPage(1); doFetch(search, activeExts, 1); }, DEBOUNCE_MS); return () => clearTimeout(debounceRef.current); }, [search]); // ── Batch token fetch after assets load ─────────────────────────────────── // 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 (!open || !assets.length) return; const s3Ids = assets .filter((a) => a.storage_provider === "s3") .map((a) => a.asset_id); if (!s3Ids.length) return; getMediaTokens(s3Ids).catch((err) => { console.warn("[AssetPickerSheet] batch token fetch failed:", err?.response?.status, err?.message); }); }, [assets, open, getMediaTokens]); // ── Reset on close ──────────────────────────────────────────────────────── useEffect(() => { if (!open) { setSearch(""); setActiveExts(new Set()); setPage(1); setSelected(null); setFilterOpen(false); } }, [open]); const toggleExt = (ext) => { setActiveExts((prev) => { const next = new Set(prev); next.has(ext) ? next.delete(ext) : next.add(ext); return next; }); setPage(1); }; const clearFilters = () => { setActiveExts(new Set()); setPage(1); }; const handleSelect = (asset) => { setSelected(asset.asset_id); // Pass the resolved stream/presigned URL as a second arg so callers // (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 = resolveStreamSrc(asset.asset_id) ?? asset.thumbnail_url ?? asset.file_url ?? null; onSelect(asset, resolvedUrl); onOpenChange(false); }; const label = fileType ? `${fileType.charAt(0).toUpperCase()}${fileType.slice(1)}s` : "Files"; const hasActiveFilters = activeExts.size > 0; return ( {/* ── Header ── */} Select {label} Click a file to attach it. {/* ── Search + Filter ── */}
setSearch(e.target.value)} className="pl-9" />
{extOptions.length > 0 && (

File type

{hasActiveFilters && ( )}
{extOptions.map((ext) => ( ))}
)}
{hasActiveFilters && (
{[...activeExts].map((ext) => ( {ext} ))}
)}
{/* ── Grid ── */}
{loading ? (
) : !assets.length ? ( ) : (
{assets.map((asset) => ( ))}
)}
{/* ── Pagination ── */} {pagination.totalPages > 1 && (
Page {pagination.page} of {pagination.totalPages}
)}
); }