diff --git a/src/lib/components/admin/AlbumForm.svelte b/src/lib/components/admin/AlbumForm.svelte index 20218ca..129d25b 100644 --- a/src/lib/components/admin/AlbumForm.svelte +++ b/src/lib/components/admin/AlbumForm.svelte @@ -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>({}) + let isSaving = $state(false) + let validationErrors = $state>({}) let showBulkAlbumModal = $state(false) let albumMedia = $state>([]) let editorInstance = $state<{ save: () => Promise; clear: () => void } | undefined>() let activeTab = $state('metadata') let pendingMediaIds = $state([]) // Photos to add after album creation - let updatedAt = $state( - 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, 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>({ - 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 @@ />
- {#if !isLoading} - - {/if} +
- {#if draftRecovery.showPrompt} - - {/if} -
{#if isLoading}
Loading album...
@@ -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; diff --git a/src/lib/components/admin/BrandingSection.svelte b/src/lib/components/admin/BrandingSection.svelte index 83dafdf..fbfd0e6 100644 --- a/src/lib/components/admin/BrandingSection.svelte +++ b/src/lib/components/admin/BrandingSection.svelte @@ -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() @@ -22,7 +24,7 @@

{title}

{#if showToggle} - + {/if}
diff --git a/src/lib/components/admin/EssayForm.svelte b/src/lib/components/admin/EssayForm.svelte index 581e738..b94a6b8 100644 --- a/src/lib/components/admin/EssayForm.svelte +++ b/src/lib/components/admin/EssayForm.svelte @@ -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(initialData?.updatedAt) // Form data let title = $state(initialData?.title || '') @@ -47,61 +40,6 @@ // Ref to the editor component let editorRef: { save: () => Promise } | 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 = - typeof saved.updatedAt === 'string' ? saved.updatedAt : saved.updatedAt.toISOString() - prime(buildPayload()) - if (draftKey) clearDraft(draftKey) - } - }) - : null - - // Draft recovery helper - const draftRecovery = useDraftRecovery>({ - 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' } @@ -130,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] @@ -192,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' @@ -227,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`) @@ -237,9 +150,10 @@ toast.dismiss(loadingToastId) toast.error(`Failed to ${mode === 'edit' ? 'save' : 'create'} essay`) console.error(err) + } finally { + isSaving = false } } - @@ -255,24 +169,16 @@ />
- {#if mode === 'edit' && autoSave} - - {/if} +
- {#if draftRecovery.showPrompt} - - {/if} -
@@ -402,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; @@ -494,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; diff --git a/src/lib/components/admin/ProjectBrandingForm.svelte b/src/lib/components/admin/ProjectBrandingForm.svelte index 59a21c6..926e49d 100644 --- a/src/lib/components/admin/ProjectBrandingForm.svelte +++ b/src/lib/components/admin/ProjectBrandingForm.svelte @@ -9,10 +9,9 @@ interface Props { formData: ProjectFormData validationErrors: Record - onSave?: () => Promise } - 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() } diff --git a/src/lib/components/admin/ProjectForm.svelte b/src/lib/components/admin/ProjectForm.svelte index 7150954..f85c5c5 100644 --- a/src/lib/components/admin/ProjectForm.svelte +++ b/src/lib/components/admin/ProjectForm.svelte @@ -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(null) - let successMessage = $state(null) // Ref to the editor component let editorRef: { save: () => Promise } | 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>({ - 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 } } - - @@ -180,36 +123,20 @@ />
- {#if !isLoading && mode === 'edit' && autoSave} - - {/if} +
- {#if draftRecovery.showPrompt} - - {/if} -
{#if isLoading}
Loading project...
{:else} - {#if error} -
{error}
- {/if} - - {#if successMessage} -
{successMessage}
- {/if} -
@@ -220,7 +147,7 @@ handleSave() }} > - +
@@ -234,7 +161,7 @@ handleSave() }} > - +
@@ -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; diff --git a/src/lib/components/admin/SimplePostForm.svelte b/src/lib/components/admin/SimplePostForm.svelte index cf78e9e..17407a8 100644 --- a/src/lib/components/admin/SimplePostForm.svelte +++ b/src/lib/components/admin/SimplePostForm.svelte @@ -1,26 +1,11 @@