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 { 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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue