From 3aec443534587084b2c06757c6ee9b6ac4910bc1 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Sun, 31 Aug 2025 11:03:27 -0700 Subject: [PATCH 1/6] feat(api): add admin API client with auth, error handling, FormData, and abortable requests --- src/lib/admin/api.ts | 93 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/lib/admin/api.ts 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() + } + } +} From f5a440a2caa09d5b3cad50998a9f3f9fc404ab12 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Sun, 31 Aug 2025 11:03:27 -0700 Subject: [PATCH 2/6] feat(api/server): add posts PATCH and optimistic concurrency (updatedAt) for posts and projects --- src/routes/api/posts/[id]/+server.ts | 64 +++++++++++++++++++++++++ src/routes/api/projects/[id]/+server.ts | 16 +++++++ 2 files changed, 80 insertions(+) diff --git a/src/routes/api/posts/[id]/+server.ts b/src/routes/api/posts/[id]/+server.ts index f1edce5..2095119 100644 --- a/src/routes/api/posts/[id]/+server.ts +++ b/src/routes/api/posts/[id]/+server.ts @@ -52,6 +52,16 @@ export const PUT: RequestHandler = async (event) => { const data = await event.request.json() + // Concurrency control: require matching updatedAt if provided + if (data.updatedAt) { + const existing = await prisma.post.findUnique({ where: { id }, select: { updatedAt: true } }) + if (!existing) return errorResponse('Post not found', 404) + const incoming = new Date(data.updatedAt) + if (existing.updatedAt.getTime() !== incoming.getTime()) { + return errorResponse('Conflict: post has changed', 409) + } + } + // Update publishedAt if status is changing to published if (data.status === 'published') { const currentPost = await prisma.post.findUnique({ @@ -141,6 +151,60 @@ export const PUT: RequestHandler = async (event) => { } } +// PATCH /api/posts/[id] - Partially update a post +export const PATCH: RequestHandler = async (event) => { + if (!checkAdminAuth(event)) { + return errorResponse('Unauthorized', 401) + } + + try { + const id = parseInt(event.params.id) + if (isNaN(id)) { + return errorResponse('Invalid post ID', 400) + } + + const data = await event.request.json() + + // Check for existence and concurrency + const existing = await prisma.post.findUnique({ where: { id } }) + if (!existing) return errorResponse('Post not found', 404) + if (data.updatedAt) { + const incoming = new Date(data.updatedAt) + if (existing.updatedAt.getTime() !== incoming.getTime()) { + return errorResponse('Conflict: post has changed', 409) + } + } + + const updateData: any = {} + + if (data.status !== undefined) { + updateData.status = data.status + if (data.status === 'published' && !existing.publishedAt) { + updateData.publishedAt = new Date() + } else if (data.status === 'draft') { + updateData.publishedAt = null + } + } + if (data.title !== undefined) updateData.title = data.title + if (data.slug !== undefined) updateData.slug = data.slug + if (data.type !== undefined) updateData.postType = data.type + if (data.content !== undefined) updateData.content = data.content + if (data.featuredImage !== undefined) updateData.featuredImage = data.featuredImage + if (data.attachedPhotos !== undefined) + updateData.attachments = data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null + if (data.tags !== undefined) updateData.tags = data.tags + if (data.publishedAt !== undefined) updateData.publishedAt = data.publishedAt + + const post = await prisma.post.update({ where: { id }, data: updateData }) + + logger.info('Post partially updated', { id: post.id, fields: Object.keys(updateData) }) + return jsonResponse(post) + } catch (error) { + logger.error('Failed to partially update post', error as Error) + return errorResponse('Failed to update post', 500) + } +} + // DELETE /api/posts/[id] - Delete a post export const DELETE: RequestHandler = async (event) => { if (!checkAdminAuth(event)) { diff --git a/src/routes/api/projects/[id]/+server.ts b/src/routes/api/projects/[id]/+server.ts index 68908a0..b136589 100644 --- a/src/routes/api/projects/[id]/+server.ts +++ b/src/routes/api/projects/[id]/+server.ts @@ -71,6 +71,14 @@ export const PUT: RequestHandler = async (event) => { slug = await ensureUniqueSlug(body.slug, 'project', id) } + // Concurrency control: if updatedAt provided, ensure it matches current + if (body.updatedAt) { + const incoming = new Date(body.updatedAt) + if (existing.updatedAt.getTime() !== incoming.getTime()) { + return errorResponse('Conflict: project has changed', 409) + } + } + // Update project const project = await prisma.project.update({ where: { id }, @@ -197,6 +205,14 @@ export const PATCH: RequestHandler = async (event) => { return errorResponse('Project not found', 404) } + // Concurrency control: if updatedAt provided, ensure it matches current + if (body.updatedAt) { + const incoming = new Date(body.updatedAt) + if (existing.updatedAt.getTime() !== incoming.getTime()) { + return errorResponse('Conflict: project has changed', 409) + } + } + // Build update data object with only provided fields const updateData: any = {} From 9bc942211a959a86ae5935f3c9ba7a27275d9fb3 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Sun, 31 Aug 2025 11:03:27 -0700 Subject: [PATCH 3/6] refactor(admin): use shared api client across projects list, posts list, new post, project edit load, and media modal --- .../components/admin/UnifiedMediaModal.svelte | 12 +--- src/routes/admin/posts/+page.svelte | 65 +++---------------- src/routes/admin/posts/new/+page.svelte | 27 ++------ src/routes/admin/projects/+page.svelte | 51 ++------------- .../admin/projects/[id]/edit/+page.svelte | 23 ++----- 5 files changed, 26 insertions(+), 152 deletions(-) diff --git a/src/lib/components/admin/UnifiedMediaModal.svelte b/src/lib/components/admin/UnifiedMediaModal.svelte index a330c7e..f28e460 100644 --- a/src/lib/components/admin/UnifiedMediaModal.svelte +++ b/src/lib/components/admin/UnifiedMediaModal.svelte @@ -225,8 +225,6 @@ // Short delay to prevent flicker await new Promise((resolve) => setTimeout(resolve, 500)) - const auth = localStorage.getItem('admin_auth') - if (!auth) return let url = `/api/media?page=${page}&limit=24` @@ -248,15 +246,7 @@ url += `&albumId=${albumId}` } - const response = await fetch(url, { - headers: { Authorization: `Basic ${auth}` } - }) - - if (!response.ok) { - throw new Error('Failed to load media') - } - - const data = await response.json() + const data = await (await import('$lib/admin/api')).api.get(url) if (page === 1) { // Only clear media after we have new data to prevent flash diff --git a/src/routes/admin/posts/+page.svelte b/src/routes/admin/posts/+page.svelte index 899ebf3..8abe1a0 100644 --- a/src/routes/admin/posts/+page.svelte +++ b/src/routes/admin/posts/+page.svelte @@ -1,6 +1,7 @@ + +{#if label} +
+ {#if status === 'saving'} + + {/if} + {label} +
+{/if} + + From c98ba3dcf052c98c9ed4e9e3a7ae4f3346de126a Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Sun, 31 Aug 2025 11:03:27 -0700 Subject: [PATCH 5/6] feat(admin): integrate autosave and local draft prompt into ProjectForm and Post Edit; add Cmd/Ctrl+S and beforeNavigate flush --- src/lib/components/admin/ProjectForm.svelte | 192 ++++++++++++++++-- src/routes/admin/posts/[id]/edit/+page.svelte | 190 +++++++++++------ 2 files changed, 303 insertions(+), 79 deletions(-) diff --git a/src/lib/components/admin/ProjectForm.svelte b/src/lib/components/admin/ProjectForm.svelte index d6452cf..75a4bb8 100644 --- a/src/lib/components/admin/ProjectForm.svelte +++ b/src/lib/components/admin/ProjectForm.svelte @@ -14,6 +14,10 @@ import { toast } from '$lib/stores/toast' import type { Project, ProjectFormData } 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 { project?: Project | null @@ -36,6 +40,55 @@ // Ref to the editor component let editorRef: any + // Local draft recovery + const draftKey = $derived(mode === 'edit' && project ? makeDraftKey('project', project.id) : null) + let showDraftPrompt = $state(false) + let draftTimestamp = $state(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 = [ { value: 'metadata', label: 'Metadata' }, { 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(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(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) { formData = { title: data.title || '', @@ -108,6 +221,8 @@ formData.caseStudyContent = content } + import { api } from '$lib/admin/api' + async function handleSave() { // Check if we're on the case study tab and should save editor content if (activeTab === 'case-study' && editorRef) { @@ -155,35 +270,33 @@ formData.caseStudyContent.content.length > 0 ? formData.caseStudyContent : 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' - const method = mode === 'edit' ? 'PUT' : 'POST' - - const response = await fetch(url, { - method, - 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`) + let savedProject + if (mode === 'edit') { + savedProject = await api.put(`/api/projects/${project?.id}`, payload) + } else { + savedProject = await api.post('/api/projects', payload) } - const savedProject = await response.json() - toast.dismiss(loadingToastId) toast.success(`Project ${mode === 'edit' ? 'saved' : 'created'} successfully!`) if (mode === 'create') { goto(`/admin/projects/${savedProject.id}/edit`) + } else { + project = savedProject } } catch (err) { 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) } finally { isSaving = false @@ -194,6 +307,26 @@ formData.status = newStatus as any 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() + }) @@ -239,6 +372,16 @@ ]} viewUrl={project?.slug ? `/work/${project.slug}` : undefined} /> + {#if mode === 'edit' && autoSave} + + {/if} + {#if mode === 'edit' && showDraftPrompt} +
+ Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}. + + +
+ {/if} {/if} @@ -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/routes/admin/posts/[id]/edit/+page.svelte b/src/routes/admin/posts/[id]/edit/+page.svelte index b152b62..09c052c 100644 --- a/src/routes/admin/posts/[id]/edit/+page.svelte +++ b/src/routes/admin/posts/[id]/edit/+page.svelte @@ -1,7 +1,9 @@ @@ -375,6 +418,14 @@ : [{ label: 'Save as Draft', status: 'draft' }]} viewUrl={slug ? `/universe/${slug}` : undefined} /> + + {#if showDraftPrompt} +
+ Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}. + + +
+ {/if} {/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; From 280bdfc06def23dd1b711b790a2fcf2ed1678ef3 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Sun, 31 Aug 2025 11:03:27 -0700 Subject: [PATCH 6/6] 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; + } +}