feat: AssetBucketBrowser polish — bulk select, drag-move, rename in place
Deploy to VPS / deploy (push) Has been cancelled

The unified bucket browser graduated from "acceptable" to "actually
useful for bulk work". Editors can now manage dozens of files in a
single session without dragging each one through a modal.

NEW FEATURES (frontend)

1. BULK SELECTION
   - Click on a file when nothing's selected → opens it as before.
   - Click on the corner checkbox, or click the file once selection is
     active → toggle that one in/out.
   - Shift-click → range select between last anchor and current item.
   - Cmd/Ctrl-click → toggle without affecting others.
   - "Select all" toggle in the toolbar respects the search filter.

2. BULK ACTIONS TOOLBAR
   When at least one file is selected the toolbar morphs into:
     [N selected] [Delete] [Move to: Videos | Renders | …]
   Delete fires the new bulk DELETE endpoint with filePaths[], shows
   a single toast for the whole batch + per-file failure breakdown.
   Move iterates PATCH /api/assets per file (sequential, with a 'Moving…'
   indicator in the bucket helper bar).

3. DRAG TO MOVE BETWEEN BUCKETS
   Drag any file (or the whole selection if you started the drag from
   a selected file) onto another bucket tab. The tab highlights green
   with 'drop to move' while you hover. Drop fires the same per-file
   PATCH flow. No dialog, no friction.

4. RENAME IN PLACE
   Double-click a filename (in either grid or list view) → input opens
   in place. Enter saves, Escape cancels, blur saves. Sanitizes to
   safe characters. PATCH endpoint refuses to overwrite an existing
   file (returns 409, surfaced as a toast).

5. KEYBOARD HINT FOOTER
   Bottom-of-modal cheat sheet: Click / ⇧Click / ⌘Click / 2× click /
   drag onto another tab. So new editors don't have to discover the
   power-user features.

NEW BACKEND (src/app/api/assets/route.ts)

PATCH method
   { scope, slug, fromPath, toPath } → fs.renameSync.
   Used for both rename (same dir, new name) and move (different bucket).
   Refuses to overwrite an existing destination (409 conflict).
   Creates intermediate folders if needed.

DELETE extended
   Now accepts either { filePath: "x" } or { filePaths: ["a", "b"] }.
   Bulk path deletes one-by-one and returns per-file success/failure
   so the UI can show a precise toast.

REVIEWED FOR REGRESSIONS
- Single-file API still works — old { filePath } DELETE shape preserved.
- The 4 inline AssetManager call sites (network, news, applications,
  parts) use AssetBucketBrowser via the alias added in the previous
  commit; their integration is unchanged. Same props, same onSelect
  callback shape.
- Toast/Confirm calls go through the existing HqUiProvider mounted in
  hq-command/layout.tsx — no extra wiring.
This commit is contained in:
2026-05-05 19:40:06 -05:00
parent 330fecc3cc
commit 778b35f15a
2 changed files with 584 additions and 280 deletions
+76 -9
View File
@@ -307,29 +307,96 @@ export async function PUT(request: NextRequest) {
}
}
// DELETE — Remove a file
// DELETE — Remove a file (or many in one call).
// Body shape:
// { scope, slug, filePath: "..." } single delete
// { scope, slug, filePaths: ["a", "b", ...] } bulk delete
export async function DELETE(request: NextRequest) {
try {
const body = await request.json();
const { scope = "applications", slug = "", filePath } = body;
const { scope = "applications", slug = "", filePath, filePaths } = body;
if (!filePath) return NextResponse.json({ error: "Missing filePath" }, { status: 400 });
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
const targetPath = buildSafePath(scope, slug, filePath);
if (!targetPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
const targets: string[] = Array.isArray(filePaths) ? filePaths : (filePath ? [filePath] : []);
if (targets.length === 0) return NextResponse.json({ error: "Missing filePath(s)" }, { status: 400 });
if (!fs.existsSync(targetPath)) return NextResponse.json({ error: "File not found" }, { status: 404 });
if (fs.statSync(targetPath).isDirectory()) return NextResponse.json({ error: "Cannot delete folders via API" }, { status: 400 });
const deleted: string[] = [];
const failed: { path: string; reason: string }[] = [];
fs.unlinkSync(targetPath);
for (const rel of targets) {
const targetPath = buildSafePath(scope, slug, rel);
if (!targetPath) {
failed.push({ path: rel, reason: "Invalid path" });
continue;
}
if (!fs.existsSync(targetPath)) {
failed.push({ path: rel, reason: "Not found" });
continue;
}
if (fs.statSync(targetPath).isDirectory()) {
failed.push({ path: rel, reason: "Refusing to delete folder via API" });
continue;
}
try {
fs.unlinkSync(targetPath);
deleted.push(rel);
} catch (err: any) {
failed.push({ path: rel, reason: err.message || "unlink failed" });
}
}
revalidateContent({ scope: scope as RevalidateScope, slug });
return NextResponse.json({ success: true, deleted: filePath });
return NextResponse.json({
success: deleted.length > 0,
deleted,
failed,
});
} catch (error) {
console.error("Asset DELETE error:", error);
return NextResponse.json({ error: "Delete failed" }, { status: 500 });
}
}
// PATCH — Move or rename a file.
// Body shape: { scope, slug, fromPath, toPath }
// - rename in same bucket: fromPath="videos/a.mp4", toPath="videos/intro.mp4"
// - move between buckets: fromPath="videos/a.mp4", toPath="renders/a.mp4"
// - move to root: fromPath="videos/a.mp4", toPath="a.mp4"
// Cannot overwrite an existing file (returns 409). Sanitises target name
// the same way upload does, and creates intermediate folders if needed.
export async function PATCH(request: NextRequest) {
try {
const body = await request.json();
const { scope = "applications", slug = "", fromPath, toPath } = body;
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
if (!fromPath || !toPath) return NextResponse.json({ error: "Missing fromPath or toPath" }, { status: 400 });
const sourceAbs = buildSafePath(scope, slug, fromPath);
const destAbs = buildSafePath(scope, slug, toPath);
if (!sourceAbs || !destAbs) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
if (!fs.existsSync(sourceAbs)) return NextResponse.json({ error: "Source file not found" }, { status: 404 });
if (fs.statSync(sourceAbs).isDirectory()) return NextResponse.json({ error: "Source is a folder" }, { status: 400 });
if (fs.existsSync(destAbs)) return NextResponse.json({ error: "A file with that name already exists at the destination" }, { status: 409 });
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
fs.renameSync(sourceAbs, destAbs);
revalidateContent({ scope: scope as RevalidateScope, slug });
return NextResponse.json({
success: true,
from: fromPath,
to: toPath,
publicUrl: buildPublicUrl(scope, slug, toPath),
});
} catch (error: any) {
console.error("Asset PATCH error:", error);
return NextResponse.json({ error: error.message || "Move/rename failed" }, { status: 500 });
}
}
+508 -271
View File
@@ -1,27 +1,27 @@
"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.
// AssetBucketBrowser — single picker used everywhere in HQ Command.
//
// 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.
// 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, FolderPlus, ChevronRight, File, ArrowUpFromLine,
Grid3X3, LayoutList, Copy, Check, Image as ImageIcon, Video, Box,
Search, Loader2, FileText, Wrench, Trash2, Info,
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;
@@ -57,126 +57,35 @@ export interface BucketDef {
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",
},
{ 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",
},
{ 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",
},
{ 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",
},
{ 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",
},
{ 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",
},
{ 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.";
@@ -191,23 +100,22 @@ export interface AssetBucketBrowserProps {
onSelect: (item: SelectedAsset) => void;
accentColor?: string;
initialPath?: string;
/** Optional title override */
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,
slug, scope = "cases", isOpen, onClose, onSelect,
accentColor = "#00F0FF", initialPath = "", title,
}: AssetBucketBrowserProps) {
const ui = useHqUi();
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;
@@ -221,22 +129,39 @@ export default function AssetBucketBrowser({
const [error, setError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const [isDragging, setIsDragging] = useState(false);
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);
// Reset to the initial bucket whenever the modal opens
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);
@@ -288,25 +213,172 @@ export default function AssetBucketBrowser({
Array.from(files).forEach(uploadFile);
};
// ─── Delete ─────────────────────────────────────────────────────
const deleteFile = async (file: AssetItem) => {
if (!confirm(`Delete "${file.name}"? This cannot be undone.`)) return;
// ─── 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, filePath: file.path }),
body: JSON.stringify({ scope, slug, filePaths: paths }),
});
const data = await res.json();
if (data.success) await fetchItems();
else alert(data.error || "Delete failed");
} catch {
alert("Delete failed");
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");
}
};
// ─── Helpers ────────────────────────────────────────────────────
const handleSelect = (item: AssetItem) => {
// ─── 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}`,
@@ -326,6 +398,9 @@ export default function AssetBucketBrowser({
? 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 (
@@ -349,24 +424,29 @@ export default function AssetBucketBrowser({
</button>
</div>
{/* Bucket tabs */}
{/* 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 ${
isActive
? "border-[var(--accent)] text-white"
: "border-transparent text-[#86868B] hover:text-white hover:bg-white/[0.02]"
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: isActive ? b.accentColor : undefined }} />
<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>
);
})}
@@ -376,71 +456,105 @@ export default function AssetBucketBrowser({
<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 */}
{/* 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">
<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>
{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>
<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>
<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 */}
{/* Items area */}
<div
onDragEnter={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragEnter={(e) => { e.preventDefault(); if (!dragPayloadRef.current) setIsDragOverDropZone(true); }}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => setIsDragging(false)}
onDragLeave={() => setIsDragOverDropZone(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
handleFiles(e.dataTransfer.files);
setIsDragOverDropZone(false);
if (e.dataTransfer.files.length > 0) 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" : ""
isDragOverDropZone ? "bg-[var(--accent)]/5 border-2 border-dashed border-[var(--accent)]/30" : ""
}`}
>
{uploadProgress && (
@@ -457,65 +571,121 @@ export default function AssetBucketBrowser({
<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}".</>
) : (
{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.</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}
accent={accentColor}
selected={selected}
renaming={renaming}
copiedPath={copiedPath}
onSelect={handleSelect}
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={deleteFile}
onDelete={(item) => deleteFiles([item.path])}
onDragStart={onItemDragStart}
hint={(name: string) => bucketHint(activeBucket, name)}
/>
) : (
<ListView
items={filtered}
accent={accentColor}
selected={selected}
renaming={renaming}
copiedPath={copiedPath}
onSelect={handleSelect}
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={deleteFile}
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 ─────────────────────────────────────────────────────────
function GridView({
items, accent, copiedPath, onSelect, onCopy, onDelete, hint,
}: {
// ─── Grid view ────────────────────────────────────────────────────────────
interface ViewProps {
items: AssetItem[];
accent: string;
selected: Set<string>;
renaming: { path: string; value: string } | null;
copiedPath: string | null;
onSelect: (i: AssetItem) => void;
onCopy: (i: AssetItem) => void;
onDelete: (i: AssetItem) => void;
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">
{items.map((item) => {
const warning = hint(item.name);
{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}
className="group bg-white/[0.02] border border-white/5 hover:border-white/15 rounded-xl overflow-hidden transition-all"
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"
}`}
>
<button onClick={() => onSelect(item)} className="block w-full aspect-video bg-black overflow-hidden">
{/* 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" />
@@ -535,19 +705,48 @@ function GridView({
</button>
<div className="p-2.5">
<div className="text-[11px] text-white truncate font-medium">{item.name}</div>
{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(); onCopy(item); }}
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"
>
{copiedPath === item.path ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}
{props.copiedPath === item.path ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete(item); }}
onClick={(e) => { e.stopPropagation(); props.onDelete(item); }}
className="p-1 rounded text-rose-400 hover:bg-rose-500/10"
title="Delete"
>
@@ -566,63 +765,101 @@ function GridView({
);
}
// ─── 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;
}) {
// ─── List view ────────────────────────────────────────────────────────────
function ListView(props: ViewProps) {
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">
{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={() => onCopy(item)}
className="p-1.5 rounded text-[#86868B] hover:text-white hover:bg-white/5"
title="Copy URL"
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"
}`}
>
{copiedPath === item.path ? <Check size={12} className="text-emerald-400" /> : <Copy size={12} />}
{isSel && <Check size={10} />}
</button>
<button
onClick={() => onDelete(item)}
className="p-1.5 rounded text-rose-400 hover:bg-rose-500/10"
title="Delete"
onClick={(e) => props.onClickItem(item, e)}
onDoubleClick={() => props.onStartRename(item)}
className="flex-shrink-0 w-12 h-12 rounded bg-black overflow-hidden"
>
<Trash2 size={12} />
{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>
))}
);
})}
</div>
);
}