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 @@
{#if !isSaving} + {#if showDraftPrompt} +
+ Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}. + + +
+ {/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/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 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; + } +} 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 @@ @@ -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; 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 @@