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:
parent
0334d3a831
commit
dfbf45f8a4
2 changed files with 156 additions and 32 deletions
|
|
@ -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…'
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue