|
124 | 124 | let saveStatusTimer: ReturnType<typeof setTimeout> | undefined; |
125 | 125 | let confirmDelete = $state(false); |
126 | 126 | let deleting = $state(false); |
| 127 | + let restoring = $state(false); |
127 | 128 | let rawMode = $state(false); |
128 | 129 | let showMoveMenu = $state(false); |
129 | 130 | let moving = $state(false); |
|
253 | 254 | // by workspaceStore.setCurrent via the /me endpoint. workspaceMembers is |
254 | 255 | // still loaded for the assignee dropdown (line ~947). |
255 | 256 | 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); |
256 | 261 | // Per-item edit predicate (PLAN-1100 / TASK-1105). Mirrors the server's |
257 | 262 | // ResolveUserPermission cascade: owner → item grant → collection grant |
258 | 263 | // → 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); |
261 | 271 | $effect(() => { |
262 | 272 | if (wsSlug && collSlug && itemSlug) { |
263 | 273 | loadData(); |
|
2066 | 2076 | } |
2067 | 2077 | } |
2068 | 2078 |
|
| 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 | +
|
2069 | 2100 | let allCollections = $derived(collectionStore.collections ?? []); |
2070 | 2101 | let moveTargets = $derived(allCollections.filter(c => c.slug !== collSlug)); |
2071 | 2102 |
|
|
2274 | 2305 | </nav> |
2275 | 2306 | </div> |
2276 | 2307 |
|
| 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 | + |
2277 | 2329 | <!-- Title --> |
2278 | 2330 | <div class="title-row"> |
2279 | 2331 | {#if formatItemRef(item)} |
|
3629 | 3681 | font-weight: 600; |
3630 | 3682 | color: var(--text-secondary); |
3631 | 3683 | } |
| 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 | + } |
3632 | 3736 | .link-row { |
3633 | 3737 | display: flex; |
3634 | 3738 | align-items: center; |
|
0 commit comments