pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-28 11:30:01 +08:00
parent b03b204861
commit bac7168b1e
100 changed files with 5958 additions and 1976 deletions
+190 -55
View File
@@ -1,25 +1,32 @@
import { useEffect, useState, useCallback } from "react";
import { Search, CheckCircle2 } from "lucide-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 { 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 api from "@/utils/api.util";
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>
);
}
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
const DEBOUNCE_MS = 400;
function AssetCard({ asset, selected, onSelect }) {
const thumb = asset.thumbnail_url ?? asset.file_url;
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;
return (
<button
@@ -28,18 +35,12 @@ function AssetCard({ asset, selected, onSelect }) {
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",
selected ? "border-primary ring-2 ring-primary/20" : "border-border",
].join(" ")}
>
<div className="aspect-video bg-muted w-full overflow-hidden">
{thumb ? (
<img
src={thumb}
alt={asset.display_name}
className="w-full h-full object-cover"
/>
<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>
@@ -48,6 +49,9 @@ function AssetCard({ asset, selected, onSelect }) {
</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">
@@ -58,45 +62,119 @@ function AssetCard({ asset, selected, onSelect }) {
);
}
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 ───────────────────────────────────────────────────────────────
export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
if (!open) return null;
const { fetchAssets, assets, pagination, loading } = useAssets();
const [search, setSearch] = useState("");
const [committed, setCommitted] = useState(""); // ← only updates on search trigger
const [page, setPage] = useState(1);
const [selected, setSelected] = useState(null);
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 LIMIT = 12;
const triggerSearch = useCallback(() => {
setCommitted(search);
setPage(1);
}, [search]);
const extOptions = EXT_OPTIONS[fileType] ?? [];
// ── Fetch only when committed search, page, or open changes ──────────────
// ── Build and fire fetch ──────────────────────────────────────────────────
const doFetch = useCallback((searchVal, extSet, pg) => {
const filters = [
...(fileType ? [{ id: "file_type", value: [fileType] }] : []),
...(searchVal.trim() ? [{ id: "display_name", value: [searchVal.trim()] }] : []),
...(extSet.size > 0 ? [{ id: "extension", value: [...extSet] }] : []),
];
fetchAssets({ page: pg, limit: LIMIT, filters });
}, [fileType, fetchAssets]);
// ── Auto-search: debounce on search input change ──────────────────────────
useEffect(() => {
if (!open) return;
fetchAssets({
page,
limit: LIMIT,
filters: [
...(fileType ? [{ id: "file_type", value: [fileType] }] : []),
...(committed ? [{ id: "display_name", value: [committed] }] : []),
],
});
}, [open, page, committed, fileType]);
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setPage(1);
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]);
// ── 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).
useEffect(() => {
if (!assets.length) return;
const s3Ids = assets
.filter((a) => a.storage_provider === "s3" && !a.thumbnail_url && !a.file_url)
.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 urls = {};
for (const [id, token] of Object.entries(tokens)) {
urls[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]);
// ── Reset on close ────────────────────────────────────────────────────────
useEffect(() => {
if (!open) {
setSearch("");
setCommitted("");
setActiveExts(new Set());
setPage(1);
setSelected(null);
setFilterOpen(false);
setStreamUrls({});
}
}, [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);
onSelect(asset);
@@ -107,6 +185,8 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
? `${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">
@@ -114,13 +194,11 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
{/* ── Header ── */}
<SheetHeader className="px-6 pt-6 pb-4 border-b">
<SheetTitle>Select {label}</SheetTitle>
<SheetDescription>
Click an asset to attach it.
</SheetDescription>
<SheetDescription>Click an asset to attach it.</SheetDescription>
</SheetHeader>
{/* ── Search ── */}
<div className="px-6 py-3 border-b">
{/* ── 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" />
@@ -128,18 +206,74 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
placeholder={`Search ${label.toLowerCase()}…`}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && triggerSearch()}
className="pl-9"
/>
</div>
<Button
type="button"
onClick={triggerSearch}
disabled={loading}
>
{loading ? <Spinner className="h-4 w-4" /> : "Search"}
</Button>
{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-3">
<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 ── */}
@@ -156,6 +290,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
<AssetCard
key={asset.asset_id}
asset={asset}
streamSrc={streamUrls[String(asset.asset_id)] ?? null}
selected={selected === asset.asset_id}
onSelect={handleSelect}
/>
@@ -194,4 +329,4 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
</SheetContent>
</Sheet>
);
}
}