feat(drafts): add local draft backup with restore prompt to EssayForm, SimplePostForm, and PhotoPostForm
This commit is contained in:
parent
c98ba3dcf0
commit
280bdfc06d
4 changed files with 338 additions and 19 deletions
49
src/lib/admin/draftStore.ts
Normal file
49
src/lib/admin/draftStore.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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<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 = [
|
||||
{ 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<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() {
|
||||
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 @@
|
|||
</div>
|
||||
{/if}
|
||||
</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>
|
||||
</header>
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
$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 @@
|
|||
|
||||
<div class="header-actions">
|
||||
{#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="secondary"
|
||||
|
|
@ -289,6 +376,20 @@
|
|||
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 {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
interface Props {
|
||||
postType: 'post'
|
||||
|
|
@ -20,7 +21,7 @@
|
|||
mode: 'create' | 'edit'
|
||||
}
|
||||
|
||||
let { postType, postId, initialData, mode }: Props = $props()
|
||||
let { postType, postId, initialData, mode }: Props = $props()
|
||||
|
||||
// State
|
||||
let isSaving = $state(false)
|
||||
|
|
@ -32,7 +33,7 @@
|
|||
let linkDescription = $state(initialData?.linkDescription || '')
|
||||
let title = $state(initialData?.title || '')
|
||||
|
||||
// Character count for posts
|
||||
// Character count for posts
|
||||
const maxLength = 280
|
||||
const textContent = $derived(() => {
|
||||
if (!content.content) return ''
|
||||
|
|
@ -44,12 +45,77 @@
|
|||
const isOverLimit = $derived(charCount > maxLength)
|
||||
|
||||
// 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
|
||||
const hasTextContent = textContent().trim().length > 0
|
||||
const hasLinkContent = linkUrl && linkUrl.trim().length > 0
|
||||
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') {
|
||||
if (isOverLimit) {
|
||||
|
|
@ -105,10 +171,11 @@
|
|||
throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} post`)
|
||||
}
|
||||
|
||||
const savedPost = await response.json()
|
||||
const savedPost = await response.json()
|
||||
|
||||
toast.dismiss(loadingToastId)
|
||||
toast.success(`Post ${publishStatus === 'published' ? 'published' : 'saved'} successfully!`)
|
||||
toast.dismiss(loadingToastId)
|
||||
toast.success(`Post ${publishStatus === 'published' ? 'published' : 'saved'} successfully!`)
|
||||
clearDraft(draftKey)
|
||||
|
||||
// Redirect back to posts list after creation
|
||||
goto('/admin/posts')
|
||||
|
|
@ -145,6 +212,13 @@
|
|||
</h1>
|
||||
</div>
|
||||
<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}>
|
||||
Save Draft
|
||||
</Button>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue