jedmund-svelte/src/lib/components/admin/EssayForm.svelte
Justin Edmund c49ce5cbb5 feat(admin): add runes-based autosave to EssayForm
- Add createAutoSaveStore for edit mode
- Add updatedAt tracking for conflict detection
- Add hasLoaded flag to prevent autosave on initial load
- Prime autosave after initial data loads
- Add AutoSaveStatus indicator in header
- Move draft recovery from inline to prominent banner
- Only save draft on autosave failure (not every change)
- Smart navigation guard (only blocks if unsaved)
- Add beforeunload warning (only if unsaved changes)
- Add keyboard shortcut (Cmd/Ctrl+S)
- Add proper cleanup on unmount
- Update clearDraft calls in restore/dismiss functions
- Fix $derived syntax (use $derived.by for draftTimeText)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 14:00:28 -07:00

797 lines
17 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { goto, beforeNavigate } from '$app/navigation'
import AdminPage from './AdminPage.svelte'
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
import Editor from './Editor.svelte'
import Button from './Button.svelte'
import Input from './Input.svelte'
import { toast } from '$lib/stores/toast'
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte'
import type { JSONContent } from '@tiptap/core'
interface Props {
postId?: number
initialData?: {
title: string
slug: string
content: JSONContent
tags: string[]
status: 'draft' | 'published'
updatedAt?: string
}
mode: 'create' | 'edit'
}
let { postId, initialData, mode }: Props = $props()
// State
let isLoading = $state(false)
let hasLoaded = $state(mode === 'create') // Create mode loads immediately
let isSaving = $state(false)
let activeTab = $state('metadata')
let showPublishMenu = $state(false)
let updatedAt = $state<string | undefined>(initialData?.updatedAt)
// Form data
let title = $state(initialData?.title || '')
let slug = $state(initialData?.slug || '')
let content = $state<JSONContent>(initialData?.content || { type: 'doc', content: [] })
let tags = $state<string[]>(initialData?.tags || [])
let status = $state<'draft' | 'published'>(initialData?.status || 'draft')
let tagInput = $state('')
// Ref to the editor component
let editorRef: any
// Draft backup
const draftKey = $derived(makeDraftKey('post', postId ?? 'new'))
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,
slug,
type: 'essay',
status,
content,
tags,
updatedAt
}
}
// Autosave store (edit mode only)
let autoSave = mode === 'edit' && postId
? createAutoSaveStore({
debounceMs: 2000,
getPayload: () => (hasLoaded ? buildPayload() : null),
save: async (payload, { signal }) => {
const response = await fetch(`/api/posts/${postId}`, {
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: any, { prime }) => {
updatedAt = saved.updatedAt
prime(buildPayload())
if (draftKey) clearDraft(draftKey)
}
})
: null
const tabOptions = [
{ value: 'metadata', label: 'Metadata' },
{ value: 'content', label: 'Content' }
]
// Auto-generate slug from title
$effect(() => {
if (title && !slug) {
slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
})
// Prime autosave on initial load (edit mode only)
$effect(() => {
if (mode === 'edit' && initialData && !hasLoaded && autoSave) {
autoSave.prime(buildPayload())
hasLoaded = true
}
})
// Trigger autosave when form data changes
$effect(() => {
title; slug; status; content; tags; activeTab
if (hasLoaded && autoSave) {
autoSave.schedule()
}
})
// Save draft only when autosave fails
$effect(() => {
if (hasLoaded && autoSave) {
const saveStatus = autoSave.status
if (saveStatus === 'error' || saveStatus === 'offline') {
saveDraft(draftKey, buildPayload())
}
}
})
// Show restore prompt if a draft exists
$effect(() => {
const draft = loadDraft<any>(draftKey)
if (draft) {
showDraftPrompt = true
draftTimestamp = draft.ts
}
})
function restoreDraft() {
const draft = loadDraft<any>(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 (navigation) => {
if (hasLoaded && autoSave) {
if (autoSave.status === 'saved') {
return
}
navigation.cancel()
try {
await autoSave.flush()
navigation.retry()
} 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
$effect(() => {
if (autoSave) {
return () => autoSave.destroy()
}
})
function addTag() {
if (tagInput && !tags.includes(tagInput)) {
tags = [...tags, tagInput]
tagInput = ''
}
}
function removeTag(tag: string) {
tags = tags.filter((t) => t !== tag)
}
function handleEditorChange(newContent: JSONContent) {
content = newContent
}
async function handleSave() {
// Check if we're on the content tab and should save editor content
if (activeTab === 'content' && editorRef) {
const editorData = await editorRef.save()
if (editorData) {
content = editorData
}
}
if (!title) {
toast.error('Title is required')
return
}
const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} essay...`)
try {
isSaving = true
const payload = {
title,
slug,
type: 'essay', // No mapping needed anymore
status,
content,
tags
}
const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts'
const method = mode === 'edit' ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload),
credentials: 'same-origin'
})
if (!response.ok) {
if (response.status === 401) {
goto('/admin/login')
return
}
throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} essay`)
}
const savedPost = await response.json()
toast.dismiss(loadingToastId)
toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`)
clearDraft(draftKey)
if (mode === 'create') {
goto(`/admin/posts/${savedPost.id}/edit`)
}
} catch (err) {
toast.dismiss(loadingToastId)
toast.error(`Failed to ${mode === 'edit' ? 'save' : 'create'} essay`)
console.error(err)
} finally {
isSaving = false
}
}
async function handlePublish() {
status = 'published'
await handleSave()
showPublishMenu = false
}
async function handleUnpublish() {
status = 'draft'
await handleSave()
showPublishMenu = false
}
function togglePublishMenu() {
showPublishMenu = !showPublishMenu
}
// Close menu when clicking outside
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement
if (!target.closest('.save-actions')) {
showPublishMenu = false
}
}
$effect(() => {
if (showPublishMenu) {
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('click', handleClickOutside)
}
}
})
</script>
<AdminPage>
<header slot="header">
<div class="header-left">
<Button variant="ghost" iconOnly onclick={() => goto('/admin/posts')}>
<svg slot="icon" width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M12.5 15L7.5 10L12.5 5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
</div>
<div class="header-center">
<AdminSegmentedControl
options={tabOptions}
value={activeTab}
onChange={(value) => (activeTab = value)}
/>
</div>
<div class="header-actions">
<div class="save-actions">
<Button variant="primary" onclick={handleSave} disabled={isSaving} class="save-button">
{status === 'published' ? 'Save' : 'Save Draft'}
</Button>
<Button
variant="primary"
iconOnly
buttonSize="medium"
active={showPublishMenu}
onclick={togglePublishMenu}
disabled={isSaving}
class="chevron-button"
>
<svg
slot="icon"
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
{#if showPublishMenu}
<div class="publish-menu">
{#if status === 'published'}
<Button variant="ghost" onclick={handleUnpublish} class="menu-item" fullWidth>
Unpublish
</Button>
{:else}
<Button variant="ghost" onclick={handlePublish} class="menu-item" fullWidth>
Publish
</Button>
{/if}
</div>
{/if}
</div>
{#if mode === 'edit' && autoSave}
<AutoSaveStatus status={autoSave.status} error={autoSave.lastError} />
{/if}
</div>
</header>
{#if 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}
<div class="admin-container">
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if successMessage}
<div class="success-message">{successMessage}</div>
{/if}
<div class="tab-panels">
<!-- Metadata Panel -->
<div class="panel content-wrapper" class:active={activeTab === 'metadata'}>
<div class="form-content">
<form
onsubmit={(e) => {
e.preventDefault()
handleSave()
}}
>
<div class="form-section">
<Input
label="Title"
size="jumbo"
bind:value={title}
required
placeholder="Essay title"
/>
<Input label="Slug" bind:value={slug} placeholder="essay-url-slug" />
<div class="tags-field">
<label class="input-label">Tags</label>
<div class="tag-input-wrapper">
<Input
bind:value={tagInput}
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
placeholder="Add tags..."
wrapperClass="tag-input"
/>
<Button variant="secondary" buttonSize="small" type="button" onclick={addTag}>
Add
</Button>
</div>
{#if tags.length > 0}
<div class="tags">
{#each tags as tag}
<span class="tag">
{tag}
<Button
variant="ghost"
iconOnly
buttonSize="small"
onclick={() => removeTag(tag)}
aria-label="Remove {tag}"
>
×
</Button>
</span>
{/each}
</div>
{/if}
</div>
</div>
</form>
</div>
</div>
<!-- Content Panel -->
<div class="panel content-wrapper" class:active={activeTab === 'content'}>
<div class="editor-content">
<Editor
bind:this={editorRef}
bind:data={content}
onChange={handleEditorChange}
placeholder="Write your essay..."
minHeight={400}
autofocus={false}
class="essay-editor"
/>
</div>
</div>
</div>
</div>
</AdminPage>
<style lang="scss">
header {
display: grid;
grid-template-columns: 250px 1fr 250px;
align-items: center;
width: 100%;
gap: $unit-2x;
.header-left {
width: 250px;
}
.header-center {
display: flex;
justify-content: center;
align-items: center;
}
.header-actions {
width: 250px;
display: flex;
justify-content: flex-end;
}
}
.admin-container {
width: 100%;
margin: 0 auto;
padding: 0 $unit-2x $unit-4x;
box-sizing: border-box;
@include breakpoint('phone') {
padding: 0 $unit-2x $unit-2x;
}
}
.save-actions {
position: relative;
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
:global(.save-button.btn-primary) {
background-color: $gray-10;
&:hover:not(:disabled) {
background-color: $gray-20;
}
&:active:not(:disabled) {
background-color: $gray-30;
}
}
.save-button {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
padding-right: $unit-2x;
}
:global(.chevron-button.btn-primary) {
background-color: $gray-10;
&:hover:not(:disabled) {
background-color: $gray-20;
}
&:active:not(:disabled) {
background-color: $gray-30;
}
&.active {
background-color: $gray-20;
}
}
.chevron-button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 1px solid rgba(255, 255, 255, 0.2);
svg {
transition: transform 0.2s ease;
}
&.active svg {
transform: rotate(180deg);
}
}
.publish-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: $unit;
background: white;
border-radius: $unit;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
overflow: hidden;
min-width: 120px;
z-index: 100;
.menu-item {
text-align: left;
}
}
.tab-panels {
position: relative;
.panel {
display: none;
box-sizing: border-box;
&.active {
display: block;
}
}
}
.content-wrapper {
background: white;
border-radius: $unit-2x;
padding: 0;
width: 100%;
margin: 0 auto;
}
.error-message,
.success-message {
padding: $unit-3x;
border-radius: $unit;
margin-bottom: $unit-4x;
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
.error-message {
background-color: #fee;
color: #d33;
}
.success-message {
background-color: #efe;
color: #363;
}
.form-section {
margin-bottom: $unit-6x;
&:last-child {
margin-bottom: 0;
}
}
.form-content {
padding: $unit-4x;
@include breakpoint('phone') {
padding: $unit-3x;
}
}
// Tags field styles
.tags-field {
margin-bottom: $unit-4x;
.input-label {
display: block;
margin-bottom: $unit;
font-size: 14px;
font-weight: 500;
color: $gray-20;
}
}
.tag-input-wrapper {
display: flex;
gap: $unit;
:global(.tag-input) {
flex: 1;
}
}
.tags {
display: flex;
flex-wrap: wrap;
gap: $unit;
margin-top: $unit-2x;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: $unit $unit-2x;
background: $gray-90;
border-radius: 20px;
font-size: 0.875rem;
color: $gray-20;
:global(.btn) {
margin-left: 4px;
font-size: 1.125rem;
line-height: 1;
}
}
.editor-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
:global(.essay-editor) {
flex: 1;
overflow: auto;
}
}
</style>