feat(files): multi-select delete + quick-delete toggle on /core/files/
Adds checkbox column with select-all header, a selection action bar showing count and "Delete X selected" (one confirm for the whole batch, sequential delete with progress tracking), and a "Quick delete" checkbox that suppresses the per-file confirm dialog for single-file deletes. Selection clears on each new search. Designed for rapid orphan cleanup after Check Orphans. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -122,7 +122,7 @@ Sorting baseline is now `build_tmp_sort` (ASC chain, no `.reverse()` on tmp-sort
|
||||
- [ ] **[Backend] `event_file` — add `cfg_json` column (post-CMSC)** — The per-file display override currently uses a localStorage workaround (`launcher_loc.current.file_display_overrides`) because `event_file` has no JSON blob column. Proper fix: add `cfg_json` to the `event_file` DB table, expose it through the FastAPI model, then migrate the frontend back to reading/writing the backend field (restoring global/cross-device persistence). Frontend code is in `launcher_file_cont.svelte` — search for `file_display_overrides`.
|
||||
- [x] **[Backend] Hosted file delete — V3 CRUD regression fix** — `DELETE /v3/action/event_file/{id}` now handles atomic cleanup (link removal, physical file, hosted_file record). Frontend updated to use this endpoint directly (2026-06-18, commit `5689bfebb`).
|
||||
- [x] **[Backend] Hosted file orphan scan endpoint** — `GET /v3/action/hosted_file/orphan_scan` live. `/core/files/` "Check Orphans" updated to use it — single call replaces N+1 per-file link fetches (2026-06-18, commit `5689bfebb`).
|
||||
- [ ] **[Files] Multi-select delete + quick delete on `/core/files/`** — Two options to explore: (1) checkbox multi-select with a "Delete Selected" bulk action, (2) a toggle to suppress the per-file confirm dialog for faster single-file cleanup sessions. Useful for clearing orphan backlog after running Check Orphans.
|
||||
- [x] **[Files] Multi-select delete + quick delete on `/core/files/`** — Implemented (2026-06-23): checkbox column with select-all header, selection action bar with "Delete X selected" (one confirm for the batch, sequential delete with progress tracking), and "Quick delete" toggle to suppress per-file confirms on single deletes.
|
||||
- [ ] **[Backend] Re-add `Access-Control-Allow-Private-Network: true` CORS header.**
|
||||
- [x] **[DevOps] Service worker `skipWaiting` + `clients.claim`** — Root cause of "users see old code / can't reproduce in dev testing": the SW sat in waiting state until all tabs closed. IDAA members leave idaa.org open all day. Fixed 2026-06-03: both calls added to `src/service-worker.js`. See mistake #16 in `BOOTSTRAP__AI_Agent_Quickstart.md`.
|
||||
- [ ] **[DevOps] Nginx proxy buffer tuning** — Buffer settings copied from PHP guide; not optimal for Node.js. `proxy_busy_buffers_size` technically exceeds safe limit. Re-examine when enabling compression (now re-enabled) stabilizes.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
ArrowUpDown,
|
||||
@@ -74,6 +74,13 @@ let loading = $state(false);
|
||||
let searched = $state(false);
|
||||
let deleting_id = $state<string | null>(null);
|
||||
|
||||
// ── Multi-select ──────────────────────────────────────────────────────────────
|
||||
let selected_ids = new SvelteSet<string>();
|
||||
let bulk_deleting = $state(false);
|
||||
let bulk_delete_progress = $state<{ done: number; total: number } | null>(null);
|
||||
// When true, single-file deletes skip the confirm dialog.
|
||||
let skip_confirm = $state(false);
|
||||
|
||||
// Auto-load on first render once the API config is ready
|
||||
$effect(() => {
|
||||
if (!$ae_api?.base_url || searched || loading) return;
|
||||
@@ -114,6 +121,7 @@ async function do_search(reset = true) {
|
||||
});
|
||||
|
||||
results = result_li ?? [];
|
||||
selected_ids.clear();
|
||||
searched = true;
|
||||
loading = false;
|
||||
}
|
||||
@@ -164,7 +172,7 @@ async function handle_delete(file: ae_HostedFile) {
|
||||
? `\n\nThis file has ${link_count} link${link_count !== 1 ? 's' : ''} (${file_links.map((l) => l.link_to_type).join(', ')}) — all will be removed.`
|
||||
: '\n\nThis file has no links and will be deleted immediately.';
|
||||
|
||||
if (!confirm(`Delete "${label}"?${link_summary}\n\nThe physical file will be removed from disk. This cannot be undone.`))
|
||||
if (!skip_confirm && !confirm(`Delete "${label}"?${link_summary}\n\nThe physical file will be removed from disk. This cannot be undone.`))
|
||||
return;
|
||||
|
||||
deleting_id = id;
|
||||
@@ -383,6 +391,84 @@ async function check_all_for_orphans() {
|
||||
orphan_checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Multi-select logic ────────────────────────────────────────────────────────
|
||||
// Must be declared after displayed_results (which depends on orphan_filter/results).
|
||||
let all_visible_selected = $derived(
|
||||
displayed_results.length > 0 &&
|
||||
displayed_results.every(f => selected_ids.has(f.hosted_file_id))
|
||||
);
|
||||
|
||||
function toggle_select(id: string) {
|
||||
if (selected_ids.has(id)) selected_ids.delete(id);
|
||||
else selected_ids.add(id);
|
||||
}
|
||||
|
||||
function toggle_select_all() {
|
||||
if (all_visible_selected) {
|
||||
for (const f of displayed_results) selected_ids.delete(f.hosted_file_id);
|
||||
} else {
|
||||
for (const f of displayed_results) selected_ids.add(f.hosted_file_id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handle_bulk_delete() {
|
||||
const ids_to_delete = displayed_results
|
||||
.filter(f => selected_ids.has(f.hosted_file_id))
|
||||
.map(f => f.hosted_file_id);
|
||||
if (ids_to_delete.length === 0) return;
|
||||
|
||||
if (!confirm(`Delete ${ids_to_delete.length} file${ids_to_delete.length !== 1 ? 's' : ''}?\n\nPhysical files will be removed from disk. This cannot be undone.`))
|
||||
return;
|
||||
|
||||
bulk_deleting = true;
|
||||
bulk_delete_progress = { done: 0, total: ids_to_delete.length };
|
||||
|
||||
for (const id of ids_to_delete) {
|
||||
try {
|
||||
if (!links_map.has(id)) {
|
||||
try {
|
||||
const result = await api.get_object({ api_cfg: $ae_api, endpoint: `/v3/action/hosted_file/${id}/links`, log_lvl: 0 });
|
||||
links_map.set(id, result?.data ?? result ?? []);
|
||||
} catch {
|
||||
links_map.set(id, []);
|
||||
}
|
||||
}
|
||||
const file_links = links_map.get(id) ?? [];
|
||||
if (file_links.length > 0) {
|
||||
for (let i = 0; i < file_links.length; i++) {
|
||||
const lnk = file_links[i];
|
||||
await api.delete_hosted_file({
|
||||
api_cfg: $ae_api,
|
||||
hosted_file_id: id,
|
||||
link_to_type: lnk.link_to_type,
|
||||
link_to_id: lnk.link_to_id_random ?? undefined,
|
||||
rm_orphan: i === file_links.length - 1,
|
||||
params: { method: 'delete' },
|
||||
log_lvl: 0
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await api.delete_hosted_file({
|
||||
api_cfg: $ae_api,
|
||||
hosted_file_id: id,
|
||||
rm_orphan: true,
|
||||
params: { method: 'delete' },
|
||||
log_lvl: 0
|
||||
});
|
||||
}
|
||||
links_map.delete(id);
|
||||
selected_ids.delete(id);
|
||||
results = results.filter(r => r.hosted_file_id !== id);
|
||||
} catch (e) {
|
||||
console.error(`[bulk delete] ${id}`, e);
|
||||
}
|
||||
bulk_delete_progress = { done: (bulk_delete_progress?.done ?? 0) + 1, total: ids_to_delete.length };
|
||||
}
|
||||
|
||||
bulk_deleting = false;
|
||||
bulk_delete_progress = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -527,6 +613,12 @@ async function check_all_for_orphans() {
|
||||
· <span class="tabular-nums">{fmt_size(total_size)}</span> total
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- Quick delete toggle — skips the per-file confirm dialog for single deletes -->
|
||||
<label class="flex cursor-pointer items-center gap-1.5 text-xs opacity-70 hover:opacity-100"
|
||||
title="Quick delete: skip the confirm dialog when deleting individual files">
|
||||
<input type="checkbox" class="checkbox checkbox-sm" bind:checked={skip_confirm} />
|
||||
Quick delete
|
||||
</label>
|
||||
<!-- Orphan check -->
|
||||
{#if orphan_filter}
|
||||
<button
|
||||
@@ -577,10 +669,48 @@ async function check_all_for_orphans() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selection action bar — visible only when rows are checked -->
|
||||
{#if selected_ids.size > 0}
|
||||
<div class="border-surface-500/20 bg-error-500/10 flex flex-wrap items-center gap-3 border-b px-4 py-2">
|
||||
<span class="text-error-700-300 text-xs font-bold">
|
||||
{selected_ids.size} file{selected_ids.size !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-filled-error text-xs"
|
||||
onclick={handle_bulk_delete}
|
||||
disabled={bulk_deleting}
|
||||
title="Delete all selected files (one confirmation for the whole batch)">
|
||||
{#if bulk_deleting && bulk_delete_progress}
|
||||
<LoaderCircle size={12} class="mr-1 animate-spin" />
|
||||
Deleting {bulk_delete_progress.done}/{bulk_delete_progress.total}…
|
||||
{:else}
|
||||
<Trash2 size={12} class="mr-1" />
|
||||
Delete {selected_ids.size} selected
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-tonal-surface text-xs"
|
||||
onclick={() => selected_ids.clear()}
|
||||
disabled={bulk_deleting}>
|
||||
<X size={12} class="mr-1" /> Clear selection
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-surface-500/20 border-b text-left text-[10px] font-bold uppercase tracking-wider opacity-50">
|
||||
<th class="px-3 py-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
checked={all_visible_selected}
|
||||
onchange={toggle_select_all}
|
||||
title="Select / deselect all visible files" />
|
||||
</th>
|
||||
<th class="px-3 py-2">
|
||||
<button type="button" class="flex items-center gap-1 hover:opacity-100" onclick={() => toggle_sort('filename')}>
|
||||
Filename
|
||||
@@ -629,7 +759,15 @@ async function check_all_for_orphans() {
|
||||
{#each displayed_results as file (file.hosted_file_id)}
|
||||
<tr
|
||||
class="border-surface-500/10 hover:bg-surface-500/5 border-b transition-colors duration-200"
|
||||
class:opacity-50={!file.enable}>
|
||||
class:opacity-50={!file.enable}
|
||||
class:bg-error-100={selected_ids.has(file.hosted_file_id)}>
|
||||
<td class="px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
checked={selected_ids.has(file.hosted_file_id)}
|
||||
onchange={() => toggle_select(file.hosted_file_id)} />
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<File size={12} class="shrink-0 opacity-40" />
|
||||
@@ -725,7 +863,7 @@ async function check_all_for_orphans() {
|
||||
{#if links_map.has(file.hosted_file_id)}
|
||||
{@const file_links = links_map.get(file.hosted_file_id) ?? []}
|
||||
<tr class="border-surface-500/10 border-b bg-surface-50-950/50">
|
||||
<td colspan="6" class="px-4 py-2">
|
||||
<td colspan="7" class="px-4 py-2">
|
||||
{#if file_links.length === 0}
|
||||
<span class="text-[10px] italic opacity-40">No links found — file may be an orphan.</span>
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user