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() { 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)
} }
@ -80,24 +83,44 @@ export function createAutoSaveStore<TPayload, TResponse = unknown>(
} }
const payload = opts.getPayload() 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) 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() 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: unknown) { } catch (e) {
if (e?.name === 'AbortError') { if (e instanceof Error && 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) {
@ -105,7 +128,10 @@ export function createAutoSaveStore<TPayload, TResponse = unknown>(
} else { } else {
setStatus('error') 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 { 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 DraftPrompt from './DraftPrompt.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'
import { toast } from '$lib/stores/toast' 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 { Album, Media } from '@prisma/client'
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
@ -39,20 +34,13 @@
// State // 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 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>()
let activeTab = $state('metadata') let activeTab = $state('metadata')
let pendingMediaIds = $state<number[]>([]) // Photos to add after album creation 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 = [ const tabOptions = [
{ value: 'metadata', label: 'Metadata' }, { value: 'metadata', label: 'Metadata' },
@ -86,81 +74,12 @@
// Derived state for existing media IDs // Derived state for existing media IDs
const existingMediaIds = $derived(albumMedia.map((item) => item.media.id)) 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 // Watch for album changes and populate form data
$effect(() => { $effect(() => {
if (album && mode === 'edit') { if (album && mode === 'edit' && !hasLoaded) {
populateFormData(album) populateFormData(album)
loadAlbumMedia() loadAlbumMedia()
hasLoaded = true
} else if (mode === 'create') { } else if (mode === 'create') {
isLoading = false 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) { function populateFormData(data: Album) {
formData = { formData = {
title: data.title || '', title: data.title || '',
@ -237,9 +113,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 || []
@ -257,7 +133,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) {
@ -267,23 +143,22 @@
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,
@ -292,7 +167,8 @@
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'
@ -366,7 +242,7 @@
) )
console.error(err) console.error(err)
} finally { } finally {
_isSaving = false isSaving = false
} }
} }
@ -399,23 +275,16 @@
/> />
</div> </div>
<div class="header-actions"> <div class="header-actions">
{#if !isLoading} <Button
<AutoSaveStatus variant="primary"
status={autoSave?.status ?? 'idle'} onclick={handleSave}
lastSavedAt={album?.updatedAt} disabled={isSaving}
/> >
{/if} {isSaving ? 'Saving...' : 'Save'}
</Button>
</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 album...</div> <div class="loading">Loading album...</div>
@ -585,25 +454,6 @@
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,6 +10,7 @@
lastSavedAt?: Date | string | null lastSavedAt?: Date | string | null
showTimestamp?: boolean showTimestamp?: boolean
compact?: boolean compact?: boolean
onclick?: () => void
} }
let { let {
@ -19,7 +20,8 @@
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
@ -81,12 +83,19 @@
</script> </script>
{#if label} {#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'} {#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>
</div> </button>
{/if} {/if}
<style lang="scss"> <style lang="scss">
@ -96,10 +105,26 @@
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,6 +6,7 @@
toggleChecked?: boolean toggleChecked?: boolean
toggleDisabled?: boolean toggleDisabled?: boolean
showToggle?: boolean showToggle?: boolean
onToggleChange?: (checked: boolean) => void
children?: import('svelte').Snippet children?: import('svelte').Snippet
} }
@ -14,6 +15,7 @@
toggleChecked = $bindable(false), toggleChecked = $bindable(false),
toggleDisabled = false, toggleDisabled = false,
showToggle = true, showToggle = true,
onToggleChange,
children children
}: Props = $props() }: Props = $props()
</script> </script>
@ -22,7 +24,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} /> <BrandingToggle bind:checked={toggleChecked} disabled={toggleDisabled} onchange={onToggleChange} />
{/if} {/if}
</header> </header>
<div class="branding-section__content"> <div class="branding-section__content">

View file

@ -6,15 +6,8 @@
import Button from './Button.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 DraftPrompt from './DraftPrompt.svelte'
import { toast } from '$lib/stores/toast' 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 { JSONContent } from '@tiptap/core'
import type { Post } from '@prisma/client'
interface Props { interface Props {
postId?: number postId?: number
@ -32,9 +25,9 @@
let { postId, initialData, mode }: Props = $props() let { postId, initialData, mode }: Props = $props()
// State // State
let hasLoaded = $state(mode === 'create') // Create mode loads immediately let hasLoaded = $state(mode === 'create')
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 || '')
@ -47,60 +40,6 @@
// Ref to the editor component // Ref to the editor component
let editorRef: { save: () => Promise<JSONContent> } | undefined 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 = [ const tabOptions = [
{ value: 'metadata', label: 'Metadata' }, { value: 'metadata', label: 'Metadata' },
{ value: 'content', label: 'Content' } { value: 'content', label: 'Content' }
@ -129,39 +68,13 @@
} }
}) })
// Prime autosave on initial load (edit mode only) // Mark as loaded for edit mode
$effect(() => { $effect(() => {
if (mode === 'edit' && initialData && !hasLoaded && autoSave) { if (mode === 'edit' && initialData && !hasLoaded) {
autoSave.prime(buildPayload())
hasLoaded = true 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() { function addTag() {
if (tagInput && !tags.includes(tagInput)) { if (tagInput && !tags.includes(tagInput)) {
tags = [...tags, tagInput] tags = [...tags, tagInput]
@ -191,16 +104,18 @@
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', // No mapping needed anymore type: 'essay',
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'
@ -226,8 +141,7 @@
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`)
@ -236,9 +150,10 @@
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>
@ -254,24 +169,16 @@
/> />
</div> </div>
<div class="header-actions"> <div class="header-actions">
{#if mode === 'edit' && autoSave} <Button
<AutoSaveStatus variant="primary"
status={autoSave.status} onclick={handleSave}
error={autoSave.lastError} disabled={isSaving}
lastSavedAt={initialData?.updatedAt} >
/> {isSaving ? 'Saving...' : 'Save'}
{/if} </Button>
</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">
<div class="tab-panels"> <div class="tab-panels">
<!-- Metadata Panel --> <!-- 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 { .tab-panels {
position: relative; position: relative;
@ -493,26 +329,6 @@
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,7 +97,8 @@ let autoSave = mode === 'edit' && postId
return await response.json() return await response.json()
}, },
onSaved: (saved: Post, { prime }) => { onSaved: (saved: Post, { prime }) => {
updatedAt = saved.updatedAt.toISOString() updatedAt =
typeof saved.updatedAt === 'string' ? saved.updatedAt : saved.updatedAt.toISOString()
prime(buildPayload()) prime(buildPayload())
if (draftKey) clearDraft(draftKey) if (draftKey) clearDraft(draftKey)
} }

View file

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

View file

@ -3,19 +3,13 @@
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 {
@ -31,42 +25,12 @@
// 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' },
@ -77,40 +41,11 @@
$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)
} }
@ -129,6 +64,7 @@
return return
} }
isSaving = true
const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} project...`) const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} project...`)
try { try {
@ -138,6 +74,12 @@
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
@ -152,6 +94,7 @@
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)
@ -161,10 +104,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>
@ -180,36 +123,20 @@
/> />
</div> </div>
<div class="header-actions"> <div class="header-actions">
{#if !isLoading && mode === 'edit' && autoSave} <Button
<AutoSaveStatus variant="primary"
status={autoSave.status} onclick={handleSave}
error={autoSave.lastError} disabled={isSaving}
lastSavedAt={project?.updatedAt} >
/> {isSaving ? 'Saving...' : 'Save'}
{/if} </Button>
</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'}>
@ -220,7 +147,7 @@
handleSave() handleSave()
}} }}
> >
<ProjectMetadataForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} /> <ProjectMetadataForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} />
</form> </form>
</div> </div>
</div> </div>
@ -234,7 +161,7 @@
handleSave() handleSave()
}} }}
> >
<ProjectBrandingForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} /> <ProjectBrandingForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} />
</form> </form>
</div> </div>
</div> </div>
@ -295,25 +222,6 @@
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;
@ -346,37 +254,12 @@
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,26 +1,11 @@
<script lang="ts"> <script lang="ts">
import { goto, beforeNavigate } from '$app/navigation' import { goto } 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'
@ -36,13 +21,11 @@
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: [] })
@ -50,7 +33,7 @@ let { postType, postId, initialData, mode }: Props = $props()
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 ''
@ -67,178 +50,11 @@ let { postType, postId, initialData, mode }: Props = $props()
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) {
@ -246,26 +62,24 @@ $effect(() => {
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', // Use simplified post type type: 'post',
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
@ -292,13 +106,11 @@ $effect(() => {
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)
@ -333,36 +145,19 @@ $effect(() => {
</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'}
@ -443,15 +238,6 @@ $effect(() => {
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;
@ -560,103 +346,4 @@ $effect(() => {
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) return if (!editor || !editor.view) 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) return if (!editor || !editor.view) 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 || linkContextPos === null || !linkContextUrl) return if (!editor || !editor.view || 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) return if (!position || !editor.view) 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,18 +15,20 @@
let isDragging = $state(false) let isDragging = $state(false)
editor.view.dom.addEventListener('dragstart', () => { if (editor.view) {
isDragging = true editor.view.dom.addEventListener('dragstart', () => {
}) 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,
@ -40,7 +42,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.dragging) { if (!view || !editor.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) return if (!editor || !editor.view) 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,11 +46,16 @@ export function createProjectFormStore(initialProject?: Project | null) {
} }
return { return {
// State is returned directly - it's already reactive in Svelte 5 // Use getters to maintain reactivity when accessing state from outside the store
// Components can read: formStore.fields.title get fields() {
// Mutation should go through methods below for validation return fields
fields, },
validationErrors, set fields(value: ProjectFormData) {
fields = value
},
get validationErrors() {
return validationErrors
},
isDirty, isDirty,
// Methods for controlled mutation // Methods for controlled mutation

View file

@ -37,6 +37,9 @@ 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
@ -148,7 +151,10 @@ 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,6 +36,9 @@ 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
@ -129,7 +132,19 @@ 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
} }
}) })
@ -269,6 +284,11 @@ 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) {