mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
360 lines
18 KiB
React
360 lines
18 KiB
React
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 (
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelect(asset)}
|
|
className={[
|
|
"relative rounded-lg border-2 overflow-hidden transition-all text-left w-full",
|
|
"hover:border-primary/60 hover:shadow-sm",
|
|
selected ? "border-primary ring-2 ring-primary/20" : "border-border",
|
|
].join(" ")}
|
|
>
|
|
<div className="relative aspect-video bg-muted w-full overflow-hidden">
|
|
{thumb ? (
|
|
<img src={thumb} alt={asset.display_name} className="w-full h-full object-cover" />
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<span className="text-xs text-muted-foreground">No preview</span>
|
|
</div>
|
|
)}
|
|
{showDuration && (
|
|
<span className="absolute bottom-1 right-1 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-medium text-white tabular-nums">
|
|
{formatPlayerTime(asset.duration)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="p-2">
|
|
<p className="text-xs font-medium truncate">{asset.display_name}</p>
|
|
{asset.extension && (
|
|
<p className="text-[10px] text-muted-foreground uppercase mt-0.5">{asset.extension}</p>
|
|
)}
|
|
</div>
|
|
{selected && (
|
|
<div className="absolute top-1.5 right-1.5">
|
|
<CheckCircle2 className="h-5 w-5 text-primary fill-white" />
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function EmptyState({ fileType }) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-48 gap-2">
|
|
<p className="text-sm text-muted-foreground">No {fileType} assets found.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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 <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, 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 (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
|
|
|
{/* ── Header ── */}
|
|
<SheetHeader className="px-6 pt-6 pb-4 border-b">
|
|
<SheetTitle>Select {label}</SheetTitle>
|
|
<SheetDescription>Click an asset to attach it.</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
{/* ── Search + Filter ── */}
|
|
<div className="px-6 py-3 border-b space-y-2">
|
|
<div className="flex gap-2">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder={`Search ${label.toLowerCase()}…`}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
|
|
{extOptions.length > 0 && (
|
|
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant={hasActiveFilters ? "default" : "outline"}
|
|
size="icon"
|
|
className="relative shrink-0"
|
|
>
|
|
<SlidersHorizontal className="h-4 w-4" />
|
|
{hasActiveFilters && (
|
|
<span className="absolute -top-1.5 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground font-medium">
|
|
{activeExts.size}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-56 p-3 space-y-1">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">File type</p>
|
|
{hasActiveFilters && (
|
|
<button
|
|
type="button"
|
|
onClick={clearFilters}
|
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{extOptions.map((ext) => (
|
|
<button
|
|
key={ext}
|
|
type="button"
|
|
onClick={() => toggleExt(ext)}
|
|
className={[
|
|
"px-2.5 py-1 rounded-full border text-xs uppercase font-mono transition-colors",
|
|
activeExts.has(ext)
|
|
? "bg-primary text-primary-foreground border-primary"
|
|
: "bg-card border-border hover:bg-muted",
|
|
].join(" ")}
|
|
>
|
|
{ext}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
</div>
|
|
|
|
{hasActiveFilters && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{[...activeExts].map((ext) => (
|
|
<Badge key={ext} variant="secondary" className="gap-1 pr-1 uppercase text-[10px] font-mono">
|
|
{ext}
|
|
<button type="button" onClick={() => toggleExt(ext)} className="ml-0.5 hover:opacity-70">
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Grid ── */}
|
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-48">
|
|
<Spinner className="h-5 w-5" />
|
|
</div>
|
|
) : !assets.length ? (
|
|
<EmptyState fileType={fileType ?? "asset"} />
|
|
) : (
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{assets.map((asset) => (
|
|
<AssetCard
|
|
key={asset.asset_id}
|
|
asset={asset}
|
|
streamSrc={resolveStreamSrc(asset.asset_id)}
|
|
selected={selected === asset.asset_id}
|
|
onSelect={handleSelect}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Pagination ── */}
|
|
{pagination.totalPages > 1 && (
|
|
<div className="flex items-center justify-between px-6 py-3 border-t text-sm">
|
|
<span className="text-muted-foreground">
|
|
Page {pagination.page} of {pagination.totalPages}
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
disabled={!pagination.hasPrevPage || loading}
|
|
onClick={() => setPage((p) => p - 1)}
|
|
className="px-3 py-1 rounded border text-xs disabled:opacity-40 hover:bg-muted transition-colors"
|
|
>
|
|
Prev
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={!pagination.hasNextPage || loading}
|
|
onClick={() => setPage((p) => p + 1)}
|
|
className="px-3 py-1 rounded border text-xs disabled:opacity-40 hover:bg-muted transition-colors"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|