From bcaa8d062a650f28fe817fdc3c717818e9bb79ee Mon Sep 17 00:00:00 2001 From: John B Nevin Date: Sat, 25 Jul 2026 09:47:51 -0500 Subject: [PATCH] feat(ui): sortable columns, size totals, and copy-URL button in blobs table - Click any column header (hash, size, type, uploader, uploaded) to sort; click again to reverse direction. Active sort shown with arrow indicator. Sensible default direction per column. - Show total byte size alongside blob counts (and filtered totals when searching/filtering). - Add 'Copy URL' action next to 'Open' for one-click blob URL copy. --- ui/src/routes/blobs/+page.svelte | 78 ++++++++++++++++++++++++++++---- 1 file changed, 69 insertions(+), 9 deletions(-) diff --git a/ui/src/routes/blobs/+page.svelte b/ui/src/routes/blobs/+page.svelte index 8695875..88979c2 100644 --- a/ui/src/routes/blobs/+page.svelte +++ b/ui/src/routes/blobs/+page.svelte @@ -6,12 +6,16 @@ import { listBlobs, listAllBlobs, deleteBlob, type BlobDescriptor } from '$lib/api'; import { formatBytes, truncate, timeAgo, isImage } from '$lib/format'; + type SortKey = 'hash' | 'size' | 'type' | 'pubkey' | 'uploaded'; + let blobs = $state([]); let loading = $state(true); let err = $state(null); let query = $state(''); let mimeFilter = $state(''); let pendingDelete = $state(null); + let sortKey = $state('uploaded'); + let sortDir = $state<1 | -1>(-1); let sess = $state(null); session.subscribe((s) => (sess = s)); @@ -28,8 +32,44 @@ }); }); + const sorted = $derived.by(() => { + const arr = [...filtered]; + const dir = sortDir; + arr.sort((a, b) => { + switch (sortKey) { + case 'size': + return (a.size - b.size) * dir; + case 'type': + return a.type.localeCompare(b.type) * dir; + case 'pubkey': + return (a.pubkey ?? '').localeCompare(b.pubkey ?? '') * dir; + case 'uploaded': + return (a.uploaded - b.uploaded) * dir; + default: + return a.sha256.localeCompare(b.sha256) * dir; + } + }); + return arr; + }); + + const totalSize = $derived(blobs.reduce((acc, b) => acc + b.size, 0)); + const filteredSize = $derived(filtered.reduce((acc, b) => acc + b.size, 0)); const mimeOptions = $derived(Array.from(new Set(blobs.map((b) => b.type.split('/')[0]))).sort()); + function toggleSort(key: SortKey) { + if (sortKey === key) { + sortDir = sortDir === 1 ? -1 : 1; + } else { + sortKey = key; + sortDir = key === 'uploaded' || key === 'size' ? -1 : 1; + } + } + + function sortIndicator(key: SortKey): string { + if (sortKey !== key) return ''; + return sortDir === 1 ? ' ▲' : ' ▼'; + } + onMount(async () => { if (!sess) { goto(`${base}/`); @@ -70,8 +110,12 @@ {sess?.isAdmin ? 'All blobs' : 'Your blobs'}

- {filtered.length} of {blobs.length} - {blobs.length === 1 ? 'blob' : 'blobs'} + {#if filtered.length === blobs.length} + {blobs.length} + {blobs.length === 1 ? 'blob' : 'blobs'} · {formatBytes(totalSize)} + {:else} + {filtered.length} of {blobs.length} blobs · {formatBytes(filteredSize)} of {formatBytes(totalSize)} + {/if}

@@ -105,21 +149,31 @@ {:else}
- + - - - + + + {#if sess?.isAdmin} - + {/if} - + - {#each filtered as b (b.sha256)} + {#each sorted as b (b.sha256)}
PreviewHashSizeType toggleSort('hash')}> + Hash{sortIndicator('hash')} + toggleSort('size')}> + Size{sortIndicator('size')} + toggleSort('type')}> + Type{sortIndicator('type')} + Uploader toggleSort('pubkey')}> + Uploader{sortIndicator('pubkey')} + Uploaded toggleSort('uploaded')}> + Uploaded{sortIndicator('uploaded')} + Actions
{#if isImage(b.type)} @@ -167,6 +221,12 @@ > Open +