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

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

NEW FEATURES (frontend)

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

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

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

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

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

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

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

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

REVIEWED FOR REGRESSIONS
- Single-file API still works — old { filePath } DELETE shape preserved.
- The 4 inline AssetManager call sites (network, news, applications,
  parts) use AssetBucketBrowser via the alias added in the previous
  commit; their integration is unchanged. Same props, same onSelect
  callback shape.
- Toast/Confirm calls go through the existing HqUiProvider mounted in
  hq-command/layout.tsx — no extra wiring.
This commit is contained in:
2026-05-05 19:40:06 -05:00
parent 330fecc3cc
commit 778b35f15a
2 changed files with 584 additions and 280 deletions
+76 -9
View File
@@ -307,29 +307,96 @@ export async function PUT(request: NextRequest) {
}
}
// DELETE — Remove a file
// DELETE — Remove a file (or many in one call).
// Body shape:
// { scope, slug, filePath: "..." } single delete
// { scope, slug, filePaths: ["a", "b", ...] } bulk delete
export async function DELETE(request: NextRequest) {
try {
const body = await request.json();
const { scope = "applications", slug = "", filePath } = body;
const { scope = "applications", slug = "", filePath, filePaths } = body;
if (!filePath) return NextResponse.json({ error: "Missing filePath" }, { status: 400 });
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
const targetPath = buildSafePath(scope, slug, filePath);
if (!targetPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
const targets: string[] = Array.isArray(filePaths) ? filePaths : (filePath ? [filePath] : []);
if (targets.length === 0) return NextResponse.json({ error: "Missing filePath(s)" }, { status: 400 });
if (!fs.existsSync(targetPath)) return NextResponse.json({ error: "File not found" }, { status: 404 });
if (fs.statSync(targetPath).isDirectory()) return NextResponse.json({ error: "Cannot delete folders via API" }, { status: 400 });
const deleted: string[] = [];
const failed: { path: string; reason: string }[] = [];
fs.unlinkSync(targetPath);
for (const rel of targets) {
const targetPath = buildSafePath(scope, slug, rel);
if (!targetPath) {
failed.push({ path: rel, reason: "Invalid path" });
continue;
}
if (!fs.existsSync(targetPath)) {
failed.push({ path: rel, reason: "Not found" });
continue;
}
if (fs.statSync(targetPath).isDirectory()) {
failed.push({ path: rel, reason: "Refusing to delete folder via API" });
continue;
}
try {
fs.unlinkSync(targetPath);
deleted.push(rel);
} catch (err: any) {
failed.push({ path: rel, reason: err.message || "unlink failed" });
}
}
revalidateContent({ scope: scope as RevalidateScope, slug });
return NextResponse.json({ success: true, deleted: filePath });
return NextResponse.json({
success: deleted.length > 0,
deleted,
failed,
});
} catch (error) {
console.error("Asset DELETE error:", error);
return NextResponse.json({ error: "Delete failed" }, { status: 500 });
}
}
// PATCH — Move or rename a file.
// Body shape: { scope, slug, fromPath, toPath }
// - rename in same bucket: fromPath="videos/a.mp4", toPath="videos/intro.mp4"
// - move between buckets: fromPath="videos/a.mp4", toPath="renders/a.mp4"
// - move to root: fromPath="videos/a.mp4", toPath="a.mp4"
// Cannot overwrite an existing file (returns 409). Sanitises target name
// the same way upload does, and creates intermediate folders if needed.
export async function PATCH(request: NextRequest) {
try {
const body = await request.json();
const { scope = "applications", slug = "", fromPath, toPath } = body;
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
if (!fromPath || !toPath) return NextResponse.json({ error: "Missing fromPath or toPath" }, { status: 400 });
const sourceAbs = buildSafePath(scope, slug, fromPath);
const destAbs = buildSafePath(scope, slug, toPath);
if (!sourceAbs || !destAbs) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
if (!fs.existsSync(sourceAbs)) return NextResponse.json({ error: "Source file not found" }, { status: 404 });
if (fs.statSync(sourceAbs).isDirectory()) return NextResponse.json({ error: "Source is a folder" }, { status: 400 });
if (fs.existsSync(destAbs)) return NextResponse.json({ error: "A file with that name already exists at the destination" }, { status: 409 });
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
fs.renameSync(sourceAbs, destAbs);
revalidateContent({ scope: scope as RevalidateScope, slug });
return NextResponse.json({
success: true,
from: fromPath,
to: toPath,
publicUrl: buildPublicUrl(scope, slug, toPath),
});
} catch (error: any) {
console.error("Asset PATCH error:", error);
return NextResponse.json({ error: error.message || "Move/rename failed" }, { status: 500 });
}
}