Skip to content

Commit 372d95c

Browse files
authored
feat(web): archived-item recovery on the item detail page (TASK-1829) (#735)
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).
1 parent 248f7c5 commit 372d95c

1 file changed

Lines changed: 106 additions & 2 deletions

File tree

  • web/src/routes/[username]/[workspace]/[collection]/[slug]

web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@
124124
let saveStatusTimer: ReturnType<typeof setTimeout> | undefined;
125125
let confirmDelete = $state(false);
126126
let deleting = $state(false);
127+
let restoring = $state(false);
127128
let rawMode = $state(false);
128129
let showMoveMenu = $state(false);
129130
let moving = $state(false);
@@ -253,11 +254,20 @@
253254
// by workspaceStore.setCurrent via the /me endpoint. workspaceMembers is
254255
// still loaded for the assignee dropdown (line ~947).
255256
let isOwner = $derived(workspaceStore.isOwner);
257+
// Archived ("soft-deleted") items are returned read-only by the API
258+
// (HTTP 200 with deleted_at populated) so they can be reviewed and
259+
// restored. TASK-1829.
260+
let isArchived = $derived(!!item?.deleted_at);
256261
// Per-item edit predicate (PLAN-1100 / TASK-1105). Mirrors the server's
257262
// ResolveUserPermission cascade: owner → item grant → collection grant
258263
// → role + visibility. Drives title / content / FieldEditor / delete /
259-
// status affordance gating below.
260-
let canEdit = $derived(item ? workspaceStore.canEditItem(item) : false);
264+
// status affordance gating below. Folds in the archived gate (TASK-1829)
265+
// so every edit affordance disables while the item is archived.
266+
let canEdit = $derived(item && !item.deleted_at ? workspaceStore.canEditItem(item) : false);
267+
// Restore requires edit permission server-side, independent of the
268+
// archived gate that forces canEdit false — otherwise the Restore CTA
269+
// would render for read-only viewers and just 403 on click (Codex).
270+
let canRestore = $derived(item ? workspaceStore.canEditItem(item) : false);
261271
$effect(() => {
262272
if (wsSlug && collSlug && itemSlug) {
263273
loadData();
@@ -2066,6 +2076,27 @@
20662076
}
20672077
}
20682078
2079+
// Restore a soft-deleted ("archived") item. Standalone async handler
2080+
// (not folded into a route-change $effect — CONVE-606/CONVE-1688) that
2081+
// mutates `item` directly: on success it re-fetches via api.items.get so
2082+
// the archived banner disappears and `canEdit` re-enables. The restore
2083+
// endpoint can 409 if the slug/invocation_slug was reclaimed while
2084+
// archived — surface that message the same way other handlers do. TASK-1829.
2085+
async function handleRestore() {
2086+
if (!item || restoring) return;
2087+
restoring = true;
2088+
try {
2089+
await api.items.restore(wsSlug, itemSlug);
2090+
const refreshed = await api.items.get(wsSlug, itemSlug);
2091+
item = withInflightTags(refreshed);
2092+
toastStore.show('Item restored', 'success');
2093+
} catch (e: any) {
2094+
toastStore.show(e.message ?? 'Failed to restore item', 'error');
2095+
} finally {
2096+
restoring = false;
2097+
}
2098+
}
2099+
20692100
let allCollections = $derived(collectionStore.collections ?? []);
20702101
let moveTargets = $derived(allCollections.filter(c => c.slug !== collSlug));
20712102
@@ -2274,6 +2305,27 @@
22742305
</nav>
22752306
</div>
22762307

2308+
<!-- Archived banner (TASK-1829) — read-only notice shown above the
2309+
title/content when the item is soft-deleted. Restore re-enables
2310+
editing by re-fetching the (now active) item. -->
2311+
{#if isArchived}
2312+
<div class="archived-banner" role="status">
2313+
<svg class="archived-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="21 8 21 21 3 21 3 8"></polyline><rect x="1" y="3" width="22" height="5"></rect><line x1="10" y1="12" x2="14" y2="12"></line></svg>
2314+
<div class="archived-text">
2315+
<strong>This item is archived</strong>
2316+
{#if item.deleted_at}
2317+
<span class="archived-date" title={new Date(item.deleted_at).toLocaleString()}>Archived {relativeTime(item.deleted_at)}</span>
2318+
{/if}
2319+
<span class="archived-hint">It's read-only until restored.</span>
2320+
</div>
2321+
{#if canRestore}
2322+
<button class="archived-restore-btn" onclick={handleRestore} disabled={restoring}>
2323+
{restoring ? 'Restoring…' : 'Restore'}
2324+
</button>
2325+
{/if}
2326+
</div>
2327+
{/if}
2328+
22772329
<!-- Title -->
22782330
<div class="title-row">
22792331
{#if formatItemRef(item)}
@@ -3629,6 +3681,58 @@
36293681
font-weight: 600;
36303682
color: var(--text-secondary);
36313683
}
3684+
/* Archived banner (TASK-1829) — accent-bordered callout matching the
3685+
.link-row.tone-* idiom; Restore mirrors .delete-confirm-btn.yes. */
3686+
.archived-banner {
3687+
display: flex;
3688+
align-items: center;
3689+
gap: var(--space-3);
3690+
margin-bottom: var(--space-4);
3691+
padding: var(--space-3);
3692+
background: var(--bg-secondary);
3693+
border: 1px solid var(--border);
3694+
border-left: 3px solid var(--accent-orange);
3695+
border-radius: var(--radius);
3696+
}
3697+
.archived-icon {
3698+
flex-shrink: 0;
3699+
color: var(--accent-orange);
3700+
}
3701+
.archived-text {
3702+
display: flex;
3703+
flex-wrap: wrap;
3704+
align-items: baseline;
3705+
gap: var(--space-1) var(--space-2);
3706+
font-size: 0.9em;
3707+
color: var(--text-secondary);
3708+
}
3709+
.archived-text strong {
3710+
color: var(--text-primary);
3711+
}
3712+
.archived-date,
3713+
.archived-hint {
3714+
color: var(--text-muted);
3715+
}
3716+
.archived-restore-btn {
3717+
margin-left: auto;
3718+
flex-shrink: 0;
3719+
padding: var(--space-1) var(--space-3);
3720+
border-radius: var(--radius);
3721+
font-size: 0.85em;
3722+
font-weight: 500;
3723+
cursor: pointer;
3724+
border: 1px solid var(--accent-orange);
3725+
background: var(--bg-secondary);
3726+
color: var(--accent-orange);
3727+
}
3728+
.archived-restore-btn:hover:not(:disabled) {
3729+
background: var(--accent-orange);
3730+
color: #fff;
3731+
}
3732+
.archived-restore-btn:disabled {
3733+
opacity: 0.5;
3734+
cursor: not-allowed;
3735+
}
36323736
.link-row {
36333737
display: flex;
36343738
align-items: center;

0 commit comments

Comments
 (0)