From 372d95cb2a50f29d0650eb4fb2c42e379a2b4d51 Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 15 Jun 2026 16:07:18 -0400 Subject: [PATCH] feat(web): archived-item recovery on the item detail page (TASK-1829) (#735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With GET now returning soft-deleted items read-only (deleted_at populated, shipped in #733), the detail route can show an archived item instead of a hard 404. Adds the recovery UI: - isArchived derived from item.deleted_at; folded into the existing canEdit derived so every edit affordance disables while archived. - A read-only "Archived" banner at the top of the item view (date via the file's relativeTime helper) with a Restore button -> api.items.restore, then re-fetches the item so the banner clears and editing re-enables. Success/error via the existing toastStore; a 409 reclaimed-slug conflict surfaces verbatim. - restoring in-flight flag; handleRestore is a standalone async function (not an effect) per CONVE-1688 / CONVE-606. No API/client/server change — GET is already ungated (#733) and api.items.restore already existed. Child of BUG-1791 (TASK-1827 in #733, TASK-1828 in #734). --- .../[collection]/[slug]/+page.svelte | 108 +++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte b/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte index 6cedee3a..ae47d553 100644 --- a/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte +++ b/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte @@ -124,6 +124,7 @@ let saveStatusTimer: ReturnType | undefined; let confirmDelete = $state(false); let deleting = $state(false); + let restoring = $state(false); let rawMode = $state(false); let showMoveMenu = $state(false); let moving = $state(false); @@ -253,11 +254,20 @@ // by workspaceStore.setCurrent via the /me endpoint. workspaceMembers is // still loaded for the assignee dropdown (line ~947). let isOwner = $derived(workspaceStore.isOwner); + // Archived ("soft-deleted") items are returned read-only by the API + // (HTTP 200 with deleted_at populated) so they can be reviewed and + // restored. TASK-1829. + let isArchived = $derived(!!item?.deleted_at); // Per-item edit predicate (PLAN-1100 / TASK-1105). Mirrors the server's // ResolveUserPermission cascade: owner → item grant → collection grant // → role + visibility. Drives title / content / FieldEditor / delete / - // status affordance gating below. - let canEdit = $derived(item ? workspaceStore.canEditItem(item) : false); + // status affordance gating below. Folds in the archived gate (TASK-1829) + // so every edit affordance disables while the item is archived. + let canEdit = $derived(item && !item.deleted_at ? workspaceStore.canEditItem(item) : false); + // Restore requires edit permission server-side, independent of the + // archived gate that forces canEdit false — otherwise the Restore CTA + // would render for read-only viewers and just 403 on click (Codex). + let canRestore = $derived(item ? workspaceStore.canEditItem(item) : false); $effect(() => { if (wsSlug && collSlug && itemSlug) { loadData(); @@ -2066,6 +2076,27 @@ } } + // Restore a soft-deleted ("archived") item. Standalone async handler + // (not folded into a route-change $effect — CONVE-606/CONVE-1688) that + // mutates `item` directly: on success it re-fetches via api.items.get so + // the archived banner disappears and `canEdit` re-enables. The restore + // endpoint can 409 if the slug/invocation_slug was reclaimed while + // archived — surface that message the same way other handlers do. TASK-1829. + async function handleRestore() { + if (!item || restoring) return; + restoring = true; + try { + await api.items.restore(wsSlug, itemSlug); + const refreshed = await api.items.get(wsSlug, itemSlug); + item = withInflightTags(refreshed); + toastStore.show('Item restored', 'success'); + } catch (e: any) { + toastStore.show(e.message ?? 'Failed to restore item', 'error'); + } finally { + restoring = false; + } + } + let allCollections = $derived(collectionStore.collections ?? []); let moveTargets = $derived(allCollections.filter(c => c.slug !== collSlug)); @@ -2274,6 +2305,27 @@ + + {#if isArchived} +
+ +
+ This item is archived + {#if item.deleted_at} + Archived {relativeTime(item.deleted_at)} + {/if} + It's read-only until restored. +
+ {#if canRestore} + + {/if} +
+ {/if} +
{#if formatItemRef(item)} @@ -3629,6 +3681,58 @@ font-weight: 600; color: var(--text-secondary); } + /* Archived banner (TASK-1829) — accent-bordered callout matching the + .link-row.tone-* idiom; Restore mirrors .delete-confirm-btn.yes. */ + .archived-banner { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-4); + padding: var(--space-3); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-left: 3px solid var(--accent-orange); + border-radius: var(--radius); + } + .archived-icon { + flex-shrink: 0; + color: var(--accent-orange); + } + .archived-text { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-1) var(--space-2); + font-size: 0.9em; + color: var(--text-secondary); + } + .archived-text strong { + color: var(--text-primary); + } + .archived-date, + .archived-hint { + color: var(--text-muted); + } + .archived-restore-btn { + margin-left: auto; + flex-shrink: 0; + padding: var(--space-1) var(--space-3); + border-radius: var(--radius); + font-size: 0.85em; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--accent-orange); + background: var(--bg-secondary); + color: var(--accent-orange); + } + .archived-restore-btn:hover:not(:disabled) { + background: var(--accent-orange); + color: #fff; + } + .archived-restore-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } .link-row { display: flex; align-items: center;