diff --git a/src/lib/components/admin/AlbumForm.svelte b/src/lib/components/admin/AlbumForm.svelte index e6e1ac3..07c449b 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,7 @@ 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?.toISOString()) const tabOptions = [ { value: 'metadata', label: 'Metadata' }, @@ -73,6 +80,64 @@ // 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) + const autoSave = mode === 'edit' && album + ? createAutoSaveStore({ + debounceMs: 2000, + getPayload: () => (hasLoaded ? buildPayload() : null), + save: async (payload, { signal }) => { + const response = await fetch(`/api/albums/${album.id}`, { + 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 = saved.updatedAt.toISOString() + prime(buildPayload()) + if (draftKey) clearDraft(draftKey) + } + }) + : null + + // 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 + } + }) + + // Form guards (navigation protection, Cmd+S, beforeunload) + useFormGuards(autoSave) + // Watch for album changes and populate form data $effect(() => { if (album && mode === 'edit') { @@ -93,6 +158,46 @@ } }) + // 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 + $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) { + return () => autoSave.destroy() + } + }) + function populateFormData(data: Album) { formData = { title: data.title || '', @@ -275,13 +380,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;