feat(drafts): add local draft backup with restore prompt to EssayForm, SimplePostForm, and PhotoPostForm

This commit is contained in:
Justin Edmund 2025-08-31 11:03:27 -07:00
parent c98ba3dcf0
commit 280bdfc06d
4 changed files with 338 additions and 19 deletions

View file

@ -0,0 +1,49 @@
export type Draft<T = unknown> = { payload: T; ts: number }
export function makeDraftKey(type: string, id: string | number) {
return `admin:draft:${type}:${id}`
}
export function saveDraft<T>(key: string, payload: T) {
try {
const entry: Draft<T> = { payload, ts: Date.now() }
localStorage.setItem(key, JSON.stringify(entry))
} catch {
// Ignore quota or serialization errors
}
}
export function loadDraft<T = unknown>(key: string): Draft<T> | null {
try {
const raw = localStorage.getItem(key)
if (!raw) return null
return JSON.parse(raw) as Draft<T>
} catch {
return null
}
}
export function clearDraft(key: string) {
try {
localStorage.removeItem(key)
} catch {}
}
export function timeAgo(ts: number): string {
const diff = Date.now() - ts
const sec = Math.floor(diff / 1000)
if (sec < 5) return 'just now'
if (sec < 60) return `${sec} second${sec !== 1 ? 's' : ''} ago`
const min = Math.floor(sec / 60)
if (min < 60) return `${min} minute${min !== 1 ? 's' : ''} ago`
const hr = Math.floor(min / 60)
if (hr < 24) return `${hr} hour${hr !== 1 ? 's' : ''} ago`
const day = Math.floor(hr / 24)
if (day <= 29) {
if (day < 7) return `${day} day${day !== 1 ? 's' : ''} ago`
const wk = Math.floor(day / 7)
return `${wk} week${wk !== 1 ? 's' : ''} ago`
}
// Beyond 29 days, show a normal localized date
return new Date(ts).toLocaleDateString()
}

View file

@ -5,7 +5,8 @@
import Editor from './Editor.svelte' import Editor from './Editor.svelte'
import Button from './Button.svelte' import Button from './Button.svelte'
import Input from './Input.svelte' import Input from './Input.svelte'
import { toast } from '$lib/stores/toast' import { toast } from '$lib/stores/toast'
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
interface Props { interface Props {
@ -37,7 +38,25 @@
let tagInput = $state('') let tagInput = $state('')
// Ref to the editor component // Ref to the editor component
let editorRef: any let editorRef: any
// Draft backup
const draftKey = $derived(makeDraftKey('post', postId ?? 'new'))
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,
slug,
type: 'essay',
status,
content,
tags
}
}
const tabOptions = [ const tabOptions = [
{ value: 'metadata', label: 'Metadata' }, { value: 'metadata', label: 'Metadata' },
@ -45,14 +64,53 @@
] ]
// Auto-generate slug from title // Auto-generate slug from title
$effect(() => { $effect(() => {
if (title && !slug) { if (title && !slug) {
slug = title slug = title
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') .replace(/^-+|-+$/g, '')
} }
}) })
// Save draft when key fields change
$effect(() => {
title; slug; status; content; tags
saveDraft(draftKey, buildPayload())
})
// Show restore prompt if a draft exists
$effect(() => {
const draft = loadDraft<any>(draftKey)
if (draft) {
showDraftPrompt = true
draftTimestamp = draft.ts
}
})
function restoreDraft() {
const draft = loadDraft<any>(draftKey)
if (!draft) return
const p = draft.payload
title = p.title ?? title
slug = p.slug ?? slug
status = p.status ?? status
content = p.content ?? content
tags = p.tags ?? tags
showDraftPrompt = false
}
function dismissDraft() {
showDraftPrompt = false
}
// Auto-update draft time text every minute when prompt visible
$effect(() => {
if (showDraftPrompt) {
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
return () => clearInterval(id)
}
})
function addTag() { function addTag() {
if (tagInput && !tags.includes(tagInput)) { if (tagInput && !tags.includes(tagInput)) {
@ -122,7 +180,8 @@
const savedPost = await response.json() const savedPost = await response.json()
toast.dismiss(loadingToastId) toast.dismiss(loadingToastId)
toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`) toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`)
clearDraft(draftKey)
if (mode === 'create') { if (mode === 'create') {
goto(`/admin/posts/${savedPost.id}/edit`) goto(`/admin/posts/${savedPost.id}/edit`)
@ -237,6 +296,13 @@
</div> </div>
{/if} {/if}
</div> </div>
{#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>
</header> </header>
@ -366,6 +432,21 @@
display: flex; display: flex;
} }
.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;
}
}
// Custom styles for save/publish buttons to maintain grey color scheme // Custom styles for save/publish buttons to maintain grey color scheme
:global(.save-button.btn-primary) { :global(.save-button.btn-primary) {
background-color: $gray-10; background-color: $gray-10;

View file

@ -5,7 +5,8 @@
import Input from './Input.svelte' import Input from './Input.svelte'
import ImageUploader from './ImageUploader.svelte' import ImageUploader from './ImageUploader.svelte'
import Editor from './Editor.svelte' import Editor from './Editor.svelte'
import { toast } from '$lib/stores/toast' import { toast } from '$lib/stores/toast'
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
import type { Media } from '@prisma/client' import type { Media } from '@prisma/client'
@ -34,7 +35,85 @@
let tags = $state(initialData?.tags?.join(', ') || '') let tags = $state(initialData?.tags?.join(', ') || '')
// Editor ref // Editor ref
let editorRef: any let editorRef: any
// Draft backup
const draftKey = $derived(makeDraftKey('post', postId ?? 'new'))
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: title.trim(),
slug: createSlug(title),
type: 'photo',
status,
content,
featuredImage: featuredImage ? featuredImage.url : null,
tags: tags
? tags
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
: []
}
}
$effect(() => {
title; status; content; featuredImage; tags
saveDraft(draftKey, buildPayload())
})
$effect(() => {
const draft = loadDraft<any>(draftKey)
if (draft) {
showDraftPrompt = true
draftTimestamp = draft.ts
}
})
function restoreDraft() {
const draft = loadDraft<any>(draftKey)
if (!draft) return
const p = draft.payload
title = p.title ?? title
status = p.status ?? status
content = p.content ?? content
tags = Array.isArray(p.tags) ? (p.tags as string[]).join(', ') : tags
if (p.featuredImage) {
featuredImage = {
id: -1,
filename: 'photo.jpg',
originalName: 'photo.jpg',
mimeType: 'image/jpeg',
size: 0,
url: p.featuredImage,
thumbnailUrl: p.featuredImage,
width: null,
height: null,
altText: null,
description: null,
usedIn: [],
createdAt: new Date(),
updatedAt: new Date()
} as any
}
showDraftPrompt = false
}
function dismissDraft() {
showDraftPrompt = false
}
// Auto-update draft time text every minute when prompt visible
$effect(() => {
if (showDraftPrompt) {
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
return () => clearInterval(id)
}
})
// Initialize featured image if editing // Initialize featured image if editing
$effect(() => { $effect(() => {
@ -146,10 +225,11 @@
throw new Error(`Failed to ${mode === 'edit' ? 'update' : 'create'} photo post`) throw new Error(`Failed to ${mode === 'edit' ? 'update' : 'create'} photo post`)
} }
const savedPost = await response.json() const savedPost = await response.json()
toast.dismiss(loadingToastId) toast.dismiss(loadingToastId)
toast.success(`Photo post ${status === 'published' ? 'published' : 'saved'} successfully!`) toast.success(`Photo post ${status === 'published' ? 'published' : 'saved'} successfully!`)
clearDraft(draftKey)
// Redirect to posts list or edit page // Redirect to posts list or edit page
if (mode === 'create') { if (mode === 'create') {
@ -186,6 +266,13 @@
<div class="header-actions"> <div class="header-actions">
{#if !isSaving} {#if !isSaving}
{#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}
<Button variant="ghost" onclick={() => goto('/admin/posts')}>Cancel</Button> <Button variant="ghost" onclick={() => goto('/admin/posts')}>Cancel</Button>
<Button <Button
variant="secondary" variant="secondary"
@ -289,6 +376,20 @@
align-items: center; align-items: center;
} }
.draft-prompt {
color: $gray-40;
font-size: 0.75rem;
.link {
background: none;
border: none;
color: $gray-20;
cursor: pointer;
margin-left: $unit;
padding: 0;
}
}
.form-container { .form-container {
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;

View file

@ -5,7 +5,8 @@
import Editor from './Editor.svelte' import Editor from './Editor.svelte'
import Button from './Button.svelte' import Button from './Button.svelte'
import Input from './Input.svelte' import Input from './Input.svelte'
import { toast } from '$lib/stores/toast' import { toast } from '$lib/stores/toast'
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
interface Props { interface Props {
postType: 'post' postType: 'post'
@ -20,7 +21,7 @@
mode: 'create' | 'edit' mode: 'create' | 'edit'
} }
let { postType, postId, initialData, mode }: Props = $props() let { postType, postId, initialData, mode }: Props = $props()
// State // State
let isSaving = $state(false) let isSaving = $state(false)
@ -32,7 +33,7 @@
let linkDescription = $state(initialData?.linkDescription || '') let linkDescription = $state(initialData?.linkDescription || '')
let title = $state(initialData?.title || '') let title = $state(initialData?.title || '')
// Character count for posts // Character count for posts
const maxLength = 280 const maxLength = 280
const textContent = $derived(() => { const textContent = $derived(() => {
if (!content.content) return '' if (!content.content) return ''
@ -44,12 +45,77 @@
const isOverLimit = $derived(charCount > maxLength) const isOverLimit = $derived(charCount > maxLength)
// Check if form has content // Check if form has content
const hasContent = $derived(() => { const hasContent = $derived(() => {
// For posts, check if either content exists or it's a link with URL // For posts, check if either content exists or it's a link with URL
const hasTextContent = textContent().trim().length > 0 const hasTextContent = textContent().trim().length > 0
const hasLinkContent = linkUrl && linkUrl.trim().length > 0 const hasLinkContent = linkUrl && linkUrl.trim().length > 0
return hasTextContent || hasLinkContent return hasTextContent || hasLinkContent
}) })
// Draft backup
const draftKey = $derived(makeDraftKey('post', postId ?? 'new'))
let showDraftPrompt = $state(false)
let draftTimestamp = $state<number | null>(null)
let timeTicker = $state(0)
const draftTimeText = $derived(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
function buildPayload() {
const payload: any = {
type: 'post',
status,
content
}
if (linkUrl && linkUrl.trim()) {
payload.title = title || linkUrl
payload.link_url = linkUrl
payload.linkDescription = linkDescription
} else if (title) {
payload.title = title
}
return payload
}
$effect(() => {
// Save draft on changes
status; content; linkUrl; linkDescription; title
saveDraft(draftKey, buildPayload())
})
$effect(() => {
const draft = loadDraft<any>(draftKey)
if (draft) {
showDraftPrompt = true
draftTimestamp = draft.ts
}
})
function restoreDraft() {
const draft = loadDraft<any>(draftKey)
if (!draft) return
const p = draft.payload
status = p.status ?? status
content = p.content ?? content
if (p.link_url) {
linkUrl = p.link_url
linkDescription = p.linkDescription ?? linkDescription
title = p.title ?? title
} else {
title = p.title ?? title
}
showDraftPrompt = false
}
function dismissDraft() {
showDraftPrompt = false
}
// Auto-update draft time text every minute when prompt visible
$effect(() => {
if (showDraftPrompt) {
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
return () => clearInterval(id)
}
})
async function handleSave(publishStatus: 'draft' | 'published') { async function handleSave(publishStatus: 'draft' | 'published') {
if (isOverLimit) { if (isOverLimit) {
@ -105,10 +171,11 @@
throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} post`) throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} post`)
} }
const savedPost = await response.json() const savedPost = await response.json()
toast.dismiss(loadingToastId) toast.dismiss(loadingToastId)
toast.success(`Post ${publishStatus === 'published' ? 'published' : 'saved'} successfully!`) toast.success(`Post ${publishStatus === 'published' ? 'published' : 'saved'} successfully!`)
clearDraft(draftKey)
// Redirect back to posts list after creation // Redirect back to posts list after creation
goto('/admin/posts') goto('/admin/posts')
@ -145,6 +212,13 @@
</h1> </h1>
</div> </div>
<div class="header-actions"> <div class="header-actions">
{#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}
<Button variant="secondary" onclick={() => handleSave('draft')} disabled={isSaving}> <Button variant="secondary" onclick={() => handleSave('draft')} disabled={isSaving}>
Save Draft Save Draft
</Button> </Button>
@ -355,4 +429,18 @@
color: $gray-60; color: $gray-60;
} }
} }
.draft-prompt {
margin-right: $unit-2x;
color: $gray-40;
font-size: 0.75rem;
.link {
background: none;
border: none;
color: $gray-20;
cursor: pointer;
margin-left: $unit;
padding: 0;
}
}
</style> </style>