diff --git a/docs/task-3-project-form-refactor-plan.md b/docs/task-3-project-form-refactor-plan.md index c01d650..7576f99 100644 --- a/docs/task-3-project-form-refactor-plan.md +++ b/docs/task-3-project-form-refactor-plan.md @@ -27,33 +27,106 @@ Refactor `ProjectForm.svelte` (currently ~719 lines) to use composable stores an 3. **Unclear boundaries**: Business logic mixed with UI orchestration 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` for reusability + - Inferred types from usage + - `ReturnType` 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 ### 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**: ```typescript export function createProjectFormStore(project?: Project) { - // Internal state - const fields = $state({ ...defaultProjectFormData }) - const validationErrors = $state>({}) - const isDirty = $derived(/* compare fields to original */) + // Reactive state using $state rune + let fields = $state({ ...defaultProjectFormData }) + let validationErrors = $state>({}) + let original = $state(project ? { ...project } : null) + + // Derived state using $derived rune + const isDirty = $derived( + original ? JSON.stringify(fields) !== JSON.stringify(original) : false + ) return { - // Read-only derived state - fields: readonly fields, - validationErrors: readonly validationErrors, + // 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, - // Actions - setField(key: keyof ProjectFormData, value: any): void - setFields(data: Partial): void - validate(): boolean - reset(): void - populateFromProject(project: Project): void - buildPayload(): ProjectPayload + // Methods for controlled mutation + setField(key: keyof ProjectFormData, value: any) { + fields[key] = value + }, + + setFields(data: Partial) { + fields = { ...fields, ...data } + }, + + validate(): boolean { + const result = projectSchema.safeParse(fields) + if (!result.success) { + validationErrors = result.error.flatten().fieldErrors as Record + 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 ### 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**: ```typescript @@ -77,24 +150,58 @@ export function useDraftRecovery(options: { onRestore: (payload: TPayload) => void enabled?: boolean }) { - const showPrompt = $state(false) - const draftTimestamp = $state(null) - const timeTicker = $state(0) + // Reactive state using $state rune + let showPrompt = $state(false) + let draftTimestamp = $state(null) + let timeTicker = $state(0) + + // Derived state for time display const draftTimeText = $derived.by(() => draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null ) - // Auto-detect draft on mount - $effect(() => { /* ... */ }) + // Auto-detect draft on mount using $effect + $effect(() => { + if (!options.draftKey || options.enabled === false) return - // Update time display every minute - $effect(() => { /* ... */ }) + const draft = loadDraft(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 { - showPrompt: readonly showPrompt, + // State returned directly - reactive in Svelte 5 + showPrompt, draftTimeText, - restore(): void - dismiss(): void + + restore() { + if (!options.draftKey) return + const draft = loadDraft(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` -**Purpose**: Extract navigation protection logic. +**Purpose**: Extract navigation protection logic using Svelte 5 runes and SvelteKit navigation APIs. **API Design**: ```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) { - // Navigation guard: flush before route change - beforeNavigate(async (navigation) => { /* ... */ }) + if (!autoSave) return // No guards needed for create mode - // Browser close warning - $effect(() => { /* addEventListener('beforeunload') */ }) + // Navigation guard: flush autosave before route change + beforeNavigate(async (navigation) => { + // If already saved, allow navigation immediately + if (autoSave.status === 'saved') return - // Cmd/Ctrl+S shortcut - $effect(() => { /* addEventListener('keydown') */ }) + // Otherwise flush pending changes + 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 } @@ -166,49 +316,137 @@ useFormGuards(autoSave) import { useDraftRecovery } from '$lib/admin/useDraftRecovery.svelte' import { useFormGuards } from '$lib/admin/useFormGuards.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() - // Create store + // Form store - centralized state management const formStore = createProjectFormStore(project) - // Autosave + // Lifecycle tracking + let hasLoaded = $state(mode === 'create') + + // Autosave (edit mode only) 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 - // Draft recovery + // Draft recovery helper const draftRecovery = useDraftRecovery({ - draftKey: makeDraftKey('project', project?.id), + draftKey: mode === 'edit' && project ? makeDraftKey('project', project.id) : null, onRestore: (payload) => formStore.setFields(payload) }) - // Guards (navigation, beforeunload, Cmd+S) + // Form guards (navigation protection, Cmd+S, beforeunload) useFormGuards(autoSave) // UI state let activeTab = $state('metadata') - // Trigger autosave on changes + // Initial load effect $effect(() => { - formStore.fields; activeTab - if (hasLoaded && autoSave) autoSave.schedule() + if (project && mode === 'edit' && !hasLoaded) { + 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() + } + } - - +
+

{mode === 'create' ? 'New Project' : formStore.fields.title}

+ +
+ {#if mode === 'edit' && autoSave} + + {/if} + + + +
+
+ + {#if draftRecovery.showPrompt} + + {/if} + + activeTab = value} + /> {#if activeTab === 'metadata'} + + {:else if activeTab === 'case-study'} {/if}
``` +**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 ### Phase 1: Create Store Factory diff --git a/src/lib/admin/useDraftRecovery.svelte.ts b/src/lib/admin/useDraftRecovery.svelte.ts new file mode 100644 index 0000000..77d6f75 --- /dev/null +++ b/src/lib/admin/useDraftRecovery.svelte.ts @@ -0,0 +1,61 @@ +import { loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore' + +export function useDraftRecovery(options: { + draftKey: string | null + onRestore: (payload: TPayload) => void + enabled?: boolean +}) { + // Reactive state using $state rune + let showPrompt = $state(false) + let draftTimestamp = $state(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(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(options.draftKey) + if (!draft) return + + options.onRestore(draft.payload) + showPrompt = false + clearDraft(options.draftKey) + }, + + dismiss() { + if (!options.draftKey) return + showPrompt = false + clearDraft(options.draftKey) + } + } +} diff --git a/src/lib/admin/useFormGuards.svelte.ts b/src/lib/admin/useFormGuards.svelte.ts new file mode 100644 index 0000000..2ab5038 --- /dev/null +++ b/src/lib/admin/useFormGuards.svelte.ts @@ -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 | 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 +} diff --git a/src/lib/components/admin/DraftPrompt.svelte b/src/lib/components/admin/DraftPrompt.svelte new file mode 100644 index 0000000..16ce008 --- /dev/null +++ b/src/lib/components/admin/DraftPrompt.svelte @@ -0,0 +1,91 @@ + + +
+
+ + Unsaved draft found{#if timeAgo} (saved {timeAgo}){/if}. + +
+ + +
+
+
+ + diff --git a/src/lib/components/admin/ProjectForm.svelte b/src/lib/components/admin/ProjectForm.svelte index e68a425..080c002 100644 --- a/src/lib/components/admin/ProjectForm.svelte +++ b/src/lib/components/admin/ProjectForm.svelte @@ -1,22 +1,23 @@ @@ -429,20 +200,20 @@
{#if !isLoading} - {#if mode === 'edit' && showDraftPrompt} -
-
- - Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}. - -
- - -
-
-
+ {#if draftRecovery.showPrompt} + {/if}
@@ -490,9 +255,9 @@ handleSave() }} > - - - + + +
@@ -501,7 +266,7 @@
diff --git a/src/lib/stores/project-form.svelte.ts b/src/lib/stores/project-form.svelte.ts new file mode 100644 index 0000000..9fe924d --- /dev/null +++ b/src/lib/stores/project-form.svelte.ts @@ -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({ ...defaultProjectFormData }) + let validationErrors = $state>({}) + let original = $state(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) { + 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