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} assets 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`
: "Assets";
const hasActiveFilters = activeExts.size > 0;
return (
{/* ── Header ── */}
Select {label}Click an asset to attach it.
{/* ── Search + Filter ── */}