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:
@@ -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">
|
||||
|
||||
624
src/routes/core/files/+page.svelte
Normal file
624
src/routes/core/files/+page.svelte
Normal 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>
|
||||
5
src/routes/core/files/+page.ts
Normal file
5
src/routes/core/files/+page.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async () => {
|
||||
return {};
|
||||
};
|
||||
Reference in New Issue
Block a user