From 778b35f15a5cda160eef3e71381c651a83b005eb Mon Sep 17 00:00:00 2001 From: DavidHerran Date: Tue, 5 May 2026 19:40:06 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20AssetBucketBrowser=20polish=20=E2=80=94?= =?UTF-8?q?=20bulk=20select,=20drag-move,=20rename=20in=20place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/assets/route.ts | 85 ++- src/components/hq/AssetBucketBrowser.tsx | 779 +++++++++++++++-------- 2 files changed, 584 insertions(+), 280 deletions(-) diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts index e685043..a676de1 100644 --- a/src/app/api/assets/route.ts +++ b/src/app/api/assets/route.ts @@ -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 }); + } } \ No newline at end of file diff --git a/src/components/hq/AssetBucketBrowser.tsx b/src/components/hq/AssetBucketBrowser.tsx index f6ead3c..4ee87a3 100644 --- a/src/components/hq/AssetBucketBrowser.tsx +++ b/src/components/hq/AssetBucketBrowser.tsx @@ -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]/ 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 -// for 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 = { 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(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(null); + // Selection + drag + rename state + const [selected, setSelected] = useState>(new Set()); + const [lastSelected, setLastSelected] = useState(null); + const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null); + const [moveBusy, setMoveBusy] = useState(false); + const [overBucketId, setOverBucketId] = useState(null); + const dragPayloadRef = useRef(null); + const fileInputRef = useRef(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({ - {/* Bucket tabs */} + {/* Bucket tabs (drop targets while dragging) */}
{buckets.map((b) => { const Icon = b.icon; const isActive = activeBucketId === b.id; + const isDropTarget = overBucketId === b.id; return ( ); })} @@ -376,71 +456,105 @@ export default function AssetBucketBrowser({
{activeBucket.description} + {moveBusy && Moving…}
- {/* Toolbar */} + {/* Toolbar — switches to bulk-action mode when items are selected */}
-
- - 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" - /> -
+ {hasSelection ? ( + <> + +
+ + {otherBuckets.length > 0 && ( +
+ Move to: + {otherBuckets.map((b) => ( + + ))} +
+ )} + + ) : ( + <> + +
+ + 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" + /> +
-
- - -
+
+ + +
- { - handleFiles(e.target.files); - e.target.value = ""; - }} - /> - + { handleFiles(e.target.files); e.target.value = ""; }} + /> + + + )}
- {/* Drop zone + items */} + {/* Items area */}
{ 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({
{error}
) : filtered.length === 0 ? (
- {searchQuery ? ( - <>No files match "{searchQuery}". - ) : ( + {searchQuery ? <>No files match "{searchQuery}". : (

No files in {activeBucket.label} yet.

-

Drop a file here or click Upload.

+

Drop a file here or click Upload. Drag a file from another tab to move it in.

)}
) : viewMode === "grid" ? ( { + 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)} /> ) : ( { + 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)} /> )}
+ + {/* Footer hint */} +
+ Click open + ⇧Click select range + ⌘Click add to selection + 2× click rename + Drag onto another tab to move +
); } -// ─── Grid view ───────────────────────────────────────────────────────── -function GridView({ - items, accent, copiedPath, onSelect, onCopy, onDelete, hint, -}: { +// ─── Grid view ──────────────────────────────────────────────────────────── +interface ViewProps { items: AssetItem[]; - accent: string; + selected: Set; + 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 (
- {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 (
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" + }`} > - + +
-
{item.name}
+ {isRen ? ( + 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" + /> + ) : ( +
props.onStartRename(item)} + className="text-[11px] text-white truncate font-medium cursor-text" + title="Double-click to rename" + > + {item.name} +
+ )} +
{item.size}
+ - -
- -
- -
+ {props.items.map((item) => { + const isSel = props.selected.has(item.path); + const isRen = props.renaming?.path === item.path; + return ( +
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" + }`} + > + + +
+ {isRen ? ( + 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" + /> + ) : ( + + )} +
+ +
+ + + +
-
- ))} + ); + })}
); }