Compare commits

...

10 commits

Author SHA1 Message Date
317db75a11 add psn-api to pnpm build allowlist 2026-01-08 00:43:13 -08:00
e09b95213c add pnpm-workspace.yaml to allow lastfm package build scripts 2026-01-08 00:31:44 -08:00
b7b5b4b4e3 fix store reactivity with getter/setter
fields was losing reactivity when passed to child components.
use getter/setter to maintain proxy reference.
2025-12-11 19:04:17 -08:00
640a0d1c19 fix type errors in autosave utils
keep the code around in case we revisit later
2025-12-11 19:04:13 -08:00
97bdccd218 remove autosave, use manual save buttons
autosave was unreliable due to svelte 5 reactivity quirks.
switched all admin forms to explicit save buttons instead.
2025-12-11 19:04:09 -08:00
2555067837 resolve merge conflict in AlbumForm 2025-12-11 14:05:28 -08:00
09d417907b fix: save branding toggle fields in project API endpoints
POST/PUT/PATCH handlers were ignoring showFeaturedImageInHeader,
showBackgroundColorInHeader, and showLogoInHeader fields sent by
the form, so background colors weren't persisting.
2025-12-11 13:54:14 -08:00
2d1d344133 fix: Make AlbumForm autosave reactive to album prop
Changed autosave from const to reactive $state that initializes when album
becomes available. This ensures autosave works even if album is null initially.

Changes:
- Moved autosave creation into $effect that runs when album is available
- Captured album.id in local variable to avoid null reference issues
- Moved useFormGuards call into same effect for proper initialization
- Fixed cleanup effect to capture autoSave instance in closure
- Added proper TypeScript typing for autoSave state
2025-11-24 07:47:43 -08:00
7c08daffe8 fix: Add editor.view null checks to prevent click errors
Added defensive checks for editor.view across editor components to prevent
"Cannot read properties of undefined (reading 'posAtCoords')" errors when
clicking before editor is fully initialized.

Fixed in:
- focusEditor utility function
- ComposerLinkManager dropdown handlers
- bubble-menu event listeners and shouldShow
- SearchAndReplace goToSelection
2025-11-24 07:42:50 -08:00
0b46ebd433 fix: Handle updatedAt as string in admin form autosave
Fixed TypeError when updatedAt field from JSON responses was incorrectly
treated as Date object. Added type guards to handle both string and Date
types in autosave callbacks across all admin forms.
2025-11-24 07:39:34 -08:00
17 changed files with 233 additions and 878 deletions

3
pnpm-workspace.yaml Normal file
View file

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

View file

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

View file

@ -3,18 +3,13 @@
import { z } from 'zod'
import AdminPage from './AdminPage.svelte'
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
import Button from './Button.svelte'
import Input from './Input.svelte'
import DropdownSelectField from './DropdownSelectField.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte'
import DraftPrompt from './DraftPrompt.svelte'
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
import SmartImage from '../SmartImage.svelte'
import Composer from './composer'
import { toast } from '$lib/stores/toast'
import { makeDraftKey, saveDraft, clearDraft } 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 type { Album, Media } from '@prisma/client'
import type { JSONContent } from '@tiptap/core'
@ -39,20 +34,13 @@
// State
let isLoading = $state(mode === 'edit')
let hasLoaded = $state(mode === 'create')
let _isSaving = $state(false)
let _validationErrors = $state<Record<string, string>>({})
let isSaving = $state(false)
let validationErrors = $state<Record<string, string>>({})
let showBulkAlbumModal = $state(false)
let albumMedia = $state<Array<{ media: Media; displayOrder: number }>>([])
let editorInstance = $state<{ save: () => Promise<JSONContent>; clear: () => void } | undefined>()
let activeTab = $state('metadata')
let pendingMediaIds = $state<number[]>([]) // Photos to add after album creation
let updatedAt = $state<string | undefined>(
album?.updatedAt
? typeof album.updatedAt === 'string'
? album.updatedAt
: album.updatedAt.toISOString()
: undefined
)
const tabOptions = [
{ value: 'metadata', label: 'Metadata' },
@ -86,81 +74,12 @@
// Derived state for existing media IDs
const existingMediaIds = $derived(albumMedia.map((item) => item.media.id))
// Draft key for autosave fallback
const draftKey = $derived(mode === 'edit' && album ? makeDraftKey('album', album.id) : null)
function buildPayload() {
return {
title: formData.title,
slug: formData.slug,
description: null,
date: formData.year || null,
location: formData.location || null,
showInUniverse: formData.showInUniverse,
status: formData.status,
content: formData.content,
updatedAt
}
}
// Autosave store (edit mode only)
// Initialized as null and created reactively when album data becomes available
let autoSave = $state<ReturnType<typeof createAutoSaveStore<ReturnType<typeof buildPayload>, Album>> | null>(null)
// INITIALIZATION ORDER:
// 1. This effect creates autoSave when album prop becomes available
// 2. useFormGuards is called immediately after creation (same effect)
// 3. Other effects check for autoSave existence before using it
$effect(() => {
// Create autoSave when album becomes available (only once)
if (mode === 'edit' && album && !autoSave) {
const albumId = album.id // Capture album ID to avoid null reference
autoSave = createAutoSaveStore({
debounceMs: 2000,
getPayload: () => (hasLoaded ? buildPayload() : null),
save: async (payload, { signal }) => {
const response = await fetch(`/api/albums/${albumId}`, {
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: Album, { prime }) => {
updatedAt =
typeof saved.updatedAt === 'string' ? saved.updatedAt : saved.updatedAt.toISOString()
prime(buildPayload())
if (draftKey) clearDraft(draftKey)
}
})
// Form guards (navigation protection, Cmd+S, beforeunload)
useFormGuards(autoSave)
}
})
// Draft recovery helper
const draftRecovery = useDraftRecovery<ReturnType<typeof buildPayload>>({
draftKey: () => draftKey,
onRestore: (payload) => {
formData.title = payload.title ?? formData.title
formData.slug = payload.slug ?? formData.slug
formData.status = payload.status ?? formData.status
formData.year = payload.date ?? formData.year
formData.location = payload.location ?? formData.location
formData.showInUniverse = payload.showInUniverse ?? formData.showInUniverse
formData.content = payload.content ?? formData.content
}
})
// Watch for album changes and populate form data
$effect(() => {
if (album && mode === 'edit') {
if (album && mode === 'edit' && !hasLoaded) {
populateFormData(album)
loadAlbumMedia()
hasLoaded = true
} else if (mode === 'create') {
isLoading = false
}
@ -176,49 +95,6 @@
}
})
// Prime autosave on initial load (edit mode only)
$effect(() => {
if (mode === 'edit' && album && !hasLoaded && autoSave) {
autoSave.prime(buildPayload())
hasLoaded = true
}
})
// Trigger autosave when form data changes
// Using `void` operator to explicitly track dependencies without using their values
// This effect re-runs whenever any of these form fields change
$effect(() => {
void formData.title
void formData.slug
void formData.status
void formData.year
void formData.location
void formData.showInUniverse
void formData.content
void activeTab
if (hasLoaded && autoSave) {
autoSave.schedule()
}
})
// Save draft only when autosave fails
$effect(() => {
if (hasLoaded && autoSave && draftKey) {
const saveStatus = autoSave.status
if (saveStatus === 'error' || saveStatus === 'offline') {
saveDraft(draftKey, buildPayload())
}
}
})
// Cleanup autosave on unmount
$effect(() => {
if (autoSave) {
const instance = autoSave
return () => instance.destroy()
}
})
function populateFormData(data: Album) {
formData = {
title: data.title || '',
@ -237,9 +113,9 @@
if (!album) return
try {
const response = await fetch(`/api/albums/${album.id}`, {
credentials: 'same-origin'
})
const response = await fetch(`/api/albums/${album.id}`, {
credentials: 'same-origin'
})
if (response.ok) {
const data = await response.json()
albumMedia = data.media || []
@ -257,7 +133,7 @@
location: formData.location || undefined,
year: formData.year || undefined
})
_validationErrors = {}
validationErrors = {}
return true
} catch (err) {
if (err instanceof z.ZodError) {
@ -267,23 +143,22 @@
errors[e.path[0].toString()] = e.message
}
})
_validationErrors = errors
validationErrors = errors
}
return false
}
}
async function _handleSave() {
async function handleSave() {
if (!validateForm()) {
toast.error('Please fix the validation errors')
return
}
isSaving = true
const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} album...`)
try {
_isSaving = true
const payload = {
title: formData.title,
slug: formData.slug,
@ -292,7 +167,8 @@
location: formData.location || null,
showInUniverse: formData.showInUniverse,
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'
@ -366,7 +242,7 @@
)
console.error(err)
} finally {
_isSaving = false
isSaving = false
}
}
@ -399,23 +275,16 @@
/>
</div>
<div class="header-actions">
{#if !isLoading}
<AutoSaveStatus
status={autoSave?.status ?? 'idle'}
lastSavedAt={album?.updatedAt}
/>
{/if}
<Button
variant="primary"
onclick={handleSave}
disabled={isSaving}
>
{isSaving ? 'Saving...' : 'Save'}
</Button>
</div>
</header>
{#if draftRecovery.showPrompt}
<DraftPrompt
timeAgo={draftRecovery.draftTimeText}
onRestore={draftRecovery.restore}
onDismiss={draftRecovery.dismiss}
/>
{/if}
<div class="admin-container">
{#if isLoading}
<div class="loading">Loading album...</div>
@ -585,25 +454,6 @@
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 {
width: 100%;
margin: 0 auto;

View file

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

View file

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

View file

@ -6,15 +6,8 @@
import Button from './Button.svelte'
import Input from './Input.svelte'
import DropdownSelectField from './DropdownSelectField.svelte'
import DraftPrompt from './DraftPrompt.svelte'
import { toast } from '$lib/stores/toast'
import { makeDraftKey, saveDraft, clearDraft } 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 AutoSaveStatus from './AutoSaveStatus.svelte'
import type { JSONContent } from '@tiptap/core'
import type { Post } from '@prisma/client'
interface Props {
postId?: number
@ -32,9 +25,9 @@
let { postId, initialData, mode }: Props = $props()
// State
let hasLoaded = $state(mode === 'create') // Create mode loads immediately
let hasLoaded = $state(mode === 'create')
let isSaving = $state(false)
let activeTab = $state('metadata')
let updatedAt = $state<string | undefined>(initialData?.updatedAt)
// Form data
let title = $state(initialData?.title || '')
@ -47,60 +40,6 @@
// Ref to the editor component
let editorRef: { save: () => Promise<JSONContent> } | undefined
// Draft key for autosave fallback
const draftKey = $derived(mode === 'edit' && postId ? makeDraftKey('post', postId) : null)
function buildPayload() {
return {
title,
slug,
type: 'essay',
status,
content,
tags,
updatedAt
}
}
// Autosave store (edit mode only)
const 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
// Draft recovery helper
const draftRecovery = useDraftRecovery<ReturnType<typeof buildPayload>>({
draftKey: () => draftKey,
onRestore: (payload) => {
title = payload.title ?? title
slug = payload.slug ?? slug
status = payload.status ?? status
content = payload.content ?? content
tags = payload.tags ?? tags
}
})
// Form guards (navigation protection, Cmd+S, beforeunload)
useFormGuards(autoSave)
const tabOptions = [
{ value: 'metadata', label: 'Metadata' },
{ value: 'content', label: 'Content' }
@ -129,39 +68,13 @@
}
})
// Prime autosave on initial load (edit mode only)
// Mark as loaded for edit mode
$effect(() => {
if (mode === 'edit' && initialData && !hasLoaded && autoSave) {
autoSave.prime(buildPayload())
if (mode === 'edit' && initialData && !hasLoaded) {
hasLoaded = true
}
})
// Trigger autosave when form data changes
$effect(() => {
void title; void slug; void status; void content; void tags; void activeTab
if (hasLoaded && autoSave) {
autoSave.schedule()
}
})
// Save draft only when autosave fails
$effect(() => {
if (hasLoaded && autoSave && draftKey) {
const saveStatus = autoSave.status
if (saveStatus === 'error' || saveStatus === 'offline') {
saveDraft(draftKey, buildPayload())
}
}
})
// Cleanup autosave on unmount
$effect(() => {
if (autoSave) {
return () => autoSave.destroy()
}
})
function addTag() {
if (tagInput && !tags.includes(tagInput)) {
tags = [...tags, tagInput]
@ -191,16 +104,18 @@
return
}
isSaving = true
const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} essay...`)
try {
const payload = {
title,
slug,
type: 'essay', // No mapping needed anymore
type: 'essay',
status,
content,
tags
tags,
updatedAt: mode === 'edit' ? initialData?.updatedAt : undefined
}
const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts'
@ -226,8 +141,7 @@
const savedPost = await response.json()
toast.dismiss(loadingToastId)
toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`)
clearDraft(draftKey)
toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`)
if (mode === 'create') {
goto(`/admin/posts/${savedPost.id}/edit`)
@ -236,9 +150,10 @@
toast.dismiss(loadingToastId)
toast.error(`Failed to ${mode === 'edit' ? 'save' : 'create'} essay`)
console.error(err)
} finally {
isSaving = false
}
}
</script>
<AdminPage>
@ -254,24 +169,16 @@
/>
</div>
<div class="header-actions">
{#if mode === 'edit' && autoSave}
<AutoSaveStatus
status={autoSave.status}
error={autoSave.lastError}
lastSavedAt={initialData?.updatedAt}
/>
{/if}
<Button
variant="primary"
onclick={handleSave}
disabled={isSaving}
>
{isSaving ? 'Saving...' : 'Save'}
</Button>
</div>
</header>
{#if draftRecovery.showPrompt}
<DraftPrompt
timeAgo={draftRecovery.draftTimeText}
onRestore={draftRecovery.restore}
onDismiss={draftRecovery.dismiss}
/>
{/if}
<div class="admin-container">
<div class="tab-panels">
<!-- Metadata Panel -->
@ -401,77 +308,6 @@
}
}
.save-actions {
position: relative;
display: flex;
}
// 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 {
position: relative;
@ -493,26 +329,6 @@
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 {
margin-bottom: $unit-6x;

View file

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

View file

@ -9,10 +9,9 @@
interface Props {
formData: ProjectFormData
validationErrors: Record<string, string>
onSave?: () => Promise<void>
}
let { formData = $bindable(), validationErrors, onSave }: Props = $props()
let { formData = $bindable(), validationErrors }: Props = $props()
// ===== Media State Management =====
// Convert logoUrl string to Media object for ImageUploader
@ -91,16 +90,47 @@
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 =====
function handleFeaturedImageUpload(media: Media) {
formData.featuredImage = media.url
featuredImageMedia = media
}
async function handleFeaturedImageRemove() {
function handleFeaturedImageRemove() {
formData.featuredImage = ''
featuredImageMedia = null
if (onSave) await onSave()
}
function handleLogoUpload(media: Media) {
@ -108,10 +138,9 @@
logoMedia = media
}
async function handleLogoRemove() {
function handleLogoRemove() {
formData.logoUrl = ''
logoMedia = null
if (onSave) await onSave()
}
</script>

View file

@ -3,19 +3,13 @@
import { api } from '$lib/admin/api'
import AdminPage from './AdminPage.svelte'
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
import Button from './Button.svelte'
import Composer from './composer'
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte'
import DraftPrompt from './DraftPrompt.svelte'
import { toast } from '$lib/stores/toast'
import type { Project } from '$lib/types/project'
import { createAutoSaveStore } from '$lib/admin/autoSave.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'
interface Props {
@ -31,42 +25,12 @@
// UI state
let isLoading = $state(mode === 'edit')
let hasLoaded = $state(mode === 'create')
let isSaving = $state(false)
let activeTab = $state('metadata')
let error = $state<string | null>(null)
let successMessage = $state<string | null>(null)
// Ref to the editor component
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 = [
{ value: 'metadata', label: 'Metadata' },
{ value: 'branding', label: 'Branding' },
@ -77,40 +41,11 @@
$effect(() => {
if (project && mode === 'edit' && !hasLoaded) {
formStore.populateFromProject(project)
if (autoSave) {
autoSave.prime(formStore.buildPayload())
}
isLoading = false
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) {
formStore.setField('caseStudyContent', content)
}
@ -129,6 +64,7 @@
return
}
isSaving = true
const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} project...`)
try {
@ -138,6 +74,12 @@
updatedAt: mode === 'edit' ? project?.updatedAt : undefined
}
console.log('[ProjectForm] Saving with payload:', {
showFeaturedImageInHeader: payload.showFeaturedImageInHeader,
showBackgroundColorInHeader: payload.showBackgroundColorInHeader,
showLogoInHeader: payload.showLogoInHeader
})
let savedProject: Project
if (mode === 'edit') {
savedProject = await api.put(`/api/projects/${project?.id}`, payload) as Project
@ -152,6 +94,7 @@
goto(`/admin/projects/${savedProject.id}/edit`)
} else {
project = savedProject
formStore.populateFromProject(savedProject)
}
} catch (err) {
toast.dismiss(loadingToastId)
@ -161,10 +104,10 @@
toast.error(`Failed to ${mode === 'edit' ? 'save' : 'create'} project`)
}
console.error(err)
} finally {
isSaving = false
}
}
</script>
<AdminPage>
@ -180,36 +123,20 @@
/>
</div>
<div class="header-actions">
{#if !isLoading && mode === 'edit' && autoSave}
<AutoSaveStatus
status={autoSave.status}
error={autoSave.lastError}
lastSavedAt={project?.updatedAt}
/>
{/if}
<Button
variant="primary"
onclick={handleSave}
disabled={isSaving}
>
{isSaving ? 'Saving...' : 'Save'}
</Button>
</div>
</header>
{#if draftRecovery.showPrompt}
<DraftPrompt
timeAgo={draftRecovery.draftTimeText}
onRestore={draftRecovery.restore}
onDismiss={draftRecovery.dismiss}
/>
{/if}
<div class="admin-container">
{#if isLoading}
<div class="loading">Loading project...</div>
{:else}
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if successMessage}
<div class="success-message">{successMessage}</div>
{/if}
<div class="tab-panels">
<!-- Metadata Panel -->
<div class="panel content-wrapper" class:active={activeTab === 'metadata'}>
@ -220,7 +147,7 @@
handleSave()
}}
>
<ProjectMetadataForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
<ProjectMetadataForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} />
</form>
</div>
</div>
@ -234,7 +161,7 @@
handleSave()
}}
>
<ProjectBrandingForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
<ProjectBrandingForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} />
</form>
</div>
</div>
@ -295,25 +222,6 @@
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 {
width: 100%;
margin: 0 auto;
@ -346,37 +254,12 @@
margin: 0 auto;
}
.loading,
.error {
.loading {
text-align: center;
padding: $unit-6x;
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 {
@include breakpoint('phone') {
padding: $unit-3x;

View file

@ -1,26 +1,11 @@
<script lang="ts">
import { goto, beforeNavigate } from '$app/navigation'
import { goto } from '$app/navigation'
import AdminPage from './AdminPage.svelte'
import type { JSONContent } from '@tiptap/core'
import type { Post } from '@prisma/client'
import Editor from './Editor.svelte'
import Button from './Button.svelte'
import Input from './Input.svelte'
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 {
postType: 'post'
@ -36,13 +21,11 @@
mode: 'create' | 'edit'
}
let { postType, postId, initialData, mode }: Props = $props()
let { postType, postId, initialData, mode }: Props = $props()
// State
let isSaving = $state(false)
let hasLoaded = $state(mode === 'create')
let status = $state<'draft' | 'published'>(initialData?.status || 'draft')
let updatedAt = $state<string | undefined>(initialData?.updatedAt)
// Form data
let content = $state<JSONContent>(initialData?.content || { type: 'doc', content: [] })
@ -50,7 +33,7 @@ let { postType, postId, initialData, mode }: Props = $props()
let linkDescription = $state(initialData?.linkDescription || '')
let title = $state(initialData?.title || '')
// Character count for posts
// Character count for posts
const maxLength = 280
const textContent = $derived.by(() => {
if (!content.content) return ''
@ -67,178 +50,11 @@ let { postType, postId, initialData, mode }: Props = $props()
const isOverLimit = $derived(charCount > maxLength)
// Check if form has content
const hasContent = $derived.by(() => {
// For posts, check if either content exists or it's a link with URL
const hasContent = $derived.by(() => {
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.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') {
if (isOverLimit) {
@ -246,26 +62,24 @@ $effect(() => {
return
}
// For link posts, URL is required
if (linkUrl && !linkUrl.trim()) {
toast.error('Link URL is required')
return
}
isSaving = true
const loadingToastId = toast.loading(
`${publishStatus === 'published' ? 'Publishing' : 'Saving'} post...`
)
try {
isSaving = true
const payload: Record<string, unknown> = {
type: 'post', // Use simplified post type
type: 'post',
status: publishStatus,
content: content
content: content,
updatedAt: mode === 'edit' ? initialData?.updatedAt : undefined
}
// Add link fields if they're provided
if (linkUrl && linkUrl.trim()) {
payload.title = title || linkUrl
payload.link_url = linkUrl
@ -292,13 +106,11 @@ $effect(() => {
throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} post`)
}
await response.json()
await response.json()
toast.dismiss(loadingToastId)
toast.success(`Post ${publishStatus === 'published' ? 'published' : 'saved'} successfully!`)
clearDraft(draftKey)
toast.dismiss(loadingToastId)
toast.success(`Post ${publishStatus === 'published' ? 'published' : 'saved'} successfully!`)
// Redirect back to posts list after creation
goto('/admin/posts')
} catch (err) {
toast.dismiss(loadingToastId)
@ -333,36 +145,19 @@ $effect(() => {
</h1>
</div>
<div class="header-actions">
{#if mode === 'edit' && autoSave}
<AutoSaveStatus status={autoSave.status} error={autoSave.lastError} />
{/if}
<Button variant="secondary" onclick={() => handleSave('draft')} disabled={isSaving}>
Save Draft
</Button>
<Button
variant="primary"
onclick={() => handleSave('published')}
disabled={isSaving || !hasContent() || (postType === 'microblog' && isOverLimit)}
disabled={isSaving || !hasContent || (postType === 'microblog' && isOverLimit)}
>
Post
</Button>
</div>
</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">
{#if postType === 'microblog'}
@ -443,15 +238,6 @@ $effect(() => {
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 {
background: white;
border-radius: $unit-2x;
@ -560,103 +346,4 @@ $effect(() => {
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>

View file

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

View file

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

View file

@ -15,18 +15,20 @@
let isDragging = $state(false)
editor.view.dom.addEventListener('dragstart', () => {
isDragging = true
})
if (editor.view) {
editor.view.dom.addEventListener('dragstart', () => {
isDragging = true
})
editor.view.dom.addEventListener('drop', () => {
isDragging = true
editor.view.dom.addEventListener('drop', () => {
isDragging = true
// Allow some time for the drop action to complete before re-enabling
setTimeout(() => {
isDragging = false
}, 100) // Adjust delay if needed
})
// Allow some time for the drop action to complete before re-enabling
setTimeout(() => {
isDragging = false
}, 100) // Adjust delay if needed
})
}
const bubbleMenuCommands = [
...commands['text-formatting'].commands,
@ -40,7 +42,7 @@
function shouldShow(props: ShouldShowProps) {
if (!props.editor.isEditable) return false
const { view, editor } = props
if (!view || editor.view.dragging) {
if (!view || !editor.view || editor.view.dragging) {
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
*/
export function focusEditor(editor: Editor | undefined, event?: MouseEvent | KeyboardEvent) {
if (!editor) return
if (!editor || !editor.view) return
// Check if there is a text selection already (i.e. a non-empty selection)
const selection = window.getSelection()
if (selection && selection.toString().length > 0) {

View file

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

View file

@ -37,6 +37,9 @@ interface ProjectCreateBody {
status?: string
password?: string | null
slug?: string
showFeaturedImageInHeader?: boolean
showBackgroundColorInHeader?: boolean
showLogoInHeader?: boolean
}
// GET /api/projects - List all projects
@ -148,7 +151,10 @@ export const POST: RequestHandler = async (event) => {
displayOrder: body.displayOrder || 0,
status: body.status || 'draft',
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,6 +36,9 @@ interface ProjectUpdateBody {
status?: string
password?: string | null
slug?: string
showFeaturedImageInHeader?: boolean
showBackgroundColorInHeader?: boolean
showLogoInHeader?: boolean
}
// GET /api/projects/[id] - Get a single project
@ -129,7 +132,19 @@ export const PUT: RequestHandler = async (event) => {
status: body.status !== undefined ? body.status : existing.status,
password: body.password !== undefined ? body.password : existing.password,
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
}
})
@ -269,6 +284,11 @@ export const PATCH: RequestHandler = async (event) => {
if (body.projectType !== undefined) updateData.projectType = body.projectType
if (body.displayOrder !== undefined) updateData.displayOrder = body.displayOrder
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
if (body.slug && body.slug !== existing.slug) {