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' import type { AutoSaveStatus } from '$lib/admin/autoSave'
interface Props { interface Props {
statusStore: { subscribe: (run: (v: AutoSaveStatus) => void) => () => void } statusStore?: { subscribe: (run: (v: AutoSaveStatus) => void) => () => void }
errorStore?: { subscribe: (run: (v: string | null) => void) => () => void } errorStore?: { subscribe: (run: (v: string | null) => void) => () => void }
status?: AutoSaveStatus
error?: string | null
compact?: boolean 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 status = $state<AutoSaveStatus>('idle')
let errorText = $state<string | null>(null) let errorText = $state<string | null>(null)
$effect(() => { $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)) const unsub = statusStore.subscribe((v) => (status = v))
let unsubErr: (() => void) | null = null let unsubErr: (() => void) | null = null
if (errorStore) unsubErr = errorStore.subscribe((v) => (errorText = v)) if (errorStore) unsubErr = errorStore.subscribe((v) => (errorText = v))
@ -21,7 +35,7 @@
} }
}) })
const label = $derived(() => { const label = $derived.by(() => {
switch (status) { switch (status) {
case 'saving': case 'saving':
return 'Saving…' return 'Saving…'

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation' import { goto, beforeNavigate } from '$app/navigation'
import { z } from 'zod' import { z } from 'zod'
import AdminPage from './AdminPage.svelte' import AdminPage from './AdminPage.svelte'
import AdminSegmentedControl from './AdminSegmentedControl.svelte' import AdminSegmentedControl from './AdminSegmentedControl.svelte'
@ -14,8 +14,7 @@
import { toast } from '$lib/stores/toast' import { toast } from '$lib/stores/toast'
import type { Project, ProjectFormData } from '$lib/types/project' import type { Project, ProjectFormData } from '$lib/types/project'
import { defaultProjectFormData } from '$lib/types/project' import { defaultProjectFormData } from '$lib/types/project'
import { beforeNavigate } from '$app/navigation' import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
import { createAutoSaveController } from '$lib/admin/autoSave'
import AutoSaveStatus from './AutoSaveStatus.svelte' import AutoSaveStatus from './AutoSaveStatus.svelte'
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore' import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
@ -28,6 +27,7 @@
// State // State
let isLoading = $state(mode === 'edit') let isLoading = $state(mode === 'edit')
let hasLoaded = $state(false)
let isSaving = $state(false) let isSaving = $state(false)
let activeTab = $state('metadata') let activeTab = $state('metadata')
let validationErrors = $state<Record<string, string>>({}) let validationErrors = $state<Record<string, string>>({})
@ -45,7 +45,7 @@
let showDraftPrompt = $state(false) let showDraftPrompt = $state(false)
let draftTimestamp = $state<number | null>(null) let draftTimestamp = $state<number | null>(null)
let timeTicker = $state(0) let timeTicker = $state(0)
const draftTimeText = $derived(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null)) const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
function buildPayload() { function buildPayload() {
return { return {
@ -75,15 +75,16 @@
// Autosave (edit mode only) // Autosave (edit mode only)
let autoSave = mode === 'edit' let autoSave = mode === 'edit'
? createAutoSaveController({ ? createAutoSaveStore({
debounceMs: 2000, debounceMs: 2000,
getPayload: () => (isLoading ? null : buildPayload()), getPayload: () => (hasLoaded ? buildPayload() : null),
save: async (payload, { signal }) => { save: async (payload, { signal }) => {
return await api.put(`/api/projects/${project?.id}`, 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 // Update baseline updatedAt on successful save
project = savedProject project = savedProject
prime(buildPayload())
if (draftKey) clearDraft(draftKey) if (draftKey) clearDraft(draftKey)
} }
}) })
@ -94,12 +95,13 @@
{ value: 'case-study', label: 'Case Study' } { 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(() => { $effect(() => {
if (project && mode === 'edit') { if (project && mode === 'edit' && !hasLoaded) {
populateFormData(project) populateFormData(project)
} else if (mode === 'create') { } else if (mode === 'create') {
isLoading = false isLoading = false
hasLoaded = true
} }
}) })
@ -147,22 +149,66 @@
caseStudyContent: p.caseStudyContent ?? formData.caseStudyContent caseStudyContent: p.caseStudyContent ?? formData.caseStudyContent
} }
showDraftPrompt = false showDraftPrompt = false
clearDraft(draftKey)
} }
function dismissDraft() { function dismissDraft() {
if (!draftKey) return
showDraftPrompt = false showDraftPrompt = false
clearDraft(draftKey)
} }
// Trigger autosave and store local draft when formData changes (edit mode) // Trigger autosave and store local draft when formData changes (edit mode)
$effect(() => { $effect(() => {
// Establish dependencies on fields // Establish dependencies on fields
formData; activeTab formData; activeTab
if (mode === 'edit' && !isLoading && autoSave) { if (mode === 'edit' && hasLoaded && autoSave) {
autoSave.schedule() autoSave.schedule()
if (draftKey) saveDraft(draftKey, buildPayload()) 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) { function populateFormData(data: Project) {
formData = { formData = {
title: data.title || '', title: data.title || '',
@ -186,6 +232,12 @@
} }
} }
isLoading = false isLoading = false
// Prime autosave with initial data to prevent immediate save
if (autoSave) {
autoSave.prime(buildPayload())
}
hasLoaded = true
} }
function validateForm() { function validateForm() {
@ -367,19 +419,26 @@
viewUrl={project?.slug ? `/work/${project.slug}` : undefined} viewUrl={project?.slug ? `/work/${project.slug}` : undefined}
/> />
{#if mode === 'edit' && autoSave} {#if mode === 'edit' && autoSave}
<AutoSaveStatus statusStore={autoSave.status} errorStore={autoSave.lastError} /> <AutoSaveStatus status={autoSave.status} error={autoSave.lastError} />
{/if} {/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} {/if}
</div> </div>
</header> </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"> <div class="admin-container">
{#if isLoading} {#if isLoading}
<div class="loading">Loading project...</div> <div class="loading">Loading project...</div>
@ -563,18 +622,69 @@
} }
} }
.draft-prompt { .draft-banner {
margin-left: $unit-2x; background: $blue-95;
color: $gray-40; border-bottom: 1px solid $blue-80;
font-size: 0.75rem; padding: $unit-2x $unit-5x;
display: flex;
justify-content: center;
align-items: center;
animation: slideDown 0.2s ease-out;
.button, .link { @keyframes slideDown {
background: none; from {
border: none; opacity: 0;
color: $gray-20; transform: translateY(-10px);
cursor: pointer; }
margin-left: $unit; to {
padding: 0; 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> </style>