Merge pull request #14 from jedmund/jedmund/autosave

Add autosave to admin interface
This commit is contained in:
Justin Edmund 2025-10-02 19:27:39 -07:00 committed by GitHub
commit c63608938a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1022 additions and 250 deletions

93
src/lib/admin/api.ts Normal file
View file

@ -0,0 +1,93 @@
import { goto } from '$app/navigation'
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
export interface RequestOptions<TBody = unknown> {
method?: HttpMethod
body?: TBody
signal?: AbortSignal
headers?: Record<string, string>
}
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<TResponse = unknown, TBody = unknown>(
url: string,
opts: RequestOptions<TBody> = {}
): Promise<TResponse> {
const { method = 'GET', body, signal, headers } = opts
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData
const mergedHeaders: Record<string, string> = {
...(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<TResponse>
}
export const api = {
get: <T = unknown>(url: string, opts: Omit<RequestOptions, 'method' | 'body'> = {}) =>
request<T>(url, { ...opts, method: 'GET' }),
post: <T = unknown, B = unknown>(url: string, body: B, opts: Omit<RequestOptions<B>, 'method' | 'body'> = {}) =>
request<T, B>(url, { ...opts, method: 'POST', body }),
put: <T = unknown, B = unknown>(url: string, body: B, opts: Omit<RequestOptions<B>, 'method' | 'body'> = {}) =>
request<T, B>(url, { ...opts, method: 'PUT', body }),
patch: <T = unknown, B = unknown>(url: string, body: B, opts: Omit<RequestOptions<B>, 'method' | 'body'> = {}) =>
request<T, B>(url, { ...opts, method: 'PATCH', body }),
delete: <T = unknown>(url: string, opts: Omit<RequestOptions, 'method' | 'body'> = {}) =>
request<T>(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()
}
}
}

107
src/lib/admin/autoSave.ts Normal file
View file

@ -0,0 +1,107 @@
export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error' | 'offline'
interface CreateAutoSaveControllerOptions<TPayload, TResponse = unknown> {
debounceMs?: number
getPayload: () => TPayload | null | undefined
save: (payload: TPayload, ctx: { signal: AbortSignal }) => Promise<TResponse>
onSaved?: (res: TResponse) => void
}
export function createAutoSaveController<TPayload, TResponse = unknown>(
opts: CreateAutoSaveControllerOptions<TPayload, TResponse>
) {
const debounceMs = opts.debounceMs ?? 2000
let timer: ReturnType<typeof setTimeout> | 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)
}
}

View 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()
}

View file

@ -0,0 +1,75 @@
<script lang="ts">
import type { AutoSaveStatus } from '$lib/admin/autoSave'
interface Props {
statusStore: { subscribe: (run: (v: AutoSaveStatus) => void) => () => void }
errorStore?: { subscribe: (run: (v: string | null) => void) => () => void }
compact?: boolean
}
let { statusStore, errorStore, compact = true }: Props = $props()
let status = $state<AutoSaveStatus>('idle')
let errorText = $state<string | null>(null)
$effect(() => {
const unsub = statusStore.subscribe((v) => (status = v))
let unsubErr: (() => void) | null = null
if (errorStore) unsubErr = errorStore.subscribe((v) => (errorText = v))
return () => {
unsub()
if (unsubErr) unsubErr()
}
})
const label = $derived(() => {
switch (status) {
case 'saving':
return 'Saving…'
case 'saved':
return 'All changes saved'
case 'offline':
return 'Offline'
case 'error':
return errorText ? `Error — ${errorText}` : 'Save failed'
case 'idle':
default:
return ''
}
})
</script>
{#if label}
<div class="autosave-status" class:compact>
{#if status === 'saving'}
<span class="spinner" aria-hidden="true"></span>
{/if}
<span class="text">{label}</span>
</div>
{/if}
<style lang="scss">
.autosave-status {
display: inline-flex;
align-items: center;
gap: 6px;
color: $gray-40;
font-size: 0.875rem;
&.compact {
font-size: 0.75rem;
}
}
.spinner {
width: 12px;
height: 12px;
border: 2px solid $gray-80;
border-top-color: $gray-40;
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View file

@ -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;

View file

@ -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;

View file

@ -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<number | null>(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<any>(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<any>(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()
})
</script>
<AdminPage>
@ -239,6 +372,16 @@
]}
viewUrl={project?.slug ? `/work/${project.slug}` : undefined}
/>
{#if mode === 'edit' && autoSave}
<AutoSaveStatus statusStore={autoSave.status} errorStore={autoSave.lastError} />
{/if}
{#if mode === 'edit' && 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}
{/if}
</div>
</header>
@ -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;
}
}
</style>

View file

@ -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>

View file

@ -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

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import { goto } from '$app/navigation'
import { api } from '$lib/admin/api'
import AdminPage from '$lib/components/admin/AdminPage.svelte'
import AdminHeader from '$lib/components/admin/AdminHeader.svelte'
import AdminFilters from '$lib/components/admin/AdminFilters.svelte'
@ -85,25 +86,7 @@
async function loadPosts() {
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
const response = await fetch('/api/posts', {
headers: { Authorization: `Basic ${auth}` }
})
if (!response.ok) {
if (response.status === 401) {
goto('/admin/login')
return
}
throw new Error('Failed to load posts')
}
const data = await response.json()
const data = await api.get('/api/posts')
posts = data.posts || []
total = data.pagination?.total || posts.length
@ -209,28 +192,11 @@
async function handleTogglePublish(event: CustomEvent<{ post: Post }>) {
const { post } = event.detail
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
const newStatus = post.status === 'published' ? 'draft' : 'published'
try {
const response = await fetch(`/api/posts/${post.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${auth}`
},
body: JSON.stringify({ status: newStatus })
})
if (response.ok) {
// Reload posts to refresh the list
await loadPosts()
}
await api.patch(`/api/posts/${post.id}`, { status: newStatus, updatedAt: post.updatedAt })
await loadPosts()
} catch (error) {
console.error('Failed to toggle publish status:', error)
}
@ -244,24 +210,11 @@
async function confirmDelete() {
if (!postToDelete) return
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
try {
const response = await fetch(`/api/posts/${postToDelete.id}`, {
method: 'DELETE',
headers: { Authorization: `Basic ${auth}` }
})
if (response.ok) {
showDeleteConfirmation = false
postToDelete = null
// Reload posts to refresh the list
await loadPosts()
}
await api.delete(`/api/posts/${postToDelete.id}`)
showDeleteConfirmation = false
postToDelete = null
await loadPosts()
} catch (error) {
console.error('Failed to delete post:', error)
}

View file

@ -1,7 +1,9 @@
<script lang="ts">
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import { onMount } from 'svelte'
import { goto, beforeNavigate } from '$app/navigation'
import { onMount } from 'svelte'
import { api } from '$lib/admin/api'
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
import AdminPage from '$lib/components/admin/AdminPage.svelte'
import Composer from '$lib/components/admin/composer'
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
@ -9,6 +11,8 @@
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
import Button from '$lib/components/admin/Button.svelte'
import StatusDropdown from '$lib/components/admin/StatusDropdown.svelte'
import { createAutoSaveController } from '$lib/admin/autoSave'
import AutoSaveStatus from '$lib/components/admin/AutoSaveStatus.svelte'
import type { JSONContent } from '@tiptap/core'
let post = $state<any>(null)
@ -27,7 +31,14 @@
let tagInput = $state('')
let showMetadata = $state(false)
let metadataButtonRef: HTMLButtonElement
let showDeleteConfirmation = $state(false)
let showDeleteConfirmation = $state(false)
// Draft backup
const draftKey = $derived(makeDraftKey('post', $page.params.id))
let showDraftPrompt = $state(false)
let draftTimestamp = $state<number | null>(null)
let timeTicker = $state(0)
const draftTimeText = $derived(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
const postTypeConfig = {
post: { icon: '💭', label: 'Post', showTitle: false, showContent: true },
@ -36,6 +47,31 @@
let config = $derived(postTypeConfig[postType])
// Autosave controller
let autoSave = createAutoSaveController({
debounceMs: 2000,
getPayload: () => {
if (!post) return null
return {
title: config?.showTitle ? title : null,
slug,
type: postType,
status,
content: config?.showContent ? content : null,
excerpt: postType === 'essay' ? excerpt : undefined,
tags,
updatedAt: post?.updatedAt
}
},
save: async (payload, { signal }) => {
const saved = await api.put(`/api/posts/${$page.params.id}`, payload, { signal })
return saved
},
onSaved: (saved: any) => {
post = saved
}
})
// Convert blocks format (from database) to Tiptap format
function convertBlocksToTiptap(blocksContent: any): JSONContent {
if (!blocksContent || !blocksContent.blocks) {
@ -135,11 +171,16 @@
}
}
onMount(async () => {
// Wait a tick to ensure page params are loaded
await new Promise((resolve) => setTimeout(resolve, 0))
await loadPost()
})
onMount(async () => {
// Wait a tick to ensure page params are loaded
await new Promise((resolve) => setTimeout(resolve, 0))
await loadPost()
const draft = loadDraft<any>(draftKey)
if (draft) {
showDraftPrompt = true
draftTimestamp = draft.ts
}
})
async function loadPost() {
const postId = $page.params.id
@ -150,20 +191,10 @@
return
}
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
try {
const response = await fetch(`/api/posts/${postId}`, {
headers: { Authorization: `Basic ${auth}` }
})
if (response.ok) {
post = await response.json()
const data = await api.get(`/api/posts/${postId}`)
if (data) {
post = data
// Populate form fields
title = post.title || ''
@ -186,14 +217,8 @@
// Set content ready after all data is loaded
contentReady = true
} else {
if (response.status === 404) {
// Fallback error messaging
loadError = 'Post not found'
} else if (response.status === 401) {
goto('/admin/login')
return
} else {
loadError = `Failed to load post: ${response.status} ${response.statusText}`
}
}
} catch (error) {
loadError = 'Network error occurred while loading post'
@ -214,12 +239,6 @@
}
async function handleSave(newStatus?: string) {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
saving = true
// Save content in native Tiptap format to preserve all formatting
@ -236,20 +255,13 @@
}
try {
const response = await fetch(`/api/posts/${$page.params.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${auth}`
},
body: JSON.stringify(postData)
const saved = await api.put(`/api/posts/${$page.params.id}`, {
...postData,
updatedAt: post?.updatedAt
})
if (response.ok) {
post = await response.json()
if (newStatus) {
status = newStatus
}
if (saved) {
post = saved
if (newStatus) status = newStatus
}
} catch (error) {
console.error('Failed to save post:', error)
@ -264,22 +276,10 @@
}
async function handleDelete() {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
try {
const response = await fetch(`/api/posts/${$page.params.id}`, {
method: 'DELETE',
headers: { Authorization: `Basic ${auth}` }
})
if (response.ok) {
showDeleteConfirmation = false
goto('/admin/posts')
}
await api.delete(`/api/posts/${$page.params.id}`)
showDeleteConfirmation = false
goto('/admin/posts')
} catch (error) {
console.error('Failed to delete post:', error)
}
@ -303,6 +303,49 @@
return () => document.removeEventListener('click', handleMetadataPopover)
}
})
// Schedule autosave on changes to key fields
$effect(() => {
// Establish dependencies
title; slug; status; content; tags; excerpt; postType; loading
if (post && !loading) {
autoSave.schedule()
saveDraft(draftKey, {
title: config?.showTitle ? title : null,
slug,
type: postType,
status,
content: config?.showContent ? content : null,
excerpt: postType === 'essay' ? excerpt : undefined,
tags,
updatedAt: post?.updatedAt
})
}
})
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
autoSave.flush()
}
}
$effect(() => {
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
})
beforeNavigate(() => {
autoSave.flush()
})
// Auto-update draft time text every minute when prompt visible
$effect(() => {
if (showDraftPrompt) {
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
return () => clearInterval(id)
}
})
</script>
<svelte:head>
@ -375,6 +418,14 @@
: [{ label: 'Save as Draft', status: 'draft' }]}
viewUrl={slug ? `/universe/${slug}` : undefined}
/>
<AutoSaveStatus statusStore={autoSave.status} errorStore={autoSave.lastError} />
{#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>
{/if}
</header>
@ -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;

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import { goto } from '$app/navigation'
import { api } from '$lib/admin/api'
import { onMount } from 'svelte'
import AdminPage from '$lib/components/admin/AdminPage.svelte'
import Composer from '$lib/components/admin/composer'
@ -73,12 +74,6 @@
}
async function handleSave(publishStatus?: 'draft' | 'published') {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
saving = true
const postData = {
title: config?.showTitle ? title : null,
@ -91,22 +86,8 @@
}
try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${auth}`
},
body: JSON.stringify(postData)
})
if (response.ok) {
const newPost = await response.json()
// Redirect to edit page after creation
goto(`/admin/posts/${newPost.id}/edit`)
} else {
console.error('Failed to create post:', response.statusText)
}
const newPost = await api.post('/api/posts', postData)
goto(`/admin/posts/${newPost.id}/edit`)
} catch (error) {
console.error('Failed to create post:', error)
} finally {

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import { goto } from '$app/navigation'
import { api } from '$lib/admin/api'
import AdminPage from '$lib/components/admin/AdminPage.svelte'
import AdminHeader from '$lib/components/admin/AdminHeader.svelte'
import AdminFilters from '$lib/components/admin/AdminFilters.svelte'
@ -79,25 +80,7 @@
async function loadProjects() {
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
const response = await fetch('/api/projects', {
headers: { Authorization: `Basic ${auth}` }
})
if (!response.ok) {
if (response.status === 401) {
goto('/admin/login')
return
}
throw new Error('Failed to load projects')
}
const data = await response.json()
const data = await api.get('/api/projects')
projects = data.projects
// Calculate status counts
@ -126,21 +109,9 @@
const project = event.detail.project
try {
const auth = localStorage.getItem('admin_auth')
const newStatus = project.status === 'published' ? 'draft' : 'published'
const response = await fetch(`/api/projects/${project.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${auth}`
},
body: JSON.stringify({ status: newStatus })
})
if (response.ok) {
await loadProjects()
}
await api.patch(`/api/projects/${project.id}`, { status: newStatus, updatedAt: project.updatedAt })
await loadProjects()
} catch (err) {
console.error('Failed to update project status:', err)
}
@ -155,16 +126,8 @@
if (!projectToDelete) return
try {
const auth = localStorage.getItem('admin_auth')
const response = await fetch(`/api/projects/${projectToDelete.id}`, {
method: 'DELETE',
headers: { Authorization: `Basic ${auth}` }
})
if (response.ok) {
await loadProjects()
}
await api.delete(`/api/projects/${projectToDelete.id}`)
await loadProjects()
} catch (err) {
console.error('Failed to delete project:', err)
} finally {

View file

@ -1,9 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import ProjectForm from '$lib/components/admin/ProjectForm.svelte'
import type { Project } from '$lib/types/project'
import { page } from '$app/stores'
import ProjectForm from '$lib/components/admin/ProjectForm.svelte'
import type { Project } from '$lib/types/project'
import { api } from '$lib/admin/api'
let project = $state<Project | null>(null)
let isLoading = $state(true)
@ -17,21 +18,7 @@
async function loadProject() {
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
const response = await fetch(`/api/projects/${projectId}`, {
headers: { Authorization: `Basic ${auth}` }
})
if (!response.ok) {
throw new Error('Failed to load project')
}
const data = await response.json()
const data = await api.get(`/api/projects/${projectId}`)
project = data
} catch (err) {
error = 'Failed to load project'

View file

@ -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)) {

View file

@ -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 = {}