Compare commits
No commits in common. "main" and "devin/1763907694-fix-linter-errors" have entirely different histories.
main
...
devin/1763
20 changed files with 1081 additions and 303 deletions
|
|
@ -1,3 +0,0 @@
|
||||||
onlyBuiltDependencies:
|
|
||||||
- "@musicorum/lastfm"
|
|
||||||
- "psn-api"
|
|
||||||
|
|
@ -8,7 +8,7 @@ export interface AutoSaveStoreOptions<TPayload, TResponse = unknown> {
|
||||||
onSaved?: (res: TResponse, ctx: { prime: (payload: TPayload) => void }) => void
|
onSaved?: (res: TResponse, ctx: { prime: (payload: TPayload) => void }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutoSaveStore<TPayload, _TResponse = unknown> {
|
export interface AutoSaveStore<TPayload> {
|
||||||
readonly status: AutoSaveStatus
|
readonly status: AutoSaveStatus
|
||||||
readonly lastError: string | null
|
readonly lastError: string | null
|
||||||
schedule: () => void
|
schedule: () => void
|
||||||
|
|
@ -36,7 +36,7 @@ export interface AutoSaveStore<TPayload, _TResponse = unknown> {
|
||||||
*/
|
*/
|
||||||
export function createAutoSaveStore<TPayload, TResponse = unknown>(
|
export function createAutoSaveStore<TPayload, TResponse = unknown>(
|
||||||
opts: AutoSaveStoreOptions<TPayload, TResponse>
|
opts: AutoSaveStoreOptions<TPayload, TResponse>
|
||||||
): AutoSaveStore<TPayload, unknown> {
|
): AutoSaveStore<TPayload> {
|
||||||
const debounceMs = opts.debounceMs ?? 2000
|
const debounceMs = opts.debounceMs ?? 2000
|
||||||
const idleResetMs = opts.idleResetMs ?? 2000
|
const idleResetMs = opts.idleResetMs ?? 2000
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
@ -70,9 +70,6 @@ export function createAutoSaveStore<TPayload, TResponse = unknown>(
|
||||||
|
|
||||||
function schedule() {
|
function schedule() {
|
||||||
if (timer) clearTimeout(timer)
|
if (timer) clearTimeout(timer)
|
||||||
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
|
|
||||||
console.debug(`[AutoSave] Scheduled (${debounceMs}ms debounce)`)
|
|
||||||
}
|
|
||||||
timer = setTimeout(() => void run(), debounceMs)
|
timer = setTimeout(() => void run(), debounceMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,44 +80,24 @@ export function createAutoSaveStore<TPayload, TResponse = unknown>(
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = opts.getPayload()
|
const payload = opts.getPayload()
|
||||||
if (!payload) {
|
if (!payload) return
|
||||||
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
|
|
||||||
console.debug('[AutoSave] Skipped: getPayload returned null/undefined')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const hash = safeHash(payload)
|
const hash = safeHash(payload)
|
||||||
if (lastSentHash && hash === lastSentHash) {
|
if (lastSentHash && hash === lastSentHash) return
|
||||||
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
|
|
||||||
console.debug('[AutoSave] Skipped: payload unchanged (hash match)')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (controller) controller.abort()
|
if (controller) controller.abort()
|
||||||
controller = new AbortController()
|
controller = new AbortController()
|
||||||
|
|
||||||
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
|
|
||||||
console.debug('[AutoSave] Saving...', { hashChanged: lastSentHash !== hash })
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus('saving')
|
setStatus('saving')
|
||||||
lastError = null
|
lastError = null
|
||||||
try {
|
try {
|
||||||
const res = await opts.save(payload, { signal: controller.signal })
|
const res = await opts.save(payload, { signal: controller.signal })
|
||||||
lastSentHash = hash
|
lastSentHash = hash
|
||||||
setStatus('saved')
|
setStatus('saved')
|
||||||
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
|
|
||||||
console.debug('[AutoSave] Saved successfully')
|
|
||||||
}
|
|
||||||
if (opts.onSaved) opts.onSaved(res, { prime })
|
if (opts.onSaved) opts.onSaved(res, { prime })
|
||||||
} catch (e) {
|
} catch (e: unknown) {
|
||||||
if (e instanceof Error && e.name === 'AbortError') {
|
if (e?.name === 'AbortError') {
|
||||||
// Newer save superseded this one
|
// Newer save superseded this one
|
||||||
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
|
|
||||||
console.debug('[AutoSave] Aborted: superseded by newer save')
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||||
|
|
@ -128,10 +105,7 @@ export function createAutoSaveStore<TPayload, TResponse = unknown>(
|
||||||
} else {
|
} else {
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
}
|
}
|
||||||
lastError = e instanceof Error ? e.message : 'Auto-save failed'
|
lastError = e?.message || 'Auto-save failed'
|
||||||
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
|
|
||||||
console.debug('[AutoSave] Error:', lastError)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
|
import { loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
|
||||||
|
|
||||||
export function useDraftRecovery<TPayload>(options: {
|
export function useDraftRecovery<TPayload>(options: {
|
||||||
draftKey: () => string | null
|
draftKey: string | null
|
||||||
onRestore: (payload: TPayload) => void
|
onRestore: (payload: TPayload) => void
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -17,10 +17,9 @@ export function useDraftRecovery<TPayload>(options: {
|
||||||
|
|
||||||
// Auto-detect draft on mount using $effect
|
// Auto-detect draft on mount using $effect
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const key = options.draftKey()
|
if (!options.draftKey || options.enabled === false) return
|
||||||
if (!key || options.enabled === false) return
|
|
||||||
|
|
||||||
const draft = loadDraft<TPayload>(key)
|
const draft = loadDraft<TPayload>(options.draftKey)
|
||||||
if (draft) {
|
if (draft) {
|
||||||
showPrompt = true
|
showPrompt = true
|
||||||
draftTimestamp = draft.ts
|
draftTimestamp = draft.ts
|
||||||
|
|
@ -44,21 +43,19 @@ export function useDraftRecovery<TPayload>(options: {
|
||||||
draftTimeText,
|
draftTimeText,
|
||||||
|
|
||||||
restore() {
|
restore() {
|
||||||
const key = options.draftKey()
|
if (!options.draftKey) return
|
||||||
if (!key) return
|
const draft = loadDraft<TPayload>(options.draftKey)
|
||||||
const draft = loadDraft<TPayload>(key)
|
|
||||||
if (!draft) return
|
if (!draft) return
|
||||||
|
|
||||||
options.onRestore(draft.payload)
|
options.onRestore(draft.payload)
|
||||||
showPrompt = false
|
showPrompt = false
|
||||||
clearDraft(key)
|
clearDraft(options.draftKey)
|
||||||
},
|
},
|
||||||
|
|
||||||
dismiss() {
|
dismiss() {
|
||||||
const key = options.draftKey()
|
if (!options.draftKey) return
|
||||||
if (!key) return
|
|
||||||
showPrompt = false
|
showPrompt = false
|
||||||
clearDraft(key)
|
clearDraft(options.draftKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@ import { beforeNavigate } from '$app/navigation'
|
||||||
import { toast } from '$lib/stores/toast'
|
import { toast } from '$lib/stores/toast'
|
||||||
import type { AutoSaveStore } from '$lib/admin/autoSave.svelte'
|
import type { AutoSaveStore } from '$lib/admin/autoSave.svelte'
|
||||||
|
|
||||||
export function useFormGuards<TPayload = unknown, _TResponse = unknown>(
|
export function useFormGuards(autoSave: AutoSaveStore<unknown, unknown> | null) {
|
||||||
autoSave: AutoSaveStore<TPayload, unknown> | null
|
|
||||||
) {
|
|
||||||
if (!autoSave) return // No guards needed for create mode
|
if (!autoSave) return // No guards needed for create mode
|
||||||
|
|
||||||
// Navigation guard: flush autosave before route change
|
// Navigation guard: flush autosave before route change
|
||||||
|
|
@ -23,12 +21,8 @@ export function useFormGuards<TPayload = unknown, _TResponse = unknown>(
|
||||||
|
|
||||||
// Warn before closing browser tab/window if unsaved changes
|
// Warn before closing browser tab/window if unsaved changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Capture autoSave in closure to avoid non-null assertions
|
|
||||||
const store = autoSave
|
|
||||||
if (!store) return
|
|
||||||
|
|
||||||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||||
if (store.status !== 'saved') {
|
if (autoSave!.status !== 'saved') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.returnValue = ''
|
event.returnValue = ''
|
||||||
}
|
}
|
||||||
|
|
@ -40,17 +34,13 @@ export function useFormGuards<TPayload = unknown, _TResponse = unknown>(
|
||||||
|
|
||||||
// Cmd/Ctrl+S keyboard shortcut for immediate save
|
// Cmd/Ctrl+S keyboard shortcut for immediate save
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Capture autoSave in closure to avoid non-null assertions
|
|
||||||
const store = autoSave
|
|
||||||
if (!store) return
|
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
const key = event.key.toLowerCase()
|
const key = event.key.toLowerCase()
|
||||||
const isModifier = event.metaKey || event.ctrlKey
|
const isModifier = event.metaKey || event.ctrlKey
|
||||||
|
|
||||||
if (isModifier && key === 's') {
|
if (isModifier && key === 's') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
store.flush().catch((error) => {
|
autoSave!.flush().catch((error) => {
|
||||||
console.error('Autosave flush failed:', error)
|
console.error('Autosave flush failed:', error)
|
||||||
toast.error('Failed to save changes')
|
toast.error('Failed to save changes')
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import AdminPage from './AdminPage.svelte'
|
import AdminPage from './AdminPage.svelte'
|
||||||
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
import AdminSegmentedControl from './AdminSegmentedControl.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 AutoSaveStatus from './AutoSaveStatus.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'
|
||||||
|
|
@ -33,9 +33,8 @@
|
||||||
|
|
||||||
// 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)
|
||||||
let albumMedia = $state<Array<{ media: Media; displayOrder: number }>>([])
|
let albumMedia = $state<Array<{ media: Media; displayOrder: number }>>([])
|
||||||
let editorInstance = $state<{ save: () => Promise<JSONContent>; clear: () => void } | undefined>()
|
let editorInstance = $state<{ save: () => Promise<JSONContent>; clear: () => void } | undefined>()
|
||||||
|
|
@ -76,10 +75,9 @@
|
||||||
|
|
||||||
// Watch for album changes and populate form data
|
// Watch for album changes and populate form data
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (album && mode === 'edit' && !hasLoaded) {
|
if (album && mode === 'edit') {
|
||||||
populateFormData(album)
|
populateFormData(album)
|
||||||
loadAlbumMedia()
|
loadAlbumMedia()
|
||||||
hasLoaded = true
|
|
||||||
} else if (mode === 'create') {
|
} else if (mode === 'create') {
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
@ -113,9 +111,9 @@
|
||||||
if (!album) return
|
if (!album) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/albums/${album.id}`, {
|
const response = await fetch(`/api/albums/${album.id}`, {
|
||||||
credentials: 'same-origin'
|
credentials: 'same-origin'
|
||||||
})
|
})
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
albumMedia = data.media || []
|
albumMedia = data.media || []
|
||||||
|
|
@ -133,7 +131,7 @@
|
||||||
location: formData.location || undefined,
|
location: formData.location || undefined,
|
||||||
year: formData.year || undefined
|
year: formData.year || undefined
|
||||||
})
|
})
|
||||||
validationErrors = {}
|
_validationErrors = {}
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof z.ZodError) {
|
if (err instanceof z.ZodError) {
|
||||||
|
|
@ -143,22 +141,23 @@
|
||||||
errors[e.path[0].toString()] = e.message
|
errors[e.path[0].toString()] = e.message
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
validationErrors = errors
|
_validationErrors = errors
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function _handleSave() {
|
||||||
if (!validateForm()) {
|
if (!validateForm()) {
|
||||||
toast.error('Please fix the validation errors')
|
toast.error('Please fix the validation errors')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isSaving = true
|
|
||||||
const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} album...`)
|
const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} album...`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_isSaving = true
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
title: formData.title,
|
title: formData.title,
|
||||||
slug: formData.slug,
|
slug: formData.slug,
|
||||||
|
|
@ -167,8 +166,7 @@
|
||||||
location: formData.location || null,
|
location: formData.location || null,
|
||||||
showInUniverse: formData.showInUniverse,
|
showInUniverse: formData.showInUniverse,
|
||||||
status: formData.status,
|
status: formData.status,
|
||||||
content: formData.content,
|
content: formData.content
|
||||||
updatedAt: mode === 'edit' ? album?.updatedAt : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = mode === 'edit' ? `/api/albums/${album?.id}` : '/api/albums'
|
const url = mode === 'edit' ? `/api/albums/${album?.id}` : '/api/albums'
|
||||||
|
|
@ -242,7 +240,7 @@
|
||||||
)
|
)
|
||||||
console.error(err)
|
console.error(err)
|
||||||
} finally {
|
} finally {
|
||||||
isSaving = false
|
_isSaving = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,13 +273,12 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<Button
|
{#if !isLoading}
|
||||||
variant="primary"
|
<AutoSaveStatus
|
||||||
onclick={handleSave}
|
status="idle"
|
||||||
disabled={isSaving}
|
lastSavedAt={album?.updatedAt}
|
||||||
>
|
/>
|
||||||
{isSaving ? 'Saving...' : 'Save'}
|
{/if}
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -454,6 +451,25 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: $gray-40;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-90;
|
||||||
|
color: $gray-10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.admin-container {
|
.admin-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
lastSavedAt?: Date | string | null
|
lastSavedAt?: Date | string | null
|
||||||
showTimestamp?: boolean
|
showTimestamp?: boolean
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
onclick?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -20,8 +19,7 @@
|
||||||
error: errorProp,
|
error: errorProp,
|
||||||
lastSavedAt,
|
lastSavedAt,
|
||||||
showTimestamp = true,
|
showTimestamp = true,
|
||||||
compact = true,
|
compact = true
|
||||||
onclick
|
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
// Support both old subscription-based stores and new reactive values
|
// Support both old subscription-based stores and new reactive values
|
||||||
|
|
@ -83,19 +81,12 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if label}
|
{#if label}
|
||||||
<button
|
<div class="autosave-status" class:compact>
|
||||||
type="button"
|
|
||||||
class="autosave-status"
|
|
||||||
class:compact
|
|
||||||
class:clickable={!!onclick && status !== 'saving'}
|
|
||||||
onclick={onclick}
|
|
||||||
disabled={status === 'saving'}
|
|
||||||
>
|
|
||||||
{#if status === 'saving'}
|
{#if status === 'saving'}
|
||||||
<span class="spinner" aria-hidden="true"></span>
|
<span class="spinner" aria-hidden="true"></span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="text">{label}</span>
|
<span class="text">{label}</span>
|
||||||
</button>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
@ -105,26 +96,10 @@
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
color: $gray-40;
|
color: $gray-40;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
font-family: inherit;
|
|
||||||
|
|
||||||
&.compact {
|
&.compact {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.clickable {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $gray-20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
toggleChecked?: boolean
|
toggleChecked?: boolean
|
||||||
toggleDisabled?: boolean
|
toggleDisabled?: boolean
|
||||||
showToggle?: boolean
|
showToggle?: boolean
|
||||||
onToggleChange?: (checked: boolean) => void
|
|
||||||
children?: import('svelte').Snippet
|
children?: import('svelte').Snippet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -15,7 +14,6 @@
|
||||||
toggleChecked = $bindable(false),
|
toggleChecked = $bindable(false),
|
||||||
toggleDisabled = false,
|
toggleDisabled = false,
|
||||||
showToggle = true,
|
showToggle = true,
|
||||||
onToggleChange,
|
|
||||||
children
|
children
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -24,7 +22,7 @@
|
||||||
<header class="branding-section__header">
|
<header class="branding-section__header">
|
||||||
<h2 class="branding-section__title">{title}</h2>
|
<h2 class="branding-section__title">{title}</h2>
|
||||||
{#if showToggle}
|
{#if showToggle}
|
||||||
<BrandingToggle bind:checked={toggleChecked} disabled={toggleDisabled} onchange={onToggleChange} />
|
<BrandingToggle bind:checked={toggleChecked} disabled={toggleDisabled} />
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
<div class="branding-section__content">
|
<div class="branding-section__content">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation'
|
import { goto, beforeNavigate } 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'
|
||||||
|
|
@ -7,7 +7,11 @@
|
||||||
import Input from './Input.svelte'
|
import Input from './Input.svelte'
|
||||||
import DropdownSelectField from './DropdownSelectField.svelte'
|
import DropdownSelectField from './DropdownSelectField.svelte'
|
||||||
import { toast } from '$lib/stores/toast'
|
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'
|
import type { JSONContent } from '@tiptap/core'
|
||||||
|
import type { Post } from '@prisma/client'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
postId?: number
|
postId?: number
|
||||||
|
|
@ -25,9 +29,9 @@
|
||||||
let { postId, initialData, mode }: Props = $props()
|
let { postId, initialData, mode }: Props = $props()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let hasLoaded = $state(mode === 'create')
|
let hasLoaded = $state(mode === 'create') // Create mode loads immediately
|
||||||
let isSaving = $state(false)
|
|
||||||
let activeTab = $state('metadata')
|
let activeTab = $state('metadata')
|
||||||
|
let updatedAt = $state<string | undefined>(initialData?.updatedAt)
|
||||||
|
|
||||||
// Form data
|
// Form data
|
||||||
let title = $state(initialData?.title || '')
|
let title = $state(initialData?.title || '')
|
||||||
|
|
@ -40,6 +44,49 @@
|
||||||
// Ref to the editor component
|
// Ref to the editor component
|
||||||
let editorRef: { save: () => Promise<JSONContent> } | undefined
|
let editorRef: { save: () => Promise<JSONContent> } | undefined
|
||||||
|
|
||||||
|
// 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: Post, { prime }) => {
|
||||||
|
updatedAt = saved.updatedAt.toISOString()
|
||||||
|
prime(buildPayload())
|
||||||
|
if (draftKey) clearDraft(draftKey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
const tabOptions = [
|
const tabOptions = [
|
||||||
{ value: 'metadata', label: 'Metadata' },
|
{ value: 'metadata', label: 'Metadata' },
|
||||||
{ value: 'content', label: 'Content' }
|
{ value: 'content', label: 'Content' }
|
||||||
|
|
@ -59,19 +106,127 @@
|
||||||
]
|
]
|
||||||
|
|
||||||
// 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)
|
||||||
|
$effect(() => {
|
||||||
|
if (mode === 'edit' && initialData && !hasLoaded && autoSave) {
|
||||||
|
autoSave.prime(buildPayload())
|
||||||
|
hasLoaded = true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mark as loaded for edit mode
|
// Trigger autosave when form data changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (mode === 'edit' && initialData && !hasLoaded) {
|
void title; void slug; void status; void content; void tags; void activeTab
|
||||||
hasLoaded = true
|
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<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
|
||||||
|
$effect(() => {
|
||||||
|
if (autoSave) {
|
||||||
|
return () => autoSave.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -104,18 +259,16 @@
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isSaving = true
|
|
||||||
const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} essay...`)
|
const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} essay...`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
title,
|
title,
|
||||||
slug,
|
slug,
|
||||||
type: 'essay',
|
type: 'essay', // No mapping needed anymore
|
||||||
status,
|
status,
|
||||||
content,
|
content,
|
||||||
tags,
|
tags
|
||||||
updatedAt: mode === 'edit' ? initialData?.updatedAt : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts'
|
const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts'
|
||||||
|
|
@ -141,7 +294,8 @@
|
||||||
const savedPost = await response.json()
|
const savedPost = await response.json()
|
||||||
|
|
||||||
toast.dismiss(loadingToastId)
|
toast.dismiss(loadingToastId)
|
||||||
toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`)
|
toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`)
|
||||||
|
clearDraft(draftKey)
|
||||||
|
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
goto(`/admin/posts/${savedPost.id}/edit`)
|
goto(`/admin/posts/${savedPost.id}/edit`)
|
||||||
|
|
@ -150,10 +304,9 @@
|
||||||
toast.dismiss(loadingToastId)
|
toast.dismiss(loadingToastId)
|
||||||
toast.error(`Failed to ${mode === 'edit' ? 'save' : 'create'} essay`)
|
toast.error(`Failed to ${mode === 'edit' ? 'save' : 'create'} essay`)
|
||||||
console.error(err)
|
console.error(err)
|
||||||
} finally {
|
|
||||||
isSaving = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AdminPage>
|
<AdminPage>
|
||||||
|
|
@ -169,16 +322,30 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<Button
|
{#if mode === 'edit' && autoSave}
|
||||||
variant="primary"
|
<AutoSaveStatus
|
||||||
onclick={handleSave}
|
status={autoSave.status}
|
||||||
disabled={isSaving}
|
error={autoSave.lastError}
|
||||||
>
|
lastSavedAt={initialData?.updatedAt}
|
||||||
{isSaving ? 'Saving...' : 'Save'}
|
/>
|
||||||
</Button>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<div class="admin-container">
|
||||||
<div class="tab-panels">
|
<div class="tab-panels">
|
||||||
<!-- Metadata Panel -->
|
<!-- Metadata Panel -->
|
||||||
|
|
@ -308,6 +475,143 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 {
|
.tab-panels {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
|
@ -329,6 +633,26 @@
|
||||||
margin: 0 auto;
|
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 {
|
.form-section {
|
||||||
margin-bottom: $unit-6x;
|
margin-bottom: $unit-6x;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,8 +97,7 @@ let autoSave = mode === 'edit' && postId
|
||||||
return await response.json()
|
return await response.json()
|
||||||
},
|
},
|
||||||
onSaved: (saved: Post, { prime }) => {
|
onSaved: (saved: Post, { prime }) => {
|
||||||
updatedAt =
|
updatedAt = saved.updatedAt.toISOString()
|
||||||
typeof saved.updatedAt === 'string' ? saved.updatedAt : saved.updatedAt.toISOString()
|
|
||||||
prime(buildPayload())
|
prime(buildPayload())
|
||||||
if (draftKey) clearDraft(draftKey)
|
if (draftKey) clearDraft(draftKey)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,10 @@
|
||||||
interface Props {
|
interface Props {
|
||||||
formData: ProjectFormData
|
formData: ProjectFormData
|
||||||
validationErrors: Record<string, string>
|
validationErrors: Record<string, string>
|
||||||
|
onSave?: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
let { formData = $bindable(), validationErrors }: Props = $props()
|
let { formData = $bindable(), validationErrors, onSave }: Props = $props()
|
||||||
|
|
||||||
// ===== Media State Management =====
|
// ===== Media State Management =====
|
||||||
// Convert logoUrl string to Media object for ImageUploader
|
// Convert logoUrl string to Media object for ImageUploader
|
||||||
|
|
@ -90,47 +91,16 @@
|
||||||
if (!hasLogo) formData.showLogoInHeader = false
|
if (!hasLogo) formData.showLogoInHeader = false
|
||||||
})
|
})
|
||||||
|
|
||||||
// Track previous toggle states to detect which one changed
|
|
||||||
let prevShowFeaturedImage: boolean | null = $state(null)
|
|
||||||
let prevShowBackgroundColor: boolean | null = $state(null)
|
|
||||||
|
|
||||||
// Mutual exclusion: only one of featured image or background color can be active
|
|
||||||
$effect(() => {
|
|
||||||
// On first run (initial load), if both are true, default to featured image taking priority
|
|
||||||
if (prevShowFeaturedImage === null && prevShowBackgroundColor === null) {
|
|
||||||
if (formData.showFeaturedImageInHeader && formData.showBackgroundColorInHeader) {
|
|
||||||
formData.showBackgroundColorInHeader = false
|
|
||||||
}
|
|
||||||
prevShowFeaturedImage = formData.showFeaturedImageInHeader
|
|
||||||
prevShowBackgroundColor = formData.showBackgroundColorInHeader
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const featuredChanged = formData.showFeaturedImageInHeader !== prevShowFeaturedImage
|
|
||||||
const bgColorChanged = formData.showBackgroundColorInHeader !== prevShowBackgroundColor
|
|
||||||
|
|
||||||
if (featuredChanged && formData.showFeaturedImageInHeader && formData.showBackgroundColorInHeader) {
|
|
||||||
// Featured image was just turned ON while background color was already ON
|
|
||||||
formData.showBackgroundColorInHeader = false
|
|
||||||
} else if (bgColorChanged && formData.showBackgroundColorInHeader && formData.showFeaturedImageInHeader) {
|
|
||||||
// Background color was just turned ON while featured image was already ON
|
|
||||||
formData.showFeaturedImageInHeader = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update previous values
|
|
||||||
prevShowFeaturedImage = formData.showFeaturedImageInHeader
|
|
||||||
prevShowBackgroundColor = formData.showBackgroundColorInHeader
|
|
||||||
})
|
|
||||||
|
|
||||||
// ===== Upload Handlers =====
|
// ===== Upload Handlers =====
|
||||||
function handleFeaturedImageUpload(media: Media) {
|
function handleFeaturedImageUpload(media: Media) {
|
||||||
formData.featuredImage = media.url
|
formData.featuredImage = media.url
|
||||||
featuredImageMedia = media
|
featuredImageMedia = media
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFeaturedImageRemove() {
|
async function handleFeaturedImageRemove() {
|
||||||
formData.featuredImage = ''
|
formData.featuredImage = ''
|
||||||
featuredImageMedia = null
|
featuredImageMedia = null
|
||||||
|
if (onSave) await onSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogoUpload(media: Media) {
|
function handleLogoUpload(media: Media) {
|
||||||
|
|
@ -138,9 +108,10 @@
|
||||||
logoMedia = media
|
logoMedia = media
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogoRemove() {
|
async function handleLogoRemove() {
|
||||||
formData.logoUrl = ''
|
formData.logoUrl = ''
|
||||||
logoMedia = null
|
logoMedia = null
|
||||||
|
if (onSave) await onSave()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,19 @@
|
||||||
import { api } from '$lib/admin/api'
|
import { api } from '$lib/admin/api'
|
||||||
import AdminPage from './AdminPage.svelte'
|
import AdminPage from './AdminPage.svelte'
|
||||||
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
||||||
import Button from './Button.svelte'
|
|
||||||
import Composer from './composer'
|
import Composer from './composer'
|
||||||
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
|
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
|
||||||
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
|
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
|
||||||
|
import AutoSaveStatus from './AutoSaveStatus.svelte'
|
||||||
|
import DraftPrompt from './DraftPrompt.svelte'
|
||||||
import { toast } from '$lib/stores/toast'
|
import { toast } from '$lib/stores/toast'
|
||||||
import type { Project } from '$lib/types/project'
|
import type { Project } from '$lib/types/project'
|
||||||
|
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
|
||||||
import { createProjectFormStore } from '$lib/stores/project-form.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'
|
||||||
import type { JSONContent } from '@tiptap/core'
|
import type { JSONContent } from '@tiptap/core'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -25,12 +31,42 @@
|
||||||
// UI state
|
// UI state
|
||||||
let isLoading = $state(mode === 'edit')
|
let isLoading = $state(mode === 'edit')
|
||||||
let hasLoaded = $state(mode === 'create')
|
let hasLoaded = $state(mode === 'create')
|
||||||
let isSaving = $state(false)
|
|
||||||
let activeTab = $state('metadata')
|
let activeTab = $state('metadata')
|
||||||
|
let error = $state<string | null>(null)
|
||||||
|
let successMessage = $state<string | null>(null)
|
||||||
|
|
||||||
// Ref to the editor component
|
// Ref to the editor component
|
||||||
let editorRef: { save: () => Promise<JSONContent> } | undefined = $state.raw()
|
let editorRef: { save: () => Promise<JSONContent> } | undefined = $state.raw()
|
||||||
|
|
||||||
|
// Draft key for autosave fallback
|
||||||
|
const draftKey = $derived(mode === 'edit' && project ? makeDraftKey('project', project.id) : null)
|
||||||
|
|
||||||
|
// Autosave (edit mode only)
|
||||||
|
const autoSave = mode === 'edit'
|
||||||
|
? createAutoSaveStore({
|
||||||
|
debounceMs: 2000,
|
||||||
|
getPayload: () => (hasLoaded ? formStore.buildPayload() : null),
|
||||||
|
save: async (payload, { signal }) => {
|
||||||
|
return await api.put(`/api/projects/${project?.id}`, payload, { signal })
|
||||||
|
},
|
||||||
|
onSaved: (savedProject: Project, { prime }) => {
|
||||||
|
project = savedProject
|
||||||
|
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 = [
|
const tabOptions = [
|
||||||
{ value: 'metadata', label: 'Metadata' },
|
{ value: 'metadata', label: 'Metadata' },
|
||||||
{ value: 'branding', label: 'Branding' },
|
{ value: 'branding', label: 'Branding' },
|
||||||
|
|
@ -41,11 +77,40 @@
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (project && mode === 'edit' && !hasLoaded) {
|
if (project && mode === 'edit' && !hasLoaded) {
|
||||||
formStore.populateFromProject(project)
|
formStore.populateFromProject(project)
|
||||||
|
if (autoSave) {
|
||||||
|
autoSave.prime(formStore.buildPayload())
|
||||||
|
}
|
||||||
isLoading = false
|
isLoading = false
|
||||||
hasLoaded = true
|
hasLoaded = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Trigger autosave when formData changes (edit mode)
|
||||||
|
$effect(() => {
|
||||||
|
// Establish dependencies on fields
|
||||||
|
void formStore.fields; void activeTab
|
||||||
|
if (mode === 'edit' && hasLoaded && autoSave) {
|
||||||
|
autoSave.schedule()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save draft only when autosave fails
|
||||||
|
$effect(() => {
|
||||||
|
if (mode === 'edit' && autoSave && draftKey) {
|
||||||
|
const status = autoSave.status
|
||||||
|
if (status === 'error' || status === 'offline') {
|
||||||
|
saveDraft(draftKey, formStore.buildPayload())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cleanup autosave on unmount
|
||||||
|
$effect(() => {
|
||||||
|
if (autoSave) {
|
||||||
|
return () => autoSave.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function handleEditorChange(content: JSONContent) {
|
function handleEditorChange(content: JSONContent) {
|
||||||
formStore.setField('caseStudyContent', content)
|
formStore.setField('caseStudyContent', content)
|
||||||
}
|
}
|
||||||
|
|
@ -64,7 +129,6 @@
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isSaving = true
|
|
||||||
const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} project...`)
|
const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} project...`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -74,12 +138,6 @@
|
||||||
updatedAt: mode === 'edit' ? project?.updatedAt : undefined
|
updatedAt: mode === 'edit' ? project?.updatedAt : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[ProjectForm] Saving with payload:', {
|
|
||||||
showFeaturedImageInHeader: payload.showFeaturedImageInHeader,
|
|
||||||
showBackgroundColorInHeader: payload.showBackgroundColorInHeader,
|
|
||||||
showLogoInHeader: payload.showLogoInHeader
|
|
||||||
})
|
|
||||||
|
|
||||||
let savedProject: Project
|
let savedProject: Project
|
||||||
if (mode === 'edit') {
|
if (mode === 'edit') {
|
||||||
savedProject = await api.put(`/api/projects/${project?.id}`, payload) as Project
|
savedProject = await api.put(`/api/projects/${project?.id}`, payload) as Project
|
||||||
|
|
@ -94,7 +152,6 @@
|
||||||
goto(`/admin/projects/${savedProject.id}/edit`)
|
goto(`/admin/projects/${savedProject.id}/edit`)
|
||||||
} else {
|
} else {
|
||||||
project = savedProject
|
project = savedProject
|
||||||
formStore.populateFromProject(savedProject)
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.dismiss(loadingToastId)
|
toast.dismiss(loadingToastId)
|
||||||
|
|
@ -104,10 +161,10 @@
|
||||||
toast.error(`Failed to ${mode === 'edit' ? 'save' : 'create'} project`)
|
toast.error(`Failed to ${mode === 'edit' ? 'save' : 'create'} project`)
|
||||||
}
|
}
|
||||||
console.error(err)
|
console.error(err)
|
||||||
} finally {
|
|
||||||
isSaving = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AdminPage>
|
<AdminPage>
|
||||||
|
|
@ -123,20 +180,36 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<Button
|
{#if !isLoading && mode === 'edit' && autoSave}
|
||||||
variant="primary"
|
<AutoSaveStatus
|
||||||
onclick={handleSave}
|
status={autoSave.status}
|
||||||
disabled={isSaving}
|
error={autoSave.lastError}
|
||||||
>
|
lastSavedAt={project?.updatedAt}
|
||||||
{isSaving ? 'Saving...' : 'Save'}
|
/>
|
||||||
</Button>
|
{/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 project...</div>
|
<div class="loading">Loading project...</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
{#if error}
|
||||||
|
<div class="error-message">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if successMessage}
|
||||||
|
<div class="success-message">{successMessage}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="tab-panels">
|
<div class="tab-panels">
|
||||||
<!-- Metadata Panel -->
|
<!-- Metadata Panel -->
|
||||||
<div class="panel content-wrapper" class:active={activeTab === 'metadata'}>
|
<div class="panel content-wrapper" class:active={activeTab === 'metadata'}>
|
||||||
|
|
@ -147,7 +220,7 @@
|
||||||
handleSave()
|
handleSave()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ProjectMetadataForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} />
|
<ProjectMetadataForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -161,7 +234,7 @@
|
||||||
handleSave()
|
handleSave()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ProjectBrandingForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} />
|
<ProjectBrandingForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -222,6 +295,25 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: $gray-40;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-90;
|
||||||
|
color: $gray-10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.admin-container {
|
.admin-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
@ -254,12 +346,37 @@
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading,
|
||||||
|
.error {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: $unit-6x;
|
padding: $unit-6x;
|
||||||
color: $gray-40;
|
color: $gray-40;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #d33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-content {
|
.form-content {
|
||||||
@include breakpoint('phone') {
|
@include breakpoint('phone') {
|
||||||
padding: $unit-3x;
|
padding: $unit-3x;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,26 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation'
|
import { goto, beforeNavigate } from '$app/navigation'
|
||||||
import AdminPage from './AdminPage.svelte'
|
import AdminPage from './AdminPage.svelte'
|
||||||
import type { JSONContent } from '@tiptap/core'
|
import type { JSONContent } from '@tiptap/core'
|
||||||
|
import type { Post } from '@prisma/client'
|
||||||
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 { toast } from '$lib/stores/toast'
|
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'
|
||||||
|
|
||||||
|
// Payload type for saving posts
|
||||||
|
interface PostPayload {
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
content: JSONContent
|
||||||
|
updatedAt?: string
|
||||||
|
title?: string
|
||||||
|
link_url?: string
|
||||||
|
linkDescription?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
postType: 'post'
|
postType: 'post'
|
||||||
|
|
@ -21,11 +36,13 @@
|
||||||
mode: 'create' | 'edit'
|
mode: 'create' | 'edit'
|
||||||
}
|
}
|
||||||
|
|
||||||
let { postType, postId, initialData, mode }: Props = $props()
|
let { postType, postId, initialData, mode }: Props = $props()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let isSaving = $state(false)
|
let isSaving = $state(false)
|
||||||
|
let hasLoaded = $state(mode === 'create')
|
||||||
let status = $state<'draft' | 'published'>(initialData?.status || 'draft')
|
let status = $state<'draft' | 'published'>(initialData?.status || 'draft')
|
||||||
|
let updatedAt = $state<string | undefined>(initialData?.updatedAt)
|
||||||
|
|
||||||
// Form data
|
// Form data
|
||||||
let content = $state<JSONContent>(initialData?.content || { type: 'doc', content: [] })
|
let content = $state<JSONContent>(initialData?.content || { type: 'doc', content: [] })
|
||||||
|
|
@ -33,7 +50,7 @@
|
||||||
let linkDescription = $state(initialData?.linkDescription || '')
|
let linkDescription = $state(initialData?.linkDescription || '')
|
||||||
let title = $state(initialData?.title || '')
|
let title = $state(initialData?.title || '')
|
||||||
|
|
||||||
// Character count for posts
|
// Character count for posts
|
||||||
const maxLength = 280
|
const maxLength = 280
|
||||||
const textContent = $derived.by(() => {
|
const textContent = $derived.by(() => {
|
||||||
if (!content.content) return ''
|
if (!content.content) return ''
|
||||||
|
|
@ -50,11 +67,178 @@
|
||||||
const isOverLimit = $derived(charCount > maxLength)
|
const isOverLimit = $derived(charCount > maxLength)
|
||||||
|
|
||||||
// Check if form has content
|
// Check if form has content
|
||||||
const hasContent = $derived.by(() => {
|
const hasContent = $derived.by(() => {
|
||||||
|
// For posts, check if either content exists or it's a link with URL
|
||||||
const hasTextContent = textContent.trim().length > 0
|
const hasTextContent = textContent.trim().length > 0
|
||||||
const hasLinkContent = linkUrl && linkUrl.trim().length > 0
|
const hasLinkContent = linkUrl && linkUrl.trim().length > 0
|
||||||
return hasTextContent || hasLinkContent
|
return hasTextContent || hasLinkContent
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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(): PostPayload {
|
||||||
|
const payload: PostPayload = {
|
||||||
|
type: 'post',
|
||||||
|
status,
|
||||||
|
content,
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
if (linkUrl && linkUrl.trim()) {
|
||||||
|
payload.title = title || linkUrl
|
||||||
|
payload.link_url = linkUrl
|
||||||
|
payload.linkDescription = linkDescription
|
||||||
|
} else if (title) {
|
||||||
|
payload.title = title
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: Post, { prime }) => {
|
||||||
|
updatedAt = saved.updatedAt.toISOString()
|
||||||
|
prime(buildPayload())
|
||||||
|
if (draftKey) clearDraft(draftKey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
|
// 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(() => {
|
||||||
|
void status; void content; void linkUrl; void linkDescription; void title
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const draft = loadDraft<PostPayload>(draftKey)
|
||||||
|
if (draft) {
|
||||||
|
showDraftPrompt = true
|
||||||
|
draftTimestamp = draft.ts
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function restoreDraft() {
|
||||||
|
const draft = loadDraft<PostPayload>(draftKey)
|
||||||
|
if (!draft) return
|
||||||
|
const p = draft.payload
|
||||||
|
status = p.status ?? status
|
||||||
|
content = p.content ?? content
|
||||||
|
if (p.link_url) {
|
||||||
|
linkUrl = p.link_url
|
||||||
|
linkDescription = p.linkDescription ?? linkDescription
|
||||||
|
title = p.title ?? title
|
||||||
|
} else {
|
||||||
|
title = p.title ?? title
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
$effect(() => {
|
||||||
|
if (autoSave) {
|
||||||
|
return () => autoSave.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function handleSave(publishStatus: 'draft' | 'published') {
|
async function handleSave(publishStatus: 'draft' | 'published') {
|
||||||
if (isOverLimit) {
|
if (isOverLimit) {
|
||||||
|
|
@ -62,24 +246,26 @@
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For link posts, URL is required
|
||||||
if (linkUrl && !linkUrl.trim()) {
|
if (linkUrl && !linkUrl.trim()) {
|
||||||
toast.error('Link URL is required')
|
toast.error('Link URL is required')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isSaving = true
|
|
||||||
const loadingToastId = toast.loading(
|
const loadingToastId = toast.loading(
|
||||||
`${publishStatus === 'published' ? 'Publishing' : 'Saving'} post...`
|
`${publishStatus === 'published' ? 'Publishing' : 'Saving'} post...`
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
isSaving = true
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
type: 'post',
|
type: 'post', // Use simplified post type
|
||||||
status: publishStatus,
|
status: publishStatus,
|
||||||
content: content,
|
content: content
|
||||||
updatedAt: mode === 'edit' ? initialData?.updatedAt : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add link fields if they're provided
|
||||||
if (linkUrl && linkUrl.trim()) {
|
if (linkUrl && linkUrl.trim()) {
|
||||||
payload.title = title || linkUrl
|
payload.title = title || linkUrl
|
||||||
payload.link_url = linkUrl
|
payload.link_url = linkUrl
|
||||||
|
|
@ -106,11 +292,13 @@
|
||||||
throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} post`)
|
throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} post`)
|
||||||
}
|
}
|
||||||
|
|
||||||
await response.json()
|
await response.json()
|
||||||
|
|
||||||
toast.dismiss(loadingToastId)
|
toast.dismiss(loadingToastId)
|
||||||
toast.success(`Post ${publishStatus === 'published' ? 'published' : 'saved'} successfully!`)
|
toast.success(`Post ${publishStatus === 'published' ? 'published' : 'saved'} successfully!`)
|
||||||
|
clearDraft(draftKey)
|
||||||
|
|
||||||
|
// Redirect back to posts list after creation
|
||||||
goto('/admin/posts')
|
goto('/admin/posts')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.dismiss(loadingToastId)
|
toast.dismiss(loadingToastId)
|
||||||
|
|
@ -145,19 +333,36 @@
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
{#if mode === 'edit' && autoSave}
|
||||||
|
<AutoSaveStatus status={autoSave.status} error={autoSave.lastError} />
|
||||||
|
{/if}
|
||||||
<Button variant="secondary" onclick={() => handleSave('draft')} disabled={isSaving}>
|
<Button variant="secondary" onclick={() => handleSave('draft')} disabled={isSaving}>
|
||||||
Save Draft
|
Save Draft
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onclick={() => handleSave('published')}
|
onclick={() => handleSave('published')}
|
||||||
disabled={isSaving || !hasContent || (postType === 'microblog' && isOverLimit)}
|
disabled={isSaving || !hasContent() || (postType === 'microblog' && isOverLimit)}
|
||||||
>
|
>
|
||||||
Post
|
Post
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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="composer-container">
|
<div class="composer-container">
|
||||||
<div class="composer">
|
<div class="composer">
|
||||||
{#if postType === 'microblog'}
|
{#if postType === 'microblog'}
|
||||||
|
|
@ -238,6 +443,15 @@
|
||||||
padding: $unit-3x;
|
padding: $unit-3x;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
padding: $unit-2x;
|
||||||
|
border-radius: $unit;
|
||||||
|
margin-bottom: $unit-3x;
|
||||||
|
background-color: #fee;
|
||||||
|
color: #d33;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
.composer {
|
.composer {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: $unit-2x;
|
border-radius: $unit-2x;
|
||||||
|
|
@ -346,4 +560,103 @@
|
||||||
color: $gray-60;
|
color: $gray-60;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.draft-banner {
|
||||||
|
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||||
|
border-bottom: 1px solid #f59e0b;
|
||||||
|
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.15);
|
||||||
|
padding: $unit-3x $unit-4x;
|
||||||
|
animation: slideDown 0.3s ease-out;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
padding: $unit-2x $unit-3x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft-banner-content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $unit-3x;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft-banner-text {
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft-banner-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit-2x;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft-banner-button {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #f59e0b;
|
||||||
|
color: #92400e;
|
||||||
|
padding: $unit $unit-3x;
|
||||||
|
border-radius: $unit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fffbeb;
|
||||||
|
border-color: #d97706;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(245, 158, 11, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dismiss {
|
||||||
|
background: transparent;
|
||||||
|
border-color: #fbbf24;
|
||||||
|
color: #b45309;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
border-color: #f59e0b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
flex: 1;
|
||||||
|
padding: $unit-1_5x $unit-2x;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
|
|
||||||
// URL convert handlers
|
// URL convert handlers
|
||||||
export function handleShowUrlConvertDropdown(pos: number, _url: string) {
|
export function handleShowUrlConvertDropdown(pos: number, _url: string) {
|
||||||
if (!editor || !editor.view) return
|
if (!editor) return
|
||||||
const coords = editor.view.coordsAtPos(pos)
|
const coords = editor.view.coordsAtPos(pos)
|
||||||
urlConvertDropdownPosition = { x: coords.left, y: coords.bottom + 5 }
|
urlConvertDropdownPosition = { x: coords.left, y: coords.bottom + 5 }
|
||||||
urlConvertPos = pos
|
urlConvertPos = pos
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
|
|
||||||
// Link context menu handlers
|
// Link context menu handlers
|
||||||
export function handleShowLinkContextMenu(pos: number, url: string) {
|
export function handleShowLinkContextMenu(pos: number, url: string) {
|
||||||
if (!editor || !editor.view) return
|
if (!editor) return
|
||||||
const coords = editor.view.coordsAtPos(pos)
|
const coords = editor.view.coordsAtPos(pos)
|
||||||
linkContextMenuPosition = { x: coords.left, y: coords.bottom + 5 }
|
linkContextMenuPosition = { x: coords.left, y: coords.bottom + 5 }
|
||||||
linkContextUrl = url
|
linkContextUrl = url
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEditLink() {
|
function handleEditLink() {
|
||||||
if (!editor || !editor.view || linkContextPos === null || !linkContextUrl) return
|
if (!editor || linkContextPos === null || !linkContextUrl) return
|
||||||
const coords = editor.view.coordsAtPos(linkContextPos)
|
const coords = editor.view.coordsAtPos(linkContextPos)
|
||||||
linkEditDialogPosition = { x: coords.left, y: coords.bottom + 5 }
|
linkEditDialogPosition = { x: coords.left, y: coords.bottom + 5 }
|
||||||
linkEditUrl = linkContextUrl
|
linkEditUrl = linkContextUrl
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
function goToSelection() {
|
function goToSelection() {
|
||||||
const { results, resultIndex } = editor.storage.searchAndReplace
|
const { results, resultIndex } = editor.storage.searchAndReplace
|
||||||
const position = results[resultIndex]
|
const position = results[resultIndex]
|
||||||
if (!position || !editor.view) return
|
if (!position) return
|
||||||
editor.commands.setTextSelection(position)
|
editor.commands.setTextSelection(position)
|
||||||
const { node } = editor.view.domAtPos(editor.state.selection.anchor)
|
const { node } = editor.view.domAtPos(editor.state.selection.anchor)
|
||||||
if (node instanceof HTMLElement) node.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
if (node instanceof HTMLElement) node.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
|
|
||||||
|
|
@ -15,20 +15,18 @@
|
||||||
|
|
||||||
let isDragging = $state(false)
|
let isDragging = $state(false)
|
||||||
|
|
||||||
if (editor.view) {
|
editor.view.dom.addEventListener('dragstart', () => {
|
||||||
editor.view.dom.addEventListener('dragstart', () => {
|
isDragging = true
|
||||||
isDragging = true
|
})
|
||||||
})
|
|
||||||
|
|
||||||
editor.view.dom.addEventListener('drop', () => {
|
editor.view.dom.addEventListener('drop', () => {
|
||||||
isDragging = true
|
isDragging = true
|
||||||
|
|
||||||
// Allow some time for the drop action to complete before re-enabling
|
// Allow some time for the drop action to complete before re-enabling
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isDragging = false
|
isDragging = false
|
||||||
}, 100) // Adjust delay if needed
|
}, 100) // Adjust delay if needed
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const bubbleMenuCommands = [
|
const bubbleMenuCommands = [
|
||||||
...commands['text-formatting'].commands,
|
...commands['text-formatting'].commands,
|
||||||
|
|
@ -42,7 +40,7 @@
|
||||||
function shouldShow(props: ShouldShowProps) {
|
function shouldShow(props: ShouldShowProps) {
|
||||||
if (!props.editor.isEditable) return false
|
if (!props.editor.isEditable) return false
|
||||||
const { view, editor } = props
|
const { view, editor } = props
|
||||||
if (!view || !editor.view || editor.view.dragging) {
|
if (!view || editor.view.dragging) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (editor.isActive('link')) return false
|
if (editor.isActive('link')) return false
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ export function getHandlePaste(editor: Editor, maxSize: number = 2) {
|
||||||
* @param event - Optional MouseEvent or KeyboardEvent triggering the focus
|
* @param event - Optional MouseEvent or KeyboardEvent triggering the focus
|
||||||
*/
|
*/
|
||||||
export function focusEditor(editor: Editor | undefined, event?: MouseEvent | KeyboardEvent) {
|
export function focusEditor(editor: Editor | undefined, event?: MouseEvent | KeyboardEvent) {
|
||||||
if (!editor || !editor.view) return
|
if (!editor) return
|
||||||
// Check if there is a text selection already (i.e. a non-empty selection)
|
// Check if there is a text selection already (i.e. a non-empty selection)
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (selection && selection.toString().length > 0) {
|
if (selection && selection.toString().length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -46,16 +46,11 @@ export function createProjectFormStore(initialProject?: Project | null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Use getters to maintain reactivity when accessing state from outside the store
|
// State is returned directly - it's already reactive in Svelte 5
|
||||||
get fields() {
|
// Components can read: formStore.fields.title
|
||||||
return fields
|
// Mutation should go through methods below for validation
|
||||||
},
|
fields,
|
||||||
set fields(value: ProjectFormData) {
|
validationErrors,
|
||||||
fields = value
|
|
||||||
},
|
|
||||||
get validationErrors() {
|
|
||||||
return validationErrors
|
|
||||||
},
|
|
||||||
isDirty,
|
isDirty,
|
||||||
|
|
||||||
// Methods for controlled mutation
|
// Methods for controlled mutation
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,16 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores'
|
import { page } from '$app/stores'
|
||||||
import { goto } from '$app/navigation'
|
import { goto, beforeNavigate } 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, clearDraft } from '$lib/admin/draftStore'
|
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } 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'
|
||||||
|
|
@ -62,8 +59,12 @@
|
||||||
let metadataButtonRef: HTMLButtonElement | undefined = $state.raw()
|
let metadataButtonRef: HTMLButtonElement | undefined = $state.raw()
|
||||||
let showDeleteConfirmation = $state(false)
|
let showDeleteConfirmation = $state(false)
|
||||||
|
|
||||||
// Draft key for autosave fallback
|
// Draft backup
|
||||||
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 },
|
||||||
|
|
@ -73,7 +74,7 @@
|
||||||
let config = $derived(postTypeConfig[postType])
|
let config = $derived(postTypeConfig[postType])
|
||||||
|
|
||||||
// Autosave store
|
// Autosave store
|
||||||
const autoSave = createAutoSaveStore({
|
let autoSave = createAutoSaveStore({
|
||||||
debounceMs: 2000,
|
debounceMs: 2000,
|
||||||
getPayload: () => {
|
getPayload: () => {
|
||||||
if (!hasLoaded) return null
|
if (!hasLoaded) return null
|
||||||
|
|
@ -108,23 +109,6 @@
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 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) {
|
||||||
|
|
@ -224,11 +208,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -346,6 +335,27 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -393,10 +403,68 @@
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
|
@ -474,12 +542,18 @@
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if draftRecovery.showPrompt}
|
{#if showDraftPrompt}
|
||||||
<DraftPrompt
|
<div class="draft-banner">
|
||||||
timeAgo={draftRecovery.draftTimeText}
|
<div class="draft-banner-content">
|
||||||
onRestore={draftRecovery.restore}
|
<span class="draft-banner-text">
|
||||||
onDismiss={draftRecovery.dismiss}
|
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}
|
{/if}
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
|
|
@ -562,6 +636,72 @@
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,6 @@ interface ProjectCreateBody {
|
||||||
status?: string
|
status?: string
|
||||||
password?: string | null
|
password?: string | null
|
||||||
slug?: string
|
slug?: string
|
||||||
showFeaturedImageInHeader?: boolean
|
|
||||||
showBackgroundColorInHeader?: boolean
|
|
||||||
showLogoInHeader?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/projects - List all projects
|
// GET /api/projects - List all projects
|
||||||
|
|
@ -151,10 +148,7 @@ export const POST: RequestHandler = async (event) => {
|
||||||
displayOrder: body.displayOrder || 0,
|
displayOrder: body.displayOrder || 0,
|
||||||
status: body.status || 'draft',
|
status: body.status || 'draft',
|
||||||
password: body.password || null,
|
password: body.password || null,
|
||||||
publishedAt: body.status === 'published' ? new Date() : null,
|
publishedAt: body.status === 'published' ? new Date() : null
|
||||||
showFeaturedImageInHeader: body.showFeaturedImageInHeader ?? true,
|
|
||||||
showBackgroundColorInHeader: body.showBackgroundColorInHeader ?? true,
|
|
||||||
showLogoInHeader: body.showLogoInHeader ?? true
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,6 @@ interface ProjectUpdateBody {
|
||||||
status?: string
|
status?: string
|
||||||
password?: string | null
|
password?: string | null
|
||||||
slug?: string
|
slug?: string
|
||||||
showFeaturedImageInHeader?: boolean
|
|
||||||
showBackgroundColorInHeader?: boolean
|
|
||||||
showLogoInHeader?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/projects/[id] - Get a single project
|
// GET /api/projects/[id] - Get a single project
|
||||||
|
|
@ -132,19 +129,7 @@ export const PUT: RequestHandler = async (event) => {
|
||||||
status: body.status !== undefined ? body.status : existing.status,
|
status: body.status !== undefined ? body.status : existing.status,
|
||||||
password: body.password !== undefined ? body.password : existing.password,
|
password: body.password !== undefined ? body.password : existing.password,
|
||||||
publishedAt:
|
publishedAt:
|
||||||
body.status === 'published' && !existing.publishedAt ? new Date() : existing.publishedAt,
|
body.status === 'published' && !existing.publishedAt ? new Date() : existing.publishedAt
|
||||||
showFeaturedImageInHeader:
|
|
||||||
body.showFeaturedImageInHeader !== undefined
|
|
||||||
? body.showFeaturedImageInHeader
|
|
||||||
: existing.showFeaturedImageInHeader,
|
|
||||||
showBackgroundColorInHeader:
|
|
||||||
body.showBackgroundColorInHeader !== undefined
|
|
||||||
? body.showBackgroundColorInHeader
|
|
||||||
: existing.showBackgroundColorInHeader,
|
|
||||||
showLogoInHeader:
|
|
||||||
body.showLogoInHeader !== undefined
|
|
||||||
? body.showLogoInHeader
|
|
||||||
: existing.showLogoInHeader
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -284,11 +269,6 @@ export const PATCH: RequestHandler = async (event) => {
|
||||||
if (body.projectType !== undefined) updateData.projectType = body.projectType
|
if (body.projectType !== undefined) updateData.projectType = body.projectType
|
||||||
if (body.displayOrder !== undefined) updateData.displayOrder = body.displayOrder
|
if (body.displayOrder !== undefined) updateData.displayOrder = body.displayOrder
|
||||||
if (body.password !== undefined) updateData.password = body.password
|
if (body.password !== undefined) updateData.password = body.password
|
||||||
if (body.showFeaturedImageInHeader !== undefined)
|
|
||||||
updateData.showFeaturedImageInHeader = body.showFeaturedImageInHeader
|
|
||||||
if (body.showBackgroundColorInHeader !== undefined)
|
|
||||||
updateData.showBackgroundColorInHeader = body.showBackgroundColorInHeader
|
|
||||||
if (body.showLogoInHeader !== undefined) updateData.showLogoInHeader = body.showLogoInHeader
|
|
||||||
|
|
||||||
// Handle slug update if provided
|
// Handle slug update if provided
|
||||||
if (body.slug && body.slug !== existing.slug) {
|
if (body.slug && body.slug !== existing.slug) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue