feat(admin): integrate autosave and local draft prompt into ProjectForm and Post Edit; add Cmd/Ctrl+S and beforeNavigate flush

This commit is contained in:
Justin Edmund 2025-08-31 11:03:27 -07:00
parent 1a5ecf9ecf
commit c98ba3dcf0
2 changed files with 303 additions and 79 deletions

View file

@ -14,6 +14,10 @@
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 { createAutoSaveController } from '$lib/admin/autoSave'
import AutoSaveStatus from './AutoSaveStatus.svelte'
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
interface Props { interface Props {
project?: Project | null project?: Project | null
@ -36,6 +40,55 @@
// Ref to the editor component // Ref to the editor component
let editorRef: any let editorRef: any
// Local draft recovery
const draftKey = $derived(mode === 'edit' && project ? makeDraftKey('project', project.id) : null)
let showDraftPrompt = $state(false)
let draftTimestamp = $state<number | null>(null)
let timeTicker = $state(0)
const draftTimeText = $derived(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
function buildPayload() {
return {
title: formData.title,
subtitle: formData.subtitle,
description: formData.description,
year: formData.year,
client: formData.client,
role: formData.role,
projectType: formData.projectType,
externalUrl: formData.externalUrl,
featuredImage: formData.featuredImage && formData.featuredImage !== '' ? formData.featuredImage : null,
logoUrl: formData.logoUrl && formData.logoUrl !== '' ? formData.logoUrl : null,
backgroundColor: formData.backgroundColor,
highlightColor: formData.highlightColor,
status: formData.status,
password: formData.status === 'password-protected' ? formData.password : null,
caseStudyContent:
formData.caseStudyContent &&
formData.caseStudyContent.content &&
formData.caseStudyContent.content.length > 0
? formData.caseStudyContent
: null,
updatedAt: project?.updatedAt
}
}
// Autosave (edit mode only)
let autoSave = mode === 'edit'
? createAutoSaveController({
debounceMs: 2000,
getPayload: () => (isLoading ? null : buildPayload()),
save: async (payload, { signal }) => {
return await api.put(`/api/projects/${project?.id}`, payload, { signal })
},
onSaved: (savedProject: any) => {
// Update baseline updatedAt on successful save
project = savedProject
if (draftKey) clearDraft(draftKey)
}
})
: null
const tabOptions = [ const tabOptions = [
{ value: 'metadata', label: 'Metadata' }, { value: 'metadata', label: 'Metadata' },
{ value: 'case-study', label: 'Case Study' } { value: 'case-study', label: 'Case Study' }
@ -50,6 +103,66 @@
} }
}) })
// Check for local draft to restore
$effect(() => {
if (mode === 'edit' && project && draftKey) {
const draft = loadDraft<any>(draftKey)
if (draft) {
// Show prompt; restoration is manual to avoid overwriting loaded data unintentionally
showDraftPrompt = true
draftTimestamp = draft.ts
}
}
})
// Auto-update draft time text every minute when prompt visible
$effect(() => {
if (showDraftPrompt) {
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
return () => clearInterval(id)
}
})
function restoreDraft() {
if (!draftKey) return
const draft = loadDraft<any>(draftKey)
if (!draft) return
const p = draft.payload
// Apply payload fields to formData
formData = {
title: p.title ?? formData.title,
subtitle: p.subtitle ?? formData.subtitle,
description: p.description ?? formData.description,
year: p.year ?? formData.year,
client: p.client ?? formData.client,
role: p.role ?? formData.role,
projectType: p.projectType ?? formData.projectType,
externalUrl: p.externalUrl ?? formData.externalUrl,
featuredImage: p.featuredImage ?? formData.featuredImage,
logoUrl: p.logoUrl ?? formData.logoUrl,
backgroundColor: p.backgroundColor ?? formData.backgroundColor,
highlightColor: p.highlightColor ?? formData.highlightColor,
status: p.status ?? formData.status,
password: p.password ?? formData.password,
caseStudyContent: p.caseStudyContent ?? formData.caseStudyContent
}
showDraftPrompt = false
}
function dismissDraft() {
showDraftPrompt = false
}
// Trigger autosave and store local draft when formData changes (edit mode)
$effect(() => {
// Establish dependencies on fields
formData; activeTab
if (mode === 'edit' && !isLoading && autoSave) {
autoSave.schedule()
if (draftKey) saveDraft(draftKey, buildPayload())
}
})
function populateFormData(data: Project) { function populateFormData(data: Project) {
formData = { formData = {
title: data.title || '', title: data.title || '',
@ -108,6 +221,8 @@
formData.caseStudyContent = content formData.caseStudyContent = content
} }
import { api } from '$lib/admin/api'
async function handleSave() { async function handleSave() {
// Check if we're on the case study tab and should save editor content // Check if we're on the case study tab and should save editor content
if (activeTab === 'case-study' && editorRef) { if (activeTab === 'case-study' && editorRef) {
@ -155,35 +270,33 @@
formData.caseStudyContent.content.length > 0 formData.caseStudyContent.content.length > 0
? formData.caseStudyContent ? formData.caseStudyContent
: null : null
,
// Include updatedAt for concurrency control in edit mode
updatedAt: mode === 'edit' ? project?.updatedAt : undefined
} }
const url = mode === 'edit' ? `/api/projects/${project?.id}` : '/api/projects' let savedProject
const method = mode === 'edit' ? 'PUT' : 'POST' if (mode === 'edit') {
savedProject = await api.put(`/api/projects/${project?.id}`, payload)
const response = await fetch(url, { } else {
method, savedProject = await api.post('/api/projects', payload)
headers: {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
if (!response.ok) {
throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} project`)
} }
const savedProject = await response.json()
toast.dismiss(loadingToastId) toast.dismiss(loadingToastId)
toast.success(`Project ${mode === 'edit' ? 'saved' : 'created'} successfully!`) toast.success(`Project ${mode === 'edit' ? 'saved' : 'created'} successfully!`)
if (mode === 'create') { if (mode === 'create') {
goto(`/admin/projects/${savedProject.id}/edit`) goto(`/admin/projects/${savedProject.id}/edit`)
} else {
project = savedProject
} }
} catch (err) { } catch (err) {
toast.dismiss(loadingToastId) toast.dismiss(loadingToastId)
toast.error(`Failed to ${mode === 'edit' ? 'save' : 'create'} project`) if ((err as any)?.status === 409) {
toast.error('This project has changed in another tab. Please reload.')
} else {
toast.error(`Failed to ${mode === 'edit' ? 'save' : 'create'} project`)
}
console.error(err) console.error(err)
} finally { } finally {
isSaving = false isSaving = false
@ -194,6 +307,26 @@
formData.status = newStatus as any formData.status = newStatus as any
await handleSave() await handleSave()
} }
// Keyboard shortcut: Cmd/Ctrl+S flushes autosave
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
if (mode === 'edit' && autoSave) autoSave.flush()
}
}
$effect(() => {
if (mode === 'edit') {
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
}
})
// Flush before navigating away
beforeNavigate(() => {
if (mode === 'edit' && autoSave) autoSave.flush()
})
</script> </script>
<AdminPage> <AdminPage>
@ -239,6 +372,16 @@
]} ]}
viewUrl={project?.slug ? `/work/${project.slug}` : undefined} viewUrl={project?.slug ? `/work/${project.slug}` : undefined}
/> />
{#if mode === 'edit' && autoSave}
<AutoSaveStatus statusStore={autoSave.status} errorStore={autoSave.lastError} />
{/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>
@ -425,4 +568,19 @@
min-height: 600px; min-height: 600px;
} }
} }
.draft-prompt {
margin-left: $unit-2x;
color: $gray-40;
font-size: 0.75rem;
.button, .link {
background: none;
border: none;
color: $gray-20;
cursor: pointer;
margin-left: $unit;
padding: 0;
}
}
</style> </style>

View file

@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores' import { page } from '$app/stores'
import { goto } from '$app/navigation' import { goto, beforeNavigate } from '$app/navigation'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { api } from '$lib/admin/api'
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
import AdminPage from '$lib/components/admin/AdminPage.svelte' import AdminPage from '$lib/components/admin/AdminPage.svelte'
import Composer from '$lib/components/admin/composer' import Composer from '$lib/components/admin/composer'
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte' import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
@ -9,6 +11,8 @@
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 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)
@ -27,7 +31,14 @@
let tagInput = $state('') let tagInput = $state('')
let showMetadata = $state(false) let showMetadata = $state(false)
let metadataButtonRef: HTMLButtonElement let metadataButtonRef: HTMLButtonElement
let showDeleteConfirmation = $state(false) let showDeleteConfirmation = $state(false)
// Draft backup
const draftKey = $derived(makeDraftKey('post', $page.params.id))
let showDraftPrompt = $state(false)
let draftTimestamp = $state<number | null>(null)
let timeTicker = $state(0)
const draftTimeText = $derived(() => (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 },
@ -36,6 +47,31 @@
let config = $derived(postTypeConfig[postType]) let config = $derived(postTypeConfig[postType])
// Autosave controller
let autoSave = createAutoSaveController({
debounceMs: 2000,
getPayload: () => {
if (!post) return null
return {
title: config?.showTitle ? title : null,
slug,
type: postType,
status,
content: config?.showContent ? content : null,
excerpt: postType === 'essay' ? excerpt : undefined,
tags,
updatedAt: post?.updatedAt
}
},
save: async (payload, { signal }) => {
const saved = await api.put(`/api/posts/${$page.params.id}`, payload, { signal })
return saved
},
onSaved: (saved: any) => {
post = saved
}
})
// Convert blocks format (from database) to Tiptap format // Convert blocks format (from database) to Tiptap format
function convertBlocksToTiptap(blocksContent: any): JSONContent { function convertBlocksToTiptap(blocksContent: any): JSONContent {
if (!blocksContent || !blocksContent.blocks) { if (!blocksContent || !blocksContent.blocks) {
@ -135,11 +171,16 @@
} }
} }
onMount(async () => { onMount(async () => {
// Wait a tick to ensure page params are loaded // Wait a tick to ensure page params are loaded
await new Promise((resolve) => setTimeout(resolve, 0)) await new Promise((resolve) => setTimeout(resolve, 0))
await loadPost() await loadPost()
}) const draft = loadDraft<any>(draftKey)
if (draft) {
showDraftPrompt = true
draftTimestamp = draft.ts
}
})
async function loadPost() { async function loadPost() {
const postId = $page.params.id const postId = $page.params.id
@ -150,20 +191,10 @@
return return
} }
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
try { try {
const response = await fetch(`/api/posts/${postId}`, { const data = await api.get(`/api/posts/${postId}`)
headers: { Authorization: `Basic ${auth}` } if (data) {
}) post = data
if (response.ok) {
post = await response.json()
// Populate form fields // Populate form fields
title = post.title || '' title = post.title || ''
@ -186,14 +217,8 @@
// Set content ready after all data is loaded // Set content ready after all data is loaded
contentReady = true contentReady = true
} else { } else {
if (response.status === 404) { // Fallback error messaging
loadError = 'Post not found' loadError = 'Post not found'
} else if (response.status === 401) {
goto('/admin/login')
return
} else {
loadError = `Failed to load post: ${response.status} ${response.statusText}`
}
} }
} catch (error) { } catch (error) {
loadError = 'Network error occurred while loading post' loadError = 'Network error occurred while loading post'
@ -214,12 +239,6 @@
} }
async function handleSave(newStatus?: string) { async function handleSave(newStatus?: string) {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
saving = true saving = true
// Save content in native Tiptap format to preserve all formatting // Save content in native Tiptap format to preserve all formatting
@ -236,20 +255,13 @@
} }
try { try {
const response = await fetch(`/api/posts/${$page.params.id}`, { const saved = await api.put(`/api/posts/${$page.params.id}`, {
method: 'PUT', ...postData,
headers: { updatedAt: post?.updatedAt
'Content-Type': 'application/json',
Authorization: `Basic ${auth}`
},
body: JSON.stringify(postData)
}) })
if (saved) {
if (response.ok) { post = saved
post = await response.json() if (newStatus) status = newStatus
if (newStatus) {
status = newStatus
}
} }
} catch (error) { } catch (error) {
console.error('Failed to save post:', error) console.error('Failed to save post:', error)
@ -264,22 +276,10 @@
} }
async function handleDelete() { async function handleDelete() {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
try { try {
const response = await fetch(`/api/posts/${$page.params.id}`, { await api.delete(`/api/posts/${$page.params.id}`)
method: 'DELETE', showDeleteConfirmation = false
headers: { Authorization: `Basic ${auth}` } goto('/admin/posts')
})
if (response.ok) {
showDeleteConfirmation = false
goto('/admin/posts')
}
} catch (error) { } catch (error) {
console.error('Failed to delete post:', error) console.error('Failed to delete post:', error)
} }
@ -303,6 +303,49 @@
return () => document.removeEventListener('click', handleMetadataPopover) return () => document.removeEventListener('click', handleMetadataPopover)
} }
}) })
// Schedule autosave on changes to key fields
$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(() => {
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
})
beforeNavigate(() => {
autoSave.flush()
})
// Auto-update draft time text every minute when prompt visible
$effect(() => {
if (showDraftPrompt) {
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
return () => clearInterval(id)
}
})
</script> </script>
<svelte:head> <svelte:head>
@ -375,6 +418,14 @@
: [{ 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} />
{#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>
@ -459,6 +510,21 @@
gap: $unit-2x; gap: $unit-2x;
} }
.draft-prompt {
margin-left: $unit-2x;
color: $gray-40;
font-size: 0.75rem;
.link {
background: none;
border: none;
color: $gray-20;
cursor: pointer;
margin-left: $unit;
padding: 0;
}
}
.btn-icon { .btn-icon {
width: 40px; width: 40px;
height: 40px; height: 40px;