refactor(admin): modularize ProjectForm with composable stores

Extract reusable form patterns following Svelte 5 best practices:

**New Store Factory** (`project-form.svelte.ts`)
- Centralizes form state management with `$state` and `$derived` runes
- Provides validation, payload building, and field mutation methods
- Type-safe with ProjectFormData interface
- Reusable across different contexts

**New Helpers**
- `useDraftRecovery.svelte.ts`: Generic draft restoration with auto-detection
- `useFormGuards.svelte.ts`: Navigation guards, beforeunload warning, Cmd+S shortcut
- `DraftPrompt.svelte`: Extracted UI component for draft recovery prompts

**Refactored ProjectForm.svelte**
- Reduced from 720 lines to 417 lines (42% reduction)
- Uses new composable helpers instead of inline logic
- Cleaner separation between UI orchestration and business logic
- All form state now managed through formStore
- Draft recovery, navigation guards fully extracted

**Benefits**
- Reusable patterns for PostForm, EssayForm, etc.
- Easier to test helpers in isolation
- Consistent UX across all admin forms
- Better maintainability and code organization

Closes Task 3 of admin modernization plan (Phase 2)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-10-07 23:24:50 -07:00
parent 0c5e9c8d13
commit 34a3e370ec
6 changed files with 663 additions and 406 deletions

View file

@ -27,33 +27,106 @@ Refactor `ProjectForm.svelte` (currently ~719 lines) to use composable stores an
3. **Unclear boundaries**: Business logic mixed with UI orchestration 3. **Unclear boundaries**: Business logic mixed with UI orchestration
4. **Maintenance burden**: Bug fixes need to be applied to multiple forms 4. **Maintenance burden**: Bug fixes need to be applied to multiple forms
## Svelte 5 Patterns & Best Practices (2025)
This refactor follows modern Svelte 5 patterns with runes:
### Key Patterns Used
1. **Runes in `.svelte.ts` files**: Store factories use runes (`$state`, `$derived`, `$effect`) in plain TypeScript modules
- File extension: `.svelte.ts` (not `.ts`) to enable rune support
- Export factory functions that return reactive state
- State is returned directly - it's already reactive in Svelte 5
2. **No "readonly" wrappers needed**: Unlike Svelte 4 stores, Svelte 5 state is reactive by default
- Just return state directly: `return { fields, setField }`
- Components can read: `formStore.fields.title`
- Encourage mutation through methods for validation control
3. **$derived for computed values**: Use `$derived` instead of manual tracking
- `const isDirty = $derived(original !== fields)`
- Automatically re-evaluates when dependencies change
4. **$effect for side effects**: Lifecycle logic in composable functions
- Event listeners: `$effect(() => { addEventListener(); return () => removeListener() })`
- Auto-cleanup via return function
- Replaces `onMount`/`onDestroy` patterns
5. **Type safety with generics**: `useDraftRecovery<TPayload>` for reusability
- Inferred types from usage
- `ReturnType<typeof factory>` for store types
6. **SvelteKit integration**: Use `beforeNavigate` for navigation guards
- Async callbacks are awaited automatically
- No need for `navigation.cancel()` + `goto()` patterns
## Proposed Architecture ## Proposed Architecture
### 1. Create Store Factory: `src/lib/stores/project-form.svelte.ts` ### 1. Create Store Factory: `src/lib/stores/project-form.svelte.ts`
**Purpose**: Centralize form state management and validation logic. **Purpose**: Centralize form state management and validation logic using Svelte 5 runes.
**API Design**: **API Design**:
```typescript ```typescript
export function createProjectFormStore(project?: Project) { export function createProjectFormStore(project?: Project) {
// Internal state // Reactive state using $state rune
const fields = $state<ProjectFormData>({ ...defaultProjectFormData }) let fields = $state<ProjectFormData>({ ...defaultProjectFormData })
const validationErrors = $state<Record<string, string>>({}) let validationErrors = $state<Record<string, string>>({})
const isDirty = $derived(/* compare fields to original */) let original = $state<ProjectFormData | null>(project ? { ...project } : null)
// Derived state using $derived rune
const isDirty = $derived(
original ? JSON.stringify(fields) !== JSON.stringify(original) : false
)
return { return {
// Read-only derived state // State is returned directly - it's already reactive in Svelte 5
fields: readonly fields, // Components can read: formStore.fields.title
validationErrors: readonly validationErrors, // Mutation should go through methods below for validation
fields,
validationErrors,
isDirty, isDirty,
// Actions // Methods for controlled mutation
setField(key: keyof ProjectFormData, value: any): void setField(key: keyof ProjectFormData, value: any) {
setFields(data: Partial<ProjectFormData>): void fields[key] = value
validate(): boolean },
reset(): void
populateFromProject(project: Project): void setFields(data: Partial<ProjectFormData>) {
buildPayload(): ProjectPayload fields = { ...fields, ...data }
},
validate(): boolean {
const result = projectSchema.safeParse(fields)
if (!result.success) {
validationErrors = result.error.flatten().fieldErrors as Record<string, string>
return false
}
validationErrors = {}
return true
},
reset() {
fields = { ...defaultProjectFormData }
validationErrors = {}
},
populateFromProject(project: Project) {
fields = {
title: project.title || '',
subtitle: project.subtitle || '',
// ... all fields
}
original = { ...fields }
},
buildPayload(): ProjectPayload {
return {
title: fields.title,
subtitle: fields.subtitle,
// ... build API payload
}
}
} }
} }
@ -68,7 +141,7 @@ export type ProjectFormStore = ReturnType<typeof createProjectFormStore>
### 2. Create Draft Recovery Helper: `src/lib/admin/useDraftRecovery.svelte.ts` ### 2. Create Draft Recovery Helper: `src/lib/admin/useDraftRecovery.svelte.ts`
**Purpose**: Extract draft restore prompt logic for reuse across all forms. **Purpose**: Extract draft restore prompt logic for reuse across all forms using Svelte 5 runes.
**API Design**: **API Design**:
```typescript ```typescript
@ -77,24 +150,58 @@ export function useDraftRecovery<TPayload>(options: {
onRestore: (payload: TPayload) => void onRestore: (payload: TPayload) => void
enabled?: boolean enabled?: boolean
}) { }) {
const showPrompt = $state(false) // Reactive state using $state rune
const draftTimestamp = $state<number | null>(null) let showPrompt = $state(false)
const timeTicker = $state(0) let draftTimestamp = $state<number | null>(null)
let timeTicker = $state(0)
// Derived state for time display
const draftTimeText = $derived.by(() => const draftTimeText = $derived.by(() =>
draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null
) )
// Auto-detect draft on mount // Auto-detect draft on mount using $effect
$effect(() => { /* ... */ }) $effect(() => {
if (!options.draftKey || options.enabled === false) return
// Update time display every minute const draft = loadDraft<TPayload>(options.draftKey)
$effect(() => { /* ... */ }) if (draft) {
showPrompt = true
draftTimestamp = draft.ts
}
})
// Update time display every minute using $effect
$effect(() => {
if (!showPrompt) return
const interval = setInterval(() => {
timeTicker = timeTicker + 1
}, 60000)
return () => clearInterval(interval)
})
return { return {
showPrompt: readonly showPrompt, // State returned directly - reactive in Svelte 5
showPrompt,
draftTimeText, draftTimeText,
restore(): void
dismiss(): void restore() {
if (!options.draftKey) return
const draft = loadDraft<TPayload>(options.draftKey)
if (!draft) return
options.onRestore(draft.payload)
showPrompt = false
clearDraft(options.draftKey)
},
dismiss() {
if (!options.draftKey) return
showPrompt = false
clearDraft(options.draftKey)
}
} }
} }
``` ```
@ -124,19 +231,62 @@ const draftRecovery = useDraftRecovery({
### 3. Create Form Guards Helper: `src/lib/admin/useFormGuards.svelte.ts` ### 3. Create Form Guards Helper: `src/lib/admin/useFormGuards.svelte.ts`
**Purpose**: Extract navigation protection logic. **Purpose**: Extract navigation protection logic using Svelte 5 runes and SvelteKit navigation APIs.
**API Design**: **API Design**:
```typescript ```typescript
import { beforeNavigate } from '$app/navigation'
import { toast } from '$lib/stores/toast'
import type { AutoSaveStore } from '$lib/admin/autoSave.svelte'
export function useFormGuards(autoSave: AutoSaveStore | null) { export function useFormGuards(autoSave: AutoSaveStore | null) {
// Navigation guard: flush before route change if (!autoSave) return // No guards needed for create mode
beforeNavigate(async (navigation) => { /* ... */ })
// Browser close warning // Navigation guard: flush autosave before route change
$effect(() => { /* addEventListener('beforeunload') */ }) beforeNavigate(async (navigation) => {
// If already saved, allow navigation immediately
if (autoSave.status === 'saved') return
// Cmd/Ctrl+S shortcut // Otherwise flush pending changes
$effect(() => { /* addEventListener('keydown') */ }) try {
await autoSave.flush()
} catch (error) {
console.error('Autosave flush failed:', error)
toast.error('Failed to save changes')
}
})
// Warn before closing browser tab/window if unsaved changes
$effect(() => {
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (autoSave!.status !== 'saved') {
event.preventDefault()
event.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
})
// Cmd/Ctrl+S keyboard shortcut for immediate save
$effect(() => {
function handleKeydown(event: KeyboardEvent) {
const key = event.key.toLowerCase()
const isModifier = event.metaKey || event.ctrlKey
if (isModifier && key === 's') {
event.preventDefault()
autoSave!.flush().catch((error) => {
console.error('Autosave flush failed:', error)
toast.error('Failed to save changes')
})
}
}
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
})
// No return value - purely side effects // No return value - purely side effects
} }
@ -166,49 +316,137 @@ useFormGuards(autoSave)
import { useDraftRecovery } from '$lib/admin/useDraftRecovery.svelte' import { useDraftRecovery } from '$lib/admin/useDraftRecovery.svelte'
import { useFormGuards } from '$lib/admin/useFormGuards.svelte' import { useFormGuards } from '$lib/admin/useFormGuards.svelte'
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte' import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
import { makeDraftKey } from '$lib/admin/draftStore'
import AdminPage from './AdminPage.svelte'
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
import Composer from './composer'
import DraftPrompt from './DraftPrompt.svelte'
import StatusDropdown from './StatusDropdown.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte'
interface Props {
project?: Project | null
mode: 'create' | 'edit'
}
// Props
let { project = null, mode }: Props = $props() let { project = null, mode }: Props = $props()
// Create store // Form store - centralized state management
const formStore = createProjectFormStore(project) const formStore = createProjectFormStore(project)
// Autosave // Lifecycle tracking
let hasLoaded = $state(mode === 'create')
// Autosave (edit mode only)
const autoSave = mode === 'edit' const autoSave = mode === 'edit'
? createAutoSaveStore({ /* ... */ }) ? createAutoSaveStore({
debounceMs: 2000,
getPayload: () => hasLoaded ? formStore.buildPayload() : null,
save: async (payload, { signal }) => {
return await api.put(`/api/projects/${project?.id}`, payload, { signal })
},
onSaved: (savedProject, { prime }) => {
project = savedProject
formStore.populateFromProject(savedProject)
prime(formStore.buildPayload())
}
})
: null : null
// Draft recovery // Draft recovery helper
const draftRecovery = useDraftRecovery({ const draftRecovery = useDraftRecovery({
draftKey: makeDraftKey('project', project?.id), draftKey: mode === 'edit' && project ? makeDraftKey('project', project.id) : null,
onRestore: (payload) => formStore.setFields(payload) onRestore: (payload) => formStore.setFields(payload)
}) })
// Guards (navigation, beforeunload, Cmd+S) // Form guards (navigation protection, Cmd+S, beforeunload)
useFormGuards(autoSave) useFormGuards(autoSave)
// UI state // UI state
let activeTab = $state('metadata') let activeTab = $state('metadata')
// Trigger autosave on changes // Initial load effect
$effect(() => { $effect(() => {
formStore.fields; activeTab if (project && mode === 'edit' && !hasLoaded) {
if (hasLoaded && autoSave) autoSave.schedule() formStore.populateFromProject(project)
autoSave?.prime(formStore.buildPayload())
hasLoaded = true
} else if (mode === 'create' && !hasLoaded) {
hasLoaded = true
}
}) })
// Trigger autosave on field changes
$effect(() => {
formStore.fields; activeTab // Establish dependencies
if (mode === 'edit' && hasLoaded && autoSave) {
autoSave.schedule()
}
})
// Manual save handler
async function handleSave() {
if (!formStore.validate()) {
toast.error('Please fix validation errors')
return
}
if (mode === 'create') {
// ... create logic
} else if (autoSave) {
await autoSave.flush()
}
}
</script> </script>
<AdminPage> <AdminPage>
<!-- Header with save actions --> <header slot="header">
<!-- Tab controls --> <h1>{mode === 'create' ? 'New Project' : formStore.fields.title}</h1>
<div class="header-actions">
{#if mode === 'edit' && autoSave}
<AutoSaveStatus status={autoSave.status} />
{/if}
<StatusDropdown bind:status={formStore.fields.status} />
<Button onclick={handleSave}>Save</Button>
</div>
</header>
{#if draftRecovery.showPrompt}
<DraftPrompt
timeAgo={draftRecovery.draftTimeText}
onRestore={draftRecovery.restore}
onDismiss={draftRecovery.dismiss}
/>
{/if}
<AdminSegmentedControl
options={[
{ value: 'metadata', label: 'Metadata' },
{ value: 'case-study', label: 'Case Study' }
]}
value={activeTab}
onChange={(value) => activeTab = value}
/>
{#if activeTab === 'metadata'} {#if activeTab === 'metadata'}
<ProjectMetadataForm bind:formData={formStore.fields} /> <ProjectMetadataForm bind:formData={formStore.fields} />
<ProjectBrandingForm bind:formData={formStore.fields} />
<ProjectImagesForm bind:formData={formStore.fields} />
{:else if activeTab === 'case-study'} {:else if activeTab === 'case-study'}
<Composer bind:content={formStore.fields.caseStudyContent} /> <Composer bind:content={formStore.fields.caseStudyContent} />
{/if} {/if}
</AdminPage> </AdminPage>
``` ```
**Key improvements**:
- ~200-300 lines instead of ~719
- All state management in `formStore`
- Reusable helpers (`useDraftRecovery`, `useFormGuards`)
- Clear separation: UI orchestration vs business logic
- Easy to test store and helpers independently
## Implementation Steps ## Implementation Steps
### Phase 1: Create Store Factory ### Phase 1: Create Store Factory

View file

@ -0,0 +1,61 @@
import { loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
export function useDraftRecovery<TPayload>(options: {
draftKey: string | null
onRestore: (payload: TPayload) => void
enabled?: boolean
}) {
// Reactive state using $state rune
let showPrompt = $state(false)
let draftTimestamp = $state<number | null>(null)
let timeTicker = $state(0)
// Derived state for time display
const draftTimeText = $derived.by(() =>
draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null
)
// Auto-detect draft on mount using $effect
$effect(() => {
if (!options.draftKey || options.enabled === false) return
const draft = loadDraft<TPayload>(options.draftKey)
if (draft) {
showPrompt = true
draftTimestamp = draft.ts
}
})
// Update time display every minute using $effect
$effect(() => {
if (!showPrompt) return
const interval = setInterval(() => {
timeTicker = timeTicker + 1
}, 60000)
return () => clearInterval(interval)
})
return {
// State returned directly - reactive in Svelte 5
showPrompt,
draftTimeText,
restore() {
if (!options.draftKey) return
const draft = loadDraft<TPayload>(options.draftKey)
if (!draft) return
options.onRestore(draft.payload)
showPrompt = false
clearDraft(options.draftKey)
},
dismiss() {
if (!options.draftKey) return
showPrompt = false
clearDraft(options.draftKey)
}
}
}

View file

@ -0,0 +1,55 @@
import { beforeNavigate } from '$app/navigation'
import { toast } from '$lib/stores/toast'
import type { AutoSaveStore } from '$lib/admin/autoSave.svelte'
export function useFormGuards(autoSave: AutoSaveStore<any, any> | null) {
if (!autoSave) return // No guards needed for create mode
// Navigation guard: flush autosave before route change
beforeNavigate(async (navigation) => {
// If already saved, allow navigation immediately
if (autoSave.status === 'saved') return
// Otherwise flush pending changes
try {
await autoSave.flush()
} catch (error: any) {
console.error('Autosave flush failed:', error)
toast.error('Failed to save changes')
}
})
// Warn before closing browser tab/window if unsaved changes
$effect(() => {
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (autoSave!.status !== 'saved') {
event.preventDefault()
event.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
})
// Cmd/Ctrl+S keyboard shortcut for immediate save
$effect(() => {
function handleKeydown(event: KeyboardEvent) {
const key = event.key.toLowerCase()
const isModifier = event.metaKey || event.ctrlKey
if (isModifier && key === 's') {
event.preventDefault()
autoSave!.flush().catch((error: any) => {
console.error('Autosave flush failed:', error)
toast.error('Failed to save changes')
})
}
}
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
})
// No return value - purely side effects
}

View file

@ -0,0 +1,91 @@
<script lang="ts">
interface Props {
timeAgo: string | null
onRestore: () => void
onDismiss: () => void
}
let { timeAgo, onRestore, onDismiss }: Props = $props()
</script>
<div class="draft-banner">
<div class="draft-banner-content">
<span class="draft-banner-text">
Unsaved draft found{#if timeAgo} (saved {timeAgo}){/if}.
</span>
<div class="draft-banner-actions">
<button class="draft-banner-button" type="button" onclick={onRestore}>Restore</button>
<button class="draft-banner-button dismiss" type="button" onclick={onDismiss}>
Dismiss
</button>
</div>
</div>
</div>
<style lang="scss">
.draft-banner {
background: $blue-95;
border-bottom: 1px solid $blue-80;
padding: $unit-2x $unit-5x;
display: flex;
justify-content: center;
align-items: center;
animation: slideDown 0.2s ease-out;
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
.draft-banner-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: $unit-3x;
width: 100%;
max-width: 1200px;
}
.draft-banner-text {
color: $blue-20;
font-size: $font-size-small;
font-weight: $font-weight-med;
}
.draft-banner-actions {
display: flex;
gap: $unit-2x;
}
.draft-banner-button {
background: $blue-50;
border: none;
color: $white;
cursor: pointer;
padding: $unit-half $unit-2x;
border-radius: $corner-radius-sm;
font-size: $font-size-small;
font-weight: $font-weight-med;
transition: background $transition-fast;
&:hover {
background: $blue-40;
}
&.dismiss {
background: transparent;
color: $blue-30;
&:hover {
background: $blue-90;
}
}
}
</style>

View file

@ -1,22 +1,23 @@
<script lang="ts"> <script lang="ts">
import { goto, beforeNavigate } from '$app/navigation' import { goto } from '$app/navigation'
import { z } from 'zod' 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 FormField from './FormField.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 ProjectImagesForm from './ProjectImagesForm.svelte' import ProjectImagesForm from './ProjectImagesForm.svelte'
import Button from './Button.svelte'
import StatusDropdown from './StatusDropdown.svelte' import StatusDropdown from './StatusDropdown.svelte'
import { projectSchema } from '$lib/schemas/project' 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, ProjectFormData } from '$lib/types/project' import type { Project } from '$lib/types/project'
import { defaultProjectFormData } from '$lib/types/project' import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte' import { createProjectFormStore } from '$lib/stores/project-form.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte' import { useDraftRecovery } from '$lib/admin/useDraftRecovery.svelte'
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore' import { useFormGuards } from '$lib/admin/useFormGuards.svelte'
import { makeDraftKey, saveDraft, clearDraft } from '$lib/admin/draftStore'
import type { ProjectFormData } from '$lib/types/project'
interface Props { interface Props {
project?: Project | null project?: Project | null
@ -25,143 +26,70 @@
let { project = null, mode }: Props = $props() let { project = null, mode }: Props = $props()
// State // Form store - centralized state management
const formStore = createProjectFormStore(project)
// UI state
let isLoading = $state(mode === 'edit') let isLoading = $state(mode === 'edit')
let hasLoaded = $state(false) let hasLoaded = $state(mode === 'create')
let isSaving = $state(false) let isSaving = $state(false)
let activeTab = $state('metadata') let activeTab = $state('metadata')
let validationErrors = $state<Record<string, string>>({})
let error = $state<string | null>(null) let error = $state<string | null>(null)
let successMessage = $state<string | null>(null) let successMessage = $state<string | null>(null)
// Form data
let formData = $state<ProjectFormData>({ ...defaultProjectFormData })
// Ref to the editor component // Ref to the editor component
let editorRef: any let editorRef: any
// Local draft recovery // Draft key for autosave fallback
const draftKey = $derived(mode === 'edit' && project ? makeDraftKey('project', project.id) : null) const draftKey = $derived(mode === 'edit' && project ? makeDraftKey('project', project.id) : null)
let showDraftPrompt = $state(false)
let draftTimestamp = $state<number | null>(null)
let timeTicker = $state(0)
const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
function buildPayload() {
return {
title: formData.title,
subtitle: formData.subtitle,
description: formData.description,
year: formData.year,
client: formData.client,
role: formData.role,
projectType: formData.projectType,
externalUrl: formData.externalUrl,
featuredImage: formData.featuredImage && formData.featuredImage !== '' ? formData.featuredImage : null,
logoUrl: formData.logoUrl && formData.logoUrl !== '' ? formData.logoUrl : null,
backgroundColor: formData.backgroundColor,
highlightColor: formData.highlightColor,
status: formData.status,
password: formData.status === 'password-protected' ? formData.password : null,
caseStudyContent:
formData.caseStudyContent &&
formData.caseStudyContent.content &&
formData.caseStudyContent.content.length > 0
? formData.caseStudyContent
: null,
updatedAt: project?.updatedAt
}
}
// Autosave (edit mode only) // Autosave (edit mode only)
let autoSave = mode === 'edit' const autoSave = mode === 'edit'
? createAutoSaveStore({ ? createAutoSaveStore({
debounceMs: 2000, debounceMs: 2000,
getPayload: () => (hasLoaded ? buildPayload() : null), getPayload: () => (hasLoaded ? formStore.buildPayload() : null),
save: async (payload, { signal }) => { save: async (payload, { signal }) => {
return await api.put(`/api/projects/${project?.id}`, payload, { signal }) return await api.put(`/api/projects/${project?.id}`, payload, { signal })
}, },
onSaved: (savedProject: any, { prime }) => { onSaved: (savedProject: any, { prime }) => {
// Update baseline updatedAt on successful save
project = savedProject project = savedProject
prime(buildPayload()) formStore.populateFromProject(savedProject)
prime(formStore.buildPayload())
if (draftKey) clearDraft(draftKey) if (draftKey) clearDraft(draftKey)
} }
}) })
: null : 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: 'case-study', label: 'Case Study' } { value: 'case-study', label: 'Case Study' }
] ]
// Watch for project changes and populate form data (only on initial load) // Initial load effect
$effect(() => { $effect(() => {
if (project && mode === 'edit' && !hasLoaded) { if (project && mode === 'edit' && !hasLoaded) {
populateFormData(project) formStore.populateFromProject(project)
} else if (mode === 'create') { if (autoSave) {
autoSave.prime(formStore.buildPayload())
}
isLoading = false isLoading = false
hasLoaded = true hasLoaded = true
} }
}) })
// Check for local draft to restore
$effect(() => {
if (mode === 'edit' && project && draftKey) {
const draft = loadDraft<any>(draftKey)
if (draft) {
// Show prompt; restoration is manual to avoid overwriting loaded data unintentionally
showDraftPrompt = true
draftTimestamp = draft.ts
}
}
})
// Auto-update draft time text every minute when prompt visible
$effect(() => {
if (showDraftPrompt) {
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
return () => clearInterval(id)
}
})
function restoreDraft() {
if (!draftKey) return
const draft = loadDraft<any>(draftKey)
if (!draft) return
const p = draft.payload
// Apply payload fields to formData
formData = {
title: p.title ?? formData.title,
subtitle: p.subtitle ?? formData.subtitle,
description: p.description ?? formData.description,
year: p.year ?? formData.year,
client: p.client ?? formData.client,
role: p.role ?? formData.role,
projectType: p.projectType ?? formData.projectType,
externalUrl: p.externalUrl ?? formData.externalUrl,
featuredImage: p.featuredImage ?? formData.featuredImage,
logoUrl: p.logoUrl ?? formData.logoUrl,
backgroundColor: p.backgroundColor ?? formData.backgroundColor,
highlightColor: p.highlightColor ?? formData.highlightColor,
status: p.status ?? formData.status,
password: p.password ?? formData.password,
caseStudyContent: p.caseStudyContent ?? formData.caseStudyContent
}
showDraftPrompt = false
clearDraft(draftKey)
}
function dismissDraft() {
if (!draftKey) return
showDraftPrompt = false
clearDraft(draftKey)
}
// Trigger autosave when formData changes (edit mode) // Trigger autosave when formData changes (edit mode)
$effect(() => { $effect(() => {
// Establish dependencies on fields // Establish dependencies on fields
formData; activeTab formStore.fields; activeTab
if (mode === 'edit' && hasLoaded && autoSave) { if (mode === 'edit' && hasLoaded && autoSave) {
autoSave.schedule() autoSave.schedule()
} }
@ -172,65 +100,11 @@
if (mode === 'edit' && autoSave && draftKey) { if (mode === 'edit' && autoSave && draftKey) {
const status = autoSave.status const status = autoSave.status
if (status === 'error' || status === 'offline') { if (status === 'error' || status === 'offline') {
saveDraft(draftKey, buildPayload()) saveDraft(draftKey, formStore.buildPayload())
} }
} }
}) })
// Navigation guard: flush autosave before navigating away (only if there are unsaved changes)
beforeNavigate(async (navigation) => {
if (mode === 'edit' && hasLoaded && autoSave) {
// If status is 'saved', there are no unsaved changes - allow navigation
if (autoSave.status === 'saved') {
return
}
// Otherwise, flush any pending changes before allowing navigation to proceed
try {
await autoSave.flush()
} catch (error) {
console.error('Autosave flush failed:', error)
toast.error('Failed to save changes')
}
}
})
// Warn before closing browser tab/window if there are unsaved changes
$effect(() => {
if (mode !== 'edit' || !autoSave) return
function handleBeforeUnload(event: BeforeUnloadEvent) {
// Only warn if there are unsaved changes
if (autoSave!.status !== 'saved') {
event.preventDefault()
event.returnValue = '' // Required for Chrome
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
})
// Keyboard shortcut: Cmd/Ctrl+S to save immediately
$effect(() => {
if (mode !== 'edit' || !autoSave) return
function handleKeydown(event: KeyboardEvent) {
if (!hasLoaded) return
const key = event.key.toLowerCase()
const isModifier = event.metaKey || event.ctrlKey
if (!isModifier || key !== 's') return
event.preventDefault()
autoSave!.flush().catch((error) => {
console.error('Autosave flush failed:', error)
toast.error('Failed to save changes')
})
}
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
})
// Cleanup autosave on unmount // Cleanup autosave on unmount
$effect(() => { $effect(() => {
if (autoSave) { if (autoSave) {
@ -238,82 +112,20 @@
} }
}) })
function populateFormData(data: Project) {
formData = {
title: data.title || '',
subtitle: data.subtitle || '',
description: data.description || '',
year: data.year || new Date().getFullYear(),
client: data.client || '',
role: data.role || '',
projectType: data.projectType || 'work',
externalUrl: data.externalUrl || '',
featuredImage:
data.featuredImage && data.featuredImage.trim() !== '' ? data.featuredImage : null,
backgroundColor: data.backgroundColor || '',
highlightColor: data.highlightColor || '',
logoUrl: data.logoUrl && data.logoUrl.trim() !== '' ? data.logoUrl : '',
status: data.status || 'draft',
password: data.password || '',
caseStudyContent: data.caseStudyContent || {
type: 'doc',
content: [{ type: 'paragraph' }]
}
}
isLoading = false
// Prime autosave with initial data to prevent immediate save
if (autoSave) {
autoSave.prime(buildPayload())
}
hasLoaded = true
}
function validateForm() {
try {
projectSchema.parse({
title: formData.title,
description: formData.description || undefined,
year: formData.year,
client: formData.client || undefined,
externalUrl: formData.externalUrl || undefined,
backgroundColor: formData.backgroundColor || undefined,
highlightColor: formData.highlightColor || undefined,
status: formData.status,
password: formData.password || undefined
})
validationErrors = {}
return true
} catch (err) {
if (err instanceof z.ZodError) {
const errors: Record<string, string> = {}
err.errors.forEach((e) => {
if (e.path[0]) {
errors[e.path[0].toString()] = e.message
}
})
validationErrors = errors
}
return false
}
}
function handleEditorChange(content: any) { function handleEditorChange(content: any) {
formData.caseStudyContent = content formStore.setField('caseStudyContent', content)
} }
import { api } from '$lib/admin/api'
async function handleSave() { async function handleSave() {
// Check if we're on the case study tab and should save editor content // Check if we're on the case study tab and should save editor content
if (activeTab === 'case-study' && editorRef) { if (activeTab === 'case-study' && editorRef) {
const editorData = await editorRef.save() const editorData = await editorRef.save()
if (editorData) { if (editorData) {
formData.caseStudyContent = editorData formStore.setField('caseStudyContent', editorData)
} }
} }
if (!validateForm()) { if (!formStore.validate()) {
toast.error('Please fix the validation errors') toast.error('Please fix the validation errors')
return return
} }
@ -324,37 +136,16 @@
isSaving = true isSaving = true
const payload = { const payload = {
title: formData.title, ...formStore.buildPayload(),
subtitle: formData.subtitle,
description: formData.description,
year: formData.year,
client: formData.client,
role: formData.role,
projectType: formData.projectType,
externalUrl: formData.externalUrl,
featuredImage:
formData.featuredImage && formData.featuredImage !== '' ? formData.featuredImage : null,
logoUrl: formData.logoUrl && formData.logoUrl !== '' ? formData.logoUrl : null,
backgroundColor: formData.backgroundColor,
highlightColor: formData.highlightColor,
status: formData.status,
password: formData.status === 'password-protected' ? formData.password : null,
caseStudyContent:
formData.caseStudyContent &&
formData.caseStudyContent.content &&
formData.caseStudyContent.content.length > 0
? formData.caseStudyContent
: null
,
// Include updatedAt for concurrency control in edit mode // Include updatedAt for concurrency control in edit mode
updatedAt: mode === 'edit' ? project?.updatedAt : undefined updatedAt: mode === 'edit' ? project?.updatedAt : undefined
} }
let savedProject let savedProject: Project
if (mode === 'edit') { if (mode === 'edit') {
savedProject = await api.put(`/api/projects/${project?.id}`, payload) savedProject = await api.put(`/api/projects/${project?.id}`, payload) as Project
} else { } else {
savedProject = await api.post('/api/projects', payload) savedProject = await api.post('/api/projects', payload) as Project
} }
toast.dismiss(loadingToastId) toast.dismiss(loadingToastId)
@ -379,29 +170,9 @@
} }
async function handleStatusChange(newStatus: string) { async function handleStatusChange(newStatus: string) {
formData.status = newStatus as any formStore.setField('status', newStatus)
await handleSave() await handleSave()
} }
// Keyboard shortcut: Cmd/Ctrl+S flushes autosave
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
if (mode === 'edit' && autoSave) autoSave.flush()
}
}
$effect(() => {
if (mode === 'edit') {
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
}
})
// Flush before navigating away
beforeNavigate(() => {
if (mode === 'edit' && autoSave) autoSave.flush()
})
</script> </script>
<AdminPage> <AdminPage>
@ -429,20 +200,20 @@
<div class="header-actions"> <div class="header-actions">
{#if !isLoading} {#if !isLoading}
<StatusDropdown <StatusDropdown
currentStatus={formData.status} currentStatus={formStore.fields.status}
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
disabled={isSaving} disabled={isSaving}
isLoading={isSaving} isLoading={isSaving}
primaryAction={formData.status === 'published' primaryAction={formStore.fields.status === 'published'
? { label: 'Save', status: 'published' } ? { label: 'Save', status: 'published' }
: { label: 'Publish', status: 'published' }} : { label: 'Publish', status: 'published' }}
dropdownActions={[ dropdownActions={[
{ label: 'Save as Draft', status: 'draft', show: formData.status !== 'draft' }, { label: 'Save as Draft', status: 'draft', show: formStore.fields.status !== 'draft' },
{ label: 'List Only', status: 'list-only', show: formData.status !== 'list-only' }, { label: 'List Only', status: 'list-only', show: formStore.fields.status !== 'list-only' },
{ {
label: 'Password Protected', label: 'Password Protected',
status: 'password-protected', status: 'password-protected',
show: formData.status !== 'password-protected' show: formStore.fields.status !== 'password-protected'
} }
]} ]}
viewUrl={project?.slug ? `/work/${project.slug}` : undefined} viewUrl={project?.slug ? `/work/${project.slug}` : undefined}
@ -454,18 +225,12 @@
</div> </div>
</header> </header>
{#if mode === 'edit' && showDraftPrompt} {#if draftRecovery.showPrompt}
<div class="draft-banner"> <DraftPrompt
<div class="draft-banner-content"> timeAgo={draftRecovery.draftTimeText}
<span class="draft-banner-text"> onRestore={draftRecovery.restore}
Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}. onDismiss={draftRecovery.dismiss}
</span> />
<div class="draft-banner-actions">
<button class="draft-banner-button" onclick={restoreDraft}>Restore</button>
<button class="draft-banner-button dismiss" onclick={dismissDraft}>Dismiss</button>
</div>
</div>
</div>
{/if} {/if}
<div class="admin-container"> <div class="admin-container">
@ -490,9 +255,9 @@
handleSave() handleSave()
}} }}
> >
<ProjectMetadataForm bind:formData {validationErrors} onSave={handleSave} /> <ProjectMetadataForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
<ProjectBrandingForm bind:formData {validationErrors} onSave={handleSave} /> <ProjectBrandingForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
<ProjectImagesForm bind:formData {validationErrors} onSave={handleSave} /> <ProjectImagesForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
</form> </form>
</div> </div>
</div> </div>
@ -501,7 +266,7 @@
<div class="panel panel-case-study" class:active={activeTab === 'case-study'}> <div class="panel panel-case-study" class:active={activeTab === 'case-study'}>
<Composer <Composer
bind:this={editorRef} bind:this={editorRef}
bind:data={formData.caseStudyContent} bind:data={formStore.fields.caseStudyContent}
onChange={handleEditorChange} onChange={handleEditorChange}
placeholder="Write your case study here..." placeholder="Write your case study here..."
minHeight={400} minHeight={400}
@ -650,70 +415,4 @@
min-height: 600px; min-height: 600px;
} }
} }
.draft-banner {
background: $blue-95;
border-bottom: 1px solid $blue-80;
padding: $unit-2x $unit-5x;
display: flex;
justify-content: center;
align-items: center;
animation: slideDown 0.2s ease-out;
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
.draft-banner-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: $unit-3x;
width: 100%;
max-width: 1200px;
}
.draft-banner-text {
color: $blue-20;
font-size: $font-size-small;
font-weight: $font-weight-med;
}
.draft-banner-actions {
display: flex;
gap: $unit-2x;
}
.draft-banner-button {
background: $blue-50;
border: none;
color: $white;
cursor: pointer;
padding: $unit-half $unit-2x;
border-radius: $corner-radius-sm;
font-size: $font-size-small;
font-weight: $font-weight-med;
transition: background $transition-fast;
&:hover {
background: $blue-40;
}
&.dismiss {
background: transparent;
color: $blue-30;
&:hover {
background: $blue-90;
}
}
}
</style> </style>

View file

@ -0,0 +1,113 @@
import { projectSchema } from '$lib/schemas/project'
import type { Project, ProjectFormData } from '$lib/types/project'
import { defaultProjectFormData } from '$lib/types/project'
export function createProjectFormStore(initialProject?: Project | null) {
// Reactive state using $state rune
let fields = $state<ProjectFormData>({ ...defaultProjectFormData })
let validationErrors = $state<Record<string, string>>({})
let original = $state<ProjectFormData | null>(null)
// Derived state using $derived rune
const isDirty = $derived(
original ? JSON.stringify(fields) !== JSON.stringify(original) : false
)
// Initialize from project if provided
if (initialProject) {
populateFromProject(initialProject)
}
function populateFromProject(project: Project) {
fields = {
title: project.title || '',
subtitle: project.subtitle || '',
description: project.description || '',
year: project.year || new Date().getFullYear(),
client: project.client || '',
role: project.role || '',
projectType: project.projectType || 'work',
externalUrl: project.externalUrl || '',
featuredImage: project.featuredImage || null,
logoUrl: project.logoUrl || '',
backgroundColor: project.backgroundColor || '',
highlightColor: project.highlightColor || '',
status: project.status || 'draft',
password: project.password || '',
caseStudyContent: project.caseStudyContent || {
type: 'doc',
content: [{ type: 'paragraph' }]
}
}
original = { ...fields }
}
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,
isDirty,
// Methods for controlled mutation
setField(key: keyof ProjectFormData, value: any) {
fields[key] = value
},
setFields(data: Partial<ProjectFormData>) {
fields = { ...fields, ...data }
},
validate(): boolean {
const result = projectSchema.safeParse(fields)
if (!result.success) {
const flattened = result.error.flatten()
validationErrors = Object.fromEntries(
Object.entries(flattened.fieldErrors).map(([key, errors]) => [
key,
Array.isArray(errors) ? errors[0] : ''
])
)
return false
}
validationErrors = {}
return true
},
reset() {
fields = { ...defaultProjectFormData }
validationErrors = {}
original = null
},
populateFromProject,
buildPayload() {
return {
title: fields.title,
subtitle: fields.subtitle,
description: fields.description,
year: fields.year,
client: fields.client,
role: fields.role,
projectType: fields.projectType,
externalUrl: fields.externalUrl,
featuredImage: fields.featuredImage && fields.featuredImage !== '' ? fields.featuredImage : null,
logoUrl: fields.logoUrl && fields.logoUrl !== '' ? fields.logoUrl : null,
backgroundColor: fields.backgroundColor,
highlightColor: fields.highlightColor,
status: fields.status,
password: fields.status === 'password-protected' ? fields.password : null,
caseStudyContent:
fields.caseStudyContent &&
fields.caseStudyContent.content &&
fields.caseStudyContent.content.length > 0
? fields.caseStudyContent
: null
}
}
}
}
export type ProjectFormStore = ReturnType<typeof createProjectFormStore>