refactor(admin): migrate ProjectForm to runes-based autosave

- Update ProjectForm to use new createAutoSaveStore with Svelte 5 runes
- Fix $derived syntax in AutoSaveStatus (use $derived.by for multi-statement)
- Add hasLoaded flag to prevent infinite loop on autosave completion
- Move draft recovery from inline header to prominent banner below header
- Style draft banner with blue info colors and slide-down animation
- Fix draft persistence by clearing localStorage on restore/dismiss
- Call beforeNavigate at top level for proper Svelte 5 lifecycle
- Add keyboard shortcut (Cmd/Ctrl+S) and navigation guard effects
- Update AutoSaveStatus to support both old stores and new reactive props

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-10-07 11:55:51 -07:00
parent 0334d3a831
commit dfbf45f8a4
2 changed files with 156 additions and 32 deletions

View file

@ -2,16 +2,30 @@
import type { AutoSaveStatus } from '$lib/admin/autoSave'
interface Props {
statusStore: { subscribe: (run: (v: AutoSaveStatus) => void) => () => void }
statusStore?: { subscribe: (run: (v: AutoSaveStatus) => void) => () => void }
errorStore?: { subscribe: (run: (v: string | null) => void) => () => void }
status?: AutoSaveStatus
error?: string | null
compact?: boolean
}
let { statusStore, errorStore, compact = true }: Props = $props()
let { statusStore, errorStore, status: statusProp, error: errorProp, compact = true }: Props = $props()
// Support both old subscription-based stores and new reactive values
let status = $state<AutoSaveStatus>('idle')
let errorText = $state<string | null>(null)
$effect(() => {
// If using direct props (new runes-based store)
if (statusProp !== undefined) {
status = statusProp
errorText = errorProp ?? null
return
}
// Otherwise use subscriptions (old store)
if (!statusStore) return
const unsub = statusStore.subscribe((v) => (status = v))
let unsubErr: (() => void) | null = null
if (errorStore) unsubErr = errorStore.subscribe((v) => (errorText = v))
@ -21,7 +35,7 @@
}
})
const label = $derived(() => {
const label = $derived.by(() => {
switch (status) {
case 'saving':
return 'Saving…'

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { goto, beforeNavigate } from '$app/navigation'
import { z } from 'zod'
import AdminPage from './AdminPage.svelte'
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
@ -14,8 +14,7 @@
import { toast } from '$lib/stores/toast'
import type { Project, ProjectFormData } from '$lib/types/project'
import { defaultProjectFormData } from '$lib/types/project'
import { beforeNavigate } from '$app/navigation'
import { createAutoSaveController } from '$lib/admin/autoSave'
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte'
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
@ -28,6 +27,7 @@
// State
let isLoading = $state(mode === 'edit')
let hasLoaded = $state(false)
let isSaving = $state(false)
let activeTab = $state('metadata')
let validationErrors = $state<Record<string, string>>({})
@ -45,7 +45,7 @@
let showDraftPrompt = $state(false)
let draftTimestamp = $state<number | null>(null)
let timeTicker = $state(0)
const draftTimeText = $derived(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
function buildPayload() {
return {
@ -75,15 +75,16 @@
// Autosave (edit mode only)
let autoSave = mode === 'edit'
? createAutoSaveController({
? createAutoSaveStore({
debounceMs: 2000,
getPayload: () => (isLoading ? null : buildPayload()),
getPayload: () => (hasLoaded ? buildPayload() : null),
save: async (payload, { signal }) => {
return await api.put(`/api/projects/${project?.id}`, payload, { signal })
},
onSaved: (savedProject: any) => {
onSaved: (savedProject: any, { prime }) => {
// Update baseline updatedAt on successful save
project = savedProject
prime(buildPayload())
if (draftKey) clearDraft(draftKey)
}
})
@ -94,12 +95,13 @@
{ value: 'case-study', label: 'Case Study' }
]
// Watch for project changes and populate form data
// Watch for project changes and populate form data (only on initial load)
$effect(() => {
if (project && mode === 'edit') {
if (project && mode === 'edit' && !hasLoaded) {
populateFormData(project)
} else if (mode === 'create') {
isLoading = false
hasLoaded = true
}
})
@ -147,22 +149,66 @@
caseStudyContent: p.caseStudyContent ?? formData.caseStudyContent
}
showDraftPrompt = false
clearDraft(draftKey)
}
function dismissDraft() {
if (!draftKey) return
showDraftPrompt = false
clearDraft(draftKey)
}
// Trigger autosave and store local draft when formData changes (edit mode)
$effect(() => {
// Establish dependencies on fields
formData; activeTab
if (mode === 'edit' && !isLoading && autoSave) {
if (mode === 'edit' && hasLoaded && autoSave) {
autoSave.schedule()
if (draftKey) saveDraft(draftKey, buildPayload())
}
})
// Navigation guard: flush autosave before navigating away
beforeNavigate(async (navigation) => {
if (mode === 'edit' && hasLoaded && autoSave) {
navigation.cancel()
try {
await autoSave.flush()
navigation.retry()
} catch (error) {
console.error('Autosave flush failed:', error)
toast.error('Failed to save changes')
}
}
})
// Keyboard shortcut: Cmd/Ctrl+S to save immediately
$effect(() => {
if (mode !== 'edit' || !autoSave) return
function handleKeydown(event: KeyboardEvent) {
if (!hasLoaded) return
const key = event.key.toLowerCase()
const isModifier = event.metaKey || event.ctrlKey
if (!isModifier || key !== 's') return
event.preventDefault()
autoSave!.flush().catch((error) => {
console.error('Autosave flush failed:', error)
toast.error('Failed to save changes')
})
}
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
})
// Cleanup autosave on unmount
$effect(() => {
if (autoSave) {
return () => autoSave.destroy()
}
})
function populateFormData(data: Project) {
formData = {
title: data.title || '',
@ -186,6 +232,12 @@
}
}
isLoading = false
// Prime autosave with initial data to prevent immediate save
if (autoSave) {
autoSave.prime(buildPayload())
}
hasLoaded = true
}
function validateForm() {
@ -367,19 +419,26 @@
viewUrl={project?.slug ? `/work/${project.slug}` : undefined}
/>
{#if mode === 'edit' && autoSave}
<AutoSaveStatus statusStore={autoSave.status} errorStore={autoSave.lastError} />
<AutoSaveStatus status={autoSave.status} error={autoSave.lastError} />
{/if}
{#if mode === 'edit' && showDraftPrompt}
<div class="draft-prompt">
Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}.
<button class="link" onclick={restoreDraft}>Restore</button>
<button class="link" onclick={dismissDraft}>Dismiss</button>
</div>
{/if}
{/if}
</div>
</header>
{#if mode === 'edit' && showDraftPrompt}
<div class="draft-banner">
<div class="draft-banner-content">
<span class="draft-banner-text">
Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}.
</span>
<div class="draft-banner-actions">
<button class="draft-banner-button" onclick={restoreDraft}>Restore</button>
<button class="draft-banner-button dismiss" onclick={dismissDraft}>Dismiss</button>
</div>
</div>
</div>
{/if}
<div class="admin-container">
{#if isLoading}
<div class="loading">Loading project...</div>
@ -563,18 +622,69 @@
}
}
.draft-prompt {
margin-left: $unit-2x;
color: $gray-40;
font-size: 0.75rem;
.draft-banner {
background: $blue-95;
border-bottom: 1px solid $blue-80;
padding: $unit-2x $unit-5x;
display: flex;
justify-content: center;
align-items: center;
animation: slideDown 0.2s ease-out;
.button, .link {
background: none;
border: none;
color: $gray-20;
cursor: pointer;
margin-left: $unit;
padding: 0;
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
.draft-banner-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: $unit-3x;
width: 100%;
max-width: 1200px;
}
.draft-banner-text {
color: $blue-20;
font-size: $font-size-small;
font-weight: $font-weight-med;
}
.draft-banner-actions {
display: flex;
gap: $unit-2x;
}
.draft-banner-button {
background: $blue-50;
border: none;
color: $white;
cursor: pointer;
padding: $unit-half $unit-2x;
border-radius: $corner-radius-sm;
font-size: $font-size-small;
font-weight: $font-weight-med;
transition: background $transition-fast;
&:hover {
background: $blue-40;
}
&.dismiss {
background: transparent;
color: $blue-30;
&:hover {
background: $blue-90;
}
}
}
</style>