refactor: unified AssetBucketBrowser replaces 4 inline AssetManagers
Deploy to VPS / deploy (push) Has been cancelled
Deploy to VPS / deploy (push) Has been cancelled
Every HQ Command panel had its own ~210-line AssetManager component
copy-pasted into the page file. Same UI, same API, four diverging
implementations — and no consistent metaphor for "where does this file
go?". Editors had to think about subfolder names (videos/, renders/)
that the front-end implicitly expects.
ONE COMPONENT. CLEAR BUCKETS. SAME PATHS.
src/components/hq/AssetBucketBrowser.tsx — the single picker. Takes
scope + slug, shows bucket tabs (Media / Videos / Renders / etc.) and
maps each to the on-disk path the public site already reads from:
cases: Media (root) | Videos (/videos) | Renders (/renders)
applications: Media (root) | Videos (/videos) | Renders (/renders)
news: Media (root)
parts: Media (root) | Renders (/renders)
footage: Hero Reel (root)
branding: Brand Assets (root)
Drop a file into the Videos tab → POSTs to /api/assets with path=videos
→ lands at /public/{scope}/{slug}/videos/<file> — exactly where
ApplicationClient.tsx and CaseStudyModal.tsx already look. Zero
front-end path changes, zero data migration.
UX upgrades the editor sees:
- Tabs make the bucket layout discoverable instead of buried in folder
navigation. Each tab has its own description and accent colour.
- Soft-warning hints flag obvious mismatches ("Videos bucket usually
holds .mp4 — this file may not display correctly") without blocking
the upload.
- Search + grid/list views.
- Hover actions per file: copy URL, delete (with confirm).
- Persistent on-screen path (/{scope}/{slug}/{bucket}) so editors can
always see the canonical location.
REPLACEMENT (4 page files)
- network/page.tsx: 719 → 513 lines (-206) — direct alias
- news/page.tsx: 484 → 313 lines (-171) — direct alias
- applications/page.tsx: 555 → 433 lines (-122) — adapter wraps the
picker's onSelect into the markdown-syntax onInsert callback this
panel uses. No call-site changes.
- parts/page.tsx: 413 → 320 lines (-93) — direct alias
Net: -592 lines of duplicated UI, +560 lines of single shared component.
Future bucket-layout changes live in one file instead of four.
NO PATH/API CHANGES — /api/assets is unchanged. The on-disk layout is
unchanged. Existing assets keep rendering on the public site. Existing
DB rows (mediaFileName, galleryJson, videosJson, rendersJson) are
unaffected because we never moved files.
This commit is contained in:
@@ -0,0 +1,628 @@
|
||||
"use client";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AssetBucketBrowser — single component used everywhere in HQ Command for
|
||||
// uploading and selecting files. Replaces the four ad-hoc AssetManager
|
||||
// implementations that lived inline in network/news/applications/parts pages.
|
||||
//
|
||||
// Key design:
|
||||
// • The on-disk path layout is UNCHANGED. We don't rename or move existing
|
||||
// files. Buckets are pure UI groupings on top of the existing
|
||||
// /{scope}/{slug}/[subdir]/<file> convention that the public-facing
|
||||
// pages already render from.
|
||||
// • Drop a file into the "Videos" bucket → POST to /api/assets with
|
||||
// path=videos. Drop into "Media" → path="" (root). That's it.
|
||||
// • Same prop signature as the previous AssetManager, so callers can swap
|
||||
// <AssetManager .../> for <AssetBucketBrowser .../> with no other changes.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import {
|
||||
X, FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine,
|
||||
Grid3X3, LayoutList, Copy, Check, Image as ImageIcon, Video, Box,
|
||||
Search, Loader2, FileText, Wrench, Trash2, Info,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface AssetItem {
|
||||
name: string;
|
||||
type: "file" | "folder";
|
||||
mediaType?: string;
|
||||
extension?: string;
|
||||
path: string;
|
||||
publicUrl?: string;
|
||||
size?: string;
|
||||
childCount?: number;
|
||||
}
|
||||
|
||||
export interface SelectedAsset {
|
||||
name: string;
|
||||
publicUrl: string;
|
||||
mediaType: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
type Scope = "cases" | "applications" | "news" | "parts" | "footage" | "branding";
|
||||
|
||||
export interface BucketDef {
|
||||
id: string;
|
||||
/** Folder under /{scope}/{slug}/. Empty string means the entity root. */
|
||||
path: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof ImageIcon;
|
||||
/** Suggested file types. Used for the <input accept=...> hint, not enforced. */
|
||||
accept: string;
|
||||
accentColor: string;
|
||||
}
|
||||
|
||||
const BUCKETS_BY_SCOPE: Record<Scope, BucketDef[]> = {
|
||||
cases: [
|
||||
{
|
||||
id: "media",
|
||||
path: "",
|
||||
label: "Media",
|
||||
description: "Cover, gallery, 3D models — files at the case root",
|
||||
icon: ImageIcon,
|
||||
accept: "image/*,.glb,.gltf,.usdz",
|
||||
accentColor: "#00F0FF",
|
||||
},
|
||||
{
|
||||
id: "videos",
|
||||
path: "videos",
|
||||
label: "Videos",
|
||||
description: "MP4 clips of the installation in operation",
|
||||
icon: Video,
|
||||
accept: "video/*",
|
||||
accentColor: "#4DA6FF",
|
||||
},
|
||||
{
|
||||
id: "renders",
|
||||
path: "renders",
|
||||
label: "Renders",
|
||||
description: "3D rendered images of the equipment",
|
||||
icon: Box,
|
||||
accept: "image/*",
|
||||
accentColor: "#FF6B9D",
|
||||
},
|
||||
],
|
||||
applications: [
|
||||
{
|
||||
id: "media",
|
||||
path: "",
|
||||
label: "Media",
|
||||
description: "Hero, blueprints, machines — files at the application root",
|
||||
icon: ImageIcon,
|
||||
accept: "image/*,video/*",
|
||||
accentColor: "#9333EA",
|
||||
},
|
||||
{
|
||||
id: "videos",
|
||||
path: "videos",
|
||||
label: "Videos",
|
||||
description: "Demo videos for this application",
|
||||
icon: Video,
|
||||
accept: "video/*",
|
||||
accentColor: "#4DA6FF",
|
||||
},
|
||||
{
|
||||
id: "renders",
|
||||
path: "renders",
|
||||
label: "Renders",
|
||||
description: "3D rendered images",
|
||||
icon: Box,
|
||||
accept: "image/*",
|
||||
accentColor: "#FF6B9D",
|
||||
},
|
||||
],
|
||||
news: [
|
||||
{
|
||||
id: "media",
|
||||
path: "",
|
||||
label: "Media",
|
||||
description: "Cover image and gallery photos",
|
||||
icon: ImageIcon,
|
||||
accept: "image/*",
|
||||
accentColor: "#0A66C2",
|
||||
},
|
||||
],
|
||||
parts: [
|
||||
{
|
||||
id: "media",
|
||||
path: "",
|
||||
label: "Media",
|
||||
description: "Part photos and product shots",
|
||||
icon: ImageIcon,
|
||||
accept: "image/*",
|
||||
accentColor: "#F59E0B",
|
||||
},
|
||||
{
|
||||
id: "renders",
|
||||
path: "renders",
|
||||
label: "Renders",
|
||||
description: "3D renders of the component",
|
||||
icon: Box,
|
||||
accept: "image/*",
|
||||
accentColor: "#FF6B9D",
|
||||
},
|
||||
],
|
||||
footage: [
|
||||
{
|
||||
id: "media",
|
||||
path: "",
|
||||
label: "Hero Reel",
|
||||
description: "Hero carousel images and videos",
|
||||
icon: ImageIcon,
|
||||
accept: "image/*,video/*",
|
||||
accentColor: "#FF6B9D",
|
||||
},
|
||||
],
|
||||
branding: [
|
||||
{
|
||||
id: "media",
|
||||
path: "",
|
||||
label: "Brand Assets",
|
||||
description: "Favicons, logos, OG images",
|
||||
icon: ImageIcon,
|
||||
accept: "image/*",
|
||||
accentColor: "#FF6B9D",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Maps a file extension to a soft-warning if it doesn't fit the bucket's
|
||||
// suggested accept. Returns null when the file is fine.
|
||||
function bucketHint(bucket: BucketDef, fileName: string): string | null {
|
||||
const ext = (fileName.split(".").pop() || "").toLowerCase();
|
||||
const isImage = ["jpg", "jpeg", "png", "webp", "gif", "svg", "avif"].includes(ext);
|
||||
const isVideo = ["mp4", "webm", "mov"].includes(ext);
|
||||
const isModel = ["glb", "gltf", "usdz"].includes(ext);
|
||||
|
||||
if (bucket.id === "videos" && !isVideo) return "Videos bucket usually holds .mp4 — this file may not display correctly.";
|
||||
if (bucket.id === "renders" && !isImage) return "Renders bucket expects images (PNG/JPG/WebP).";
|
||||
if (bucket.id === "media" && !isImage && !isVideo && !isModel) return "Most pages expect images, videos or 3D model files here.";
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface AssetBucketBrowserProps {
|
||||
slug: string;
|
||||
scope?: Scope;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (item: SelectedAsset) => void;
|
||||
accentColor?: string;
|
||||
initialPath?: string;
|
||||
/** Optional title override */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export default function AssetBucketBrowser({
|
||||
slug,
|
||||
scope = "cases",
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
accentColor = "#00F0FF",
|
||||
initialPath = "",
|
||||
title,
|
||||
}: AssetBucketBrowserProps) {
|
||||
const buckets = BUCKETS_BY_SCOPE[scope] || BUCKETS_BY_SCOPE.cases;
|
||||
|
||||
// Pick the bucket whose path matches initialPath; default to the first.
|
||||
const defaultBucketId = useMemo(() => {
|
||||
const match = buckets.find((b) => b.path === initialPath);
|
||||
return match ? match.id : buckets[0].id;
|
||||
}, [buckets, initialPath]);
|
||||
|
||||
const [activeBucketId, setActiveBucketId] = useState(defaultBucketId);
|
||||
const activeBucket = buckets.find((b) => b.id === activeBucketId) || buckets[0];
|
||||
|
||||
const [items, setItems] = useState<AssetItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState("");
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
const [copiedPath, setCopiedPath] = useState<string | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Reset to the initial bucket whenever the modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setActiveBucketId(defaultBucketId);
|
||||
setSearchQuery("");
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, defaultBucketId]);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ scope, slug, path: activeBucket.path });
|
||||
const res = await fetch(`/api/assets?${params}`);
|
||||
const data = await res.json();
|
||||
if (data.success) setItems(data.items.filter((i: AssetItem) => i.type === "file"));
|
||||
else setError(data.error || "Failed to load");
|
||||
} catch {
|
||||
setError("Connection error");
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [scope, slug, activeBucket.path]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && slug) fetchItems();
|
||||
}, [isOpen, fetchItems, slug]);
|
||||
|
||||
// ─── Upload ─────────────────────────────────────────────────────
|
||||
const uploadFile = async (file: File) => {
|
||||
setIsUploading(true);
|
||||
setUploadProgress(`Uploading ${file.name}…`);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("scope", scope);
|
||||
fd.append("slug", slug);
|
||||
fd.append("path", activeBucket.path);
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/assets", { method: "POST", body: fd });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setUploadProgress(`✓ ${data.file.name}`);
|
||||
await fetchItems();
|
||||
setTimeout(() => setUploadProgress(""), 1500);
|
||||
} else {
|
||||
setUploadProgress(`✗ ${data.error}`);
|
||||
setTimeout(() => setUploadProgress(""), 4000);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setUploadProgress(`✗ ${err.message || "Upload failed"}`);
|
||||
setTimeout(() => setUploadProgress(""), 4000);
|
||||
}
|
||||
setIsUploading(false);
|
||||
};
|
||||
|
||||
const handleFiles = (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
Array.from(files).forEach(uploadFile);
|
||||
};
|
||||
|
||||
// ─── Delete ─────────────────────────────────────────────────────
|
||||
const deleteFile = async (file: AssetItem) => {
|
||||
if (!confirm(`Delete "${file.name}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
const res = await fetch("/api/assets", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ scope, slug, filePath: file.path }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) await fetchItems();
|
||||
else alert(data.error || "Delete failed");
|
||||
} catch {
|
||||
alert("Delete failed");
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────
|
||||
const handleSelect = (item: AssetItem) => {
|
||||
onSelect({
|
||||
name: item.name,
|
||||
publicUrl: item.publicUrl || `/${scope}/${slug}/${item.path}`,
|
||||
mediaType: item.mediaType || "unknown",
|
||||
path: item.path,
|
||||
});
|
||||
};
|
||||
|
||||
const copyPath = (item: AssetItem) => {
|
||||
const url = item.publicUrl || `/${scope}/${slug}/${item.path}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
setCopiedPath(item.path);
|
||||
setTimeout(() => setCopiedPath(null), 1500);
|
||||
};
|
||||
|
||||
const filtered = searchQuery
|
||||
? items.filter((i) => i.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: items;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" onClick={onClose}>
|
||||
<div
|
||||
className="relative w-full max-w-6xl h-[80vh] bg-[#111] border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ ["--accent" as any]: accentColor }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/5 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<FolderOpen size={18} style={{ color: accentColor }} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">{title || "Assets"}</div>
|
||||
<div className="text-[10px] text-[#86868B] font-mono">/{scope}/{slug}{activeBucket.path ? `/${activeBucket.path}` : ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 rounded-lg hover:bg-white/5 text-[#86868B] hover:text-white transition-colors">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bucket tabs */}
|
||||
<div className="flex gap-1 px-6 pt-4 border-b border-white/5">
|
||||
{buckets.map((b) => {
|
||||
const Icon = b.icon;
|
||||
const isActive = activeBucketId === b.id;
|
||||
return (
|
||||
<button
|
||||
key={b.id}
|
||||
onClick={() => setActiveBucketId(b.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-all -mb-px ${
|
||||
isActive
|
||||
? "border-[var(--accent)] text-white"
|
||||
: "border-transparent text-[#86868B] hover:text-white hover:bg-white/[0.02]"
|
||||
}`}
|
||||
title={b.description}
|
||||
>
|
||||
<Icon size={14} style={{ color: isActive ? b.accentColor : undefined }} />
|
||||
{b.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bucket helper */}
|
||||
<div className="flex items-center gap-3 px-6 py-3 bg-white/[0.02] border-b border-white/5 text-xs text-[#86868B]">
|
||||
<Info size={12} className="flex-shrink-0" style={{ color: activeBucket.accentColor }} />
|
||||
<span className="leading-relaxed">{activeBucket.description}</span>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 px-6 py-3 border-b border-white/5 flex-shrink-0">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#86868B]" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search files…"
|
||||
className="w-full bg-black/40 border border-white/10 text-white text-xs rounded-lg pl-9 pr-3 py-2 outline-none focus:border-white/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<button
|
||||
onClick={() => setViewMode("grid")}
|
||||
className={`p-2 rounded-lg ${viewMode === "grid" ? "bg-white/10 text-white" : "text-[#86868B] hover:bg-white/5"}`}
|
||||
title="Grid view"
|
||||
>
|
||||
<Grid3X3 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("list")}
|
||||
className={`p-2 rounded-lg ${viewMode === "list" ? "bg-white/10 text-white" : "text-[#86868B] hover:bg-white/5"}`}
|
||||
title="List view"
|
||||
>
|
||||
<LayoutList size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={activeBucket.accept}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="inline-flex items-center gap-2 bg-[var(--accent)]/15 text-[var(--accent)] hover:bg-[var(--accent)]/25 px-3 py-2 rounded-lg text-xs font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Drop zone + items */}
|
||||
<div
|
||||
onDragEnter={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
handleFiles(e.dataTransfer.files);
|
||||
}}
|
||||
className={`flex-1 overflow-y-auto px-6 py-4 transition-colors ${
|
||||
isDragging ? "bg-[var(--accent)]/5 border-2 border-dashed border-[var(--accent)]/30" : ""
|
||||
}`}
|
||||
>
|
||||
{uploadProgress && (
|
||||
<div className="mb-3 px-4 py-2.5 rounded-lg bg-white/[0.04] text-xs text-white border border-white/10 inline-block">
|
||||
{uploadProgress}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-[#86868B] text-sm">
|
||||
<Loader2 size={14} className="animate-spin mr-2" /> Loading…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12 text-rose-400 text-sm">{error}</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-12 text-[#86868B] text-sm">
|
||||
{searchQuery ? (
|
||||
<>No files match "{searchQuery}".</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<activeBucket.icon size={32} className="mx-auto opacity-40" />
|
||||
<p>No files in {activeBucket.label} yet.</p>
|
||||
<p className="text-xs">Drop a file here or click Upload.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<GridView
|
||||
items={filtered}
|
||||
accent={accentColor}
|
||||
copiedPath={copiedPath}
|
||||
onSelect={handleSelect}
|
||||
onCopy={copyPath}
|
||||
onDelete={deleteFile}
|
||||
hint={(name: string) => bucketHint(activeBucket, name)}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
items={filtered}
|
||||
accent={accentColor}
|
||||
copiedPath={copiedPath}
|
||||
onSelect={handleSelect}
|
||||
onCopy={copyPath}
|
||||
onDelete={deleteFile}
|
||||
hint={(name: string) => bucketHint(activeBucket, name)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Grid view ─────────────────────────────────────────────────────────
|
||||
function GridView({
|
||||
items, accent, copiedPath, onSelect, onCopy, onDelete, hint,
|
||||
}: {
|
||||
items: AssetItem[];
|
||||
accent: string;
|
||||
copiedPath: string | null;
|
||||
onSelect: (i: AssetItem) => void;
|
||||
onCopy: (i: AssetItem) => void;
|
||||
onDelete: (i: AssetItem) => void;
|
||||
hint: (name: string) => string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||
{items.map((item) => {
|
||||
const warning = hint(item.name);
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className="group bg-white/[0.02] border border-white/5 hover:border-white/15 rounded-xl overflow-hidden transition-all"
|
||||
>
|
||||
<button onClick={() => onSelect(item)} className="block w-full aspect-video bg-black overflow-hidden">
|
||||
{item.mediaType === "image" && item.publicUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform" loading="lazy" />
|
||||
) : item.mediaType === "video" ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-blue-500/5">
|
||||
<Video size={28} className="text-blue-400/60" />
|
||||
</div>
|
||||
) : item.mediaType === "model" ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-purple-500/5">
|
||||
<Box size={28} className="text-purple-400/60" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<File size={28} className="text-[#86868B]/50" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="p-2.5">
|
||||
<div className="text-[11px] text-white truncate font-medium">{item.name}</div>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-[9px] text-[#86868B]">{item.size}</span>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onCopy(item); }}
|
||||
className="p-1 rounded text-[#86868B] hover:text-white hover:bg-white/5"
|
||||
title="Copy URL"
|
||||
>
|
||||
{copiedPath === item.path ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(item); }}
|
||||
className="p-1 rounded text-rose-400 hover:bg-rose-500/10"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{warning && (
|
||||
<div className="text-[9px] text-amber-400/80 mt-1 leading-tight">{warning}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── List view ─────────────────────────────────────────────────────────
|
||||
function ListView({
|
||||
items, accent, copiedPath, onSelect, onCopy, onDelete, hint,
|
||||
}: {
|
||||
items: AssetItem[];
|
||||
accent: string;
|
||||
copiedPath: string | null;
|
||||
onSelect: (i: AssetItem) => void;
|
||||
onCopy: (i: AssetItem) => void;
|
||||
onDelete: (i: AssetItem) => void;
|
||||
hint: (name: string) => string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.path}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/[0.02] group"
|
||||
>
|
||||
<button onClick={() => onSelect(item)} className="flex-shrink-0 w-12 h-12 rounded bg-black overflow-hidden">
|
||||
{item.mediaType === "image" && item.publicUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />
|
||||
) : item.mediaType === "video" ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-blue-500/5"><Video size={16} className="text-blue-400/60" /></div>
|
||||
) : item.mediaType === "model" ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-purple-500/5"><Box size={16} className="text-purple-400/60" /></div>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center"><File size={16} className="text-[#86868B]/50" /></div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<button onClick={() => onSelect(item)} className="block text-left">
|
||||
<div className="text-xs text-white font-medium truncate">{item.name}</div>
|
||||
<div className="text-[10px] text-[#86868B]">{item.size}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => onCopy(item)}
|
||||
className="p-1.5 rounded text-[#86868B] hover:text-white hover:bg-white/5"
|
||||
title="Copy URL"
|
||||
>
|
||||
{copiedPath === item.path ? <Check size={12} className="text-emerald-400" /> : <Copy size={12} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(item)}
|
||||
className="p-1.5 rounded text-rose-400 hover:bg-rose-500/10"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user