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 }[] = [];
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user