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:
parent
c49ce5cbb5
commit
6ed1b0f1a8
1 changed files with 273 additions and 82 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue