From c6d154a11701cf24acea84965ae7c1964f12cf3b Mon Sep 17 00:00:00 2001 From: xarmian Date: Thu, 26 Mar 2026 23:49:34 +0000 Subject: [PATCH] Add archive functionality with bulk archive and show-archived toggle Board columns and list groups now show an archive icon on hover with inline confirmation to bulk-archive all items in a group. Collection page has a "Show archived" checkbox that loads archived items via include_archived param. Restore handler wired up for restoring individual archived items. --- .../components/collections/BoardView.svelte | 86 ++++++++++++++++++- .../components/collections/ListView.svelte | 86 ++++++++++++++++++- .../[workspace]/[collection]/+page.svelte | 73 ++++++++++++++-- 3 files changed, 236 insertions(+), 9 deletions(-) diff --git a/web/src/lib/components/collections/BoardView.svelte b/web/src/lib/components/collections/BoardView.svelte index d13a1bbb..824a9db9 100644 --- a/web/src/lib/components/collections/BoardView.svelte +++ b/web/src/lib/components/collections/BoardView.svelte @@ -12,11 +12,14 @@ groupField?: string; onStatusChange: (item: Item, newStatus: string) => void; onReorder?: (updates: { slug: string; sort_order: number }[]) => void; + onArchiveColumn?: (items: Item[]) => void; itemProgress?: Record; relationLabels?: Record; } - let { items, collection, groupField = 'status', onStatusChange, onReorder, itemProgress, relationLabels }: Props = $props(); + let { items, collection, groupField = 'status', onStatusChange, onReorder, onArchiveColumn, itemProgress, relationLabels }: Props = $props(); + + let confirmArchiveColumn = $state(null); const flipDurationMs = 200; const touchDragDelayMs = 500; @@ -124,7 +127,23 @@
{formatLabel(colValue)} - {colItems.length} +
+ {colItems.length} + {#if onArchiveColumn && colItems.length > 0} + {#if confirmArchiveColumn === colValue} + + + + + {:else} + + {/if} + {/if} +
void; onReorder?: (updates: { slug: string; sort_order: number }[]) => void; + onArchiveGroup?: (items: Item[]) => void; itemProgress?: Record; relationLabels?: Record; } @@ -25,10 +26,13 @@ statusOptions, onStatusChange, onReorder, + onArchiveGroup, itemProgress, relationLabels }: Props = $props(); + let confirmArchiveGroup = $state(null); + const flipDurationMs = 200; const touchDragDelayMs = 500; @@ -169,7 +173,23 @@ >▾ {formatLabel(groupName)} - {itemCount(groupItems)} + + {itemCount(groupItems)} + {#if onArchiveGroup && itemCount(groupItems) > 0} + {#if confirmArchiveGroup === groupName} + + + + + {:else} + + {/if} + {/if} + {#if !collapsedGroups.has(groupName)} @@ -250,6 +270,70 @@ background: var(--bg-hover); } + .group-actions { + display: flex; + align-items: center; + gap: var(--space-1); + flex-shrink: 0; + } + + .group-header:hover .archive-group-btn { + opacity: 1; + } + + .archive-group-btn { + background: none; + border: none; + color: var(--text-muted); + font-size: 0.8em; + cursor: pointer; + padding: 2px 4px; + border-radius: var(--radius-sm); + opacity: 0; + transition: opacity 0.15s; + line-height: 1; + } + + .archive-group-btn:hover { + color: var(--text-primary); + background: var(--bg-hover); + } + + .archive-confirm { + display: flex; + gap: var(--space-1); + align-items: center; + } + + .archive-yes { + background: none; + border: none; + color: var(--accent-red, #ef4444); + font-size: 0.78em; + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius-sm); + white-space: nowrap; + } + + .archive-yes:hover { + background: color-mix(in srgb, var(--accent-red, #ef4444) 10%, transparent); + } + + .archive-no { + background: none; + border: none; + color: var(--text-muted); + font-size: 0.78em; + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius-sm); + } + + .archive-no:hover { + color: var(--text-primary); + } + .collapse-icon { font-size: 0.7em; transition: transform 0.15s ease; diff --git a/web/src/routes/[workspace]/[collection]/+page.svelte b/web/src/routes/[workspace]/[collection]/+page.svelte index 32b02329..50ce7cd9 100644 --- a/web/src/routes/[workspace]/[collection]/+page.svelte +++ b/web/src/routes/[workspace]/[collection]/+page.svelte @@ -19,11 +19,12 @@ let viewMode = $state('list'); let activeFilters = $state>({}); let searchQuery = $state(''); + let showArchived = $state(false); let itemProgress = $state>({}); let relationLabels = $state>({}); - let wsSlug = $derived(page.params.workspace); - let collSlug = $derived(page.params.collection); + let wsSlug = $derived(page.params.workspace ?? ''); + let collSlug = $derived(page.params.collection ?? ''); // Persist view mode to localStorage per collection function saveViewMode(mode: ViewMode) { @@ -73,7 +74,7 @@ } $effect(() => { - if (wsSlug && collSlug) loadCollection(wsSlug, collSlug); + if (wsSlug && collSlug) loadCollection(wsSlug, collSlug, showArchived); }); // Subscribe to SSE events for live updates to this collection's items @@ -120,12 +121,13 @@ unsubscribeSSE?.(); }); - async function loadCollection(ws: string, coll: string) { + async function loadCollection(ws: string, coll: string, includeArchived = false) { loading = true; try { + const listParams = includeArchived ? { include_archived: true } : undefined; const [collData, itemsData] = await Promise.all([ api.collections.get(ws, coll), - api.items.listByCollection(ws, coll) + api.items.listByCollection(ws, coll, listParams) ]); collection = collData; items = itemsData; @@ -317,12 +319,40 @@ } } + async function handleBulkArchive(itemsToArchive: Item[]) { + if (!wsSlug) return; + const count = itemsToArchive.length; + try { + for (const item of itemsToArchive) { + await api.items.delete(wsSlug, item.slug); + } + items = items.filter((i) => !itemsToArchive.some((a) => a.id === i.id)); + toastStore.show(`Archived ${count} item${count !== 1 ? 's' : ''}`, 'success'); + } catch { + toastStore.show('Failed to archive some items', 'error'); + } + } + + async function handleRestore(item: Item) { + if (!wsSlug) return; + try { + const restored = await api.items.restore(wsSlug, item.slug); + const idx = items.findIndex((i) => i.id === item.id); + if (idx !== -1) { + items[idx] = restored; + } + toastStore.show(`Restored "${item.title}"`, 'success'); + } catch { + toastStore.show('Failed to restore item', 'error'); + } + } + function formatLabel(value: string): string { return value.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } -
+
{#if loading}
Loading...
{:else if !collection} @@ -374,6 +404,11 @@ {relationLabels} /> + + @@ -403,6 +438,7 @@ {groupField} onStatusChange={handleStatusChange} onReorder={handleReorder} + onArchiveColumn={handleBulkArchive} {itemProgress} {relationLabels} /> @@ -414,6 +450,7 @@ {statusOptions} onStatusChange={handleStatusChange} onReorder={handleReorder} + onArchiveGroup={handleBulkArchive} {itemProgress} {relationLabels} /> @@ -428,6 +465,11 @@ padding: var(--space-8) var(--space-6); } + .collection-page.board-active { + max-width: none; + padding: var(--space-6) var(--space-6); + } + .loading { text-align: center; padding-top: 20vh; @@ -556,6 +598,25 @@ background: var(--bg-hover); } + .archive-toggle { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 0.82em; + color: var(--text-muted); + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + } + + .archive-toggle input { + accent-color: var(--accent-blue); + } + + .archive-toggle:hover { + color: var(--text-secondary); + } + .new-btn { background: var(--accent-blue); color: #fff;