Files
starr-philproperties/src/components/generic/AssetPickerSheet.jsx
T
2026-08-03 12:03:11 +08:00

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>
);
}