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.
This commit is contained in:
xarmian
2026-03-26 23:49:34 +00:00
parent 49a35ef1c0
commit c6d154a117
3 changed files with 236 additions and 9 deletions
@@ -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<string, { total: number; done: number }>;
relationLabels?: Record<string, string>;
}
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<string | null>(null);
const flipDurationMs = 200;
const touchDragDelayMs = 500;
@@ -124,7 +127,23 @@
<div class="kanban-column" role="group" aria-label="{formatLabel(colValue)} column">
<div class="column-header {columnCssClass(colValue)}">
<span class="column-name">{formatLabel(colValue)}</span>
<span class="column-count">{colItems.length}</span>
<div class="column-actions">
<span class="column-count">{colItems.length}</span>
{#if onArchiveColumn && colItems.length > 0}
{#if confirmArchiveColumn === colValue}
<span class="archive-confirm">
<button class="archive-yes" onclick={() => { onArchiveColumn(colItems); confirmArchiveColumn = null; }}>Archive {colItems.length}?</button>
<button class="archive-no" onclick={() => confirmArchiveColumn = null}>Cancel</button>
</span>
{:else}
<button
class="archive-col-btn"
title="Archive all {formatLabel(colValue).toLowerCase()} items"
onclick={() => confirmArchiveColumn = colValue}
>&#128451;</button>
{/if}
{/if}
</div>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
@@ -185,6 +204,12 @@
font-size: 0.9em;
}
.column-actions {
display: flex;
align-items: center;
gap: var(--space-1);
}
.column-header.col-in-progress {
border-bottom-color: var(--accent-amber);
}
@@ -210,6 +235,63 @@
border-radius: 10px;
}
.archive-col-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;
}
.column-header:hover .archive-col-btn {
opacity: 1;
}
.archive-col-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.75em;
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.75em;
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius-sm);
}
.archive-no:hover {
color: var(--text-primary);
}
.column-cards {
display: flex;
flex-direction: column;
@@ -14,6 +14,7 @@
statusOptions?: string[];
onStatusChange?: (item: Item, newStatus: string) => void;
onReorder?: (updates: { slug: string; sort_order: number }[]) => void;
onArchiveGroup?: (items: Item[]) => void;
itemProgress?: Record<string, { total: number; done: number }>;
relationLabels?: Record<string, string>;
}
@@ -25,10 +26,13 @@
statusOptions,
onStatusChange,
onReorder,
onArchiveGroup,
itemProgress,
relationLabels
}: Props = $props();
let confirmArchiveGroup = $state<string | null>(null);
const flipDurationMs = 200;
const touchDragDelayMs = 500;
@@ -169,7 +173,23 @@
>&#9662;</span
>
<span class="group-title">{formatLabel(groupName)}</span>
<span class="group-count">{itemCount(groupItems)}</span>
<span class="group-actions">
<span class="group-count">{itemCount(groupItems)}</span>
{#if onArchiveGroup && itemCount(groupItems) > 0}
{#if confirmArchiveGroup === groupName}
<span class="archive-confirm">
<button class="archive-yes" onclick={(e) => { e.stopPropagation(); onArchiveGroup(groupItems); confirmArchiveGroup = null; }}>Archive {itemCount(groupItems)}?</button>
<button class="archive-no" onclick={(e) => { e.stopPropagation(); confirmArchiveGroup = null; }}>Cancel</button>
</span>
{:else}
<button
class="archive-group-btn"
title="Archive all {formatLabel(groupName).toLowerCase()} items"
onclick={(e) => { e.stopPropagation(); confirmArchiveGroup = groupName; }}
>&#128451;</button>
{/if}
{/if}
</span>
</button>
{#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;
@@ -19,11 +19,12 @@
let viewMode = $state<ViewMode>('list');
let activeFilters = $state<Record<string, string>>({});
let searchQuery = $state('');
let showArchived = $state(false);
let itemProgress = $state<Record<string, { total: number; done: number }>>({});
let relationLabels = $state<Record<string, string>>({});
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());
}
</script>
<div class="collection-page">
<div class="collection-page" class:board-active={viewMode === 'board'}>
{#if loading}
<div class="loading">Loading...</div>
{:else if !collection}
@@ -374,6 +404,11 @@
{relationLabels}
/>
<label class="archive-toggle">
<input type="checkbox" bind:checked={showArchived} />
<span>Show archived</span>
</label>
<button class="new-btn" onclick={createNewItem} disabled={creatingNew}>
+ New {singularName()}
</button>
@@ -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;