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:
parent
1a5ecf9ecf
commit
c98ba3dcf0
2 changed files with 303 additions and 79 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue