c3d196df03
Deploy to VPS / deploy (push) Has been cancelled
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>
875 lines
38 KiB
TypeScript
875 lines
38 KiB
TypeScript
"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 "{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. 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>
|
||
);
|
||
}
|