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`.
|
- [ ] **[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 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`).
|
- [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.**
|
- [ ] **[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`.
|
- [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.
|
- [ ] **[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">
|
<script lang="ts">
|
||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import {
|
import {
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
@@ -74,6 +74,13 @@ let loading = $state(false);
|
|||||||
let searched = $state(false);
|
let searched = $state(false);
|
||||||
let deleting_id = $state<string | null>(null);
|
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
|
// Auto-load on first render once the API config is ready
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!$ae_api?.base_url || searched || loading) return;
|
if (!$ae_api?.base_url || searched || loading) return;
|
||||||
@@ -114,6 +121,7 @@ async function do_search(reset = true) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
results = result_li ?? [];
|
results = result_li ?? [];
|
||||||
|
selected_ids.clear();
|
||||||
searched = true;
|
searched = true;
|
||||||
loading = false;
|
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 ${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.';
|
: '\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;
|
return;
|
||||||
|
|
||||||
deleting_id = id;
|
deleting_id = id;
|
||||||
@@ -383,6 +391,84 @@ async function check_all_for_orphans() {
|
|||||||
orphan_checking = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<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 class="tabular-nums">{fmt_size(total_size)}</span> total
|
||||||
</span>
|
</span>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<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 -->
|
<!-- Orphan check -->
|
||||||
{#if orphan_filter}
|
{#if orphan_filter}
|
||||||
<button
|
<button
|
||||||
@@ -577,10 +669,48 @@ async function check_all_for_orphans() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-xs">
|
<table class="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-surface-500/20 border-b text-left text-[10px] font-bold uppercase tracking-wider opacity-50">
|
<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">
|
<th class="px-3 py-2">
|
||||||
<button type="button" class="flex items-center gap-1 hover:opacity-100" onclick={() => toggle_sort('filename')}>
|
<button type="button" class="flex items-center gap-1 hover:opacity-100" onclick={() => toggle_sort('filename')}>
|
||||||
Filename
|
Filename
|
||||||
@@ -629,7 +759,15 @@ async function check_all_for_orphans() {
|
|||||||
{#each displayed_results as file (file.hosted_file_id)}
|
{#each displayed_results as file (file.hosted_file_id)}
|
||||||
<tr
|
<tr
|
||||||
class="border-surface-500/10 hover:bg-surface-500/5 border-b transition-colors duration-200"
|
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">
|
<td class="px-3 py-2">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<File size={12} class="shrink-0 opacity-40" />
|
<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)}
|
{#if links_map.has(file.hosted_file_id)}
|
||||||
{@const file_links = links_map.get(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">
|
<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}
|
{#if file_links.length === 0}
|
||||||
<span class="text-[10px] italic opacity-40">No links found — file may be an orphan.</span>
|
<span class="text-[10px] italic opacity-40">No links found — file may be an orphan.</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
Reference in New Issue
Block a user