Compare commits

..

No commits in common. "main" and "devin/1763907694-fix-linter-errors" have entirely different histories.

20 changed files with 1081 additions and 303 deletions

View file

@ -1,3 +0,0 @@
onlyBuiltDependencies:
- "@musicorum/lastfm"
- "psn-api"

View file

@ -8,7 +8,7 @@ export interface AutoSaveStoreOptions<TPayload, TResponse = unknown> {
onSaved?: (res: TResponse, ctx: { prime: (payload: TPayload) => void }) => void onSaved?: (res: TResponse, ctx: { prime: (payload: TPayload) => void }) => void
} }
export interface AutoSaveStore<TPayload, _TResponse = unknown> { export interface AutoSaveStore<TPayload> {
readonly status: AutoSaveStatus readonly status: AutoSaveStatus
readonly lastError: string | null readonly lastError: string | null
schedule: () => void schedule: () => void
@ -36,7 +36,7 @@ export interface AutoSaveStore<TPayload, _TResponse = unknown> {
*/ */
export function createAutoSaveStore<TPayload, TResponse = unknown>( export function createAutoSaveStore<TPayload, TResponse = unknown>(
opts: AutoSaveStoreOptions<TPayload, TResponse> opts: AutoSaveStoreOptions<TPayload, TResponse>
): AutoSaveStore<TPayload, unknown> { ): AutoSaveStore<TPayload> {
const debounceMs = opts.debounceMs ?? 2000 const debounceMs = opts.debounceMs ?? 2000
const idleResetMs = opts.idleResetMs ?? 2000 const idleResetMs = opts.idleResetMs ?? 2000
let timer: ReturnType<typeof setTimeout> | null = null let timer: ReturnType<typeof setTimeout> | null = null
@ -70,9 +70,6 @@ export function createAutoSaveStore<TPayload, TResponse = unknown>(
function schedule() { function schedule() {
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer)
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
console.debug(`[AutoSave] Scheduled (${debounceMs}ms debounce)`)
}
timer = setTimeout(() => void run(), debounceMs) timer = setTimeout(() => void run(), debounceMs)
} }
@ -83,44 +80,24 @@ export function createAutoSaveStore<TPayload, TResponse = unknown>(
} }
const payload = opts.getPayload() const payload = opts.getPayload()
if (!payload) { if (!payload) return
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
console.debug('[AutoSave] Skipped: getPayload returned null/undefined')
}
return
}
const hash = safeHash(payload) const hash = safeHash(payload)
if (lastSentHash && hash === lastSentHash) { if (lastSentHash && hash === lastSentHash) return
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
console.debug('[AutoSave] Skipped: payload unchanged (hash match)')
}
return
}
if (controller) controller.abort() if (controller) controller.abort()
controller = new AbortController() controller = new AbortController()
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
console.debug('[AutoSave] Saving...', { hashChanged: lastSentHash !== hash })
}
setStatus('saving') setStatus('saving')
lastError = null lastError = null
try { try {
const res = await opts.save(payload, { signal: controller.signal }) const res = await opts.save(payload, { signal: controller.signal })
lastSentHash = hash lastSentHash = hash
setStatus('saved') setStatus('saved')
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
console.debug('[AutoSave] Saved successfully')
}
if (opts.onSaved) opts.onSaved(res, { prime }) if (opts.onSaved) opts.onSaved(res, { prime })
} catch (e) { } catch (e: unknown) {
if (e instanceof Error && e.name === 'AbortError') { if (e?.name === 'AbortError') {
// Newer save superseded this one // Newer save superseded this one
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
console.debug('[AutoSave] Aborted: superseded by newer save')
}
return return
} }
if (typeof navigator !== 'undefined' && navigator.onLine === false) { if (typeof navigator !== 'undefined' && navigator.onLine === false) {
@ -128,10 +105,7 @@ export function createAutoSaveStore<TPayload, TResponse = unknown>(
} else { } else {
setStatus('error') setStatus('error')
} }
lastError = e instanceof Error ? e.message : 'Auto-save failed' lastError = e?.message || 'Auto-save failed'
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
console.debug('[AutoSave] Error:', lastError)
}
} }
} }

View file

@ -1,7 +1,7 @@
import { loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore' import { loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
export function useDraftRecovery<TPayload>(options: { export function useDraftRecovery<TPayload>(options: {
draftKey: () => string | null draftKey: string | null
onRestore: (payload: TPayload) => void onRestore: (payload: TPayload) => void
enabled?: boolean enabled?: boolean
}) { }) {
@ -17,10 +17,9 @@ export function useDraftRecovery<TPayload>(options: {
// Auto-detect draft on mount using $effect // Auto-detect draft on mount using $effect
$effect(() => { $effect(() => {
const key = options.draftKey() if (!options.draftKey || options.enabled === false) return
if (!key || options.enabled === false) return
const draft = loadDraft<TPayload>(key) const draft = loadDraft<TPayload>(options.draftKey)
if (draft) { if (draft) {
showPrompt = true showPrompt = true
draftTimestamp = draft.ts draftTimestamp = draft.ts
@ -44,21 +43,19 @@ export function useDraftRecovery<TPayload>(options: {
draftTimeText, draftTimeText,
restore() { restore() {
const key = options.draftKey() if (!options.draftKey) return
if (!key) return const draft = loadDraft<TPayload>(options.draftKey)
const draft = loadDraft<TPayload>(key)
if (!draft) return if (!draft) return
options.onRestore(draft.payload) options.onRestore(draft.payload)
showPrompt = false showPrompt = false
clearDraft(key) clearDraft(options.draftKey)
}, },
dismiss() { dismiss() {
const key = options.draftKey() if (!options.draftKey) return
if (!key) return
showPrompt = false showPrompt = false
clearDraft(key) clearDraft(options.draftKey)
} }
} }
} }

View file

@ -2,9 +2,7 @@ import { beforeNavigate } from '$app/navigation'
import { toast } from '$lib/stores/toast' import { toast } from '$lib/stores/toast'
import type { AutoSaveStore } from '$lib/admin/autoSave.svelte' import type { AutoSaveStore } from '$lib/admin/autoSave.svelte'
export function useFormGuards<TPayload = unknown, _TResponse = unknown>( export function useFormGuards(autoSave: AutoSaveStore<unknown, unknown> | null) {
autoSave: AutoSaveStore<TPayload, unknown> | null
) {
if (!autoSave) return // No guards needed for create mode if (!autoSave) return // No guards needed for create mode
// Navigation guard: flush autosave before route change // Navigation guard: flush autosave before route change
@ -23,12 +21,8 @@ export function useFormGuards<TPayload = unknown, _TResponse = unknown>(
// Warn before closing browser tab/window if unsaved changes // Warn before closing browser tab/window if unsaved changes
$effect(() => { $effect(() => {
// Capture autoSave in closure to avoid non-null assertions
const store = autoSave
if (!store) return
function handleBeforeUnload(event: BeforeUnloadEvent) { function handleBeforeUnload(event: BeforeUnloadEvent) {
if (store.status !== 'saved') { if (autoSave!.status !== 'saved') {
event.preventDefault() event.preventDefault()
event.returnValue = '' event.returnValue = ''
} }
@ -40,17 +34,13 @@ export function useFormGuards<TPayload = unknown, _TResponse = unknown>(
// Cmd/Ctrl+S keyboard shortcut for immediate save // Cmd/Ctrl+S keyboard shortcut for immediate save
$effect(() => { $effect(() => {
// Capture autoSave in closure to avoid non-null assertions
const store = autoSave
if (!store) return
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
const key = event.key.toLowerCase() const key = event.key.toLowerCase()
const isModifier = event.metaKey || event.ctrlKey const isModifier = event.metaKey || event.ctrlKey
if (isModifier && key === 's') { if (isModifier && key === 's') {
event.preventDefault() event.preventDefault()
store.flush().catch((error) => { autoSave!.flush().catch((error) => {
console.error('Autosave flush failed:', error) console.error('Autosave flush failed:', error)
toast.error('Failed to save changes') toast.error('Failed to save changes')
}) })

View file

@ -3,9 +3,9 @@
import { z } from 'zod' import { z } from 'zod'
import AdminPage from './AdminPage.svelte' import AdminPage from './AdminPage.svelte'
import AdminSegmentedControl from './AdminSegmentedControl.svelte' import AdminSegmentedControl from './AdminSegmentedControl.svelte'
import Button from './Button.svelte'
import Input from './Input.svelte' import Input from './Input.svelte'
import DropdownSelectField from './DropdownSelectField.svelte' import DropdownSelectField from './DropdownSelectField.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte'
import UnifiedMediaModal from './UnifiedMediaModal.svelte' import UnifiedMediaModal from './UnifiedMediaModal.svelte'
import SmartImage from '../SmartImage.svelte' import SmartImage from '../SmartImage.svelte'
import Composer from './composer' import Composer from './composer'
@ -33,9 +33,8 @@
// State // State
let isLoading = $state(mode === 'edit') let isLoading = $state(mode === 'edit')
let hasLoaded = $state(mode === 'create') let _isSaving = $state(false)
let isSaving = $state(false) let _validationErrors = $state<Record<string, string>>({})
let validationErrors = $state<Record<string, string>>({})
let showBulkAlbumModal = $state(false) let showBulkAlbumModal = $state(false)
let albumMedia = $state<Array<{ media: Media; displayOrder: number }>>([]) let albumMedia = $state<Array<{ media: Media; displayOrder: number }>>([])
let editorInstance = $state<{ save: () => Promise<JSONContent>; clear: () => void } | undefined>() let editorInstance = $state<{ save: () => Promise<JSONContent>; clear: () => void } | undefined>()
@ -76,10 +75,9 @@
// Watch for album changes and populate form data // Watch for album changes and populate form data
$effect(() => { $effect(() => {
if (album && mode === 'edit' && !hasLoaded) { if (album && mode === 'edit') {
populateFormData(album) populateFormData(album)
loadAlbumMedia() loadAlbumMedia()
hasLoaded = true
} else if (mode === 'create') { } else if (mode === 'create') {
isLoading = false isLoading = false
} }
@ -113,9 +111,9 @@
if (!album) return if (!album) return
try { try {
const response = await fetch(`/api/albums/${album.id}`, { const response = await fetch(`/api/albums/${album.id}`, {
credentials: 'same-origin' credentials: 'same-origin'
}) })
if (response.ok) { if (response.ok) {
const data = await response.json() const data = await response.json()
albumMedia = data.media || [] albumMedia = data.media || []
@ -133,7 +131,7 @@
location: formData.location || undefined, location: formData.location || undefined,
year: formData.year || undefined year: formData.year || undefined
}) })
validationErrors = {} _validationErrors = {}
return true return true
} catch (err) { } catch (err) {
if (err instanceof z.ZodError) { if (err instanceof z.ZodError) {
@ -143,22 +141,23 @@
errors[e.path[0].toString()] = e.message errors[e.path[0].toString()] = e.message
} }
}) })
validationErrors = errors _validationErrors = errors
} }
return false return false
} }
} }
async function handleSave() { async function _handleSave() {
if (!validateForm()) { if (!validateForm()) {
toast.error('Please fix the validation errors') toast.error('Please fix the validation errors')
return return
} }
isSaving = true
const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} album...`) const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} album...`)
try { try {
_isSaving = true
const payload = { const payload = {
title: formData.title, title: formData.title,
slug: formData.slug, slug: formData.slug,
@ -167,8 +166,7 @@
location: formData.location || null, location: formData.location || null,
showInUniverse: formData.showInUniverse, showInUniverse: formData.showInUniverse,
status: formData.status, status: formData.status,
content: formData.content, content: formData.content
updatedAt: mode === 'edit' ? album?.updatedAt : undefined
} }
const url = mode === 'edit' ? `/api/albums/${album?.id}` : '/api/albums' const url = mode === 'edit' ? `/api/albums/${album?.id}` : '/api/albums'
@ -242,7 +240,7 @@
) )
console.error(err) console.error(err)
} finally { } finally {
isSaving = false _isSaving = false
} }
} }
@ -275,13 +273,12 @@
/> />
</div> </div>
<div class="header-actions"> <div class="header-actions">
<Button {#if !isLoading}
variant="primary" <AutoSaveStatus
onclick={handleSave} status="idle"
disabled={isSaving} lastSavedAt={album?.updatedAt}
> />
{isSaving ? 'Saving...' : 'Save'} {/if}
</Button>
</div> </div>
</header> </header>
@ -454,6 +451,25 @@
white-space: nowrap; white-space: nowrap;
} }
.btn-icon {
width: 40px;
height: 40px;
border: none;
background: none;
color: $gray-40;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.2s ease;
&:hover {
background: $gray-90;
color: $gray-10;
}
}
.admin-container { .admin-container {
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;

View file

@ -10,7 +10,6 @@
lastSavedAt?: Date | string | null lastSavedAt?: Date | string | null
showTimestamp?: boolean showTimestamp?: boolean
compact?: boolean compact?: boolean
onclick?: () => void
} }
let { let {
@ -20,8 +19,7 @@
error: errorProp, error: errorProp,
lastSavedAt, lastSavedAt,
showTimestamp = true, showTimestamp = true,
compact = true, compact = true
onclick
}: Props = $props() }: Props = $props()
// Support both old subscription-based stores and new reactive values // Support both old subscription-based stores and new reactive values
@ -83,19 +81,12 @@
</script> </script>
{#if label} {#if label}
<button <div class="autosave-status" class:compact>
type="button"
class="autosave-status"
class:compact
class:clickable={!!onclick && status !== 'saving'}
onclick={onclick}
disabled={status === 'saving'}
>
{#if status === 'saving'} {#if status === 'saving'}
<span class="spinner" aria-hidden="true"></span> <span class="spinner" aria-hidden="true"></span>
{/if} {/if}
<span class="text">{label}</span> <span class="text">{label}</span>
</button> </div>
{/if} {/if}
<style lang="scss"> <style lang="scss">
@ -105,26 +96,10 @@
gap: 6px; gap: 6px;
color: $gray-40; color: $gray-40;
font-size: 0.875rem; font-size: 0.875rem;
background: none;
border: none;
padding: 0;
font-family: inherit;
&.compact { &.compact {
font-size: 0.75rem; font-size: 0.75rem;
} }
&.clickable {
cursor: pointer;
&:hover {
color: $gray-20;
}
}
&:disabled {
cursor: default;
}
} }
.spinner { .spinner {

View file

@ -6,7 +6,6 @@
toggleChecked?: boolean toggleChecked?: boolean
toggleDisabled?: boolean toggleDisabled?: boolean
showToggle?: boolean showToggle?: boolean
onToggleChange?: (checked: boolean) => void
children?: import('svelte').Snippet children?: import('svelte').Snippet
} }
@ -15,7 +14,6 @@
toggleChecked = $bindable(false), toggleChecked = $bindable(false),
toggleDisabled = false, toggleDisabled = false,
showToggle = true, showToggle = true,
onToggleChange,
children children
}: Props = $props() }: Props = $props()
</script> </script>
@ -24,7 +22,7 @@
<header class="branding-section__header"> <header class="branding-section__header">
<h2 class="branding-section__title">{title}</h2> <h2 class="branding-section__title">{title}</h2>
{#if showToggle} {#if showToggle}
<BrandingToggle bind:checked={toggleChecked} disabled={toggleDisabled} onchange={onToggleChange} /> <BrandingToggle bind:checked={toggleChecked} disabled={toggleDisabled} />
{/if} {/if}
</header> </header>
<div class="branding-section__content"> <div class="branding-section__content">

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation' import { goto, beforeNavigate } from '$app/navigation'
import AdminPage from './AdminPage.svelte' import AdminPage from './AdminPage.svelte'
import AdminSegmentedControl from './AdminSegmentedControl.svelte' import AdminSegmentedControl from './AdminSegmentedControl.svelte'
import Editor from './Editor.svelte' import Editor from './Editor.svelte'
@ -7,7 +7,11 @@
import Input from './Input.svelte' import Input from './Input.svelte'
import DropdownSelectField from './DropdownSelectField.svelte' import DropdownSelectField from './DropdownSelectField.svelte'
import { toast } from '$lib/stores/toast' import { toast } from '$lib/stores/toast'
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte'
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
import type { Post } from '@prisma/client'
interface Props { interface Props {
postId?: number postId?: number
@ -25,9 +29,9 @@
let { postId, initialData, mode }: Props = $props() let { postId, initialData, mode }: Props = $props()
// State // State
let hasLoaded = $state(mode === 'create') let hasLoaded = $state(mode === 'create') // Create mode loads immediately
let isSaving = $state(false)
let activeTab = $state('metadata') let activeTab = $state('metadata')
let updatedAt = $state<string | undefined>(initialData?.updatedAt)
// Form data // Form data
let title = $state(initialData?.title || '') let title = $state(initialData?.title || '')
@ -40,6 +44,49 @@
// Ref to the editor component // Ref to the editor component
let editorRef: { save: () => Promise<JSONContent> } | undefined let editorRef: { save: () => Promise<JSONContent> } | undefined
// 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.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
function buildPayload() {
return {
title,
slug,
type: 'essay',
status,
content,
tags,
updatedAt
}
}
// Autosave store (edit mode only)
let autoSave = mode === 'edit' && postId
? createAutoSaveStore({
debounceMs: 2000,
getPayload: () => (hasLoaded ? buildPayload() : null),
save: async (payload, { signal }) => {
const response = await fetch(`/api/posts/${postId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'same-origin',
signal
})
if (!response.ok) throw new Error('Failed to save')
return await response.json()
},
onSaved: (saved: Post, { prime }) => {
updatedAt = saved.updatedAt.toISOString()
prime(buildPayload())
if (draftKey) clearDraft(draftKey)
}
})
: null
const tabOptions = [ const tabOptions = [
{ value: 'metadata', label: 'Metadata' }, { value: 'metadata', label: 'Metadata' },
{ value: 'content', label: 'Content' } { value: 'content', label: 'Content' }
@ -59,19 +106,127 @@
] ]
// Auto-generate slug from title // Auto-generate slug from title
$effect(() => { $effect(() => {
if (title && !slug) { if (title && !slug) {
slug = title slug = title
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') .replace(/^-+|-+$/g, '')
} }
})
// Prime autosave on initial load (edit mode only)
$effect(() => {
if (mode === 'edit' && initialData && !hasLoaded && autoSave) {
autoSave.prime(buildPayload())
hasLoaded = true
}
}) })
// Mark as loaded for edit mode // Trigger autosave when form data changes
$effect(() => { $effect(() => {
if (mode === 'edit' && initialData && !hasLoaded) { void title; void slug; void status; void content; void tags; void activeTab
hasLoaded = true if (hasLoaded && autoSave) {
autoSave.schedule()
}
})
// Save draft only when autosave fails
$effect(() => {
if (hasLoaded && autoSave) {
const saveStatus = autoSave.status
if (saveStatus === 'error' || saveStatus === 'offline') {
saveDraft(draftKey, buildPayload())
}
}
})
// Show restore prompt if a draft exists
$effect(() => {
const draft = loadDraft<ReturnType<typeof buildPayload>>(draftKey)
if (draft) {
showDraftPrompt = true
draftTimestamp = draft.ts
}
})
function restoreDraft() {
const draft = loadDraft<ReturnType<typeof buildPayload>>(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
clearDraft(draftKey)
}
function dismissDraft() {
showDraftPrompt = false
clearDraft(draftKey)
}
// Auto-update draft time text every minute when prompt visible
$effect(() => {
if (showDraftPrompt) {
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
return () => clearInterval(id)
}
})
// Navigation guard: flush autosave before navigating away (only if unsaved)
beforeNavigate(async () => {
if (hasLoaded && autoSave) {
if (autoSave.status === 'saved') {
return
}
// Flush any pending changes before allowing navigation to proceed
try {
await autoSave.flush()
} catch (error) {
console.error('Autosave flush failed:', error)
}
}
})
// Warn before closing browser tab/window if there are unsaved changes
$effect(() => {
if (!hasLoaded || !autoSave) return
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (autoSave!.status !== 'saved') {
event.preventDefault()
event.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
})
// Keyboard shortcut: Cmd/Ctrl+S to save immediately
$effect(() => {
if (!hasLoaded || !autoSave) return
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
autoSave!.flush().catch((error) => {
console.error('Autosave flush failed:', error)
})
}
}
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
})
// Cleanup autosave on unmount
$effect(() => {
if (autoSave) {
return () => autoSave.destroy()
} }
}) })
@ -104,18 +259,16 @@
return return
} }
isSaving = true
const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} essay...`) const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} essay...`)
try { try {
const payload = { const payload = {
title, title,
slug, slug,
type: 'essay', type: 'essay', // No mapping needed anymore
status, status,
content, content,
tags, tags
updatedAt: mode === 'edit' ? initialData?.updatedAt : undefined
} }
const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts' const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts'
@ -141,7 +294,8 @@
const savedPost = await response.json() const savedPost = await response.json()
toast.dismiss(loadingToastId) toast.dismiss(loadingToastId)
toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`) toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`)
clearDraft(draftKey)
if (mode === 'create') { if (mode === 'create') {
goto(`/admin/posts/${savedPost.id}/edit`) goto(`/admin/posts/${savedPost.id}/edit`)
@ -150,10 +304,9 @@
toast.dismiss(loadingToastId) toast.dismiss(loadingToastId)
toast.error(`Failed to ${mode === 'edit' ? 'save' : 'create'} essay`) toast.error(`Failed to ${mode === 'edit' ? 'save' : 'create'} essay`)
console.error(err) console.error(err)
} finally {
isSaving = false
} }
} }
</script> </script>
<AdminPage> <AdminPage>
@ -169,16 +322,30 @@
/> />
</div> </div>
<div class="header-actions"> <div class="header-actions">
<Button {#if mode === 'edit' && autoSave}
variant="primary" <AutoSaveStatus
onclick={handleSave} status={autoSave.status}
disabled={isSaving} error={autoSave.lastError}
> lastSavedAt={initialData?.updatedAt}
{isSaving ? 'Saving...' : 'Save'} />
</Button> {/if}
</div> </div>
</header> </header>
{#if showDraftPrompt}
<div class="draft-banner">
<div class="draft-banner-content">
<span class="draft-banner-text">
Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}.
</span>
<div class="draft-banner-actions">
<button class="draft-banner-button" onclick={restoreDraft}>Restore</button>
<button class="draft-banner-button dismiss" onclick={dismissDraft}>Dismiss</button>
</div>
</div>
</div>
{/if}
<div class="admin-container"> <div class="admin-container">
<div class="tab-panels"> <div class="tab-panels">
<!-- Metadata Panel --> <!-- Metadata Panel -->
@ -308,6 +475,143 @@
} }
} }
.save-actions {
position: relative;
display: flex;
}
.draft-banner {
background: $blue-95;
border-bottom: 1px solid $blue-80;
padding: $unit-2x $unit-5x;
display: flex;
justify-content: center;
align-items: center;
animation: slideDown 0.2s ease-out;
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
.draft-banner-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: $unit-3x;
width: 100%;
max-width: 1200px;
}
.draft-banner-text {
color: $blue-20;
font-size: $font-size-small;
font-weight: $font-weight-med;
}
.draft-banner-actions {
display: flex;
gap: $unit-2x;
}
.draft-banner-button {
background: $blue-50;
border: none;
color: $white;
cursor: pointer;
padding: $unit-half $unit-2x;
border-radius: $corner-radius-sm;
font-size: $font-size-small;
font-weight: $font-weight-med;
transition: background $transition-fast;
&:hover {
background: $blue-40;
}
&.dismiss {
background: transparent;
color: $blue-30;
&:hover {
background: $blue-90;
}
}
}
// Custom styles for save/publish buttons to maintain grey color scheme
:global(.save-button.btn-primary) {
background-color: $gray-10;
&:hover:not(:disabled) {
background-color: $gray-20;
}
&:active:not(:disabled) {
background-color: $gray-30;
}
}
.save-button {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
padding-right: $unit-2x;
}
:global(.chevron-button.btn-primary) {
background-color: $gray-10;
&:hover:not(:disabled) {
background-color: $gray-20;
}
&:active:not(:disabled) {
background-color: $gray-30;
}
&.active {
background-color: $gray-20;
}
}
.chevron-button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 1px solid rgba(255, 255, 255, 0.2);
svg {
transition: transform 0.2s ease;
}
&.active svg {
transform: rotate(180deg);
}
}
.publish-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: $unit;
background: white;
border-radius: $unit;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
overflow: hidden;
min-width: 120px;
z-index: 100;
.menu-item {
text-align: left;
}
}
.tab-panels { .tab-panels {
position: relative; position: relative;
@ -329,6 +633,26 @@
margin: 0 auto; margin: 0 auto;
} }
.error-message,
.success-message {
padding: $unit-3x;
border-radius: $unit;
margin-bottom: $unit-4x;
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
.error-message {
background-color: #fee;
color: #d33;
}
.success-message {
background-color: #efe;
color: #363;
}
.form-section { .form-section {
margin-bottom: $unit-6x; margin-bottom: $unit-6x;

View file

@ -97,8 +97,7 @@ let autoSave = mode === 'edit' && postId
return await response.json() return await response.json()
}, },
onSaved: (saved: Post, { prime }) => { onSaved: (saved: Post, { prime }) => {
updatedAt = updatedAt = saved.updatedAt.toISOString()
typeof saved.updatedAt === 'string' ? saved.updatedAt : saved.updatedAt.toISOString()
prime(buildPayload()) prime(buildPayload())
if (draftKey) clearDraft(draftKey) if (draftKey) clearDraft(draftKey)
} }

View file

@ -9,9 +9,10 @@
interface Props { interface Props {
formData: ProjectFormData formData: ProjectFormData
validationErrors: Record<string, string> validationErrors: Record<string, string>
onSave?: () => Promise<void>
} }
let { formData = $bindable(), validationErrors }: Props = $props() let { formData = $bindable(), validationErrors, onSave }: Props = $props()
// ===== Media State Management ===== // ===== Media State Management =====
// Convert logoUrl string to Media object for ImageUploader // Convert logoUrl string to Media object for ImageUploader
@ -90,47 +91,16 @@
if (!hasLogo) formData.showLogoInHeader = false if (!hasLogo) formData.showLogoInHeader = false
}) })
// Track previous toggle states to detect which one changed
let prevShowFeaturedImage: boolean | null = $state(null)
let prevShowBackgroundColor: boolean | null = $state(null)
// Mutual exclusion: only one of featured image or background color can be active
$effect(() => {
// On first run (initial load), if both are true, default to featured image taking priority
if (prevShowFeaturedImage === null && prevShowBackgroundColor === null) {
if (formData.showFeaturedImageInHeader && formData.showBackgroundColorInHeader) {
formData.showBackgroundColorInHeader = false
}
prevShowFeaturedImage = formData.showFeaturedImageInHeader
prevShowBackgroundColor = formData.showBackgroundColorInHeader
return
}
const featuredChanged = formData.showFeaturedImageInHeader !== prevShowFeaturedImage
const bgColorChanged = formData.showBackgroundColorInHeader !== prevShowBackgroundColor
if (featuredChanged && formData.showFeaturedImageInHeader && formData.showBackgroundColorInHeader) {
// Featured image was just turned ON while background color was already ON
formData.showBackgroundColorInHeader = false
} else if (bgColorChanged && formData.showBackgroundColorInHeader && formData.showFeaturedImageInHeader) {
// Background color was just turned ON while featured image was already ON
formData.showFeaturedImageInHeader = false
}
// Update previous values
prevShowFeaturedImage = formData.showFeaturedImageInHeader
prevShowBackgroundColor = formData.showBackgroundColorInHeader
})
// ===== Upload Handlers ===== // ===== Upload Handlers =====
function handleFeaturedImageUpload(media: Media) { function handleFeaturedImageUpload(media: Media) {
formData.featuredImage = media.url formData.featuredImage = media.url
featuredImageMedia = media featuredImageMedia = media
} }
function handleFeaturedImageRemove() { async function handleFeaturedImageRemove() {
formData.featuredImage = '' formData.featuredImage = ''
featuredImageMedia = null featuredImageMedia = null
if (onSave) await onSave()
} }
function handleLogoUpload(media: Media) { function handleLogoUpload(media: Media) {
@ -138,9 +108,10 @@
logoMedia = media logoMedia = media
} }
function handleLogoRemove() { async function handleLogoRemove() {
formData.logoUrl = '' formData.logoUrl = ''
logoMedia = null logoMedia = null
if (onSave) await onSave()
} }
</script> </script>

View file

@ -3,13 +3,19 @@
import { api } from '$lib/admin/api' import { api } from '$lib/admin/api'
import AdminPage from './AdminPage.svelte' import AdminPage from './AdminPage.svelte'
import AdminSegmentedControl from './AdminSegmentedControl.svelte' import AdminSegmentedControl from './AdminSegmentedControl.svelte'
import Button from './Button.svelte'
import Composer from './composer' import Composer from './composer'
import ProjectMetadataForm from './ProjectMetadataForm.svelte' import ProjectMetadataForm from './ProjectMetadataForm.svelte'
import ProjectBrandingForm from './ProjectBrandingForm.svelte' import ProjectBrandingForm from './ProjectBrandingForm.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte'
import DraftPrompt from './DraftPrompt.svelte'
import { toast } from '$lib/stores/toast' import { toast } from '$lib/stores/toast'
import type { Project } from '$lib/types/project' import type { Project } from '$lib/types/project'
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
import { createProjectFormStore } from '$lib/stores/project-form.svelte' import { createProjectFormStore } from '$lib/stores/project-form.svelte'
import { useDraftRecovery } from '$lib/admin/useDraftRecovery.svelte'
import { useFormGuards } from '$lib/admin/useFormGuards.svelte'
import { makeDraftKey, saveDraft, clearDraft } from '$lib/admin/draftStore'
import type { ProjectFormData } from '$lib/types/project'
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
interface Props { interface Props {
@ -25,12 +31,42 @@
// UI state // UI state
let isLoading = $state(mode === 'edit') let isLoading = $state(mode === 'edit')
let hasLoaded = $state(mode === 'create') let hasLoaded = $state(mode === 'create')
let isSaving = $state(false)
let activeTab = $state('metadata') let activeTab = $state('metadata')
let error = $state<string | null>(null)
let successMessage = $state<string | null>(null)
// Ref to the editor component // Ref to the editor component
let editorRef: { save: () => Promise<JSONContent> } | undefined = $state.raw() let editorRef: { save: () => Promise<JSONContent> } | undefined = $state.raw()
// Draft key for autosave fallback
const draftKey = $derived(mode === 'edit' && project ? makeDraftKey('project', project.id) : null)
// Autosave (edit mode only)
const autoSave = mode === 'edit'
? createAutoSaveStore({
debounceMs: 2000,
getPayload: () => (hasLoaded ? formStore.buildPayload() : null),
save: async (payload, { signal }) => {
return await api.put(`/api/projects/${project?.id}`, payload, { signal })
},
onSaved: (savedProject: Project, { prime }) => {
project = savedProject
formStore.populateFromProject(savedProject)
prime(formStore.buildPayload())
if (draftKey) clearDraft(draftKey)
}
})
: null
// Draft recovery helper
const draftRecovery = useDraftRecovery<Partial<ProjectFormData>>({
draftKey: () => draftKey,
onRestore: (payload) => formStore.setFields(payload)
})
// Form guards (navigation protection, Cmd+S, beforeunload)
useFormGuards(autoSave)
const tabOptions = [ const tabOptions = [
{ value: 'metadata', label: 'Metadata' }, { value: 'metadata', label: 'Metadata' },
{ value: 'branding', label: 'Branding' }, { value: 'branding', label: 'Branding' },
@ -41,11 +77,40 @@
$effect(() => { $effect(() => {
if (project && mode === 'edit' && !hasLoaded) { if (project && mode === 'edit' && !hasLoaded) {
formStore.populateFromProject(project) formStore.populateFromProject(project)
if (autoSave) {
autoSave.prime(formStore.buildPayload())
}
isLoading = false isLoading = false
hasLoaded = true hasLoaded = true
} }
}) })
// Trigger autosave when formData changes (edit mode)
$effect(() => {
// Establish dependencies on fields
void formStore.fields; void activeTab
if (mode === 'edit' && hasLoaded && autoSave) {
autoSave.schedule()
}
})
// Save draft only when autosave fails
$effect(() => {
if (mode === 'edit' && autoSave && draftKey) {
const status = autoSave.status
if (status === 'error' || status === 'offline') {
saveDraft(draftKey, formStore.buildPayload())
}
}
})
// Cleanup autosave on unmount
$effect(() => {
if (autoSave) {
return () => autoSave.destroy()
}
})
function handleEditorChange(content: JSONContent) { function handleEditorChange(content: JSONContent) {
formStore.setField('caseStudyContent', content) formStore.setField('caseStudyContent', content)
} }
@ -64,7 +129,6 @@
return return
} }
isSaving = true
const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} project...`) const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} project...`)
try { try {
@ -74,12 +138,6 @@
updatedAt: mode === 'edit' ? project?.updatedAt : undefined updatedAt: mode === 'edit' ? project?.updatedAt : undefined
} }
console.log('[ProjectForm] Saving with payload:', {
showFeaturedImageInHeader: payload.showFeaturedImageInHeader,
showBackgroundColorInHeader: payload.showBackgroundColorInHeader,
showLogoInHeader: payload.showLogoInHeader
})
let savedProject: Project let savedProject: Project
if (mode === 'edit') { if (mode === 'edit') {
savedProject = await api.put(`/api/projects/${project?.id}`, payload) as Project savedProject = await api.put(`/api/projects/${project?.id}`, payload) as Project
@ -94,7 +152,6 @@
goto(`/admin/projects/${savedProject.id}/edit`) goto(`/admin/projects/${savedProject.id}/edit`)
} else { } else {
project = savedProject project = savedProject
formStore.populateFromProject(savedProject)
} }
} catch (err) { } catch (err) {
toast.dismiss(loadingToastId) toast.dismiss(loadingToastId)
@ -104,10 +161,10 @@
toast.error(`Failed to ${mode === 'edit' ? 'save' : 'create'} project`) toast.error(`Failed to ${mode === 'edit' ? 'save' : 'create'} project`)
} }
console.error(err) console.error(err)
} finally {
isSaving = false
} }
} }
</script> </script>
<AdminPage> <AdminPage>
@ -123,20 +180,36 @@
/> />
</div> </div>
<div class="header-actions"> <div class="header-actions">
<Button {#if !isLoading && mode === 'edit' && autoSave}
variant="primary" <AutoSaveStatus
onclick={handleSave} status={autoSave.status}
disabled={isSaving} error={autoSave.lastError}
> lastSavedAt={project?.updatedAt}
{isSaving ? 'Saving...' : 'Save'} />
</Button> {/if}
</div> </div>
</header> </header>
{#if draftRecovery.showPrompt}
<DraftPrompt
timeAgo={draftRecovery.draftTimeText}
onRestore={draftRecovery.restore}
onDismiss={draftRecovery.dismiss}
/>
{/if}
<div class="admin-container"> <div class="admin-container">
{#if isLoading} {#if isLoading}
<div class="loading">Loading project...</div> <div class="loading">Loading project...</div>
{:else} {:else}
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if successMessage}
<div class="success-message">{successMessage}</div>
{/if}
<div class="tab-panels"> <div class="tab-panels">
<!-- Metadata Panel --> <!-- Metadata Panel -->
<div class="panel content-wrapper" class:active={activeTab === 'metadata'}> <div class="panel content-wrapper" class:active={activeTab === 'metadata'}>
@ -147,7 +220,7 @@
handleSave() handleSave()
}} }}
> >
<ProjectMetadataForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} /> <ProjectMetadataForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
</form> </form>
</div> </div>
</div> </div>
@ -161,7 +234,7 @@
handleSave() handleSave()
}} }}
> >
<ProjectBrandingForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} /> <ProjectBrandingForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
</form> </form>
</div> </div>
</div> </div>
@ -222,6 +295,25 @@
white-space: nowrap; white-space: nowrap;
} }
.btn-icon {
width: 40px;
height: 40px;
border: none;
background: none;
color: $gray-40;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.2s ease;
&:hover {
background: $gray-90;
color: $gray-10;
}
}
.admin-container { .admin-container {
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
@ -254,12 +346,37 @@
margin: 0 auto; margin: 0 auto;
} }
.loading { .loading,
.error {
text-align: center; text-align: center;
padding: $unit-6x; padding: $unit-6x;
color: $gray-40; color: $gray-40;
} }
.error {
color: #d33;
}
.error-message,
.success-message {
padding: $unit-3x;
border-radius: $unit;
margin-bottom: $unit-4x;
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
.error-message {
background-color: #fee;
color: #d33;
}
.success-message {
background-color: #efe;
color: #363;
}
.form-content { .form-content {
@include breakpoint('phone') { @include breakpoint('phone') {
padding: $unit-3x; padding: $unit-3x;

View file

@ -1,11 +1,26 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation' import { goto, beforeNavigate } from '$app/navigation'
import AdminPage from './AdminPage.svelte' import AdminPage from './AdminPage.svelte'
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
import type { Post } from '@prisma/client'
import Editor from './Editor.svelte' import Editor from './Editor.svelte'
import Button from './Button.svelte' import Button from './Button.svelte'
import Input from './Input.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 { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte'
// Payload type for saving posts
interface PostPayload {
type: string
status: string
content: JSONContent
updatedAt?: string
title?: string
link_url?: string
linkDescription?: string
}
interface Props { interface Props {
postType: 'post' postType: 'post'
@ -21,11 +36,13 @@
mode: 'create' | 'edit' mode: 'create' | 'edit'
} }
let { postType, postId, initialData, mode }: Props = $props() let { postType, postId, initialData, mode }: Props = $props()
// State // State
let isSaving = $state(false) let isSaving = $state(false)
let hasLoaded = $state(mode === 'create')
let status = $state<'draft' | 'published'>(initialData?.status || 'draft') let status = $state<'draft' | 'published'>(initialData?.status || 'draft')
let updatedAt = $state<string | undefined>(initialData?.updatedAt)
// Form data // Form data
let content = $state<JSONContent>(initialData?.content || { type: 'doc', content: [] }) let content = $state<JSONContent>(initialData?.content || { type: 'doc', content: [] })
@ -33,7 +50,7 @@
let linkDescription = $state(initialData?.linkDescription || '') let linkDescription = $state(initialData?.linkDescription || '')
let title = $state(initialData?.title || '') let title = $state(initialData?.title || '')
// Character count for posts // Character count for posts
const maxLength = 280 const maxLength = 280
const textContent = $derived.by(() => { const textContent = $derived.by(() => {
if (!content.content) return '' if (!content.content) return ''
@ -50,11 +67,178 @@
const isOverLimit = $derived(charCount > maxLength) const isOverLimit = $derived(charCount > maxLength)
// Check if form has content // Check if form has content
const hasContent = $derived.by(() => { const hasContent = $derived.by(() => {
// For posts, check if either content exists or it's a link with URL
const hasTextContent = textContent.trim().length > 0 const hasTextContent = textContent.trim().length > 0
const hasLinkContent = linkUrl && linkUrl.trim().length > 0 const hasLinkContent = linkUrl && linkUrl.trim().length > 0
return hasTextContent || hasLinkContent 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.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
function buildPayload(): PostPayload {
const payload: PostPayload = {
type: 'post',
status,
content,
updatedAt
}
if (linkUrl && linkUrl.trim()) {
payload.title = title || linkUrl
payload.link_url = linkUrl
payload.linkDescription = linkDescription
} else if (title) {
payload.title = title
}
return payload
}
// Autosave store (edit mode only)
let autoSave = mode === 'edit' && postId
? createAutoSaveStore({
debounceMs: 2000,
getPayload: () => (hasLoaded ? buildPayload() : null),
save: async (payload, { signal }) => {
const response = await fetch(`/api/posts/${postId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'same-origin',
signal
})
if (!response.ok) throw new Error('Failed to save')
return await response.json()
},
onSaved: (saved: Post, { prime }) => {
updatedAt = saved.updatedAt.toISOString()
prime(buildPayload())
if (draftKey) clearDraft(draftKey)
}
})
: null
// Prime autosave on initial load (edit mode only)
$effect(() => {
if (mode === 'edit' && initialData && !hasLoaded && autoSave) {
autoSave.prime(buildPayload())
hasLoaded = true
}
})
// Trigger autosave when form data changes
$effect(() => {
void status; void content; void linkUrl; void linkDescription; void title
if (hasLoaded && autoSave) {
autoSave.schedule()
}
})
// Save draft only when autosave fails
$effect(() => {
if (hasLoaded && autoSave) {
const saveStatus = autoSave.status
if (saveStatus === 'error' || saveStatus === 'offline') {
saveDraft(draftKey, buildPayload())
}
}
})
$effect(() => {
const draft = loadDraft<PostPayload>(draftKey)
if (draft) {
showDraftPrompt = true
draftTimestamp = draft.ts
}
})
function restoreDraft() {
const draft = loadDraft<PostPayload>(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
clearDraft(draftKey)
}
function dismissDraft() {
showDraftPrompt = false
clearDraft(draftKey)
}
// Auto-update draft time text every minute when prompt visible
$effect(() => {
if (showDraftPrompt) {
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
return () => clearInterval(id)
}
})
// Navigation guard: flush autosave before navigating away (only if unsaved)
beforeNavigate(async (_navigation) => {
if (hasLoaded && autoSave) {
if (autoSave.status === 'saved') {
return
}
// Flush any pending changes before allowing navigation to proceed
try {
await autoSave.flush()
} catch (error) {
console.error('Autosave flush failed:', error)
}
}
})
// Warn before closing browser tab/window if there are unsaved changes
$effect(() => {
if (!hasLoaded || !autoSave) return
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (autoSave!.status !== 'saved') {
event.preventDefault()
event.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
})
// Keyboard shortcut: Cmd/Ctrl+S to save immediately
$effect(() => {
if (!hasLoaded || !autoSave) return
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
autoSave!.flush().catch((error) => {
console.error('Autosave flush failed:', error)
})
}
}
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
})
// Cleanup autosave on unmount
$effect(() => {
if (autoSave) {
return () => autoSave.destroy()
}
})
async function handleSave(publishStatus: 'draft' | 'published') { async function handleSave(publishStatus: 'draft' | 'published') {
if (isOverLimit) { if (isOverLimit) {
@ -62,24 +246,26 @@
return return
} }
// For link posts, URL is required
if (linkUrl && !linkUrl.trim()) { if (linkUrl && !linkUrl.trim()) {
toast.error('Link URL is required') toast.error('Link URL is required')
return return
} }
isSaving = true
const loadingToastId = toast.loading( const loadingToastId = toast.loading(
`${publishStatus === 'published' ? 'Publishing' : 'Saving'} post...` `${publishStatus === 'published' ? 'Publishing' : 'Saving'} post...`
) )
try { try {
isSaving = true
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
type: 'post', type: 'post', // Use simplified post type
status: publishStatus, status: publishStatus,
content: content, content: content
updatedAt: mode === 'edit' ? initialData?.updatedAt : undefined
} }
// Add link fields if they're provided
if (linkUrl && linkUrl.trim()) { if (linkUrl && linkUrl.trim()) {
payload.title = title || linkUrl payload.title = title || linkUrl
payload.link_url = linkUrl payload.link_url = linkUrl
@ -106,11 +292,13 @@
throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} post`) throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} post`)
} }
await response.json() await response.json()
toast.dismiss(loadingToastId) toast.dismiss(loadingToastId)
toast.success(`Post ${publishStatus === 'published' ? 'published' : 'saved'} successfully!`) toast.success(`Post ${publishStatus === 'published' ? 'published' : 'saved'} successfully!`)
clearDraft(draftKey)
// Redirect back to posts list after creation
goto('/admin/posts') goto('/admin/posts')
} catch (err) { } catch (err) {
toast.dismiss(loadingToastId) toast.dismiss(loadingToastId)
@ -145,19 +333,36 @@
</h1> </h1>
</div> </div>
<div class="header-actions"> <div class="header-actions">
{#if mode === 'edit' && autoSave}
<AutoSaveStatus status={autoSave.status} error={autoSave.lastError} />
{/if}
<Button variant="secondary" onclick={() => handleSave('draft')} disabled={isSaving}> <Button variant="secondary" onclick={() => handleSave('draft')} disabled={isSaving}>
Save Draft Save Draft
</Button> </Button>
<Button <Button
variant="primary" variant="primary"
onclick={() => handleSave('published')} onclick={() => handleSave('published')}
disabled={isSaving || !hasContent || (postType === 'microblog' && isOverLimit)} disabled={isSaving || !hasContent() || (postType === 'microblog' && isOverLimit)}
> >
Post Post
</Button> </Button>
</div> </div>
</header> </header>
{#if showDraftPrompt}
<div class="draft-banner">
<div class="draft-banner-content">
<span class="draft-banner-text">
Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}.
</span>
<div class="draft-banner-actions">
<button class="draft-banner-button" onclick={restoreDraft}>Restore</button>
<button class="draft-banner-button dismiss" onclick={dismissDraft}>Dismiss</button>
</div>
</div>
</div>
{/if}
<div class="composer-container"> <div class="composer-container">
<div class="composer"> <div class="composer">
{#if postType === 'microblog'} {#if postType === 'microblog'}
@ -238,6 +443,15 @@
padding: $unit-3x; padding: $unit-3x;
} }
.error-message {
padding: $unit-2x;
border-radius: $unit;
margin-bottom: $unit-3x;
background-color: #fee;
color: #d33;
font-size: 0.875rem;
}
.composer { .composer {
background: white; background: white;
border-radius: $unit-2x; border-radius: $unit-2x;
@ -346,4 +560,103 @@
color: $gray-60; color: $gray-60;
} }
} }
.draft-banner {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border-bottom: 1px solid #f59e0b;
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.15);
padding: $unit-3x $unit-4x;
animation: slideDown 0.3s ease-out;
@include breakpoint('phone') {
padding: $unit-2x $unit-3x;
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.draft-banner-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: $unit-3x;
@include breakpoint('phone') {
flex-direction: column;
align-items: flex-start;
gap: $unit-2x;
}
}
.draft-banner-text {
color: #92400e;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.5;
@include breakpoint('phone') {
font-size: 0.8125rem;
}
}
.draft-banner-actions {
display: flex;
gap: $unit-2x;
flex-shrink: 0;
@include breakpoint('phone') {
width: 100%;
}
}
.draft-banner-button {
background: white;
border: 1px solid #f59e0b;
color: #92400e;
padding: $unit $unit-3x;
border-radius: $unit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
&:hover {
background: #fffbeb;
border-color: #d97706;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(245, 158, 11, 0.2);
}
&:active {
transform: translateY(0);
}
&.dismiss {
background: transparent;
border-color: #fbbf24;
color: #b45309;
&:hover {
background: rgba(255, 255, 255, 0.5);
border-color: #f59e0b;
}
}
@include breakpoint('phone') {
flex: 1;
padding: $unit-1_5x $unit-2x;
font-size: 0.8125rem;
}
}
</style> </style>

View file

@ -32,7 +32,7 @@
// URL convert handlers // URL convert handlers
export function handleShowUrlConvertDropdown(pos: number, _url: string) { export function handleShowUrlConvertDropdown(pos: number, _url: string) {
if (!editor || !editor.view) return if (!editor) return
const coords = editor.view.coordsAtPos(pos) const coords = editor.view.coordsAtPos(pos)
urlConvertDropdownPosition = { x: coords.left, y: coords.bottom + 5 } urlConvertDropdownPosition = { x: coords.left, y: coords.bottom + 5 }
urlConvertPos = pos urlConvertPos = pos
@ -48,7 +48,7 @@
// Link context menu handlers // Link context menu handlers
export function handleShowLinkContextMenu(pos: number, url: string) { export function handleShowLinkContextMenu(pos: number, url: string) {
if (!editor || !editor.view) return if (!editor) return
const coords = editor.view.coordsAtPos(pos) const coords = editor.view.coordsAtPos(pos)
linkContextMenuPosition = { x: coords.left, y: coords.bottom + 5 } linkContextMenuPosition = { x: coords.left, y: coords.bottom + 5 }
linkContextUrl = url linkContextUrl = url
@ -65,7 +65,7 @@
} }
function handleEditLink() { function handleEditLink() {
if (!editor || !editor.view || linkContextPos === null || !linkContextUrl) return if (!editor || linkContextPos === null || !linkContextUrl) return
const coords = editor.view.coordsAtPos(linkContextPos) const coords = editor.view.coordsAtPos(linkContextPos)
linkEditDialogPosition = { x: coords.left, y: coords.bottom + 5 } linkEditDialogPosition = { x: coords.left, y: coords.bottom + 5 }
linkEditUrl = linkContextUrl linkEditUrl = linkContextUrl

View file

@ -32,7 +32,7 @@
function goToSelection() { function goToSelection() {
const { results, resultIndex } = editor.storage.searchAndReplace const { results, resultIndex } = editor.storage.searchAndReplace
const position = results[resultIndex] const position = results[resultIndex]
if (!position || !editor.view) return if (!position) return
editor.commands.setTextSelection(position) editor.commands.setTextSelection(position)
const { node } = editor.view.domAtPos(editor.state.selection.anchor) const { node } = editor.view.domAtPos(editor.state.selection.anchor)
if (node instanceof HTMLElement) node.scrollIntoView({ behavior: 'smooth', block: 'center' }) if (node instanceof HTMLElement) node.scrollIntoView({ behavior: 'smooth', block: 'center' })

View file

@ -15,20 +15,18 @@
let isDragging = $state(false) let isDragging = $state(false)
if (editor.view) { editor.view.dom.addEventListener('dragstart', () => {
editor.view.dom.addEventListener('dragstart', () => { isDragging = true
isDragging = true })
})
editor.view.dom.addEventListener('drop', () => { editor.view.dom.addEventListener('drop', () => {
isDragging = true isDragging = true
// Allow some time for the drop action to complete before re-enabling // Allow some time for the drop action to complete before re-enabling
setTimeout(() => { setTimeout(() => {
isDragging = false isDragging = false
}, 100) // Adjust delay if needed }, 100) // Adjust delay if needed
}) })
}
const bubbleMenuCommands = [ const bubbleMenuCommands = [
...commands['text-formatting'].commands, ...commands['text-formatting'].commands,
@ -42,7 +40,7 @@
function shouldShow(props: ShouldShowProps) { function shouldShow(props: ShouldShowProps) {
if (!props.editor.isEditable) return false if (!props.editor.isEditable) return false
const { view, editor } = props const { view, editor } = props
if (!view || !editor.view || editor.view.dragging) { if (!view || editor.view.dragging) {
return false return false
} }
if (editor.isActive('link')) return false if (editor.isActive('link')) return false

View file

@ -114,7 +114,7 @@ export function getHandlePaste(editor: Editor, maxSize: number = 2) {
* @param event - Optional MouseEvent or KeyboardEvent triggering the focus * @param event - Optional MouseEvent or KeyboardEvent triggering the focus
*/ */
export function focusEditor(editor: Editor | undefined, event?: MouseEvent | KeyboardEvent) { export function focusEditor(editor: Editor | undefined, event?: MouseEvent | KeyboardEvent) {
if (!editor || !editor.view) return if (!editor) return
// Check if there is a text selection already (i.e. a non-empty selection) // Check if there is a text selection already (i.e. a non-empty selection)
const selection = window.getSelection() const selection = window.getSelection()
if (selection && selection.toString().length > 0) { if (selection && selection.toString().length > 0) {

View file

@ -46,16 +46,11 @@ export function createProjectFormStore(initialProject?: Project | null) {
} }
return { return {
// Use getters to maintain reactivity when accessing state from outside the store // State is returned directly - it's already reactive in Svelte 5
get fields() { // Components can read: formStore.fields.title
return fields // Mutation should go through methods below for validation
}, fields,
set fields(value: ProjectFormData) { validationErrors,
fields = value
},
get validationErrors() {
return validationErrors
},
isDirty, isDirty,
// Methods for controlled mutation // Methods for controlled mutation

View file

@ -1,19 +1,16 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores' import { page } from '$app/stores'
import { goto } from '$app/navigation' import { goto, beforeNavigate } from '$app/navigation'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { api } from '$lib/admin/api' import { api } from '$lib/admin/api'
import { makeDraftKey, saveDraft, clearDraft } from '$lib/admin/draftStore' import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
import { useDraftRecovery } from '$lib/admin/useDraftRecovery.svelte'
import { useFormGuards } from '$lib/admin/useFormGuards.svelte'
import AdminPage from '$lib/components/admin/AdminPage.svelte' import AdminPage from '$lib/components/admin/AdminPage.svelte'
import Composer from '$lib/components/admin/composer' import Composer from '$lib/components/admin/composer'
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte' import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte' import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte'
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte' import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
import DraftPrompt from '$lib/components/admin/DraftPrompt.svelte'
import StatusDropdown from '$lib/components/admin/StatusDropdown.svelte' import StatusDropdown from '$lib/components/admin/StatusDropdown.svelte'
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
import AutoSaveStatus from '$lib/components/admin/AutoSaveStatus.svelte' import AutoSaveStatus from '$lib/components/admin/AutoSaveStatus.svelte'
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
import type { Post } from '@prisma/client' import type { Post } from '@prisma/client'
@ -62,8 +59,12 @@
let metadataButtonRef: HTMLButtonElement | undefined = $state.raw() let metadataButtonRef: HTMLButtonElement | undefined = $state.raw()
let showDeleteConfirmation = $state(false) let showDeleteConfirmation = $state(false)
// Draft key for autosave fallback // Draft backup
const draftKey = $derived(makeDraftKey('post', $page.params.id)) 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.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
const postTypeConfig = { const postTypeConfig = {
post: { icon: '💭', label: 'Post', showTitle: false, showContent: true }, post: { icon: '💭', label: 'Post', showTitle: false, showContent: true },
@ -73,7 +74,7 @@
let config = $derived(postTypeConfig[postType]) let config = $derived(postTypeConfig[postType])
// Autosave store // Autosave store
const autoSave = createAutoSaveStore({ let autoSave = createAutoSaveStore({
debounceMs: 2000, debounceMs: 2000,
getPayload: () => { getPayload: () => {
if (!hasLoaded) return null if (!hasLoaded) return null
@ -108,23 +109,6 @@
} }
}) })
// Draft recovery helper
const draftRecovery = useDraftRecovery<DraftPayload>({
draftKey: () => draftKey,
onRestore: (payload) => {
if (payload.title !== undefined) title = payload.title ?? ''
if (payload.slug !== undefined) slug = payload.slug
if (payload.type !== undefined) postType = payload.type as 'post' | 'essay'
if (payload.status !== undefined) status = payload.status as 'draft' | 'published'
if (payload.content !== undefined) content = payload.content ?? { type: 'doc', content: [] }
if (payload.excerpt !== undefined) excerpt = payload.excerpt ?? ''
if (payload.tags !== undefined) tags = payload.tags
}
})
// Form guards (navigation protection, Cmd+S, beforeunload)
useFormGuards(autoSave)
// Convert blocks format (from database) to Tiptap format // Convert blocks format (from database) to Tiptap format
function convertBlocksToTiptap(blocksContent: BlockContent): JSONContent { function convertBlocksToTiptap(blocksContent: BlockContent): JSONContent {
if (!blocksContent || !blocksContent.blocks) { if (!blocksContent || !blocksContent.blocks) {
@ -224,11 +208,16 @@
} }
} }
onMount(async () => { onMount(async () => {
// Wait a tick to ensure page params are loaded // Wait a tick to ensure page params are loaded
await new Promise((resolve) => setTimeout(resolve, 0)) await new Promise((resolve) => setTimeout(resolve, 0))
await loadPost() await loadPost()
}) const draft = loadDraft<DraftPayload>(draftKey)
if (draft) {
showDraftPrompt = true
draftTimestamp = draft.ts
}
})
async function loadPost() { async function loadPost() {
const postId = $page.params.id const postId = $page.params.id
@ -346,6 +335,27 @@
} }
} }
function restoreDraft() {
const draft = loadDraft<DraftPayload>(draftKey)
if (!draft) return
const p = draft.payload
// Apply payload fields to form
if (p.title !== undefined) title = p.title
if (p.slug !== undefined) slug = p.slug
if (p.type !== undefined) postType = p.type
if (p.status !== undefined) status = p.status
if (p.content !== undefined) content = p.content
if (p.excerpt !== undefined) excerpt = p.excerpt
if (p.tags !== undefined) tags = p.tags
showDraftPrompt = false
clearDraft(draftKey)
}
function dismissDraft() {
showDraftPrompt = false
clearDraft(draftKey)
}
function handleMetadataPopover(event: MouseEvent) { function handleMetadataPopover(event: MouseEvent) {
const target = event.target as Node const target = event.target as Node
// Don't close if clicking inside the metadata button or anywhere in a metadata popover // Don't close if clicking inside the metadata button or anywhere in a metadata popover
@ -393,10 +403,68 @@
} }
}) })
// Navigation guard: flush autosave before navigating away (only if there are unsaved changes)
beforeNavigate(async (_navigation) => {
if (hasLoaded) {
// If status is 'saved', there are no unsaved changes - allow navigation
if (autoSave.status === 'saved') {
return
}
// Otherwise, flush any pending changes before allowing navigation to proceed
try {
await autoSave.flush()
} catch (error) {
console.error('Autosave flush failed:', error)
}
}
})
// Warn before closing browser tab/window if there are unsaved changes
$effect(() => {
if (!hasLoaded) return
function handleBeforeUnload(event: BeforeUnloadEvent) {
// Only warn if there are unsaved changes
if (autoSave.status !== 'saved') {
event.preventDefault()
event.returnValue = '' // Required for Chrome
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
})
// Keyboard shortcut: Cmd/Ctrl+S to save immediately
$effect(() => {
if (!hasLoaded) return
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
autoSave.flush().catch((error) => {
console.error('Autosave flush failed:', error)
})
}
}
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
})
// Cleanup autosave on unmount // Cleanup autosave on unmount
$effect(() => { $effect(() => {
return () => autoSave.destroy() return () => autoSave.destroy()
}) })
// Auto-update draft time text every minute when prompt visible
$effect(() => {
if (showDraftPrompt) {
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
return () => clearInterval(id)
}
})
</script> </script>
<svelte:head> <svelte:head>
@ -474,12 +542,18 @@
{/if} {/if}
</header> </header>
{#if draftRecovery.showPrompt} {#if showDraftPrompt}
<DraftPrompt <div class="draft-banner">
timeAgo={draftRecovery.draftTimeText} <div class="draft-banner-content">
onRestore={draftRecovery.restore} <span class="draft-banner-text">
onDismiss={draftRecovery.dismiss} Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}.
/> </span>
<div class="draft-banner-actions">
<button class="draft-banner-button" onclick={restoreDraft}>Restore</button>
<button class="draft-banner-button dismiss" onclick={dismissDraft}>Dismiss</button>
</div>
</div>
</div>
{/if} {/if}
{#if loading} {#if loading}
@ -562,6 +636,72 @@
gap: $unit-2x; gap: $unit-2x;
} }
.draft-banner {
background: $blue-95;
border-bottom: 1px solid $blue-80;
padding: $unit-2x $unit-5x;
display: flex;
justify-content: center;
align-items: center;
animation: slideDown 0.2s ease-out;
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
.draft-banner-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: $unit-3x;
width: 100%;
max-width: 1200px;
}
.draft-banner-text {
color: $blue-20;
font-size: $font-size-small;
font-weight: $font-weight-med;
}
.draft-banner-actions {
display: flex;
gap: $unit-2x;
}
.draft-banner-button {
background: $blue-50;
border: none;
color: $white;
cursor: pointer;
padding: $unit-half $unit-2x;
border-radius: $corner-radius-sm;
font-size: $font-size-small;
font-weight: $font-weight-med;
transition: background $transition-fast;
&:hover {
background: $blue-40;
}
&.dismiss {
background: transparent;
color: $blue-30;
&:hover {
background: $blue-90;
}
}
}
.btn-icon { .btn-icon {
width: 40px; width: 40px;
height: 40px; height: 40px;

View file

@ -37,9 +37,6 @@ interface ProjectCreateBody {
status?: string status?: string
password?: string | null password?: string | null
slug?: string slug?: string
showFeaturedImageInHeader?: boolean
showBackgroundColorInHeader?: boolean
showLogoInHeader?: boolean
} }
// GET /api/projects - List all projects // GET /api/projects - List all projects
@ -151,10 +148,7 @@ export const POST: RequestHandler = async (event) => {
displayOrder: body.displayOrder || 0, displayOrder: body.displayOrder || 0,
status: body.status || 'draft', status: body.status || 'draft',
password: body.password || null, password: body.password || null,
publishedAt: body.status === 'published' ? new Date() : null, publishedAt: body.status === 'published' ? new Date() : null
showFeaturedImageInHeader: body.showFeaturedImageInHeader ?? true,
showBackgroundColorInHeader: body.showBackgroundColorInHeader ?? true,
showLogoInHeader: body.showLogoInHeader ?? true
} }
}) })

View file

@ -36,9 +36,6 @@ interface ProjectUpdateBody {
status?: string status?: string
password?: string | null password?: string | null
slug?: string slug?: string
showFeaturedImageInHeader?: boolean
showBackgroundColorInHeader?: boolean
showLogoInHeader?: boolean
} }
// GET /api/projects/[id] - Get a single project // GET /api/projects/[id] - Get a single project
@ -132,19 +129,7 @@ export const PUT: RequestHandler = async (event) => {
status: body.status !== undefined ? body.status : existing.status, status: body.status !== undefined ? body.status : existing.status,
password: body.password !== undefined ? body.password : existing.password, password: body.password !== undefined ? body.password : existing.password,
publishedAt: publishedAt:
body.status === 'published' && !existing.publishedAt ? new Date() : existing.publishedAt, body.status === 'published' && !existing.publishedAt ? new Date() : existing.publishedAt
showFeaturedImageInHeader:
body.showFeaturedImageInHeader !== undefined
? body.showFeaturedImageInHeader
: existing.showFeaturedImageInHeader,
showBackgroundColorInHeader:
body.showBackgroundColorInHeader !== undefined
? body.showBackgroundColorInHeader
: existing.showBackgroundColorInHeader,
showLogoInHeader:
body.showLogoInHeader !== undefined
? body.showLogoInHeader
: existing.showLogoInHeader
} }
}) })
@ -284,11 +269,6 @@ export const PATCH: RequestHandler = async (event) => {
if (body.projectType !== undefined) updateData.projectType = body.projectType if (body.projectType !== undefined) updateData.projectType = body.projectType
if (body.displayOrder !== undefined) updateData.displayOrder = body.displayOrder if (body.displayOrder !== undefined) updateData.displayOrder = body.displayOrder
if (body.password !== undefined) updateData.password = body.password if (body.password !== undefined) updateData.password = body.password
if (body.showFeaturedImageInHeader !== undefined)
updateData.showFeaturedImageInHeader = body.showFeaturedImageInHeader
if (body.showBackgroundColorInHeader !== undefined)
updateData.showBackgroundColorInHeader = body.showBackgroundColorInHeader
if (body.showLogoInHeader !== undefined) updateData.showLogoInHeader = body.showLogoInHeader
// Handle slug update if provided // Handle slug update if provided
if (body.slug && body.slug !== existing.slug) { if (body.slug && body.slug !== existing.slug) {