refactor(admin): add autosave to PhotoPostForm

Added runes-based autosave functionality to PhotoPostForm following the same pattern as EssayForm:
- Added autosave store with updatedAt conflict detection
- Smart navigation guards and beforeunload warnings
- Draft recovery banner instead of inline prompt
- Only saves to localStorage on autosave failure
- Added AutoSaveStatus component

🤖 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 14:04:20 -07:00
parent c49ce5cbb5
commit 6ed1b0f1a8

View file

@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation' import { goto, beforeNavigate } from '$app/navigation'
import AdminPage from './AdminPage.svelte' import AdminPage from './AdminPage.svelte'
import Button from './Button.svelte' import Button from './Button.svelte'
import Input from './Input.svelte' import Input from './Input.svelte'
import ImageUploader from './ImageUploader.svelte' import ImageUploader from './ImageUploader.svelte'
import Editor from './Editor.svelte' import Editor from './Editor.svelte'
import { toast } from '$lib/stores/toast' import { toast } from '$lib/stores/toast'
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore' import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte'
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
import type { Media } from '@prisma/client' import type { Media } from '@prisma/client'
@ -18,6 +20,7 @@ import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/ad
featuredImage?: string featuredImage?: string
status: 'draft' | 'published' status: 'draft' | 'published'
tags?: string[] tags?: string[]
updatedAt?: string
} }
mode: 'create' | 'edit' mode: 'create' | 'edit'
} }
@ -26,7 +29,9 @@ import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/ad
// State // State
let isSaving = $state(false) let isSaving = $state(false)
let hasLoaded = $state(mode === 'create')
let status = $state<'draft' | 'published'>(initialData?.status || 'draft') let status = $state<'draft' | 'published'>(initialData?.status || 'draft')
let updatedAt = $state<string | undefined>(initialData?.updatedAt)
// Form data // Form data
let title = $state(initialData?.title || '') let title = $state(initialData?.title || '')
@ -35,14 +40,14 @@ import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/ad
let tags = $state(initialData?.tags?.join(', ') || '') let tags = $state(initialData?.tags?.join(', ') || '')
// Editor ref // Editor ref
let editorRef: any let editorRef: any
// Draft backup // Draft backup
const draftKey = $derived(makeDraftKey('post', postId ?? 'new')) const draftKey = $derived(makeDraftKey('post', postId ?? 'new'))
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 {
@ -57,14 +62,60 @@ function buildPayload() {
.split(',') .split(',')
.map((tag) => tag.trim()) .map((tag) => tag.trim())
.filter(Boolean) .filter(Boolean)
: [] : [],
updatedAt
} }
} }
$effect(() => { // Autosave store (edit mode only)
title; status; content; featuredImage; tags let autoSave = mode === 'edit' && postId
saveDraft(draftKey, buildPayload()) ? createAutoSaveStore({
}) debounceMs: 2000,
getPayload: () => (hasLoaded ? buildPayload() : null),
save: async (payload, { signal }) => {
const response = await fetch(`/api/posts/${postId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'same-origin',
signal
})
if (!response.ok) throw new Error('Failed to save')
return await response.json()
},
onSaved: (saved: any, { prime }) => {
updatedAt = saved.updatedAt
prime(buildPayload())
if (draftKey) clearDraft(draftKey)
}
})
: null
// Prime autosave on initial load (edit mode only)
$effect(() => {
if (mode === 'edit' && initialData && !hasLoaded && autoSave) {
autoSave.prime(buildPayload())
hasLoaded = true
}
})
// Trigger autosave when form data changes
$effect(() => {
title; status; content; featuredImage; tags
if (hasLoaded && autoSave) {
autoSave.schedule()
}
})
// Save draft only when autosave fails
$effect(() => {
if (hasLoaded && autoSave) {
const saveStatus = autoSave.status
if (saveStatus === 'error' || saveStatus === 'offline') {
saveDraft(draftKey, buildPayload())
}
}
})
$effect(() => { $effect(() => {
const draft = loadDraft<any>(draftKey) const draft = loadDraft<any>(draftKey)
@ -74,46 +125,103 @@ $effect(() => {
} }
}) })
function restoreDraft() { function restoreDraft() {
const draft = loadDraft<any>(draftKey) const draft = loadDraft<any>(draftKey)
if (!draft) return if (!draft) return
const p = draft.payload const p = draft.payload
title = p.title ?? title title = p.title ?? title
status = p.status ?? status status = p.status ?? status
content = p.content ?? content content = p.content ?? content
tags = Array.isArray(p.tags) ? (p.tags as string[]).join(', ') : tags tags = Array.isArray(p.tags) ? (p.tags as string[]).join(', ') : tags
if (p.featuredImage) { if (p.featuredImage) {
featuredImage = { featuredImage = {
id: -1, id: -1,
filename: 'photo.jpg', filename: 'photo.jpg',
originalName: 'photo.jpg', originalName: 'photo.jpg',
mimeType: 'image/jpeg', mimeType: 'image/jpeg',
size: 0, size: 0,
url: p.featuredImage, url: p.featuredImage,
thumbnailUrl: p.featuredImage, thumbnailUrl: p.featuredImage,
width: null, width: null,
height: null, height: null,
altText: null, altText: null,
description: null, description: null,
usedIn: [], usedIn: [],
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date() updatedAt: new Date()
} as any } as any
} }
showDraftPrompt = false showDraftPrompt = false
} clearDraft(draftKey)
}
function dismissDraft() { function dismissDraft() {
showDraftPrompt = false showDraftPrompt = false
} clearDraft(draftKey)
}
// Auto-update draft time text every minute when prompt visible // Auto-update draft time text every minute when prompt visible
$effect(() => { $effect(() => {
if (showDraftPrompt) { if (showDraftPrompt) {
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000) const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
return () => clearInterval(id) return () => clearInterval(id)
} }
}) })
// Navigation guard: flush autosave before navigating away (only if unsaved)
beforeNavigate(async (navigation) => {
if (hasLoaded && autoSave) {
if (autoSave.status === 'saved') {
return
}
navigation.cancel()
try {
await autoSave.flush()
navigation.retry()
} catch (error) {
console.error('Autosave flush failed:', error)
}
}
})
// Warn before closing browser tab/window if there are unsaved changes
$effect(() => {
if (!hasLoaded || !autoSave) return
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (autoSave!.status !== 'saved') {
event.preventDefault()
event.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
})
// Keyboard shortcut: Cmd/Ctrl+S to save immediately
$effect(() => {
if (!hasLoaded || !autoSave) return
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
autoSave!.flush().catch((error) => {
console.error('Autosave flush failed:', error)
})
}
}
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
})
// Cleanup autosave on unmount
$effect(() => {
if (autoSave) {
return () => autoSave.destroy()
}
})
// Initialize featured image if editing // Initialize featured image if editing
$effect(() => { $effect(() => {
@ -264,12 +372,8 @@ $effect(() => {
<div class="header-actions"> <div class="header-actions">
{#if !isSaving} {#if !isSaving}
{#if showDraftPrompt} {#if mode === 'edit' && autoSave}
<div class="draft-prompt"> <AutoSaveStatus status={autoSave.status} error={autoSave.lastError} />
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}
<Button variant="ghost" onclick={() => goto('/admin/posts')}>Cancel</Button> <Button variant="ghost" onclick={() => goto('/admin/posts')}>Cancel</Button>
<Button <Button
@ -290,11 +394,21 @@ $effect(() => {
</div> </div>
</header> </header>
<div class="form-container"> {#if showDraftPrompt}
{#if error} <div class="draft-banner">
<div class="error-message">{error}</div> <div class="draft-banner-content">
{/if} <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="form-container">
<div class="form-content"> <div class="form-content">
<!-- Featured Photo Upload --> <!-- Featured Photo Upload -->
<div class="form-section"> <div class="form-section">
@ -374,17 +488,103 @@ $effect(() => {
align-items: center; align-items: center;
} }
.draft-prompt { .draft-banner {
color: $gray-40; background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
font-size: 0.75rem; border-bottom: 1px solid #f59e0b;
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.15);
padding: $unit-3x $unit-4x;
animation: slideDown 0.3s ease-out;
.link { @include breakpoint('phone') {
background: none; padding: $unit-2x $unit-3x;
border: none; }
color: $gray-20; }
cursor: pointer;
margin-left: $unit; @keyframes slideDown {
padding: 0; from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.draft-banner-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: $unit-3x;
@include breakpoint('phone') {
flex-direction: column;
align-items: flex-start;
gap: $unit-2x;
}
}
.draft-banner-text {
color: #92400e;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.5;
@include breakpoint('phone') {
font-size: 0.8125rem;
}
}
.draft-banner-actions {
display: flex;
gap: $unit-2x;
flex-shrink: 0;
@include breakpoint('phone') {
width: 100%;
}
}
.draft-banner-button {
background: white;
border: 1px solid #f59e0b;
color: #92400e;
padding: $unit $unit-3x;
border-radius: $unit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
&:hover {
background: #fffbeb;
border-color: #d97706;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(245, 158, 11, 0.2);
}
&:active {
transform: translateY(0);
}
&.dismiss {
background: transparent;
border-color: #fbbf24;
color: #b45309;
&:hover {
background: rgba(255, 255, 255, 0.5);
border-color: #f59e0b;
}
}
@include breakpoint('phone') {
flex: 1;
padding: $unit-1_5x $unit-2x;
font-size: 0.8125rem;
} }
} }
@ -398,15 +598,6 @@ $effect(() => {
} }
} }
.error-message {
background-color: #fee;
color: #d33;
padding: $unit-3x;
border-radius: $unit;
margin-bottom: $unit-4x;
text-align: center;
}
.form-content { .form-content {
background: white; background: white;
border-radius: $unit-2x; border-radius: $unit-2x;