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:
@@ -370,6 +370,20 @@ These helper endpoints let the frontend request small server-side transformation
|
|||||||
- Add `?background=true` to schedule the clip asynchronously — returns `202 Accepted` immediately; poll the `hosted_file` record for completion.
|
- Add `?background=true` to schedule the clip asynchronously — returns `202 Accepted` immediately; poll the `hosted_file` record for completion.
|
||||||
- Returns 400 on synchronous failure; 202 when scheduled successfully.
|
- Returns 400 on synchronous failure; 202 when scheduled successfully.
|
||||||
|
|
||||||
|
- **Get Links**
|
||||||
|
- Method: `GET`
|
||||||
|
- Path: `/v3/action/hosted_file/{hosted_file_id}/links`
|
||||||
|
- Auth: standard V3 headers
|
||||||
|
- Returns: array of `{ link_to_type, link_to_id, link_to_id_random }` for every record in `hosted_file_link`. Empty array if no links exist (file is an orphan).
|
||||||
|
- Use this to assess what objects are using a file before deleting it.
|
||||||
|
|
||||||
|
- **Delete**
|
||||||
|
- Method: `DELETE`
|
||||||
|
- Path: `/v3/action/hosted_file/{hosted_file_id}`
|
||||||
|
- Query params: `link_to_type`, `link_to_id` (random string), `method` (`hide` | `disable` | `delete`, default `hide`), `rm_orphan` (bool, default `false`)
|
||||||
|
- Behavior: removes the specified link record, then if `rm_orphan=true` and no links remain, applies `method` to the file. Use `method=delete` to hard-delete the physical file and DB record. Without `link_to_type`/`link_to_id`, no link is removed; `rm_orphan` only fires if the file already has zero links.
|
||||||
|
- Use the `/links` endpoint first to get `link_to_id_random` — the delete endpoint resolves `link_to_id` as a random string, not an integer.
|
||||||
|
|
||||||
Frontend guidance:
|
Frontend guidance:
|
||||||
|
|
||||||
- Call these routes with the same `link_to_type` / `link_to_id` you plan to associate the resulting hosted_file with — the server resolves random IDs for you.
|
- Call these routes with the same `link_to_type` / `link_to_id` you plan to associate the resulting hosted_file with — the server resolves random IDs for you.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ae_loc, ae_sess, ae_api, slct } from '$lib/stores/ae_stores';
|
|||||||
import {
|
import {
|
||||||
Building,
|
Building,
|
||||||
Database,
|
Database,
|
||||||
|
File,
|
||||||
Globe,
|
Globe,
|
||||||
History,
|
History,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -92,6 +93,11 @@ onMount(() => {
|
|||||||
class="btn btn-sm preset-tonal-surface">
|
class="btn btn-sm preset-tonal-surface">
|
||||||
<Phone size={14} class="mr-1" /> Contacts
|
<Phone size={14} class="mr-1" /> Contacts
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="/core/files"
|
||||||
|
class="btn btn-sm preset-tonal-secondary">
|
||||||
|
<File size={14} class="mr-1" /> Files
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/core/activity_logs"
|
href="/core/activity_logs"
|
||||||
class="btn btn-sm preset-tonal-surface">
|
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