Files
flux-srl/src/components/hq/AssetBucketBrowser.tsx
T
davidherran c3d196df03
Deploy to VPS / deploy (push) Has been cancelled
feat: auto-optimize images on CMS upload via AssetBucketBrowser
Enables the existing Sharp pipeline for all uploads — WebP conversion,
auto-orient, 2560px cap, content-hash filenames.  Upload toast now
shows compression savings percentage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 15:21:08 -05:00

875 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// ─────────────────────────────────────────────────────────────────────────────
// AssetBucketBrowser — single picker used everywhere in HQ Command.
//
// What's in the box:
// • Bucket tabs (Media / Videos / Renders / etc.) per scope. Each tab
// maps to an on-disk subfolder. Public-facing layout unchanged.
// • Upload via drag-drop, file picker or paste-URL.
// • Bulk select with click + Shift-click + Cmd/Ctrl-click. Bulk delete
// and bulk move-to-bucket from the toolbar.
// • Drag a file onto another bucket tab to MOVE it (PATCH /api/assets).
// • Double-click a filename to rename in place.
// • Search, grid/list views, copy URL per file.
// • Toast + confirm via the global HqUiProvider — no browser popups.
// ─────────────────────────────────────────────────────────────────────────────
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import {
X, FolderOpen, Upload, File, Grid3X3, LayoutList, Copy, Check,
Image as ImageIcon, Video, Box, Search, Loader2, Trash2, Info,
ArrowRightLeft, Pencil, CheckSquare, Square,
} from "lucide-react";
import { useHqUi } from "@/components/hq/Toast";
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 and gallery images at the case root", icon: ImageIcon, accept: "image/*", accentColor: "#00F0FF" },
{ id: "videos", path: "videos", label: "Videos", description: "MP4 clips of the installation in operation", icon: Video, accept: "video/*", accentColor: "#4DA6FF" },
{ id: "models", path: "models", label: "3D Models", description: "GLB/USDZ for AR viewer and 3D display", icon: Box, accept: ".glb,.gltf,.usdz", accentColor: "#A855F7" },
{ 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" },
],
};
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 === "models" && !isModel) return "Models bucket expects 3D files (.glb, .gltf, .usdz).";
if (bucket.id === "media" && !isImage && !isVideo) return "Media bucket expects images or videos here.";
return null;
}
export interface AssetBucketBrowserProps {
slug: string;
scope?: Scope;
isOpen: boolean;
onClose: () => void;
onSelect: (item: SelectedAsset) => void;
accentColor?: string;
initialPath?: string;
title?: string;
}
// Shape used internally to track drag state across the modal.
interface DragPayload {
paths: string[];
fromBucketId: string;
}
export default function AssetBucketBrowser({
slug, scope = "cases", isOpen, onClose, onSelect,
accentColor = "#00F0FF", initialPath = "", title,
}: AssetBucketBrowserProps) {
const ui = useHqUi();
const buckets = BUCKETS_BY_SCOPE[scope] || BUCKETS_BY_SCOPE.cases;
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 [isDragOverDropZone, setIsDragOverDropZone] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [copiedPath, setCopiedPath] = useState<string | null>(null);
// Selection + drag + rename state
const [selected, setSelected] = useState<Set<string>>(new Set());
const [lastSelected, setLastSelected] = useState<string | null>(null);
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null);
const [moveBusy, setMoveBusy] = useState(false);
const [overBucketId, setOverBucketId] = useState<string | null>(null);
const dragPayloadRef = useRef<DragPayload | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
setActiveBucketId(defaultBucketId);
setSearchQuery("");
setError(null);
setSelected(new Set());
setLastSelected(null);
setRenaming(null);
}
}, [isOpen, defaultBucketId]);
// Reset selection when switching buckets
useEffect(() => {
setSelected(new Set());
setLastSelected(null);
setRenaming(null);
}, [activeBucketId]);
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);
fd.append("optimize", "1");
const res = await fetch("/api/assets", { method: "POST", body: fd });
const data = await res.json();
if (data.success) {
const f = data.file;
if (f.optimized && f.savedBytes > 0) {
const pct = Math.round((f.savedBytes / f.originalBytes) * 100);
setUploadProgress(`${f.name} (${pct}% optimized)`);
} else {
setUploadProgress(`${f.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);
};
// ─── Bulk delete ────────────────────────────────────────────────
const deleteFiles = async (paths: string[]) => {
if (paths.length === 0) return;
const ok = await ui.confirm({
title: paths.length === 1 ? "Delete file" : `Delete ${paths.length} files`,
message: paths.length === 1
? `Permanently delete "${paths[0].split("/").pop()}"? This cannot be undone.`
: `Permanently delete ${paths.length} files? This cannot be undone.`,
confirmLabel: "Delete",
destructive: true,
});
if (!ok) return;
try {
const res = await fetch("/api/assets", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scope, slug, filePaths: paths }),
});
const data = await res.json();
if (data.success) {
ui.toast(
data.deleted.length === 1 ? "File deleted." : `${data.deleted.length} files deleted.`,
"success"
);
if (data.failed?.length > 0) ui.toast(`${data.failed.length} files failed: ${data.failed.map((f: any) => f.reason).join(", ")}`, "error");
} else {
ui.toast(data.error || "Delete failed", "error");
}
setSelected(new Set());
await fetchItems();
} catch (err: any) {
ui.toast(err.message || "Delete failed", "error");
}
};
// ─── Move (single file or bulk) ─────────────────────────────────
const moveFiles = async (paths: string[], toBucketId: string) => {
if (paths.length === 0) return;
const target = buckets.find((b) => b.id === toBucketId);
if (!target) return;
setMoveBusy(true);
let okCount = 0;
let failCount = 0;
for (const fromPath of paths) {
const filename = fromPath.split("/").pop()!;
const toPath = target.path ? `${target.path}/${filename}` : filename;
if (fromPath === toPath) continue;
try {
const res = await fetch("/api/assets", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scope, slug, fromPath, toPath }),
});
const data = await res.json();
if (data.success) okCount++; else failCount++;
} catch {
failCount++;
}
}
setMoveBusy(false);
setSelected(new Set());
if (okCount > 0) ui.toast(`Moved ${okCount} file${okCount > 1 ? "s" : ""} to ${target.label}.`, "success");
if (failCount > 0) ui.toast(`${failCount} file${failCount > 1 ? "s" : ""} could not be moved (name conflict?).`, "error");
await fetchItems();
};
// ─── Rename ─────────────────────────────────────────────────────
const submitRename = async () => {
if (!renaming) return;
const newName = renaming.value.trim();
const oldName = renaming.path.split("/").pop()!;
if (!newName || newName === oldName) {
setRenaming(null);
return;
}
const cleanName = newName.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, "");
if (!cleanName) {
ui.toast("Invalid filename.", "error");
return;
}
const dir = renaming.path.includes("/") ? renaming.path.slice(0, renaming.path.lastIndexOf("/")) : "";
const toPath = dir ? `${dir}/${cleanName}` : cleanName;
try {
const res = await fetch("/api/assets", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scope, slug, fromPath: renaming.path, toPath }),
});
const data = await res.json();
if (data.success) {
ui.toast("Renamed.", "success");
setRenaming(null);
await fetchItems();
} else {
ui.toast(data.error || "Rename failed", "error");
}
} catch (err: any) {
ui.toast(err.message || "Rename failed", "error");
}
};
// ─── Selection helpers ──────────────────────────────────────────
const toggleSelect = (path: string, e?: React.MouseEvent) => {
const next = new Set(selected);
if (e?.shiftKey && lastSelected) {
// Range select
const ids = filtered.map((i) => i.path);
const a = ids.indexOf(lastSelected);
const b = ids.indexOf(path);
if (a >= 0 && b >= 0) {
const [start, end] = a < b ? [a, b] : [b, a];
for (let i = start; i <= end; i++) next.add(ids[i]);
}
} else if (e?.metaKey || e?.ctrlKey) {
if (next.has(path)) next.delete(path); else next.add(path);
} else {
// Plain click toggles, leaves others alone
if (next.has(path)) next.delete(path); else next.add(path);
}
setSelected(next);
setLastSelected(path);
};
const selectAll = () => {
if (selected.size === filtered.length) setSelected(new Set());
else setSelected(new Set(filtered.map((i) => i.path)));
};
// ─── Drag-between-buckets ───────────────────────────────────────
const onItemDragStart = (e: React.DragEvent, item: AssetItem) => {
// If the dragged item isn't in selection, drag just that one. If it is,
// drag the whole selection.
let paths: string[];
if (selected.has(item.path)) paths = Array.from(selected);
else { paths = [item.path]; setSelected(new Set([item.path])); }
dragPayloadRef.current = { paths, fromBucketId: activeBucketId };
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", paths.join(","));
};
const onTabDragOver = (e: React.DragEvent, bucketId: string) => {
if (!dragPayloadRef.current) return;
if (dragPayloadRef.current.fromBucketId === bucketId) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setOverBucketId(bucketId);
};
const onTabDragLeave = () => setOverBucketId(null);
const onTabDrop = async (e: React.DragEvent, bucketId: string) => {
e.preventDefault();
setOverBucketId(null);
const payload = dragPayloadRef.current;
dragPayloadRef.current = null;
if (!payload || payload.fromBucketId === bucketId) return;
await moveFiles(payload.paths, bucketId);
};
// ─── Misc ────────────────────────────────────────────────────────
const handlePick = (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;
const otherBuckets = buckets.filter((b) => b.id !== activeBucketId);
const hasSelection = selected.size > 0;
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 (drop targets while dragging) */}
<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;
const isDropTarget = overBucketId === b.id;
return (
<button
key={b.id}
onClick={() => setActiveBucketId(b.id)}
onDragOver={(e) => onTabDragOver(e, b.id)}
onDragLeave={onTabDragLeave}
onDrop={(e) => onTabDrop(e, b.id)}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-all -mb-px ${
isDropTarget ? "border-emerald-400 bg-emerald-500/10 text-emerald-300"
: 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: isDropTarget ? "#34D399" : isActive ? b.accentColor : undefined }} />
{b.label}
{isDropTarget && <span className="text-[9px] uppercase tracking-widest">drop to move</span>}
</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>
{moveBusy && <span className="ml-auto flex items-center gap-1 text-[#00F0FF]"><Loader2 size={12} className="animate-spin" /> Moving</span>}
</div>
{/* Toolbar — switches to bulk-action mode when items are selected */}
<div className="flex items-center gap-2 px-6 py-3 border-b border-white/5 flex-shrink-0">
{hasSelection ? (
<>
<button
onClick={() => setSelected(new Set())}
className="flex items-center gap-1.5 text-xs text-[#86868B] hover:text-white px-2 py-1.5"
>
<X size={13} /> {selected.size} selected
</button>
<div className="h-4 w-px bg-white/10" />
<button
onClick={() => deleteFiles(Array.from(selected))}
className="flex items-center gap-1.5 bg-rose-500/10 text-rose-400 hover:bg-rose-500/20 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
>
<Trash2 size={12} /> Delete
</button>
{otherBuckets.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-[#86868B]">
<ArrowRightLeft size={12} /> Move to:
{otherBuckets.map((b) => (
<button
key={b.id}
onClick={() => moveFiles(Array.from(selected), b.id)}
disabled={moveBusy}
className="px-2 py-1 rounded text-[11px] font-medium hover:bg-white/5 disabled:opacity-50"
style={{ color: b.accentColor }}
>
{b.label}
</button>
))}
</div>
)}
</>
) : (
<>
<button
onClick={selectAll}
className="flex items-center gap-1.5 text-xs text-[#86868B] hover:text-white px-2 py-1.5"
title="Select all (filtered)"
>
{selected.size === filtered.length && filtered.length > 0 ? <CheckSquare size={13} /> : <Square size={13} />}
Select all
</button>
<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>
{/* Items area */}
<div
onDragEnter={(e) => { e.preventDefault(); if (!dragPayloadRef.current) setIsDragOverDropZone(true); }}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => setIsDragOverDropZone(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragOverDropZone(false);
if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files);
}}
className={`flex-1 overflow-y-auto px-6 py-4 transition-colors ${
isDragOverDropZone ? "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 &quot;{searchQuery}&quot;.</> : (
<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. Drag a file from another tab to move it in.</p>
</div>
)}
</div>
) : viewMode === "grid" ? (
<GridView
items={filtered}
selected={selected}
renaming={renaming}
copiedPath={copiedPath}
onClickItem={(item, e) => {
if (hasSelection || e.shiftKey || e.metaKey || e.ctrlKey) toggleSelect(item.path, e);
else handlePick(item);
}}
onToggleSelect={(item, e) => toggleSelect(item.path, e)}
onStartRename={(item) => setRenaming({ path: item.path, value: item.name })}
onChangeRename={(value) => setRenaming((r) => r ? { ...r, value } : r)}
onSubmitRename={submitRename}
onCancelRename={() => setRenaming(null)}
onCopy={copyPath}
onDelete={(item) => deleteFiles([item.path])}
onDragStart={onItemDragStart}
hint={(name: string) => bucketHint(activeBucket, name)}
/>
) : (
<ListView
items={filtered}
selected={selected}
renaming={renaming}
copiedPath={copiedPath}
onClickItem={(item, e) => {
if (hasSelection || e.shiftKey || e.metaKey || e.ctrlKey) toggleSelect(item.path, e);
else handlePick(item);
}}
onToggleSelect={(item, e) => toggleSelect(item.path, e)}
onStartRename={(item) => setRenaming({ path: item.path, value: item.name })}
onChangeRename={(value) => setRenaming((r) => r ? { ...r, value } : r)}
onSubmitRename={submitRename}
onCancelRename={() => setRenaming(null)}
onCopy={copyPath}
onDelete={(item) => deleteFiles([item.path])}
onDragStart={onItemDragStart}
hint={(name: string) => bucketHint(activeBucket, name)}
/>
)}
</div>
{/* Footer hint */}
<div className="px-6 py-2.5 border-t border-white/5 text-[10px] text-[#86868B] flex items-center gap-4">
<span><kbd className="bg-white/5 border border-white/10 rounded px-1.5 py-0.5 font-mono">Click</kbd> open</span>
<span><kbd className="bg-white/5 border border-white/10 rounded px-1.5 py-0.5 font-mono">Click</kbd> select range</span>
<span><kbd className="bg-white/5 border border-white/10 rounded px-1.5 py-0.5 font-mono">Click</kbd> add to selection</span>
<span><kbd className="bg-white/5 border border-white/10 rounded px-1.5 py-0.5 font-mono">2× click</kbd> rename</span>
<span>Drag onto another tab to move</span>
</div>
</div>
</div>
);
}
// ─── Grid view ────────────────────────────────────────────────────────────
interface ViewProps {
items: AssetItem[];
selected: Set<string>;
renaming: { path: string; value: string } | null;
copiedPath: string | null;
onClickItem: (item: AssetItem, e: React.MouseEvent) => void;
onToggleSelect: (item: AssetItem, e: React.MouseEvent) => void;
onStartRename: (item: AssetItem) => void;
onChangeRename: (value: string) => void;
onSubmitRename: () => void;
onCancelRename: () => void;
onCopy: (item: AssetItem) => void;
onDelete: (item: AssetItem) => void;
onDragStart: (e: React.DragEvent, item: AssetItem) => void;
hint: (name: string) => string | null;
}
function GridView(props: ViewProps) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
{props.items.map((item) => {
const isSel = props.selected.has(item.path);
const isRen = props.renaming?.path === item.path;
const warning = props.hint(item.name);
return (
<div
key={item.path}
draggable
onDragStart={(e) => props.onDragStart(e, item)}
className={`group bg-white/[0.02] border rounded-xl overflow-hidden transition-all ${
isSel ? "border-[#00F0FF] ring-2 ring-[#00F0FF]/30" : "border-white/5 hover:border-white/15"
}`}
>
{/* Selection checkbox overlay */}
<button
onClick={(e) => { e.stopPropagation(); props.onToggleSelect(item, e); }}
className={`absolute m-2 z-10 w-5 h-5 rounded flex items-center justify-center transition-all ${
isSel ? "bg-[#00F0FF] text-black opacity-100"
: "bg-black/60 text-white/40 opacity-0 group-hover:opacity-100 hover:bg-black/80"
}`}
title="Select"
>
{isSel && <Check size={12} />}
</button>
<button
onClick={(e) => props.onClickItem(item, e)}
onDoubleClick={(e) => { e.stopPropagation(); props.onStartRename(item); }}
className="block w-full aspect-video bg-black overflow-hidden cursor-pointer relative"
>
{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">
{isRen ? (
<input
autoFocus
type="text"
value={props.renaming!.value}
onChange={(e) => props.onChangeRename(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") props.onSubmitRename();
else if (e.key === "Escape") props.onCancelRename();
}}
onBlur={props.onSubmitRename}
className="w-full bg-black/60 border border-[#00F0FF]/40 text-white text-[11px] font-mono rounded px-2 py-1 outline-none"
/>
) : (
<div
onDoubleClick={() => props.onStartRename(item)}
className="text-[11px] text-white truncate font-medium cursor-text"
title="Double-click to rename"
>
{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(); props.onStartRename(item); }}
className="p-1 rounded text-[#86868B] hover:text-white hover:bg-white/5"
title="Rename"
>
<Pencil size={11} />
</button>
<button
onClick={(e) => { e.stopPropagation(); props.onCopy(item); }}
className="p-1 rounded text-[#86868B] hover:text-white hover:bg-white/5"
title="Copy URL"
>
{props.copiedPath === item.path ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}
</button>
<button
onClick={(e) => { e.stopPropagation(); props.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(props: ViewProps) {
return (
<div className="space-y-1">
{props.items.map((item) => {
const isSel = props.selected.has(item.path);
const isRen = props.renaming?.path === item.path;
return (
<div
key={item.path}
draggable
onDragStart={(e) => props.onDragStart(e, item)}
className={`flex items-center gap-3 px-3 py-2 rounded-lg group ${
isSel ? "bg-[#00F0FF]/10 border border-[#00F0FF]/30" : "hover:bg-white/[0.02] border border-transparent"
}`}
>
<button
onClick={(e) => { e.stopPropagation(); props.onToggleSelect(item, e); }}
className={`w-4 h-4 rounded flex items-center justify-center flex-shrink-0 ${
isSel ? "bg-[#00F0FF] text-black"
: "border border-white/20 text-transparent hover:border-white/40"
}`}
>
{isSel && <Check size={10} />}
</button>
<button
onClick={(e) => props.onClickItem(item, e)}
onDoubleClick={() => props.onStartRename(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">
{isRen ? (
<input
autoFocus
type="text"
value={props.renaming!.value}
onChange={(e) => props.onChangeRename(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") props.onSubmitRename();
else if (e.key === "Escape") props.onCancelRename();
}}
onBlur={props.onSubmitRename}
className="w-full bg-black/60 border border-[#00F0FF]/40 text-white text-xs font-mono rounded px-2 py-1 outline-none"
/>
) : (
<button
onClick={(e) => props.onClickItem(item, e)}
onDoubleClick={() => props.onStartRename(item)}
className="block text-left w-full"
>
<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={() => props.onStartRename(item)}
className="p-1.5 rounded text-[#86868B] hover:text-white hover:bg-white/5"
title="Rename"
>
<Pencil size={12} />
</button>
<button
onClick={() => props.onCopy(item)}
className="p-1.5 rounded text-[#86868B] hover:text-white hover:bg-white/5"
title="Copy URL"
>
{props.copiedPath === item.path ? <Check size={12} className="text-emerald-400" /> : <Copy size={12} />}
</button>
<button
onClick={() => props.onDelete(item)}
className="p-1.5 rounded text-rose-400 hover:bg-rose-500/10"
title="Delete"
>
<Trash2 size={12} />
</button>
</div>
</div>
);
})}
</div>
);
}