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) { export async function DELETE(request: NextRequest) {
try { try {
const body = await request.json(); 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 (!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 (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
const targetPath = buildSafePath(scope, slug, filePath); const targets: string[] = Array.isArray(filePaths) ? filePaths : (filePath ? [filePath] : []);
if (!targetPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 }); 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 }); const deleted: string[] = [];
if (fs.statSync(targetPath).isDirectory()) return NextResponse.json({ error: "Cannot delete folders via API" }, { status: 400 }); 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 }); revalidateContent({ scope: scope as RevalidateScope, slug });
return NextResponse.json({ success: true, deleted: filePath }); return NextResponse.json({
success: deleted.length > 0,
deleted,
failed,
});
} catch (error) { } catch (error) {
console.error("Asset DELETE error:", error); console.error("Asset DELETE error:", error);
return NextResponse.json({ error: "Delete failed" }, { status: 500 }); 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"; "use client";
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// AssetBucketBrowser — single component used everywhere in HQ Command for // AssetBucketBrowser — single picker used everywhere in HQ Command.
// uploading and selecting files. Replaces the four ad-hoc AssetManager
// implementations that lived inline in network/news/applications/parts pages.
// //
// Key design: // What's in the box:
// • The on-disk path layout is UNCHANGED. We don't rename or move existing // • Bucket tabs (Media / Videos / Renders / etc.) per scope. Each tab
// files. Buckets are pure UI groupings on top of the existing // maps to an on-disk subfolder. Public-facing layout unchanged.
// /{scope}/{slug}/[subdir]/<file> convention that the public-facing // • Upload via drag-drop, file picker or paste-URL.
// pages already render from. // • Bulk select with click + Shift-click + Cmd/Ctrl-click. Bulk delete
// • Drop a file into the "Videos" bucket → POST to /api/assets with // and bulk move-to-bucket from the toolbar.
// path=videos. Drop into "Media" → path="" (root). That's it. // • Drag a file onto another bucket tab to MOVE it (PATCH /api/assets).
// • Same prop signature as the previous AssetManager, so callers can swap // • Double-click a filename to rename in place.
// <AssetManager .../> for <AssetBucketBrowser .../> with no other changes. // • 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 { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { import {
X, FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine, X, FolderOpen, Upload, File, Grid3X3, LayoutList, Copy, Check,
Grid3X3, LayoutList, Copy, Check, Image as ImageIcon, Video, Box, Image as ImageIcon, Video, Box, Search, Loader2, Trash2, Info,
Search, Loader2, FileText, Wrench, Trash2, Info, ArrowRightLeft, Pencil, CheckSquare, Square,
} from "lucide-react"; } from "lucide-react";
import { useHqUi } from "@/components/hq/Toast";
export interface AssetItem { export interface AssetItem {
name: string; name: string;
@@ -57,126 +57,35 @@ export interface BucketDef {
const BUCKETS_BY_SCOPE: Record<Scope, BucketDef[]> = { const BUCKETS_BY_SCOPE: Record<Scope, BucketDef[]> = {
cases: [ 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: "media", { id: "videos", path: "videos", label: "Videos", description: "MP4 clips of the installation in operation", icon: Video, accept: "video/*", accentColor: "#4DA6FF" },
path: "", { id: "renders", path: "renders", label: "Renders", description: "3D rendered images of the equipment", icon: Box, accept: "image/*", accentColor: "#FF6B9D" },
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: [ applications: [
{ { id: "media", path: "", label: "Media", description: "Hero, blueprints, machines — files at the application root", icon: ImageIcon, accept: "image/*,video/*", accentColor: "#9333EA" },
id: "media", { id: "videos", path: "videos", label: "Videos", description: "Demo videos for this application", icon: Video, accept: "video/*", accentColor: "#4DA6FF" },
path: "", { id: "renders", path: "renders", label: "Renders", description: "3D rendered images", icon: Box, accept: "image/*", accentColor: "#FF6B9D" },
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: [ 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: [ parts: [
{ { id: "media", path: "", label: "Media", description: "Part photos and product shots", icon: ImageIcon, accept: "image/*", accentColor: "#F59E0B" },
id: "media", { id: "renders", path: "renders", label: "Renders", description: "3D renders of the component", icon: Box, accept: "image/*", accentColor: "#FF6B9D" },
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: [ 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: [ 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 { function bucketHint(bucket: BucketDef, fileName: string): string | null {
const ext = (fileName.split(".").pop() || "").toLowerCase(); const ext = (fileName.split(".").pop() || "").toLowerCase();
const isImage = ["jpg", "jpeg", "png", "webp", "gif", "svg", "avif"].includes(ext); const isImage = ["jpg", "jpeg", "png", "webp", "gif", "svg", "avif"].includes(ext);
const isVideo = ["mp4", "webm", "mov"].includes(ext); const isVideo = ["mp4", "webm", "mov"].includes(ext);
const isModel = ["glb", "gltf", "usdz"].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 === "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 === "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."; 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; onSelect: (item: SelectedAsset) => void;
accentColor?: string; accentColor?: string;
initialPath?: string; initialPath?: string;
/** Optional title override */
title?: string; title?: string;
} }
// Shape used internally to track drag state across the modal.
interface DragPayload {
paths: string[];
fromBucketId: string;
}
export default function AssetBucketBrowser({ export default function AssetBucketBrowser({
slug, slug, scope = "cases", isOpen, onClose, onSelect,
scope = "cases", accentColor = "#00F0FF", initialPath = "", title,
isOpen,
onClose,
onSelect,
accentColor = "#00F0FF",
initialPath = "",
title,
}: AssetBucketBrowserProps) { }: AssetBucketBrowserProps) {
const ui = useHqUi();
const buckets = BUCKETS_BY_SCOPE[scope] || BUCKETS_BY_SCOPE.cases; 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 defaultBucketId = useMemo(() => {
const match = buckets.find((b) => b.path === initialPath); const match = buckets.find((b) => b.path === initialPath);
return match ? match.id : buckets[0].id; return match ? match.id : buckets[0].id;
@@ -221,22 +129,39 @@ export default function AssetBucketBrowser({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(""); const [uploadProgress, setUploadProgress] = useState("");
const [isDragging, setIsDragging] = useState(false); const [isDragOverDropZone, setIsDragOverDropZone] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [copiedPath, setCopiedPath] = useState<string | null>(null); 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); const fileInputRef = useRef<HTMLInputElement>(null);
// Reset to the initial bucket whenever the modal opens
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setActiveBucketId(defaultBucketId); setActiveBucketId(defaultBucketId);
setSearchQuery(""); setSearchQuery("");
setError(null); setError(null);
setSelected(new Set());
setLastSelected(null);
setRenaming(null);
} }
}, [isOpen, defaultBucketId]); }, [isOpen, defaultBucketId]);
// Reset selection when switching buckets
useEffect(() => {
setSelected(new Set());
setLastSelected(null);
setRenaming(null);
}, [activeBucketId]);
const fetchItems = useCallback(async () => { const fetchItems = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
@@ -288,25 +213,172 @@ export default function AssetBucketBrowser({
Array.from(files).forEach(uploadFile); Array.from(files).forEach(uploadFile);
}; };
// ─── Delete ───────────────────────────────────────────────────── // ─── Bulk delete ────────────────────────────────────────────────
const deleteFile = async (file: AssetItem) => { const deleteFiles = async (paths: string[]) => {
if (!confirm(`Delete "${file.name}"? This cannot be undone.`)) return; 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 { try {
const res = await fetch("/api/assets", { const res = await fetch("/api/assets", {
method: "DELETE", method: "DELETE",
headers: { "Content-Type": "application/json" }, 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(); const data = await res.json();
if (data.success) await fetchItems(); if (data.success) {
else alert(data.error || "Delete failed"); ui.toast(
} catch { data.deleted.length === 1 ? "File deleted." : `${data.deleted.length} files deleted.`,
alert("Delete failed"); "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 ──────────────────────────────────────────────────── // ─── Move (single file or bulk) ─────────────────────────────────
const handleSelect = (item: AssetItem) => { 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({ onSelect({
name: item.name, name: item.name,
publicUrl: item.publicUrl || `/${scope}/${slug}/${item.path}`, 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.filter((i) => i.name.toLowerCase().includes(searchQuery.toLowerCase()))
: items; : items;
const otherBuckets = buckets.filter((b) => b.id !== activeBucketId);
const hasSelection = selected.size > 0;
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
@@ -349,24 +424,29 @@ export default function AssetBucketBrowser({
</button> </button>
</div> </div>
{/* Bucket tabs */} {/* Bucket tabs (drop targets while dragging) */}
<div className="flex gap-1 px-6 pt-4 border-b border-white/5"> <div className="flex gap-1 px-6 pt-4 border-b border-white/5">
{buckets.map((b) => { {buckets.map((b) => {
const Icon = b.icon; const Icon = b.icon;
const isActive = activeBucketId === b.id; const isActive = activeBucketId === b.id;
const isDropTarget = overBucketId === b.id;
return ( return (
<button <button
key={b.id} key={b.id}
onClick={() => setActiveBucketId(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 ${ className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-all -mb-px ${
isActive isDropTarget ? "border-emerald-400 bg-emerald-500/10 text-emerald-300"
? "border-[var(--accent)] text-white" : isActive ? "border-[var(--accent)] text-white"
: "border-transparent text-[#86868B] hover:text-white hover:bg-white/[0.02]" : "border-transparent text-[#86868B] hover:text-white hover:bg-white/[0.02]"
}`} }`}
title={b.description} 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} {b.label}
{isDropTarget && <span className="text-[9px] uppercase tracking-widest">drop to move</span>}
</button> </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]"> <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 }} /> <Info size={12} className="flex-shrink-0" style={{ color: activeBucket.accentColor }} />
<span className="leading-relaxed">{activeBucket.description}</span> <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> </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="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"> {hasSelection ? (
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#86868B]" /> <>
<input <button
type="text" onClick={() => setSelected(new Set())}
value={searchQuery} className="flex items-center gap-1.5 text-xs text-[#86868B] hover:text-white px-2 py-1.5"
onChange={(e) => setSearchQuery(e.target.value)} >
placeholder="Search files…" <X size={13} /> {selected.size} selected
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" </button>
/> <div className="h-4 w-px bg-white/10" />
</div> <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"> <div className="flex items-center gap-1 ml-auto">
<button <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">
onClick={() => setViewMode("grid")} <Grid3X3 size={14} />
className={`p-2 rounded-lg ${viewMode === "grid" ? "bg-white/10 text-white" : "text-[#86868B] hover:bg-white/5"}`} </button>
title="Grid view" <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} />
<Grid3X3 size={14} /> </button>
</button> </div>
<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 <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept={activeBucket.accept} accept={activeBucket.accept}
multiple multiple
className="hidden" className="hidden"
onChange={(e) => { onChange={(e) => { handleFiles(e.target.files); e.target.value = ""; }}
handleFiles(e.target.files); />
e.target.value = ""; <button
}} onClick={() => fileInputRef.current?.click()}
/> disabled={isUploading}
<button 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"
onClick={() => fileInputRef.current?.click()} >
disabled={isUploading} {isUploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
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" Upload
> </button>
{isUploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />} </>
Upload )}
</button>
</div> </div>
{/* Drop zone + items */} {/* Items area */}
<div <div
onDragEnter={(e) => { e.preventDefault(); setIsDragging(true); }} onDragEnter={(e) => { e.preventDefault(); if (!dragPayloadRef.current) setIsDragOverDropZone(true); }}
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
onDragLeave={() => setIsDragging(false)} onDragLeave={() => setIsDragOverDropZone(false)}
onDrop={(e) => { onDrop={(e) => {
e.preventDefault(); e.preventDefault();
setIsDragging(false); setIsDragOverDropZone(false);
handleFiles(e.dataTransfer.files); if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files);
}} }}
className={`flex-1 overflow-y-auto px-6 py-4 transition-colors ${ 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 && ( {uploadProgress && (
@@ -457,65 +571,121 @@ export default function AssetBucketBrowser({
<div className="text-center py-12 text-rose-400 text-sm">{error}</div> <div className="text-center py-12 text-rose-400 text-sm">{error}</div>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className="text-center py-12 text-[#86868B] text-sm"> <div className="text-center py-12 text-[#86868B] text-sm">
{searchQuery ? ( {searchQuery ? <>No files match &quot;{searchQuery}&quot;.</> : (
<>No files match "{searchQuery}".</>
) : (
<div className="space-y-2"> <div className="space-y-2">
<activeBucket.icon size={32} className="mx-auto opacity-40" /> <activeBucket.icon size={32} className="mx-auto opacity-40" />
<p>No files in {activeBucket.label} yet.</p> <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>
)} )}
</div> </div>
) : viewMode === "grid" ? ( ) : viewMode === "grid" ? (
<GridView <GridView
items={filtered} items={filtered}
accent={accentColor} selected={selected}
renaming={renaming}
copiedPath={copiedPath} 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} onCopy={copyPath}
onDelete={deleteFile} onDelete={(item) => deleteFiles([item.path])}
onDragStart={onItemDragStart}
hint={(name: string) => bucketHint(activeBucket, name)} hint={(name: string) => bucketHint(activeBucket, name)}
/> />
) : ( ) : (
<ListView <ListView
items={filtered} items={filtered}
accent={accentColor} selected={selected}
renaming={renaming}
copiedPath={copiedPath} 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} onCopy={copyPath}
onDelete={deleteFile} onDelete={(item) => deleteFiles([item.path])}
onDragStart={onItemDragStart}
hint={(name: string) => bucketHint(activeBucket, name)} hint={(name: string) => bucketHint(activeBucket, name)}
/> />
)} )}
</div> </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>
</div> </div>
); );
} }
// ─── Grid view ───────────────────────────────────────────────────────── // ─── Grid view ────────────────────────────────────────────────────────────
function GridView({ interface ViewProps {
items, accent, copiedPath, onSelect, onCopy, onDelete, hint,
}: {
items: AssetItem[]; items: AssetItem[];
accent: string; selected: Set<string>;
renaming: { path: string; value: string } | null;
copiedPath: string | null; copiedPath: string | null;
onSelect: (i: AssetItem) => void; onClickItem: (item: AssetItem, e: React.MouseEvent) => void;
onCopy: (i: AssetItem) => void; onToggleSelect: (item: AssetItem, e: React.MouseEvent) => void;
onDelete: (i: AssetItem) => 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; hint: (name: string) => string | null;
}) { }
function GridView(props: ViewProps) {
return ( return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
{items.map((item) => { {props.items.map((item) => {
const warning = hint(item.name); const isSel = props.selected.has(item.path);
const isRen = props.renaming?.path === item.path;
const warning = props.hint(item.name);
return ( return (
<div <div
key={item.path} 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 ? ( {item.mediaType === "image" && item.publicUrl ? (
// eslint-disable-next-line @next/next/no-img-element // 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" /> <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> </button>
<div className="p-2.5"> <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"> <div className="flex items-center justify-between mt-1.5">
<span className="text-[9px] text-[#86868B]">{item.size}</span> <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"> <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button <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" className="p-1 rounded text-[#86868B] hover:text-white hover:bg-white/5"
title="Copy URL" 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>
<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" className="p-1 rounded text-rose-400 hover:bg-rose-500/10"
title="Delete" title="Delete"
> >
@@ -566,63 +765,101 @@ function GridView({
); );
} }
// ─── List view ───────────────────────────────────────────────────────── // ─── List view ────────────────────────────────────────────────────────────
function ListView({ function ListView(props: ViewProps) {
items, accent, copiedPath, onSelect, onCopy, onDelete, hint,
}: {
items: AssetItem[];
accent: string;
copiedPath: string | null;
onSelect: (i: AssetItem) => void;
onCopy: (i: AssetItem) => void;
onDelete: (i: AssetItem) => void;
hint: (name: string) => string | null;
}) {
return ( return (
<div className="space-y-1"> <div className="space-y-1">
{items.map((item) => ( {props.items.map((item) => {
<div const isSel = props.selected.has(item.path);
key={item.path} const isRen = props.renaming?.path === item.path;
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/[0.02] group" return (
> <div
<button onClick={() => onSelect(item)} className="flex-shrink-0 w-12 h-12 rounded bg-black overflow-hidden"> key={item.path}
{item.mediaType === "image" && item.publicUrl ? ( draggable
// eslint-disable-next-line @next/next/no-img-element onDragStart={(e) => props.onDragStart(e, item)}
<img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" /> className={`flex items-center gap-3 px-3 py-2 rounded-lg group ${
) : item.mediaType === "video" ? ( isSel ? "bg-[#00F0FF]/10 border border-[#00F0FF]/30" : "hover:bg-white/[0.02] border border-transparent"
<div className="w-full h-full flex items-center justify-center bg-blue-500/5"><Video size={16} className="text-blue-400/60" /></div> }`}
) : item.mediaType === "model" ? ( >
<div className="w-full h-full flex items-center justify-center bg-purple-500/5"><Box size={16} className="text-purple-400/60" /></div>
) : (
<div className="w-full h-full flex items-center justify-center"><File size={16} className="text-[#86868B]/50" /></div>
)}
</button>
<div className="flex-1 min-w-0">
<button onClick={() => onSelect(item)} className="block text-left">
<div className="text-xs text-white font-medium truncate">{item.name}</div>
<div className="text-[10px] text-[#86868B]">{item.size}</div>
</button>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
onClick={() => onCopy(item)} onClick={(e) => { e.stopPropagation(); props.onToggleSelect(item, e); }}
className="p-1.5 rounded text-[#86868B] hover:text-white hover:bg-white/5" className={`w-4 h-4 rounded flex items-center justify-center flex-shrink-0 ${
title="Copy URL" 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>
<button <button
onClick={() => onDelete(item)} onClick={(e) => props.onClickItem(item, e)}
className="p-1.5 rounded text-rose-400 hover:bg-rose-500/10" onDoubleClick={() => props.onStartRename(item)}
title="Delete" 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> </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> );
))} })}
</div> </div>
); );
} }