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:
parent
0c5e9c8d13
commit
34a3e370ec
6 changed files with 663 additions and 406 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
61
src/lib/admin/useDraftRecovery.svelte.ts
Normal file
61
src/lib/admin/useDraftRecovery.svelte.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/lib/admin/useFormGuards.svelte.ts
Normal file
55
src/lib/admin/useFormGuards.svelte.ts
Normal 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
|
||||||
|
}
|
||||||
91
src/lib/components/admin/DraftPrompt.svelte
Normal file
91
src/lib/components/admin/DraftPrompt.svelte
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
113
src/lib/stores/project-form.svelte.ts
Normal file
113
src/lib/stores/project-form.svelte.ts
Normal 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>
|
||||||
Loading…
Reference in a new issue