diff --git a/src/lib/admin/api.ts b/src/lib/admin/api.ts
new file mode 100644
index 0000000..5424034
--- /dev/null
+++ b/src/lib/admin/api.ts
@@ -0,0 +1,93 @@
+import { goto } from '$app/navigation'
+
+export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
+
+export interface RequestOptions
{
+ method?: HttpMethod
+ body?: TBody
+ signal?: AbortSignal
+ headers?: Record
+}
+
+export interface ApiError extends Error {
+ status: number
+ details?: unknown
+}
+
+function getAuthHeader() {
+ if (typeof localStorage === 'undefined') return {}
+ const auth = localStorage.getItem('admin_auth')
+ return auth ? { Authorization: `Basic ${auth}` } : {}
+}
+
+async function handleResponse(res: Response) {
+ if (res.status === 401) {
+ // Redirect to login for unauthorized requests
+ try {
+ goto('/admin/login')
+ } catch {}
+ }
+
+ const contentType = res.headers.get('content-type') || ''
+ const isJson = contentType.includes('application/json')
+ const data = isJson ? await res.json().catch(() => undefined) : undefined
+
+ if (!res.ok) {
+ const err: ApiError = Object.assign(new Error('Request failed'), {
+ status: res.status,
+ details: data
+ })
+ throw err
+ }
+ return data
+}
+
+export async function request(
+ url: string,
+ opts: RequestOptions = {}
+): Promise {
+ const { method = 'GET', body, signal, headers } = opts
+
+ const isFormData = typeof FormData !== 'undefined' && body instanceof FormData
+ const mergedHeaders: Record = {
+ ...(isFormData ? {} : { 'Content-Type': 'application/json' }),
+ ...getAuthHeader(),
+ ...(headers || {})
+ }
+
+ const res = await fetch(url, {
+ method,
+ headers: mergedHeaders,
+ body: body ? (isFormData ? (body as any) : JSON.stringify(body)) : undefined,
+ signal
+ })
+
+ return handleResponse(res) as Promise
+}
+
+export const api = {
+ get: (url: string, opts: Omit = {}) =>
+ request(url, { ...opts, method: 'GET' }),
+ post: (url: string, body: B, opts: Omit, 'method' | 'body'> = {}) =>
+ request(url, { ...opts, method: 'POST', body }),
+ put: (url: string, body: B, opts: Omit, 'method' | 'body'> = {}) =>
+ request(url, { ...opts, method: 'PUT', body }),
+ patch: (url: string, body: B, opts: Omit, 'method' | 'body'> = {}) =>
+ request(url, { ...opts, method: 'PATCH', body }),
+ delete: (url: string, opts: Omit = {}) =>
+ request(url, { ...opts, method: 'DELETE' })
+}
+
+export function createAbortable() {
+ let controller: AbortController | null = null
+ return {
+ nextSignal() {
+ if (controller) controller.abort()
+ controller = new AbortController()
+ return controller.signal
+ },
+ abort() {
+ if (controller) controller.abort()
+ }
+ }
+}
diff --git a/src/lib/admin/autoSave.ts b/src/lib/admin/autoSave.ts
new file mode 100644
index 0000000..431f592
--- /dev/null
+++ b/src/lib/admin/autoSave.ts
@@ -0,0 +1,107 @@
+export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error' | 'offline'
+
+interface CreateAutoSaveControllerOptions {
+ debounceMs?: number
+ getPayload: () => TPayload | null | undefined
+ save: (payload: TPayload, ctx: { signal: AbortSignal }) => Promise
+ onSaved?: (res: TResponse) => void
+}
+
+export function createAutoSaveController(
+ opts: CreateAutoSaveControllerOptions
+) {
+ const debounceMs = opts.debounceMs ?? 2000
+ let timer: ReturnType | null = null
+ let controller: AbortController | null = null
+ let lastSentHash: string | null = null
+
+ let _status: AutoSaveStatus = 'idle'
+ let _lastError: string | null = null
+ const statusSubs = new Set<(v: AutoSaveStatus) => void>()
+ const errorSubs = new Set<(v: string | null) => void>()
+
+ function setStatus(next: AutoSaveStatus) {
+ _status = next
+ statusSubs.forEach((fn) => fn(_status))
+ }
+
+ function schedule() {
+ if (timer) clearTimeout(timer)
+ timer = setTimeout(() => void run(), debounceMs)
+ }
+
+ async function run() {
+ if (timer) {
+ clearTimeout(timer)
+ timer = null
+ }
+
+ const payload = opts.getPayload()
+ if (!payload) return
+
+ const hash = safeHash(payload)
+ if (lastSentHash && hash === lastSentHash) return
+
+ if (controller) controller.abort()
+ controller = new AbortController()
+
+ setStatus('saving')
+ _lastError = null
+ try {
+ const res = await opts.save(payload, { signal: controller.signal })
+ lastSentHash = hash
+ setStatus('saved')
+ if (opts.onSaved) opts.onSaved(res)
+ } catch (e: any) {
+ if (e?.name === 'AbortError') {
+ // Newer save superseded this one
+ return
+ }
+ if (typeof navigator !== 'undefined' && navigator.onLine === false) {
+ setStatus('offline')
+ } else {
+ setStatus('error')
+ }
+ _lastError = e?.message || 'Auto-save failed'
+ errorSubs.forEach((fn) => fn(_lastError))
+ }
+ }
+
+ function flush() {
+ return run()
+ }
+
+ function destroy() {
+ if (timer) clearTimeout(timer)
+ if (controller) controller.abort()
+ }
+
+ return {
+ status: {
+ subscribe(run: (v: AutoSaveStatus) => void) {
+ run(_status)
+ statusSubs.add(run)
+ return () => statusSubs.delete(run)
+ }
+ },
+ lastError: {
+ subscribe(run: (v: string | null) => void) {
+ run(_lastError)
+ errorSubs.add(run)
+ return () => errorSubs.delete(run)
+ }
+ },
+ schedule,
+ flush,
+ destroy
+ }
+}
+
+function safeHash(obj: unknown): string {
+ try {
+ return JSON.stringify(obj)
+ } catch {
+ // Fallback for circular structures; not expected for form payloads
+ return String(obj)
+ }
+}
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/AutoSaveStatus.svelte b/src/lib/components/admin/AutoSaveStatus.svelte
new file mode 100644
index 0000000..adf48eb
--- /dev/null
+++ b/src/lib/components/admin/AutoSaveStatus.svelte
@@ -0,0 +1,75 @@
+
+
+{#if label}
+
+ {#if status === 'saving'}
+
+ {/if}
+ {label}
+
+{/if}
+
+
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 @@
@@ -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;
+ }
+ }
diff --git a/src/lib/components/admin/SimplePostForm.svelte b/src/lib/components/admin/SimplePostForm.svelte
index 4b98aac..293a4dd 100644
--- a/src/lib/components/admin/SimplePostForm.svelte
+++ b/src/lib/components/admin/SimplePostForm.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'
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(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(draftKey)
+ if (draft) {
+ showDraftPrompt = true
+ draftTimestamp = draft.ts
+ }
+})
+
+function restoreDraft() {
+ const draft = loadDraft(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 @@
{/if}
@@ -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;
diff --git a/src/routes/admin/posts/new/+page.svelte b/src/routes/admin/posts/new/+page.svelte
index c435f8a..d587f1e 100644
--- a/src/routes/admin/posts/new/+page.svelte
+++ b/src/routes/admin/posts/new/+page.svelte
@@ -1,6 +1,7 @@