From 280bdfc06def23dd1b711b790a2fcf2ed1678ef3 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Sun, 31 Aug 2025 11:03:27 -0700 Subject: [PATCH] feat(drafts): add local draft backup with restore prompt to EssayForm, SimplePostForm, and PhotoPostForm --- src/lib/admin/draftStore.ts | 49 ++++++++ src/lib/components/admin/EssayForm.svelte | 93 ++++++++++++++- src/lib/components/admin/PhotoPostForm.svelte | 111 +++++++++++++++++- .../components/admin/SimplePostForm.svelte | 104 ++++++++++++++-- 4 files changed, 338 insertions(+), 19 deletions(-) create mode 100644 src/lib/admin/draftStore.ts diff --git a/src/lib/admin/draftStore.ts b/src/lib/admin/draftStore.ts new file mode 100644 index 0000000..08fc1ed --- /dev/null +++ b/src/lib/admin/draftStore.ts @@ -0,0 +1,49 @@ +export type Draft = { payload: T; ts: number } + +export function makeDraftKey(type: string, id: string | number) { + return `admin:draft:${type}:${id}` +} + +export function saveDraft(key: string, payload: T) { + try { + const entry: Draft = { payload, ts: Date.now() } + localStorage.setItem(key, JSON.stringify(entry)) + } catch { + // Ignore quota or serialization errors + } +} + +export function loadDraft(key: string): Draft | null { + try { + const raw = localStorage.getItem(key) + if (!raw) return null + return JSON.parse(raw) as Draft + } 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() +} diff --git a/src/lib/components/admin/EssayForm.svelte b/src/lib/components/admin/EssayForm.svelte index 2318f12..d12ccde 100644 --- a/src/lib/components/admin/EssayForm.svelte +++ b/src/lib/components/admin/EssayForm.svelte @@ -5,7 +5,8 @@ import Editor from './Editor.svelte' import Button from './Button.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' interface Props { @@ -37,7 +38,25 @@ let tagInput = $state('') // 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(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 = [ { value: 'metadata', label: 'Metadata' }, @@ -45,14 +64,53 @@ ] // Auto-generate slug from title - $effect(() => { - if (title && !slug) { +$effect(() => { + if (title && !slug) { slug = title .toLowerCase() .replace(/[^a-z0-9]+/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(draftKey) + if (draft) { + showDraftPrompt = true + draftTimestamp = draft.ts + } +}) + +function restoreDraft() { + const draft = loadDraft(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() { if (tagInput && !tags.includes(tagInput)) { @@ -122,7 +180,8 @@ const savedPost = await response.json() toast.dismiss(loadingToastId) - toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`) + toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`) + clearDraft(draftKey) if (mode === 'create') { goto(`/admin/posts/${savedPost.id}/edit`) @@ -237,6 +296,13 @@ {/if} + {#if showDraftPrompt} +
+ Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}. + + +
+ {/if} @@ -366,6 +432,21 @@ 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 :global(.save-button.btn-primary) { background-color: $gray-10; diff --git a/src/lib/components/admin/PhotoPostForm.svelte b/src/lib/components/admin/PhotoPostForm.svelte index db6f0a8..dddcf12 100644 --- a/src/lib/components/admin/PhotoPostForm.svelte +++ b/src/lib/components/admin/PhotoPostForm.svelte @@ -5,7 +5,8 @@ import Input from './Input.svelte' import ImageUploader from './ImageUploader.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 { Media } from '@prisma/client' @@ -34,7 +35,85 @@ let tags = $state(initialData?.tags?.join(', ') || '') // Editor ref - let editorRef: any +let editorRef: any + +// Draft backup +const draftKey = $derived(makeDraftKey('post', postId ?? 'new')) +let showDraftPrompt = $state(false) +let draftTimestamp = $state(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(draftKey) + if (draft) { + showDraftPrompt = true + draftTimestamp = draft.ts + } +}) + +function restoreDraft() { + const draft = loadDraft(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 $effect(() => { @@ -146,10 +225,11 @@ 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.success(`Photo post ${status === 'published' ? 'published' : 'saved'} successfully!`) + toast.dismiss(loadingToastId) + toast.success(`Photo post ${status === 'published' ? 'published' : 'saved'} successfully!`) + clearDraft(draftKey) // Redirect to posts list or edit page if (mode === 'create') { @@ -186,6 +266,13 @@
{#if !isSaving} + {#if showDraftPrompt} +
+ Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}. + + +
+ {/if}
+ {#if showDraftPrompt} +
+ Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}. + + +
+ {/if} @@ -355,4 +429,18 @@ 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; + } +}