diff --git a/src/lib/admin/autoSave.svelte.ts b/src/lib/admin/autoSave.svelte.ts index 6e5bf3c..d4b50e6 100644 --- a/src/lib/admin/autoSave.svelte.ts +++ b/src/lib/admin/autoSave.svelte.ts @@ -8,7 +8,7 @@ export interface AutoSaveStoreOptions { onSaved?: (res: TResponse, ctx: { prime: (payload: TPayload) => void }) => void } -export interface AutoSaveStore { +export interface AutoSaveStore { readonly status: AutoSaveStatus readonly lastError: string | null schedule: () => void @@ -36,7 +36,7 @@ export interface AutoSaveStore { */ export function createAutoSaveStore( opts: AutoSaveStoreOptions -): AutoSaveStore { +): AutoSaveStore { const debounceMs = opts.debounceMs ?? 2000 const idleResetMs = opts.idleResetMs ?? 2000 let timer: ReturnType | null = null diff --git a/src/lib/admin/useDraftRecovery.svelte.ts b/src/lib/admin/useDraftRecovery.svelte.ts index 77d6f75..c8f84df 100644 --- a/src/lib/admin/useDraftRecovery.svelte.ts +++ b/src/lib/admin/useDraftRecovery.svelte.ts @@ -1,7 +1,7 @@ import { loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore' export function useDraftRecovery(options: { - draftKey: string | null + draftKey: () => string | null onRestore: (payload: TPayload) => void enabled?: boolean }) { @@ -17,9 +17,10 @@ export function useDraftRecovery(options: { // Auto-detect draft on mount using $effect $effect(() => { - if (!options.draftKey || options.enabled === false) return + const key = options.draftKey() + if (!key || options.enabled === false) return - const draft = loadDraft(options.draftKey) + const draft = loadDraft(key) if (draft) { showPrompt = true draftTimestamp = draft.ts @@ -43,19 +44,21 @@ export function useDraftRecovery(options: { draftTimeText, restore() { - if (!options.draftKey) return - const draft = loadDraft(options.draftKey) + const key = options.draftKey() + if (!key) return + const draft = loadDraft(key) if (!draft) return options.onRestore(draft.payload) showPrompt = false - clearDraft(options.draftKey) + clearDraft(key) }, dismiss() { - if (!options.draftKey) return + const key = options.draftKey() + if (!key) return showPrompt = false - clearDraft(options.draftKey) + clearDraft(key) } } } diff --git a/src/lib/admin/useFormGuards.svelte.ts b/src/lib/admin/useFormGuards.svelte.ts index 0d84759..3b3ac9f 100644 --- a/src/lib/admin/useFormGuards.svelte.ts +++ b/src/lib/admin/useFormGuards.svelte.ts @@ -2,7 +2,9 @@ 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 +) { if (!autoSave) return // No guards needed for create mode // Navigation guard: flush autosave before route change @@ -21,8 +23,12 @@ export function useFormGuards(autoSave: AutoSaveStore | null) // Warn before closing browser tab/window if unsaved changes $effect(() => { + // Capture autoSave in closure to avoid non-null assertions + const store = autoSave + if (!store) return + function handleBeforeUnload(event: BeforeUnloadEvent) { - if (autoSave!.status !== 'saved') { + if (store.status !== 'saved') { event.preventDefault() event.returnValue = '' } @@ -34,13 +40,17 @@ export function useFormGuards(autoSave: AutoSaveStore | null) // Cmd/Ctrl+S keyboard shortcut for immediate save $effect(() => { + // Capture autoSave in closure to avoid non-null assertions + const store = autoSave + if (!store) return + 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) => { + store.flush().catch((error) => { console.error('Autosave flush failed:', error) toast.error('Failed to save changes') }) diff --git a/src/lib/components/admin/AlbumForm.svelte b/src/lib/components/admin/AlbumForm.svelte index e6e1ac3..20218ca 100644 --- a/src/lib/components/admin/AlbumForm.svelte +++ b/src/lib/components/admin/AlbumForm.svelte @@ -6,10 +6,15 @@ import Input from './Input.svelte' import DropdownSelectField from './DropdownSelectField.svelte' import AutoSaveStatus from './AutoSaveStatus.svelte' + import DraftPrompt from './DraftPrompt.svelte' import UnifiedMediaModal from './UnifiedMediaModal.svelte' import SmartImage from '../SmartImage.svelte' import Composer from './composer' import { toast } from '$lib/stores/toast' + import { makeDraftKey, saveDraft, clearDraft } from '$lib/admin/draftStore' + import { createAutoSaveStore } from '$lib/admin/autoSave.svelte' + import { useDraftRecovery } from '$lib/admin/useDraftRecovery.svelte' + import { useFormGuards } from '$lib/admin/useFormGuards.svelte' import type { Album, Media } from '@prisma/client' import type { JSONContent } from '@tiptap/core' @@ -33,6 +38,7 @@ // State let isLoading = $state(mode === 'edit') + let hasLoaded = $state(mode === 'create') let _isSaving = $state(false) let _validationErrors = $state>({}) let showBulkAlbumModal = $state(false) @@ -40,6 +46,13 @@ let editorInstance = $state<{ save: () => Promise; clear: () => void } | undefined>() let activeTab = $state('metadata') let pendingMediaIds = $state([]) // Photos to add after album creation + let updatedAt = $state( + album?.updatedAt + ? typeof album.updatedAt === 'string' + ? album.updatedAt + : album.updatedAt.toISOString() + : undefined + ) const tabOptions = [ { value: 'metadata', label: 'Metadata' }, @@ -73,6 +86,76 @@ // Derived state for existing media IDs const existingMediaIds = $derived(albumMedia.map((item) => item.media.id)) + // Draft key for autosave fallback + const draftKey = $derived(mode === 'edit' && album ? makeDraftKey('album', album.id) : null) + + function buildPayload() { + return { + title: formData.title, + slug: formData.slug, + description: null, + date: formData.year || null, + location: formData.location || null, + showInUniverse: formData.showInUniverse, + status: formData.status, + content: formData.content, + updatedAt + } + } + + // Autosave store (edit mode only) + // Initialized as null and created reactively when album data becomes available + let autoSave = $state, Album>> | null>(null) + + // INITIALIZATION ORDER: + // 1. This effect creates autoSave when album prop becomes available + // 2. useFormGuards is called immediately after creation (same effect) + // 3. Other effects check for autoSave existence before using it + $effect(() => { + // Create autoSave when album becomes available (only once) + if (mode === 'edit' && album && !autoSave) { + const albumId = album.id // Capture album ID to avoid null reference + autoSave = createAutoSaveStore({ + debounceMs: 2000, + getPayload: () => (hasLoaded ? buildPayload() : null), + save: async (payload, { signal }) => { + const response = await fetch(`/api/albums/${albumId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + credentials: 'same-origin', + signal + }) + if (!response.ok) throw new Error('Failed to save') + return await response.json() + }, + onSaved: (saved: Album, { prime }) => { + updatedAt = + typeof saved.updatedAt === 'string' ? saved.updatedAt : saved.updatedAt.toISOString() + prime(buildPayload()) + if (draftKey) clearDraft(draftKey) + } + }) + + // Form guards (navigation protection, Cmd+S, beforeunload) + useFormGuards(autoSave) + } + }) + + // Draft recovery helper + const draftRecovery = useDraftRecovery>({ + draftKey: () => draftKey, + onRestore: (payload) => { + formData.title = payload.title ?? formData.title + formData.slug = payload.slug ?? formData.slug + formData.status = payload.status ?? formData.status + formData.year = payload.date ?? formData.year + formData.location = payload.location ?? formData.location + formData.showInUniverse = payload.showInUniverse ?? formData.showInUniverse + formData.content = payload.content ?? formData.content + } + }) + // Watch for album changes and populate form data $effect(() => { if (album && mode === 'edit') { @@ -93,6 +176,49 @@ } }) + // Prime autosave on initial load (edit mode only) + $effect(() => { + if (mode === 'edit' && album && !hasLoaded && autoSave) { + autoSave.prime(buildPayload()) + hasLoaded = true + } + }) + + // Trigger autosave when form data changes + // Using `void` operator to explicitly track dependencies without using their values + // This effect re-runs whenever any of these form fields change + $effect(() => { + void formData.title + void formData.slug + void formData.status + void formData.year + void formData.location + void formData.showInUniverse + void formData.content + void activeTab + if (hasLoaded && autoSave) { + autoSave.schedule() + } + }) + + // Save draft only when autosave fails + $effect(() => { + if (hasLoaded && autoSave && draftKey) { + const saveStatus = autoSave.status + if (saveStatus === 'error' || saveStatus === 'offline') { + saveDraft(draftKey, buildPayload()) + } + } + }) + + // Cleanup autosave on unmount + $effect(() => { + if (autoSave) { + const instance = autoSave + return () => instance.destroy() + } + }) + function populateFormData(data: Album) { formData = { title: data.title || '', @@ -275,13 +401,21 @@
{#if !isLoading} {/if}
+ {#if draftRecovery.showPrompt} + + {/if} +
{#if isLoading}
Loading album...
diff --git a/src/lib/components/admin/EssayForm.svelte b/src/lib/components/admin/EssayForm.svelte index d51bb8b..e802b53 100644 --- a/src/lib/components/admin/EssayForm.svelte +++ b/src/lib/components/admin/EssayForm.svelte @@ -1,14 +1,17 @@ @@ -542,18 +474,12 @@ $effect(() => { {/if} - {#if showDraftPrompt} -
-
- - Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}. - -
- - -
-
-
+ {#if draftRecovery.showPrompt} + {/if} {#if loading} @@ -636,72 +562,6 @@ $effect(() => { gap: $unit-2x; } - .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; - } - } - } - .btn-icon { width: 40px; height: 40px;