Merge pull request #14 from jedmund/jedmund/autosave
Add autosave to admin interface
This commit is contained in:
commit
c63608938a
16 changed files with 1022 additions and 250 deletions
93
src/lib/admin/api.ts
Normal file
93
src/lib/admin/api.ts
Normal 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
107
src/lib/admin/autoSave.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
49
src/lib/admin/draftStore.ts
Normal file
49
src/lib/admin/draftStore.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
export type Draft<T = unknown> = { payload: T; ts: number }
|
||||
|
||||
export function makeDraftKey(type: string, id: string | number) {
|
||||
return `admin:draft:${type}:${id}`
|
||||
}
|
||||
|
||||
export function saveDraft<T>(key: string, payload: T) {
|
||||
try {
|
||||
const entry: Draft<T> = { payload, ts: Date.now() }
|
||||
localStorage.setItem(key, JSON.stringify(entry))
|
||||
} catch {
|
||||
// Ignore quota or serialization errors
|
||||
}
|
||||
}
|
||||
|
||||
export function loadDraft<T = unknown>(key: string): Draft<T> | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw) as Draft<T>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDraft(key: string) {
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts
|
||||
const sec = Math.floor(diff / 1000)
|
||||
if (sec < 5) return 'just now'
|
||||
if (sec < 60) return `${sec} second${sec !== 1 ? 's' : ''} ago`
|
||||
const min = Math.floor(sec / 60)
|
||||
if (min < 60) return `${min} minute${min !== 1 ? 's' : ''} ago`
|
||||
const hr = Math.floor(min / 60)
|
||||
if (hr < 24) return `${hr} hour${hr !== 1 ? 's' : ''} ago`
|
||||
const day = Math.floor(hr / 24)
|
||||
if (day <= 29) {
|
||||
if (day < 7) return `${day} day${day !== 1 ? 's' : ''} ago`
|
||||
const wk = Math.floor(day / 7)
|
||||
return `${wk} week${wk !== 1 ? 's' : ''} ago`
|
||||
}
|
||||
// Beyond 29 days, show a normal localized date
|
||||
return new Date(ts).toLocaleDateString()
|
||||
}
|
||||
75
src/lib/components/admin/AutoSaveStatus.svelte
Normal file
75
src/lib/components/admin/AutoSaveStatus.svelte
Normal 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>
|
||||
|
|
@ -5,7 +5,8 @@
|
|||
import Editor from './Editor.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import Input from './Input.svelte'
|
||||
import { toast } from '$lib/stores/toast'
|
||||
import { toast } from '$lib/stores/toast'
|
||||
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -37,7 +38,25 @@
|
|||
let tagInput = $state('')
|
||||
|
||||
// Ref to the editor component
|
||||
let editorRef: any
|
||||
let editorRef: any
|
||||
|
||||
// Draft backup
|
||||
const draftKey = $derived(makeDraftKey('post', postId ?? 'new'))
|
||||
let showDraftPrompt = $state(false)
|
||||
let draftTimestamp = $state<number | null>(null)
|
||||
let timeTicker = $state(0)
|
||||
const draftTimeText = $derived(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
|
||||
|
||||
function buildPayload() {
|
||||
return {
|
||||
title,
|
||||
slug,
|
||||
type: 'essay',
|
||||
status,
|
||||
content,
|
||||
tags
|
||||
}
|
||||
}
|
||||
|
||||
const tabOptions = [
|
||||
{ value: 'metadata', label: 'Metadata' },
|
||||
|
|
@ -45,14 +64,53 @@
|
|||
]
|
||||
|
||||
// Auto-generate slug from title
|
||||
$effect(() => {
|
||||
if (title && !slug) {
|
||||
$effect(() => {
|
||||
if (title && !slug) {
|
||||
slug = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Save draft when key fields change
|
||||
$effect(() => {
|
||||
title; slug; status; content; tags
|
||||
saveDraft(draftKey, buildPayload())
|
||||
})
|
||||
|
||||
// Show restore prompt if a draft exists
|
||||
$effect(() => {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
if (draft) {
|
||||
showDraftPrompt = true
|
||||
draftTimestamp = draft.ts
|
||||
}
|
||||
})
|
||||
|
||||
function restoreDraft() {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
if (!draft) return
|
||||
const p = draft.payload
|
||||
title = p.title ?? title
|
||||
slug = p.slug ?? slug
|
||||
status = p.status ?? status
|
||||
content = p.content ?? content
|
||||
tags = p.tags ?? tags
|
||||
showDraftPrompt = false
|
||||
}
|
||||
|
||||
function dismissDraft() {
|
||||
showDraftPrompt = false
|
||||
}
|
||||
|
||||
// Auto-update draft time text every minute when prompt visible
|
||||
$effect(() => {
|
||||
if (showDraftPrompt) {
|
||||
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
|
||||
return () => clearInterval(id)
|
||||
}
|
||||
})
|
||||
|
||||
function addTag() {
|
||||
if (tagInput && !tags.includes(tagInput)) {
|
||||
|
|
@ -122,7 +180,8 @@
|
|||
const savedPost = await response.json()
|
||||
|
||||
toast.dismiss(loadingToastId)
|
||||
toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`)
|
||||
toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`)
|
||||
clearDraft(draftKey)
|
||||
|
||||
if (mode === 'create') {
|
||||
goto(`/admin/posts/${savedPost.id}/edit`)
|
||||
|
|
@ -237,6 +296,13 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showDraftPrompt}
|
||||
<div class="draft-prompt">
|
||||
Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}.
|
||||
<button class="link" onclick={restoreDraft}>Restore</button>
|
||||
<button class="link" onclick={dismissDraft}>Dismiss</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -366,6 +432,21 @@
|
|||
display: flex;
|
||||
}
|
||||
|
||||
.draft-prompt {
|
||||
margin-left: $unit-2x;
|
||||
color: $gray-40;
|
||||
font-size: 0.75rem;
|
||||
|
||||
.link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $gray-20;
|
||||
cursor: pointer;
|
||||
margin-left: $unit;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom styles for save/publish buttons to maintain grey color scheme
|
||||
:global(.save-button.btn-primary) {
|
||||
background-color: $gray-10;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
import Input from './Input.svelte'
|
||||
import ImageUploader from './ImageUploader.svelte'
|
||||
import Editor from './Editor.svelte'
|
||||
import { toast } from '$lib/stores/toast'
|
||||
import { toast } from '$lib/stores/toast'
|
||||
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
|
|
@ -34,7 +35,85 @@
|
|||
let tags = $state(initialData?.tags?.join(', ') || '')
|
||||
|
||||
// Editor ref
|
||||
let editorRef: any
|
||||
let editorRef: any
|
||||
|
||||
// Draft backup
|
||||
const draftKey = $derived(makeDraftKey('post', postId ?? 'new'))
|
||||
let showDraftPrompt = $state(false)
|
||||
let draftTimestamp = $state<number | null>(null)
|
||||
let timeTicker = $state(0)
|
||||
const draftTimeText = $derived(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
|
||||
|
||||
function buildPayload() {
|
||||
return {
|
||||
title: title.trim(),
|
||||
slug: createSlug(title),
|
||||
type: 'photo',
|
||||
status,
|
||||
content,
|
||||
featuredImage: featuredImage ? featuredImage.url : null,
|
||||
tags: tags
|
||||
? tags
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
title; status; content; featuredImage; tags
|
||||
saveDraft(draftKey, buildPayload())
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
if (draft) {
|
||||
showDraftPrompt = true
|
||||
draftTimestamp = draft.ts
|
||||
}
|
||||
})
|
||||
|
||||
function restoreDraft() {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
if (!draft) return
|
||||
const p = draft.payload
|
||||
title = p.title ?? title
|
||||
status = p.status ?? status
|
||||
content = p.content ?? content
|
||||
tags = Array.isArray(p.tags) ? (p.tags as string[]).join(', ') : tags
|
||||
if (p.featuredImage) {
|
||||
featuredImage = {
|
||||
id: -1,
|
||||
filename: 'photo.jpg',
|
||||
originalName: 'photo.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 0,
|
||||
url: p.featuredImage,
|
||||
thumbnailUrl: p.featuredImage,
|
||||
width: null,
|
||||
height: null,
|
||||
altText: null,
|
||||
description: null,
|
||||
usedIn: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
} as any
|
||||
}
|
||||
showDraftPrompt = false
|
||||
}
|
||||
|
||||
function dismissDraft() {
|
||||
showDraftPrompt = false
|
||||
}
|
||||
|
||||
// Auto-update draft time text every minute when prompt visible
|
||||
$effect(() => {
|
||||
if (showDraftPrompt) {
|
||||
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
|
||||
return () => clearInterval(id)
|
||||
}
|
||||
})
|
||||
|
||||
// Initialize featured image if editing
|
||||
$effect(() => {
|
||||
|
|
@ -146,10 +225,11 @@
|
|||
throw new Error(`Failed to ${mode === 'edit' ? 'update' : 'create'} photo post`)
|
||||
}
|
||||
|
||||
const savedPost = await response.json()
|
||||
const savedPost = await response.json()
|
||||
|
||||
toast.dismiss(loadingToastId)
|
||||
toast.success(`Photo post ${status === 'published' ? 'published' : 'saved'} successfully!`)
|
||||
toast.dismiss(loadingToastId)
|
||||
toast.success(`Photo post ${status === 'published' ? 'published' : 'saved'} successfully!`)
|
||||
clearDraft(draftKey)
|
||||
|
||||
// Redirect to posts list or edit page
|
||||
if (mode === 'create') {
|
||||
|
|
@ -186,6 +266,13 @@
|
|||
|
||||
<div class="header-actions">
|
||||
{#if !isSaving}
|
||||
{#if showDraftPrompt}
|
||||
<div class="draft-prompt">
|
||||
Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}.
|
||||
<button class="link" onclick={restoreDraft}>Restore</button>
|
||||
<button class="link" onclick={dismissDraft}>Dismiss</button>
|
||||
</div>
|
||||
{/if}
|
||||
<Button variant="ghost" onclick={() => goto('/admin/posts')}>Cancel</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
|
@ -289,6 +376,20 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.draft-prompt {
|
||||
color: $gray-40;
|
||||
font-size: 0.75rem;
|
||||
|
||||
.link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $gray-20;
|
||||
cursor: pointer;
|
||||
margin-left: $unit;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue