feat(files): add Hosted Files admin page at /core/files/

- Search hosted files across all accounts (including disabled)
- Sortable columns, pagination, per-row delete, download, and SHA-256 copy
- Lazy-load file link records per row via /v3/action/hosted_file/{id}/links
- Fix delete to load links first, remove each via correct link_to_type/link_to_id_random,
  then hard-delete with method=delete and rm_orphan=true
- Remove Linked To and Group columns (moved Group/ForType to filter bar only)
- SHA-256 column now visible at lg breakpoint (was xl)
- Added /core/files nav link to /core layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-18 17:32:45 -04:00
parent fa7889bd80
commit 015a38fd14
4 changed files with 649 additions and 0 deletions

View File

@@ -5,6 +5,7 @@ import { ae_loc, ae_sess, ae_api, slct } from '$lib/stores/ae_stores';
import {
Building,
Database,
File,
Globe,
History,
LayoutDashboard,
@@ -92,6 +93,11 @@ onMount(() => {
class="btn btn-sm preset-tonal-surface">
<Phone size={14} class="mr-1" /> Contacts
</a>
<a
href="/core/files"
class="btn btn-sm preset-tonal-secondary">
<File size={14} class="mr-1" /> Files
</a>
<a
href="/core/activity_logs"
class="btn btn-sm preset-tonal-surface">

View File

@@ -0,0 +1,624 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import { goto } from '$app/navigation';
import {
ArrowUpDown,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
ClipboardCopy,
Download,
File,
FileX,
Funnel,
Link,
LoaderCircle,
RefreshCw,
Search,
Trash2,
X
} from '@lucide/svelte';
import { api } from '$lib/api/api';
import { ae_loc, ae_api } from '$lib/stores/ae_stores';
import { ae_util } from '$lib/ae_utils/ae_utils';
import { load_ae_obj_li__account } from '$lib/ae_core/ae_core__account';
import { delete_ae_obj_id__hosted_file, download_ae_obj_id__hosted_file } from '$lib/ae_core/core__hosted_files';
import type { ae_HostedFile } from '$lib/types/ae_types';
// ── Account map ───────────────────────────────────────────────────────────────
// Loaded with enabled:'all' so disabled client accounts appear in the dropdown.
// This is intentional — the primary use case for this page is cleaning up files
// that belong to old/inactive accounts.
let account_li: any[] = $state([]);
let account_map = new SvelteMap<string, string>();
onMount(async () => {
if (!$ae_loc.manager_access) {
goto('/core');
return;
}
const accts = await load_ae_obj_li__account({
api_cfg: $ae_api,
enabled: 'all',
hidden: 'all',
try_cache: false,
log_lvl: 0
});
account_li = accts ?? [];
account_map.clear();
for (const a of account_li) {
const label = a.code ?? a.name ?? a.account_id;
account_map.set(a.account_id, a.enable ? label : `${label} (disabled)`);
}
});
// ── Filter state ──────────────────────────────────────────────────────────────
let qry_filename = $state('');
let qry_account_id = $state('');
let qry_for_type = $state('');
let qry_group = $state('');
let qry_enabled = $state<'all' | 'enabled' | 'not_enabled'>('all');
let qry_hidden = $state<'all' | 'not_hidden' | 'hidden'>('all');
let page_limit = $state(50);
let page_offset = $state(0);
let sort_col = $state('updated_on');
let sort_dir = $state<'ASC' | 'DESC'>('DESC');
// ── Results ───────────────────────────────────────────────────────────────────
let results: ae_HostedFile[] = $state([]);
let loading = $state(false);
let searched = $state(false);
let deleting_id = $state<string | null>(null);
// Auto-load on first render once the API config is ready
$effect(() => {
if (!$ae_api?.base_url || searched || loading) return;
untrack(() => do_search());
});
// ── Search ────────────────────────────────────────────────────────────────────
async function do_search(reset = true) {
if (reset) page_offset = 0;
loading = true;
const search_query: any = { and: [] };
if (qry_filename.trim()) {
search_query.and.push({ field: 'filename', op: 'like', value: qry_filename.trim() });
}
if (qry_account_id.trim()) {
// account_id_random matches the V3 search field convention (same as data_stores)
search_query.and.push({ field: 'account_id_random', op: 'eq', value: qry_account_id.trim() });
}
if (qry_for_type.trim()) {
search_query.and.push({ field: 'for_type', op: 'like', value: qry_for_type.trim() });
}
if (qry_group.trim()) {
search_query.and.push({ field: 'group', op: 'like', value: qry_group.trim() });
}
const result_li = await api.search_ae_obj({
api_cfg: $ae_api,
obj_type: 'hosted_file',
search_query,
enabled: qry_enabled,
hidden: qry_hidden,
order_by_li: { [sort_col]: sort_dir },
limit: page_limit,
offset: page_offset,
log_lvl: 0
});
results = result_li ?? [];
searched = true;
loading = false;
}
function clear_filters() {
qry_filename = '';
qry_account_id = '';
qry_for_type = '';
qry_group = '';
qry_enabled = 'all';
qry_hidden = 'all';
results = [];
searched = false;
}
function toggle_sort(col: string) {
if (sort_col === col) {
sort_dir = sort_dir === 'ASC' ? 'DESC' : 'ASC';
} else {
sort_col = col;
sort_dir = 'ASC';
}
do_search(false);
}
// ── Delete ────────────────────────────────────────────────────────────────────
async function handle_delete(file: ae_HostedFile) {
const id = file.hosted_file_id;
const label = (file as any).filename_w_ext ?? file.filename ?? id;
// Load links if not already fetched — needed to pass correct link_to_type/link_to_id_random
// and to warn the user how many links will be removed.
if (!links_map.has(id)) {
links_loading.set(id, true);
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 (e) {
links_map.set(id, []);
} finally {
links_loading.delete(id);
}
}
const file_links = links_map.get(id) ?? [];
const link_count = file_links.length;
const link_summary = link_count > 0
? `\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.`))
return;
deleting_id = id;
try {
// Remove each link in sequence, then hard-delete on the final call.
// The backend only cleans up the physical file + record when rm_orphan=true
// and no links remain, so we must delete all links first.
if (file_links.length > 0) {
for (let i = 0; i < file_links.length; i++) {
const lnk = file_links[i];
const is_last = i === file_links.length - 1;
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: is_last,
params: { method: 'delete' },
log_lvl: 1
});
}
} else {
// Already an orphan — just clean it up directly.
await api.delete_hosted_file({
api_cfg: $ae_api,
hosted_file_id: id,
rm_orphan: true,
params: { method: 'delete' },
log_lvl: 1
});
}
links_map.delete(id);
results = results.filter((r) => r.hosted_file_id !== id);
} catch (e) {
console.error('[delete hosted_file]', e);
alert('Delete failed — check console for details.');
} finally {
deleting_id = null;
}
}
// ── Download ──────────────────────────────────────────────────────────────────
let downloading_id = $state<string | null>(null);
async function handle_download(file: ae_HostedFile) {
downloading_id = file.hosted_file_id;
try {
await download_ae_obj_id__hosted_file({
api_cfg: $ae_api,
hosted_file_id: file.hosted_file_id,
filename: (file as any).filename_w_ext ?? file.filename,
auto_download: true,
log_lvl: 1
});
} catch (e) {
console.error('[download hosted_file]', e);
alert('Download failed — check console for details.');
} finally {
downloading_id = null;
}
}
async function copy_hash(hash: string) {
await navigator.clipboard.writeText(hash);
}
// ── Links (lazy per row) ──────────────────────────────────────────────────────
// Keyed by hosted_file_id. Value: null = not loaded, [] = loaded/empty, [...] = loaded with links.
let links_map = new SvelteMap<string, { link_to_type: string; link_to_id: number; link_to_id_random: string | null }[] | null>();
let links_loading = new SvelteMap<string, boolean>();
async function toggle_links(file: ae_HostedFile) {
const id = file.hosted_file_id;
if (links_map.has(id)) {
links_map.delete(id);
return;
}
links_loading.set(id, true);
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 (e) {
console.error('[hosted_file links]', e);
links_map.set(id, []);
} finally {
links_loading.delete(id);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmt_size(bytes: number | undefined): string {
if (!bytes) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1_048_576).toFixed(1)} MB`;
return `${(bytes / 1_073_741_824).toFixed(2)} GB`;
}
function fmt_account(account_id: string | null | undefined): string {
if (!account_id) return '—';
return account_map.get(account_id) ?? account_id.slice(0, 8) + '…';
}
function fmt_date(val: string | Date | null | undefined) {
if (!val) return '—';
return ae_util.iso_datetime_formatter(val as any, 'datetime_12_short');
}
let total_size = $derived(results.reduce((sum, f) => sum + (f.size ?? 0), 0));
</script>
<div class="space-y-6">
<!-- ── Header ──────────────────────────────────────────────────────────── -->
<header class="bg-surface-100-900 border-surface-500/10 flex flex-wrap items-center justify-between gap-4 rounded-xl border p-4 shadow-lg">
<div class="flex items-center gap-3">
<div class="bg-primary-500/10 rounded-lg p-2">
<File size={24} class="text-primary-500" />
</div>
<div>
<h1 class="h2 font-black tracking-tight">Hosted Files</h1>
<p class="text-xs font-bold tracking-widest uppercase opacity-50">File Storage Management</p>
</div>
</div>
</header>
<!-- ── Filter bar ──────────────────────────────────────────────────────── -->
<div class="card preset-tonal-surface border-surface-500/10 space-y-3 border p-4 shadow-xl">
<div class="flex items-center gap-2 text-[10px] font-bold tracking-widest uppercase opacity-50">
<Funnel size={11} /> Filters
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">
Filename <span class="font-normal opacity-60">— use <code>%</code> as wildcard</span>
</span>
<input
type="search"
bind:value={qry_filename}
placeholder="report%.pdf"
class="input input-sm w-full font-mono text-xs"
onkeydown={(e) => e.key === 'Enter' && do_search()} />
</label>
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">Account</span>
<select class="select select-sm w-full text-xs" bind:value={qry_account_id}>
<option value="">All accounts</option>
{#each account_li as acct (acct.account_id)}
<option value={acct.account_id}>
{acct.code ?? acct.name ?? acct.account_id}
{acct.enable ? '' : ' (disabled)'}
</option>
{/each}
</select>
</label>
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">For Type</span>
<input
type="search"
bind:value={qry_for_type}
placeholder="event, person…"
class="input input-sm w-full font-mono text-xs"
onkeydown={(e) => e.key === 'Enter' && do_search()} />
</label>
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">Group</span>
<input
type="search"
bind:value={qry_group}
placeholder="file group…"
class="input input-sm w-full font-mono text-xs"
onkeydown={(e) => e.key === 'Enter' && do_search()} />
</label>
</div>
<div class="flex flex-wrap items-center gap-3 pt-1">
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">Status</span>
<select class="select select-sm text-xs" bind:value={qry_enabled}>
<option value="all">All</option>
<option value="enabled">Enabled</option>
<option value="not_enabled">Disabled</option>
</select>
</label>
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">Hidden</span>
<select class="select select-sm text-xs" bind:value={qry_hidden}>
<option value="all">All</option>
<option value="not_hidden">Visible only</option>
<option value="hidden">Hidden only</option>
</select>
</label>
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">Per page</span>
<select class="select select-sm text-xs" bind:value={page_limit}>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</label>
<div class="flex flex-1 items-end justify-end gap-2">
<button
type="button"
onclick={clear_filters}
class="btn btn-sm preset-tonal-surface text-xs"
title="Clear all filters">
<X size={13} class="mr-1" /> Clear
</button>
<button
type="button"
onclick={() => do_search()}
class="btn preset-filled-primary"
disabled={loading}>
{#if loading}
<LoaderCircle size={16} class="mr-2 animate-spin" />
{:else}
<Search size={16} class="mr-2" />
{/if}
Search
</button>
</div>
</div>
</div>
<!-- ── Results ───────────────────────────────────────────────────────────── -->
{#if results.length > 0}
<div class="card preset-tonal-surface border-surface-500/10 border shadow-xl">
<div class="border-surface-500/20 flex items-center justify-between border-b px-4 py-2">
<span class="text-xs font-bold opacity-60">
{results.length} file{results.length !== 1 ? 's' : ''}
{#if results.length === page_limit}
<span class="opacity-50"> (may be more — increase per-page or paginate)</span>
{/if}
· <span class="tabular-nums">{fmt_size(total_size)}</span> total
</span>
<div class="flex items-center gap-2">
{#if page_offset > 0}
<button
type="button"
class="btn btn-sm preset-tonal-surface"
onclick={() => { page_offset = Math.max(0, page_offset - page_limit); do_search(false); }}>
<ChevronLeft size={14} />
</button>
{/if}
{#if results.length === page_limit}
<button
type="button"
class="btn btn-sm preset-tonal-surface"
onclick={() => { page_offset += page_limit; do_search(false); }}>
<ChevronRight size={14} />
</button>
{/if}
<button
type="button"
onclick={() => do_search(false)}
class="btn btn-sm preset-tonal-surface"
title="Refresh">
<RefreshCw size={13} />
</button>
</div>
</div>
<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">
<button type="button" class="flex items-center gap-1 hover:opacity-100" onclick={() => toggle_sort('filename')}>
Filename
{#if sort_col === 'filename'}
{#if sort_dir === 'ASC'}<ChevronUp size={10} />{:else}<ChevronDown size={10} />{/if}
{:else}
<ArrowUpDown size={10} class="opacity-30" />
{/if}
</button>
</th>
<th class="px-3 py-2">
<button type="button" class="flex items-center gap-1 hover:opacity-100" onclick={() => toggle_sort('size')}>
Size
{#if sort_col === 'size'}
{#if sort_dir === 'ASC'}<ChevronUp size={10} />{:else}<ChevronDown size={10} />{/if}
{:else}
<ArrowUpDown size={10} class="opacity-30" />
{/if}
</button>
</th>
<th class="hidden px-3 py-2 lg:table-cell">
<button type="button" class="flex items-center gap-1 hover:opacity-100" onclick={() => toggle_sort('created_on')}>
Created
{#if sort_col === 'created_on'}
{#if sort_dir === 'ASC'}<ChevronUp size={10} />{:else}<ChevronDown size={10} />{/if}
{:else}
<ArrowUpDown size={10} class="opacity-30" />
{/if}
</button>
</th>
<th class="hidden px-3 py-2 lg:table-cell">
<button type="button" class="flex items-center gap-1 hover:opacity-100" onclick={() => toggle_sort('updated_on')}>
Updated
{#if sort_col === 'updated_on'}
{#if sort_dir === 'ASC'}<ChevronUp size={10} />{:else}<ChevronDown size={10} />{/if}
{:else}
<ArrowUpDown size={10} class="opacity-30" />
{/if}
</button>
</th>
<th class="hidden px-3 py-2 lg:table-cell">SHA-256</th>
<th class="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{#each 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}>
<td class="px-3 py-2">
<div class="flex items-center gap-1.5">
<File size={12} class="shrink-0 opacity-40" />
<span class="font-mono">
{(file as any).filename_w_ext ?? file.filename ?? '—'}
</span>
{#if !file.enable}
<span class="badge preset-tonal-error ml-1 text-[9px]">off</span>
{/if}
{#if file.hide}
<span class="badge preset-tonal-surface ml-1 text-[9px]">hidden</span>
{/if}
</div>
</td>
<td class="px-3 py-2 tabular-nums">{fmt_size(file.size)}</td>
<td class="hidden px-3 py-2 whitespace-nowrap opacity-50 lg:table-cell">
{fmt_date(file.created_on)}
</td>
<td class="hidden px-3 py-2 whitespace-nowrap opacity-50 lg:table-cell">
{fmt_date(file.updated_on)}
</td>
<!-- SHA-256: full hash on hover; copy button for server-side file tracing -->
<td class="hidden px-3 py-2 lg:table-cell">
{#if file.hash_sha256}
<div class="flex items-center gap-1">
<span
class="font-mono text-[10px] opacity-50"
title={file.hash_sha256}>
{file.hash_sha256.slice(0, 12)}
</span>
<button
type="button"
class="btn btn-icon btn-sm preset-tonal-surface opacity-40 hover:opacity-100 transition-opacity"
onclick={() => copy_hash(file.hash_sha256!)}
title="Copy full SHA-256 to clipboard">
<ClipboardCopy size={11} />
</button>
</div>
{:else}
<span class="opacity-30"></span>
{/if}
</td>
<td class="px-3 py-2">
<div class="flex items-center gap-1">
<button
type="button"
class="btn btn-sm text-xs transition-all
{links_map.has(file.hosted_file_id)
? 'preset-tonal-tertiary'
: 'preset-tonal-surface hover:preset-tonal-tertiary'}"
onclick={() => toggle_links(file)}
disabled={links_loading.get(file.hosted_file_id)}
title="Show/hide file links">
{#if links_loading.get(file.hosted_file_id)}
<LoaderCircle size={12} class="animate-spin" />
{:else}
<Link size={12} />
{/if}
</button>
<button
type="button"
class="btn btn-sm preset-tonal-surface hover:preset-tonal-primary text-xs transition-all"
onclick={() => handle_download(file)}
disabled={downloading_id === file.hosted_file_id}
title="Download file">
{#if downloading_id === file.hosted_file_id}
<LoaderCircle size={12} class="animate-spin" />
{:else}
<Download size={12} />
{/if}
</button>
<button
type="button"
class="btn btn-sm preset-tonal-error hover:preset-filled-error text-xs transition-all"
onclick={() => handle_delete(file)}
disabled={deleting_id === file.hosted_file_id}
title="Delete file record and physical file (if orphaned)">
{#if deleting_id === file.hosted_file_id}
<LoaderCircle size={12} class="animate-spin" />
{:else}
<Trash2 size={12} />
{/if}
</button>
</div>
</td>
</tr>
<!-- Links sub-row — shown when the link toggle is active -->
{#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">
{#if file_links.length === 0}
<span class="text-[10px] italic opacity-40">No links found — file may be an orphan.</span>
{:else}
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-[10px] font-bold uppercase tracking-wider opacity-40">Linked to:</span>
{#each file_links as lnk}
<span
class="badge preset-tonal-secondary font-mono text-[10px]"
title={lnk.link_to_id_random ?? String(lnk.link_to_id)}>
{lnk.link_to_type}
<span class="opacity-60 ml-0.5">
/ {lnk.link_to_id_random ? lnk.link_to_id_random.slice(0, 8) + '…' : lnk.link_to_id}
</span>
</span>
{/each}
</div>
{/if}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
</div>
{:else if searched && !loading}
<div class="py-12 text-center opacity-50">
<FileX size={48} class="mx-auto mb-4 opacity-30" />
<p class="text-lg font-bold">No files found</p>
<p class="text-sm">Try different filters.</p>
</div>
{:else if !searched && !loading}
<div class="py-12 text-center opacity-30">
<File size={48} class="mx-auto mb-4" />
<p class="text-sm">Use the filters above to search for files.</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,5 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async () => {
return {};
};