refactor: Phase 1 admin form system unification

- Refactor EssayForm to use useDraftRecovery and useFormGuards composables
- Refactor AlbumForm to add autosave and use composables
- Refactor posts edit page to use composables
- Replace inline draft recovery logic with useDraftRecovery composable
- Replace inline form guards with useFormGuards composable
- Replace inline draft banners with DraftPrompt component
- Remove ~200 lines of duplicated code across forms
- Maintain zero lint errors throughout refactoring

Co-Authored-By: Justin Edmund <justin@jedmund.com>
This commit is contained in:
Devin AI 2025-11-24 15:30:56 +00:00
parent 6609759e88
commit 5e58d31f7e
3 changed files with 219 additions and 386 deletions

View file

@ -6,10 +6,15 @@
import Input from './Input.svelte' import Input from './Input.svelte'
import DropdownSelectField from './DropdownSelectField.svelte' import DropdownSelectField from './DropdownSelectField.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte' import AutoSaveStatus from './AutoSaveStatus.svelte'
import DraftPrompt from './DraftPrompt.svelte'
import UnifiedMediaModal from './UnifiedMediaModal.svelte' import UnifiedMediaModal from './UnifiedMediaModal.svelte'
import SmartImage from '../SmartImage.svelte' import SmartImage from '../SmartImage.svelte'
import Composer from './composer' import Composer from './composer'
import { toast } from '$lib/stores/toast' 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 { Album, Media } from '@prisma/client'
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
@ -33,6 +38,7 @@
// State // State
let isLoading = $state(mode === 'edit') let isLoading = $state(mode === 'edit')
let hasLoaded = $state(mode === 'create')
let _isSaving = $state(false) let _isSaving = $state(false)
let _validationErrors = $state<Record<string, string>>({}) let _validationErrors = $state<Record<string, string>>({})
let showBulkAlbumModal = $state(false) let showBulkAlbumModal = $state(false)
@ -40,6 +46,7 @@
let editorInstance = $state<{ save: () => Promise<JSONContent>; clear: () => void } | undefined>() let editorInstance = $state<{ save: () => Promise<JSONContent>; clear: () => void } | undefined>()
let activeTab = $state('metadata') let activeTab = $state('metadata')
let pendingMediaIds = $state<number[]>([]) // Photos to add after album creation let pendingMediaIds = $state<number[]>([]) // Photos to add after album creation
let updatedAt = $state<string | undefined>(album?.updatedAt?.toISOString())
const tabOptions = [ const tabOptions = [
{ value: 'metadata', label: 'Metadata' }, { value: 'metadata', label: 'Metadata' },
@ -73,6 +80,64 @@
// Derived state for existing media IDs // Derived state for existing media IDs
const existingMediaIds = $derived(albumMedia.map((item) => item.media.id)) 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<ReturnType<typeof buildPayload>>({
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 // Watch for album changes and populate form data
$effect(() => { $effect(() => {
if (album && mode === 'edit') { 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) { function populateFormData(data: Album) {
formData = { formData = {
title: data.title || '', title: data.title || '',
@ -275,13 +380,21 @@
<div class="header-actions"> <div class="header-actions">
{#if !isLoading} {#if !isLoading}
<AutoSaveStatus <AutoSaveStatus
status="idle" status={autoSave?.status ?? 'idle'}
lastSavedAt={album?.updatedAt} lastSavedAt={album?.updatedAt}
/> />
{/if} {/if}
</div> </div>
</header> </header>
{#if draftRecovery.showPrompt}
<DraftPrompt
timeAgo={draftRecovery.draftTimeText}
onRestore={draftRecovery.restore}
onDismiss={draftRecovery.dismiss}
/>
{/if}
<div class="admin-container"> <div class="admin-container">
{#if isLoading} {#if isLoading}
<div class="loading">Loading album...</div> <div class="loading">Loading album...</div>

View file

@ -1,14 +1,17 @@
<script lang="ts"> <script lang="ts">
import { goto, beforeNavigate } from '$app/navigation' import { goto } from '$app/navigation'
import AdminPage from './AdminPage.svelte' import AdminPage from './AdminPage.svelte'
import AdminSegmentedControl from './AdminSegmentedControl.svelte' import AdminSegmentedControl from './AdminSegmentedControl.svelte'
import Editor from './Editor.svelte' import Editor from './Editor.svelte'
import Button from './Button.svelte' import Button from './Button.svelte'
import Input from './Input.svelte' import Input from './Input.svelte'
import DropdownSelectField from './DropdownSelectField.svelte' import DropdownSelectField from './DropdownSelectField.svelte'
import DraftPrompt from './DraftPrompt.svelte'
import { toast } from '$lib/stores/toast' import { toast } from '$lib/stores/toast'
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore' import { makeDraftKey, saveDraft, clearDraft } from '$lib/admin/draftStore'
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte' import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
import { useDraftRecovery } from '$lib/admin/useDraftRecovery.svelte'
import { useFormGuards } from '$lib/admin/useFormGuards.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte' import AutoSaveStatus from './AutoSaveStatus.svelte'
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
import type { Post } from '@prisma/client' import type { Post } from '@prisma/client'
@ -44,14 +47,10 @@
// Ref to the editor component // Ref to the editor component
let editorRef: { save: () => Promise<JSONContent> } | undefined let editorRef: { save: () => Promise<JSONContent> } | undefined
// Draft backup // Draft key for autosave fallback
const draftKey = $derived(makeDraftKey('post', postId ?? 'new')) const draftKey = $derived(mode === 'edit' && postId ? makeDraftKey('post', postId) : 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() { function buildPayload() {
return { return {
title, title,
slug, slug,
@ -61,10 +60,10 @@ function buildPayload() {
tags, tags,
updatedAt updatedAt
} }
} }
// Autosave store (edit mode only) // Autosave store (edit mode only)
let autoSave = mode === 'edit' && postId const autoSave = mode === 'edit' && postId
? createAutoSaveStore({ ? createAutoSaveStore({
debounceMs: 2000, debounceMs: 2000,
getPayload: () => (hasLoaded ? buildPayload() : null), getPayload: () => (hasLoaded ? buildPayload() : null),
@ -87,6 +86,21 @@ let autoSave = mode === 'edit' && postId
}) })
: null : null
// Draft recovery helper
const draftRecovery = useDraftRecovery<ReturnType<typeof buildPayload>>({
draftKey: () => draftKey,
onRestore: (payload) => {
title = payload.title ?? title
slug = payload.slug ?? slug
status = payload.status ?? status
content = payload.content ?? content
tags = payload.tags ?? tags
}
})
// Form guards (navigation protection, Cmd+S, beforeunload)
useFormGuards(autoSave)
const tabOptions = [ const tabOptions = [
{ value: 'metadata', label: 'Metadata' }, { value: 'metadata', label: 'Metadata' },
{ value: 'content', label: 'Content' } { value: 'content', label: 'Content' }
@ -106,14 +120,14 @@ let autoSave = mode === 'edit' && postId
] ]
// Auto-generate slug from title // Auto-generate slug from title
$effect(() => { $effect(() => {
if (title && !slug) { if (title && !slug) {
slug = title slug = title
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') .replace(/^-+|-+$/g, '')
} }
}) })
// Prime autosave on initial load (edit mode only) // Prime autosave on initial load (edit mode only)
$effect(() => { $effect(() => {
@ -133,7 +147,7 @@ $effect(() => {
// Save draft only when autosave fails // Save draft only when autosave fails
$effect(() => { $effect(() => {
if (hasLoaded && autoSave) { if (hasLoaded && autoSave && draftKey) {
const saveStatus = autoSave.status const saveStatus = autoSave.status
if (saveStatus === 'error' || saveStatus === 'offline') { if (saveStatus === 'error' || saveStatus === 'offline') {
saveDraft(draftKey, buildPayload()) saveDraft(draftKey, buildPayload())
@ -141,88 +155,6 @@ $effect(() => {
} }
}) })
// Show restore prompt if a draft exists
$effect(() => {
const draft = loadDraft<ReturnType<typeof buildPayload>>(draftKey)
if (draft) {
showDraftPrompt = true
draftTimestamp = draft.ts
}
})
function restoreDraft() {
const draft = loadDraft<ReturnType<typeof buildPayload>>(draftKey)
if (!draft) return
const p = draft.payload
title = p.title ?? title
slug = p.slug ?? slug
status = p.status ?? status
content = p.content ?? content
tags = p.tags ?? tags
showDraftPrompt = false
clearDraft(draftKey)
}
function dismissDraft() {
showDraftPrompt = false
clearDraft(draftKey)
}
// Auto-update draft time text every minute when prompt visible
$effect(() => {
if (showDraftPrompt) {
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
return () => clearInterval(id)
}
})
// Navigation guard: flush autosave before navigating away (only if unsaved)
beforeNavigate(async () => {
if (hasLoaded && autoSave) {
if (autoSave.status === 'saved') {
return
}
// Flush any pending changes before allowing navigation to proceed
try {
await autoSave.flush()
} catch (error) {
console.error('Autosave flush failed:', error)
}
}
})
// Warn before closing browser tab/window if there are unsaved changes
$effect(() => {
if (!hasLoaded || !autoSave) return
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (autoSave!.status !== 'saved') {
event.preventDefault()
event.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
})
// Keyboard shortcut: Cmd/Ctrl+S to save immediately
$effect(() => {
if (!hasLoaded || !autoSave) return
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
autoSave!.flush().catch((error) => {
console.error('Autosave flush failed:', error)
})
}
}
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
})
// Cleanup autosave on unmount // Cleanup autosave on unmount
$effect(() => { $effect(() => {
if (autoSave) { if (autoSave) {
@ -332,18 +264,12 @@ $effect(() => {
</div> </div>
</header> </header>
{#if 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">
@ -480,72 +406,6 @@ $effect(() => {
display: flex; display: flex;
} }
.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;
}
}
}
// Custom styles for save/publish buttons to maintain grey color scheme // Custom styles for save/publish buttons to maintain grey color scheme
:global(.save-button.btn-primary) { :global(.save-button.btn-primary) {
background-color: $gray-10; background-color: $gray-10;

View file

@ -1,16 +1,19 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores' import { page } from '$app/stores'
import { goto, beforeNavigate } from '$app/navigation' import { goto } from '$app/navigation'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { api } from '$lib/admin/api' import { api } from '$lib/admin/api'
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore' 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 AdminPage from '$lib/components/admin/AdminPage.svelte' import AdminPage from '$lib/components/admin/AdminPage.svelte'
import Composer from '$lib/components/admin/composer' import Composer from '$lib/components/admin/composer'
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte' import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte' import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte'
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte' import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
import DraftPrompt from '$lib/components/admin/DraftPrompt.svelte'
import StatusDropdown from '$lib/components/admin/StatusDropdown.svelte' import StatusDropdown from '$lib/components/admin/StatusDropdown.svelte'
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
import AutoSaveStatus from '$lib/components/admin/AutoSaveStatus.svelte' import AutoSaveStatus from '$lib/components/admin/AutoSaveStatus.svelte'
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
import type { Post } from '@prisma/client' import type { Post } from '@prisma/client'
@ -59,12 +62,8 @@ import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/ad
let metadataButtonRef: HTMLButtonElement | undefined = $state.raw() let metadataButtonRef: HTMLButtonElement | undefined = $state.raw()
let showDeleteConfirmation = $state(false) let showDeleteConfirmation = $state(false)
// Draft backup // Draft key for autosave fallback
const draftKey = $derived(makeDraftKey('post', $page.params.id)) const draftKey = $derived(makeDraftKey('post', $page.params.id))
let showDraftPrompt = $state(false)
let draftTimestamp = $state<number | null>(null)
let timeTicker = $state(0)
const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
const postTypeConfig = { const postTypeConfig = {
post: { icon: '💭', label: 'Post', showTitle: false, showContent: true }, post: { icon: '💭', label: 'Post', showTitle: false, showContent: true },
@ -74,7 +73,7 @@ const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(d
let config = $derived(postTypeConfig[postType]) let config = $derived(postTypeConfig[postType])
// Autosave store // Autosave store
let autoSave = createAutoSaveStore({ const autoSave = createAutoSaveStore({
debounceMs: 2000, debounceMs: 2000,
getPayload: () => { getPayload: () => {
if (!hasLoaded) return null if (!hasLoaded) return null
@ -109,6 +108,23 @@ const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(d
} }
}) })
// Draft recovery helper
const draftRecovery = useDraftRecovery<DraftPayload>({
draftKey: () => draftKey,
onRestore: (payload) => {
if (payload.title !== undefined) title = payload.title ?? ''
if (payload.slug !== undefined) slug = payload.slug
if (payload.type !== undefined) postType = payload.type as 'post' | 'essay'
if (payload.status !== undefined) status = payload.status as 'draft' | 'published'
if (payload.content !== undefined) content = payload.content ?? { type: 'doc', content: [] }
if (payload.excerpt !== undefined) excerpt = payload.excerpt ?? ''
if (payload.tags !== undefined) tags = payload.tags
}
})
// Form guards (navigation protection, Cmd+S, beforeunload)
useFormGuards(autoSave)
// Convert blocks format (from database) to Tiptap format // Convert blocks format (from database) to Tiptap format
function convertBlocksToTiptap(blocksContent: BlockContent): JSONContent { function convertBlocksToTiptap(blocksContent: BlockContent): JSONContent {
if (!blocksContent || !blocksContent.blocks) { if (!blocksContent || !blocksContent.blocks) {
@ -208,16 +224,11 @@ const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(d
} }
} }
onMount(async () => { onMount(async () => {
// Wait a tick to ensure page params are loaded // Wait a tick to ensure page params are loaded
await new Promise((resolve) => setTimeout(resolve, 0)) await new Promise((resolve) => setTimeout(resolve, 0))
await loadPost() await loadPost()
const draft = loadDraft<DraftPayload>(draftKey) })
if (draft) {
showDraftPrompt = true
draftTimestamp = draft.ts
}
})
async function loadPost() { async function loadPost() {
const postId = $page.params.id const postId = $page.params.id
@ -335,27 +346,6 @@ onMount(async () => {
} }
} }
function restoreDraft() {
const draft = loadDraft<DraftPayload>(draftKey)
if (!draft) return
const p = draft.payload
// Apply payload fields to form
if (p.title !== undefined) title = p.title
if (p.slug !== undefined) slug = p.slug
if (p.type !== undefined) postType = p.type
if (p.status !== undefined) status = p.status
if (p.content !== undefined) content = p.content
if (p.excerpt !== undefined) excerpt = p.excerpt
if (p.tags !== undefined) tags = p.tags
showDraftPrompt = false
clearDraft(draftKey)
}
function dismissDraft() {
showDraftPrompt = false
clearDraft(draftKey)
}
function handleMetadataPopover(event: MouseEvent) { function handleMetadataPopover(event: MouseEvent) {
const target = event.target as Node const target = event.target as Node
// Don't close if clicking inside the metadata button or anywhere in a metadata popover // Don't close if clicking inside the metadata button or anywhere in a metadata popover
@ -403,68 +393,10 @@ onMount(async () => {
} }
}) })
// Navigation guard: flush autosave before navigating away (only if there are unsaved changes)
beforeNavigate(async (_navigation) => {
if (hasLoaded) {
// 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)
}
}
})
// Warn before closing browser tab/window if there are unsaved changes
$effect(() => {
if (!hasLoaded) 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 (!hasLoaded) return
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
autoSave.flush().catch((error) => {
console.error('Autosave flush failed:', error)
})
}
}
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
})
// Cleanup autosave on unmount // Cleanup autosave on unmount
$effect(() => { $effect(() => {
return () => autoSave.destroy() return () => autoSave.destroy()
}) })
// Auto-update draft time text every minute when prompt visible
$effect(() => {
if (showDraftPrompt) {
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
return () => clearInterval(id)
}
})
</script> </script>
<svelte:head> <svelte:head>
@ -542,18 +474,12 @@ $effect(() => {
{/if} {/if}
</header> </header>
{#if 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}
{#if loading} {#if loading}
@ -636,72 +562,6 @@ $effect(() => {
gap: $unit-2x; 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 { .btn-icon {
width: 40px; width: 40px;
height: 40px; height: 40px;