refactor(admin): migrate Posts editor to runes-based autosave
- Update to use createAutoSaveStore with Svelte 5 runes - Fix $derived syntax (use $derived.by for draftTimeText) - Add hasLoaded flag to prevent autosave on initial load - Add prime() call after loading post data - Move draft recovery from inline header to prominent banner - Implement missing restoreDraft() and dismissDraft() functions - Only save draft on autosave failure (not every change) - Smart navigation guard (only blocks if unsaved) - Add beforeunload warning (only if unsaved changes) - Update AutoSaveStatus to use reactive props - Add keyboard shortcut and proper cleanup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
672eb47143
commit
32b4d16f9a
1 changed files with 208 additions and 54 deletions
|
|
@ -11,12 +11,13 @@ import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/ad
|
||||||
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
|
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
|
||||||
import Button from '$lib/components/admin/Button.svelte'
|
import Button from '$lib/components/admin/Button.svelte'
|
||||||
import StatusDropdown from '$lib/components/admin/StatusDropdown.svelte'
|
import StatusDropdown from '$lib/components/admin/StatusDropdown.svelte'
|
||||||
import { createAutoSaveController } from '$lib/admin/autoSave'
|
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
|
||||||
import AutoSaveStatus from '$lib/components/admin/AutoSaveStatus.svelte'
|
import AutoSaveStatus from '$lib/components/admin/AutoSaveStatus.svelte'
|
||||||
import type { JSONContent } from '@tiptap/core'
|
import type { JSONContent } from '@tiptap/core'
|
||||||
|
|
||||||
let post = $state<any>(null)
|
let post = $state<any>(null)
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
|
let hasLoaded = $state(false)
|
||||||
let saving = $state(false)
|
let saving = $state(false)
|
||||||
let loadError = $state('')
|
let loadError = $state('')
|
||||||
let contentReady = $state(false)
|
let contentReady = $state(false)
|
||||||
|
|
@ -38,7 +39,7 @@ const draftKey = $derived(makeDraftKey('post', $page.params.id))
|
||||||
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))
|
||||||
|
|
||||||
const postTypeConfig = {
|
const postTypeConfig = {
|
||||||
post: { icon: '💭', label: 'Post', showTitle: false, showContent: true },
|
post: { icon: '💭', label: 'Post', showTitle: false, showContent: true },
|
||||||
|
|
@ -47,11 +48,11 @@ const draftTimeText = $derived(() => (draftTimestamp ? (timeTicker, timeAgo(draf
|
||||||
|
|
||||||
let config = $derived(postTypeConfig[postType])
|
let config = $derived(postTypeConfig[postType])
|
||||||
|
|
||||||
// Autosave controller
|
// Autosave store
|
||||||
let autoSave = createAutoSaveController({
|
let autoSave = createAutoSaveStore({
|
||||||
debounceMs: 2000,
|
debounceMs: 2000,
|
||||||
getPayload: () => {
|
getPayload: () => {
|
||||||
if (!post) return null
|
if (!hasLoaded) return null
|
||||||
return {
|
return {
|
||||||
title: config?.showTitle ? title : null,
|
title: config?.showTitle ? title : null,
|
||||||
slug,
|
slug,
|
||||||
|
|
@ -67,8 +68,19 @@ const draftTimeText = $derived(() => (draftTimestamp ? (timeTicker, timeAgo(draf
|
||||||
const saved = await api.put(`/api/posts/${$page.params.id}`, payload, { signal })
|
const saved = await api.put(`/api/posts/${$page.params.id}`, payload, { signal })
|
||||||
return saved
|
return saved
|
||||||
},
|
},
|
||||||
onSaved: (saved: any) => {
|
onSaved: (saved: any, { prime }) => {
|
||||||
post = saved
|
post = saved
|
||||||
|
prime({
|
||||||
|
title: config?.showTitle ? title : null,
|
||||||
|
slug,
|
||||||
|
type: postType,
|
||||||
|
status,
|
||||||
|
content: config?.showContent ? content : null,
|
||||||
|
excerpt: postType === 'essay' ? excerpt : undefined,
|
||||||
|
tags,
|
||||||
|
updatedAt: saved.updatedAt
|
||||||
|
})
|
||||||
|
if (draftKey) clearDraft(draftKey)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -216,6 +228,19 @@ onMount(async () => {
|
||||||
|
|
||||||
// Set content ready after all data is loaded
|
// Set content ready after all data is loaded
|
||||||
contentReady = true
|
contentReady = true
|
||||||
|
|
||||||
|
// Prime autosave with initial data to prevent immediate save
|
||||||
|
autoSave.prime({
|
||||||
|
title: config?.showTitle ? title : null,
|
||||||
|
slug,
|
||||||
|
type: postType,
|
||||||
|
status,
|
||||||
|
content: config?.showContent ? content : null,
|
||||||
|
excerpt: postType === 'essay' ? excerpt : undefined,
|
||||||
|
tags,
|
||||||
|
updatedAt: post.updatedAt
|
||||||
|
})
|
||||||
|
hasLoaded = true
|
||||||
} else {
|
} else {
|
||||||
// Fallback error messaging
|
// Fallback error messaging
|
||||||
loadError = 'Post not found'
|
loadError = 'Post not found'
|
||||||
|
|
@ -285,6 +310,27 @@ onMount(async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restoreDraft() {
|
||||||
|
const draft = loadDraft<any>(draftKey)
|
||||||
|
if (!draft) return
|
||||||
|
const p = draft.payload
|
||||||
|
// Apply payload fields to form
|
||||||
|
if (p.title !== undefined) title = p.title
|
||||||
|
if (p.slug !== undefined) slug = p.slug
|
||||||
|
if (p.type !== undefined) postType = p.type
|
||||||
|
if (p.status !== undefined) status = p.status
|
||||||
|
if (p.content !== undefined) content = p.content
|
||||||
|
if (p.excerpt !== undefined) excerpt = p.excerpt
|
||||||
|
if (p.tags !== undefined) tags = p.tags
|
||||||
|
showDraftPrompt = false
|
||||||
|
clearDraft(draftKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissDraft() {
|
||||||
|
showDraftPrompt = false
|
||||||
|
clearDraft(draftKey)
|
||||||
|
}
|
||||||
|
|
||||||
function handleMetadataPopover(event: MouseEvent) {
|
function handleMetadataPopover(event: MouseEvent) {
|
||||||
const target = event.target as Node
|
const target = event.target as Node
|
||||||
// Don't close if clicking inside the metadata button or anywhere in a metadata popover
|
// Don't close if clicking inside the metadata button or anywhere in a metadata popover
|
||||||
|
|
@ -304,40 +350,90 @@ onMount(async () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Schedule autosave on changes to key fields
|
// Trigger autosave when form data changes
|
||||||
$effect(() => {
|
|
||||||
// Establish dependencies
|
|
||||||
title; slug; status; content; tags; excerpt; postType; loading
|
|
||||||
if (post && !loading) {
|
|
||||||
autoSave.schedule()
|
|
||||||
saveDraft(draftKey, {
|
|
||||||
title: config?.showTitle ? title : null,
|
|
||||||
slug,
|
|
||||||
type: postType,
|
|
||||||
status,
|
|
||||||
content: config?.showContent ? content : null,
|
|
||||||
excerpt: postType === 'essay' ? excerpt : undefined,
|
|
||||||
tags,
|
|
||||||
updatedAt: post?.updatedAt
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
|
|
||||||
e.preventDefault()
|
|
||||||
autoSave.flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
// Establish dependencies
|
||||||
|
title; slug; status; content; tags; excerpt; postType
|
||||||
|
if (hasLoaded) {
|
||||||
|
autoSave.schedule()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save draft only when autosave fails
|
||||||
|
$effect(() => {
|
||||||
|
if (hasLoaded) {
|
||||||
|
const saveStatus = autoSave.status
|
||||||
|
if (saveStatus === 'error' || saveStatus === 'offline') {
|
||||||
|
saveDraft(draftKey, {
|
||||||
|
title: config?.showTitle ? title : null,
|
||||||
|
slug,
|
||||||
|
type: postType,
|
||||||
|
status,
|
||||||
|
content: config?.showContent ? content : null,
|
||||||
|
excerpt: postType === 'essay' ? excerpt : undefined,
|
||||||
|
tags,
|
||||||
|
updatedAt: post?.updatedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigation guard: flush autosave before navigating away (only if there are unsaved changes)
|
||||||
|
beforeNavigate(async (navigation) => {
|
||||||
|
if (hasLoaded) {
|
||||||
|
// If status is 'saved', there are no unsaved changes - allow navigation
|
||||||
|
if (autoSave.status === 'saved') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, flush any pending changes before navigating
|
||||||
|
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) return
|
||||||
|
|
||||||
|
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||||
|
// Only warn if there are unsaved changes
|
||||||
|
if (autoSave.status !== 'saved') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.returnValue = '' // Required for Chrome
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Keyboard shortcut: Cmd/Ctrl+S to save immediately
|
||||||
|
$effect(() => {
|
||||||
|
if (!hasLoaded) 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)
|
document.addEventListener('keydown', handleKeydown)
|
||||||
return () => document.removeEventListener('keydown', handleKeydown)
|
return () => document.removeEventListener('keydown', handleKeydown)
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeNavigate(() => {
|
// Cleanup autosave on unmount
|
||||||
autoSave.flush()
|
$effect(() => {
|
||||||
})
|
return () => autoSave.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
// Auto-update draft time text every minute when prompt visible
|
// Auto-update draft time text every minute when prompt visible
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -418,18 +514,25 @@ $effect(() => {
|
||||||
: [{ label: 'Save as Draft', status: 'draft' }]}
|
: [{ label: 'Save as Draft', status: 'draft' }]}
|
||||||
viewUrl={slug ? `/universe/${slug}` : undefined}
|
viewUrl={slug ? `/universe/${slug}` : undefined}
|
||||||
/>
|
/>
|
||||||
<AutoSaveStatus statusStore={autoSave.status} errorStore={autoSave.lastError} />
|
<AutoSaveStatus status={autoSave.status} error={autoSave.lastError} />
|
||||||
{#if 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}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{#if 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}
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading-container">
|
<div class="loading-container">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
|
|
@ -510,18 +613,69 @@ $effect(() => {
|
||||||
gap: $unit-2x;
|
gap: $unit-2x;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
|
|
||||||
.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue