mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-03 12:10:31 +00:00
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:
@@ -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}
|
||||
>🗃</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 @@
|
||||
>▾</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; }}
|
||||
>🗃</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;
|
||||
|
||||
Reference in New Issue
Block a user