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) {
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "{searchQuery}".</> : (
|
||||||
<>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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user