feat: AssetBucketBrowser polish — bulk select, drag-move, rename in place
Deploy to VPS / deploy (push) Has been cancelled
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:
@@ -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 }[] = [];
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
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,10 +456,53 @@ 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">
|
||||
{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
|
||||
@@ -392,18 +515,10 @@ export default function AssetBucketBrowser({
|
||||
</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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
@@ -414,10 +529,7 @@ export default function AssetBucketBrowser({
|
||||
accept={activeBucket.accept}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
onChange={(e) => { handleFiles(e.target.files); e.target.value = ""; }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
@@ -427,20 +539,22 @@ export default function AssetBucketBrowser({
|
||||
{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 "{searchQuery}".</> : (
|
||||
<div className="space-y-2">
|
||||
<activeBucket.icon size={32} className="mx-auto opacity-40" />
|
||||
<p>No files in {activeBucket.label} yet.</p>
|
||||
<p className="text-xs">Drop a file here or click Upload.</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"
|
||||
}`}
|
||||
>
|
||||
{/* 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"
|
||||
>
|
||||
<button onClick={() => onSelect(item)} className="block w-full aspect-video 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 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,26 +765,37 @@ 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) => (
|
||||
{props.items.map((item) => {
|
||||
const isSel = props.selected.has(item.path);
|
||||
const isRen = props.renaming?.path === item.path;
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/[0.02] group"
|
||||
draggable
|
||||
onDragStart={(e) => props.onDragStart(e, item)}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg group ${
|
||||
isSel ? "bg-[#00F0FF]/10 border border-[#00F0FF]/30" : "hover:bg-white/[0.02] border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); props.onToggleSelect(item, e); }}
|
||||
className={`w-4 h-4 rounded flex items-center justify-center flex-shrink-0 ${
|
||||
isSel ? "bg-[#00F0FF] text-black"
|
||||
: "border border-white/20 text-transparent hover:border-white/40"
|
||||
}`}
|
||||
>
|
||||
{isSel && <Check size={10} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => props.onClickItem(item, e)}
|
||||
onDoubleClick={() => props.onStartRename(item)}
|
||||
className="flex-shrink-0 w-12 h-12 rounded bg-black overflow-hidden"
|
||||
>
|
||||
<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" />
|
||||
@@ -599,22 +809,48 @@ function ListView({
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<button onClick={() => onSelect(item)} className="block text-left">
|
||||
{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={() => onCopy(item)}
|
||||
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"
|
||||
>
|
||||
{copiedPath === item.path ? <Check size={12} className="text-emerald-400" /> : <Copy size={12} />}
|
||||
{props.copiedPath === item.path ? <Check size={12} className="text-emerald-400" /> : <Copy size={12} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(item)}
|
||||
onClick={() => props.onDelete(item)}
|
||||
className="p-1.5 rounded text-rose-400 hover:bg-rose-500/10"
|
||||
title="Delete"
|
||||
>
|
||||
@@ -622,7 +858,8 @@ function ListView({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user