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
|
||||
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
|
||||
|
||||
### 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<ProjectFormData>({ ...defaultProjectFormData })
|
||||
const validationErrors = $state<Record<string, string>>({})
|
||||
const isDirty = $derived(/* compare fields to original */)
|
||||
// Reactive state using $state rune
|
||||
let fields = $state<ProjectFormData>({ ...defaultProjectFormData })
|
||||
let validationErrors = $state<Record<string, string>>({})
|
||||
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 {
|
||||
// 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<ProjectFormData>): 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<ProjectFormData>) {
|
||||
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`
|
||||
|
||||
**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<TPayload>(options: {
|
|||
onRestore: (payload: TPayload) => void
|
||||
enabled?: boolean
|
||||
}) {
|
||||
const showPrompt = $state(false)
|
||||
const draftTimestamp = $state<number | null>(null)
|
||||
const timeTicker = $state(0)
|
||||
// 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
|
||||
$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<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 {
|
||||
showPrompt: readonly showPrompt,
|
||||
// State returned directly - reactive in Svelte 5
|
||||
showPrompt,
|
||||
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`
|
||||
|
||||
**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()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdminPage>
|
||||
<!-- Header with save actions -->
|
||||
<!-- Tab controls -->
|
||||
<header slot="header">
|
||||
<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'}
|
||||
<ProjectMetadataForm bind:formData={formStore.fields} />
|
||||
<ProjectBrandingForm bind:formData={formStore.fields} />
|
||||
<ProjectImagesForm bind:formData={formStore.fields} />
|
||||
{:else if activeTab === 'case-study'}
|
||||
<Composer bind:content={formStore.fields.caseStudyContent} />
|
||||
{/if}
|
||||
</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
|
||||
|
||||
### 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">
|
||||
import { goto, beforeNavigate } from '$app/navigation'
|
||||
import { z } from 'zod'
|
||||
import { goto } from '$app/navigation'
|
||||
import { api } from '$lib/admin/api'
|
||||
import AdminPage from './AdminPage.svelte'
|
||||
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
||||
import FormField from './FormField.svelte'
|
||||
import Composer from './composer'
|
||||
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
|
||||
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
|
||||
import ProjectImagesForm from './ProjectImagesForm.svelte'
|
||||
import Button from './Button.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 type { Project, ProjectFormData } from '$lib/types/project'
|
||||
import { defaultProjectFormData } from '$lib/types/project'
|
||||
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
|
||||
import AutoSaveStatus from './AutoSaveStatus.svelte'
|
||||
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
|
||||
import type { Project } from '$lib/types/project'
|
||||
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
|
||||
import { createProjectFormStore } from '$lib/stores/project-form.svelte'
|
||||
import { useDraftRecovery } from '$lib/admin/useDraftRecovery.svelte'
|
||||
import { useFormGuards } from '$lib/admin/useFormGuards.svelte'
|
||||
import { makeDraftKey, saveDraft, clearDraft } from '$lib/admin/draftStore'
|
||||
import type { ProjectFormData } from '$lib/types/project'
|
||||
|
||||
interface Props {
|
||||
project?: Project | null
|
||||
|
|
@ -25,143 +26,70 @@
|
|||
|
||||
let { project = null, mode }: Props = $props()
|
||||
|
||||
// State
|
||||
// Form store - centralized state management
|
||||
const formStore = createProjectFormStore(project)
|
||||
|
||||
// UI state
|
||||
let isLoading = $state(mode === 'edit')
|
||||
let hasLoaded = $state(false)
|
||||
let hasLoaded = $state(mode === 'create')
|
||||
let isSaving = $state(false)
|
||||
let activeTab = $state('metadata')
|
||||
let validationErrors = $state<Record<string, string>>({})
|
||||
let error = $state<string | null>(null)
|
||||
let successMessage = $state<string | null>(null)
|
||||
|
||||
// Form data
|
||||
let formData = $state<ProjectFormData>({ ...defaultProjectFormData })
|
||||
|
||||
// Ref to the editor component
|
||||
let editorRef: any
|
||||
|
||||
// Local draft recovery
|
||||
// Draft key for autosave fallback
|
||||
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)
|
||||
let autoSave = mode === 'edit'
|
||||
const autoSave = mode === 'edit'
|
||||
? createAutoSaveStore({
|
||||
debounceMs: 2000,
|
||||
getPayload: () => (hasLoaded ? buildPayload() : null),
|
||||
getPayload: () => (hasLoaded ? formStore.buildPayload() : null),
|
||||
save: async (payload, { signal }) => {
|
||||
return await api.put(`/api/projects/${project?.id}`, payload, { signal })
|
||||
},
|
||||
onSaved: (savedProject: any, { prime }) => {
|
||||
// Update baseline updatedAt on successful save
|
||||
project = savedProject
|
||||
prime(buildPayload())
|
||||
formStore.populateFromProject(savedProject)
|
||||
prime(formStore.buildPayload())
|
||||
if (draftKey) clearDraft(draftKey)
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
// Draft recovery helper
|
||||
const draftRecovery = useDraftRecovery<Partial<ProjectFormData>>({
|
||||
draftKey: draftKey,
|
||||
onRestore: (payload) => formStore.setFields(payload)
|
||||
})
|
||||
|
||||
// Form guards (navigation protection, Cmd+S, beforeunload)
|
||||
useFormGuards(autoSave)
|
||||
|
||||
const tabOptions = [
|
||||
{ value: 'metadata', label: 'Metadata' },
|
||||
{ value: 'case-study', label: 'Case Study' }
|
||||
]
|
||||
|
||||
// Watch for project changes and populate form data (only on initial load)
|
||||
// Initial load effect
|
||||
$effect(() => {
|
||||
if (project && mode === 'edit' && !hasLoaded) {
|
||||
populateFormData(project)
|
||||
} else if (mode === 'create') {
|
||||
formStore.populateFromProject(project)
|
||||
if (autoSave) {
|
||||
autoSave.prime(formStore.buildPayload())
|
||||
}
|
||||
isLoading = false
|
||||
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)
|
||||
$effect(() => {
|
||||
// Establish dependencies on fields
|
||||
formData; activeTab
|
||||
formStore.fields; activeTab
|
||||
if (mode === 'edit' && hasLoaded && autoSave) {
|
||||
autoSave.schedule()
|
||||
}
|
||||
|
|
@ -172,65 +100,11 @@
|
|||
if (mode === 'edit' && autoSave && draftKey) {
|
||||
const status = autoSave.status
|
||||
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
|
||||
$effect(() => {
|
||||
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) {
|
||||
formData.caseStudyContent = content
|
||||
formStore.setField('caseStudyContent', content)
|
||||
}
|
||||
|
||||
import { api } from '$lib/admin/api'
|
||||
|
||||
async function handleSave() {
|
||||
// Check if we're on the case study tab and should save editor content
|
||||
if (activeTab === 'case-study' && editorRef) {
|
||||
const editorData = await editorRef.save()
|
||||
if (editorData) {
|
||||
formData.caseStudyContent = editorData
|
||||
formStore.setField('caseStudyContent', editorData)
|
||||
}
|
||||
}
|
||||
|
||||
if (!validateForm()) {
|
||||
if (!formStore.validate()) {
|
||||
toast.error('Please fix the validation errors')
|
||||
return
|
||||
}
|
||||
|
|
@ -324,37 +136,16 @@
|
|||
isSaving = true
|
||||
|
||||
const payload = {
|
||||
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
|
||||
,
|
||||
...formStore.buildPayload(),
|
||||
// Include updatedAt for concurrency control in edit mode
|
||||
updatedAt: mode === 'edit' ? project?.updatedAt : undefined
|
||||
}
|
||||
|
||||
let savedProject
|
||||
let savedProject: Project
|
||||
if (mode === 'edit') {
|
||||
savedProject = await api.put(`/api/projects/${project?.id}`, payload)
|
||||
savedProject = await api.put(`/api/projects/${project?.id}`, payload) as Project
|
||||
} else {
|
||||
savedProject = await api.post('/api/projects', payload)
|
||||
savedProject = await api.post('/api/projects', payload) as Project
|
||||
}
|
||||
|
||||
toast.dismiss(loadingToastId)
|
||||
|
|
@ -379,29 +170,9 @@
|
|||
}
|
||||
|
||||
async function handleStatusChange(newStatus: string) {
|
||||
formData.status = newStatus as any
|
||||
formStore.setField('status', newStatus)
|
||||
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>
|
||||
|
||||
<AdminPage>
|
||||
|
|
@ -429,20 +200,20 @@
|
|||
<div class="header-actions">
|
||||
{#if !isLoading}
|
||||
<StatusDropdown
|
||||
currentStatus={formData.status}
|
||||
currentStatus={formStore.fields.status}
|
||||
onStatusChange={handleStatusChange}
|
||||
disabled={isSaving}
|
||||
isLoading={isSaving}
|
||||
primaryAction={formData.status === 'published'
|
||||
primaryAction={formStore.fields.status === 'published'
|
||||
? { label: 'Save', status: 'published' }
|
||||
: { label: 'Publish', status: 'published' }}
|
||||
dropdownActions={[
|
||||
{ label: 'Save as Draft', status: 'draft', show: formData.status !== 'draft' },
|
||||
{ label: 'List Only', status: 'list-only', show: formData.status !== 'list-only' },
|
||||
{ label: 'Save as Draft', status: 'draft', show: formStore.fields.status !== 'draft' },
|
||||
{ label: 'List Only', status: 'list-only', show: formStore.fields.status !== 'list-only' },
|
||||
{
|
||||
label: 'Password Protected',
|
||||
status: 'password-protected',
|
||||
show: formData.status !== 'password-protected'
|
||||
show: formStore.fields.status !== 'password-protected'
|
||||
}
|
||||
]}
|
||||
viewUrl={project?.slug ? `/work/${project.slug}` : undefined}
|
||||
|
|
@ -454,18 +225,12 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
{#if mode === 'edit' && showDraftPrompt}
|
||||
<div class="draft-banner">
|
||||
<div class="draft-banner-content">
|
||||
<span class="draft-banner-text">
|
||||
Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}.
|
||||
</span>
|
||||
<div class="draft-banner-actions">
|
||||
<button class="draft-banner-button" onclick={restoreDraft}>Restore</button>
|
||||
<button class="draft-banner-button dismiss" onclick={dismissDraft}>Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if draftRecovery.showPrompt}
|
||||
<DraftPrompt
|
||||
timeAgo={draftRecovery.draftTimeText}
|
||||
onRestore={draftRecovery.restore}
|
||||
onDismiss={draftRecovery.dismiss}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="admin-container">
|
||||
|
|
@ -490,9 +255,9 @@
|
|||
handleSave()
|
||||
}}
|
||||
>
|
||||
<ProjectMetadataForm bind:formData {validationErrors} onSave={handleSave} />
|
||||
<ProjectBrandingForm bind:formData {validationErrors} onSave={handleSave} />
|
||||
<ProjectImagesForm bind:formData {validationErrors} onSave={handleSave} />
|
||||
<ProjectMetadataForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
|
||||
<ProjectBrandingForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
|
||||
<ProjectImagesForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -501,7 +266,7 @@
|
|||
<div class="panel panel-case-study" class:active={activeTab === 'case-study'}>
|
||||
<Composer
|
||||
bind:this={editorRef}
|
||||
bind:data={formData.caseStudyContent}
|
||||
bind:data={formStore.fields.caseStudyContent}
|
||||
onChange={handleEditorChange}
|
||||
placeholder="Write your case study here..."
|
||||
minHeight={400}
|
||||
|
|
@ -650,70 +415,4 @@
|
|||
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>
|
||||
|
|
|
|||
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