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 type { Project, ProjectFormData } 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 {
project?: Project | null
@ -36,6 +40,55 @@
// Ref to the editor component
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 = [
{ value: 'metadata', label: 'Metadata' },
{ 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) {
formData = {
title: data.title || '',
@ -108,6 +221,8 @@
formData.caseStudyContent = content
}
import { api } from '$lib/admin/api'
async function handleSave() {
// Check if we're on the case study tab and should save editor content
if (activeTab === 'case-study' && editorRef) {
@ -155,35 +270,33 @@
formData.caseStudyContent.content.length > 0
? formData.caseStudyContent
: 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'
const method = mode === 'edit' ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
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`)
let savedProject
if (mode === 'edit') {
savedProject = await api.put(`/api/projects/${project?.id}`, payload)
} else {
savedProject = await api.post('/api/projects', payload)
}
const savedProject = await response.json()
toast.dismiss(loadingToastId)
toast.success(`Project ${mode === 'edit' ? 'saved' : 'created'} successfully!`)
if (mode === 'create') {
goto(`/admin/projects/${savedProject.id}/edit`)
} else {
project = savedProject
}
} catch (err) {
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)
} finally {
isSaving = false
@ -194,6 +307,26 @@
formData.status = newStatus as any
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>
<AdminPage>
@ -239,6 +372,16 @@
]}
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}
</div>
</header>
@ -425,4 +568,19 @@
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>

View file

@ -1,7 +1,9 @@
<script lang="ts">
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import { onMount } from 'svelte'
import { goto, beforeNavigate } from '$app/navigation'
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 Composer from '$lib/components/admin/composer'
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
@ -9,6 +11,8 @@
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
import Button from '$lib/components/admin/Button.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'
let post = $state<any>(null)
@ -27,7 +31,14 @@
let tagInput = $state('')
let showMetadata = $state(false)
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 = {
post: { icon: '💭', label: 'Post', showTitle: false, showContent: true },
@ -36,6 +47,31 @@
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
function convertBlocksToTiptap(blocksContent: any): JSONContent {
if (!blocksContent || !blocksContent.blocks) {
@ -135,11 +171,16 @@
}
}
onMount(async () => {
// Wait a tick to ensure page params are loaded
await new Promise((resolve) => setTimeout(resolve, 0))
await loadPost()
})
onMount(async () => {
// Wait a tick to ensure page params are loaded
await new Promise((resolve) => setTimeout(resolve, 0))
await loadPost()
const draft = loadDraft<any>(draftKey)
if (draft) {
showDraftPrompt = true
draftTimestamp = draft.ts
}
})
async function loadPost() {
const postId = $page.params.id
@ -150,20 +191,10 @@
return
}
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
try {
const response = await fetch(`/api/posts/${postId}`, {
headers: { Authorization: `Basic ${auth}` }
})
if (response.ok) {
post = await response.json()
const data = await api.get(`/api/posts/${postId}`)
if (data) {
post = data
// Populate form fields
title = post.title || ''
@ -186,14 +217,8 @@
// Set content ready after all data is loaded
contentReady = true
} else {
if (response.status === 404) {
// Fallback error messaging
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) {
loadError = 'Network error occurred while loading post'
@ -214,12 +239,6 @@
}
async function handleSave(newStatus?: string) {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
saving = true
// Save content in native Tiptap format to preserve all formatting
@ -236,20 +255,13 @@
}
try {
const response = await fetch(`/api/posts/${$page.params.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${auth}`
},
body: JSON.stringify(postData)
const saved = await api.put(`/api/posts/${$page.params.id}`, {
...postData,
updatedAt: post?.updatedAt
})
if (response.ok) {
post = await response.json()
if (newStatus) {
status = newStatus
}
if (saved) {
post = saved
if (newStatus) status = newStatus
}
} catch (error) {
console.error('Failed to save post:', error)
@ -264,22 +276,10 @@
}
async function handleDelete() {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
try {
const response = await fetch(`/api/posts/${$page.params.id}`, {
method: 'DELETE',
headers: { Authorization: `Basic ${auth}` }
})
if (response.ok) {
showDeleteConfirmation = false
goto('/admin/posts')
}
await api.delete(`/api/posts/${$page.params.id}`)
showDeleteConfirmation = false
goto('/admin/posts')
} catch (error) {
console.error('Failed to delete post:', error)
}
@ -303,6 +303,49 @@
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>
<svelte:head>
@ -375,6 +418,14 @@
: [{ label: 'Save as Draft', status: 'draft' }]}
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>
{/if}
</header>
@ -459,6 +510,21 @@
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 {
width: 40px;
height: 40px;