remove autosave, use manual save buttons
autosave was unreliable due to svelte 5 reactivity quirks. switched all admin forms to explicit save buttons instead.
This commit is contained in:
parent
2555067837
commit
97bdccd218
6 changed files with 113 additions and 848 deletions
|
|
@ -3,18 +3,13 @@
|
|||
import { z } from 'zod'
|
||||
import AdminPage from './AdminPage.svelte'
|
||||
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import Input from './Input.svelte'
|
||||
import DropdownSelectField from './DropdownSelectField.svelte'
|
||||
import AutoSaveStatus from './AutoSaveStatus.svelte'
|
||||
import DraftPrompt from './DraftPrompt.svelte'
|
||||
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
|
||||
import SmartImage from '../SmartImage.svelte'
|
||||
import Composer from './composer'
|
||||
import { toast } from '$lib/stores/toast'
|
||||
import { makeDraftKey, saveDraft, clearDraft } from '$lib/admin/draftStore'
|
||||
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
|
||||
import { useDraftRecovery } from '$lib/admin/useDraftRecovery.svelte'
|
||||
import { useFormGuards } from '$lib/admin/useFormGuards.svelte'
|
||||
import type { Album, Media } from '@prisma/client'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
|
||||
|
|
@ -39,20 +34,13 @@
|
|||
// State
|
||||
let isLoading = $state(mode === 'edit')
|
||||
let hasLoaded = $state(mode === 'create')
|
||||
let _isSaving = $state(false)
|
||||
let _validationErrors = $state<Record<string, string>>({})
|
||||
let isSaving = $state(false)
|
||||
let validationErrors = $state<Record<string, string>>({})
|
||||
let showBulkAlbumModal = $state(false)
|
||||
let albumMedia = $state<Array<{ media: Media; displayOrder: number }>>([])
|
||||
let editorInstance = $state<{ save: () => Promise<JSONContent>; clear: () => void } | undefined>()
|
||||
let activeTab = $state('metadata')
|
||||
let pendingMediaIds = $state<number[]>([]) // Photos to add after album creation
|
||||
let updatedAt = $state<string | undefined>(
|
||||
album?.updatedAt
|
||||
? typeof album.updatedAt === 'string'
|
||||
? album.updatedAt
|
||||
: album.updatedAt.toISOString()
|
||||
: undefined
|
||||
)
|
||||
|
||||
const tabOptions = [
|
||||
{ value: 'metadata', label: 'Metadata' },
|
||||
|
|
@ -86,81 +74,12 @@
|
|||
// Derived state for existing media IDs
|
||||
const existingMediaIds = $derived(albumMedia.map((item) => item.media.id))
|
||||
|
||||
// Draft key for autosave fallback
|
||||
const draftKey = $derived(mode === 'edit' && album ? makeDraftKey('album', album.id) : null)
|
||||
|
||||
function buildPayload() {
|
||||
return {
|
||||
title: formData.title,
|
||||
slug: formData.slug,
|
||||
description: null,
|
||||
date: formData.year || null,
|
||||
location: formData.location || null,
|
||||
showInUniverse: formData.showInUniverse,
|
||||
status: formData.status,
|
||||
content: formData.content,
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
// Autosave store (edit mode only)
|
||||
// Initialized as null and created reactively when album data becomes available
|
||||
let autoSave = $state<ReturnType<typeof createAutoSaveStore<ReturnType<typeof buildPayload>, Album>> | null>(null)
|
||||
|
||||
// INITIALIZATION ORDER:
|
||||
// 1. This effect creates autoSave when album prop becomes available
|
||||
// 2. useFormGuards is called immediately after creation (same effect)
|
||||
// 3. Other effects check for autoSave existence before using it
|
||||
$effect(() => {
|
||||
// Create autoSave when album becomes available (only once)
|
||||
if (mode === 'edit' && album && !autoSave) {
|
||||
const albumId = album.id // Capture album ID to avoid null reference
|
||||
autoSave = createAutoSaveStore({
|
||||
debounceMs: 2000,
|
||||
getPayload: () => (hasLoaded ? buildPayload() : null),
|
||||
save: async (payload, { signal }) => {
|
||||
const response = await fetch(`/api/albums/${albumId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
credentials: 'same-origin',
|
||||
signal
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to save')
|
||||
return await response.json()
|
||||
},
|
||||
onSaved: (saved: Album, { prime }) => {
|
||||
updatedAt =
|
||||
typeof saved.updatedAt === 'string' ? saved.updatedAt : saved.updatedAt.toISOString()
|
||||
prime(buildPayload())
|
||||
if (draftKey) clearDraft(draftKey)
|
||||
}
|
||||
})
|
||||
|
||||
// Form guards (navigation protection, Cmd+S, beforeunload)
|
||||
useFormGuards(autoSave)
|
||||
}
|
||||
})
|
||||
|
||||
// Draft recovery helper
|
||||
const draftRecovery = useDraftRecovery<ReturnType<typeof buildPayload>>({
|
||||
draftKey: () => draftKey,
|
||||
onRestore: (payload) => {
|
||||
formData.title = payload.title ?? formData.title
|
||||
formData.slug = payload.slug ?? formData.slug
|
||||
formData.status = payload.status ?? formData.status
|
||||
formData.year = payload.date ?? formData.year
|
||||
formData.location = payload.location ?? formData.location
|
||||
formData.showInUniverse = payload.showInUniverse ?? formData.showInUniverse
|
||||
formData.content = payload.content ?? formData.content
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for album changes and populate form data
|
||||
$effect(() => {
|
||||
if (album && mode === 'edit') {
|
||||
if (album && mode === 'edit' && !hasLoaded) {
|
||||
populateFormData(album)
|
||||
loadAlbumMedia()
|
||||
hasLoaded = true
|
||||
} else if (mode === 'create') {
|
||||
isLoading = false
|
||||
}
|
||||
|
|
@ -176,49 +95,6 @@
|
|||
}
|
||||
})
|
||||
|
||||
// Prime autosave on initial load (edit mode only)
|
||||
$effect(() => {
|
||||
if (mode === 'edit' && album && !hasLoaded && autoSave) {
|
||||
autoSave.prime(buildPayload())
|
||||
hasLoaded = true
|
||||
}
|
||||
})
|
||||
|
||||
// Trigger autosave when form data changes
|
||||
// Using `void` operator to explicitly track dependencies without using their values
|
||||
// This effect re-runs whenever any of these form fields change
|
||||
$effect(() => {
|
||||
void formData.title
|
||||
void formData.slug
|
||||
void formData.status
|
||||
void formData.year
|
||||
void formData.location
|
||||
void formData.showInUniverse
|
||||
void formData.content
|
||||
void activeTab
|
||||
if (hasLoaded && autoSave) {
|
||||
autoSave.schedule()
|
||||
}
|
||||
})
|
||||
|
||||
// Save draft only when autosave fails
|
||||
$effect(() => {
|
||||
if (hasLoaded && autoSave && draftKey) {
|
||||
const saveStatus = autoSave.status
|
||||
if (saveStatus === 'error' || saveStatus === 'offline') {
|
||||
saveDraft(draftKey, buildPayload())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup autosave on unmount
|
||||
$effect(() => {
|
||||
if (autoSave) {
|
||||
const instance = autoSave
|
||||
return () => instance.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function populateFormData(data: Album) {
|
||||
formData = {
|
||||
title: data.title || '',
|
||||
|
|
@ -237,9 +113,9 @@
|
|||
if (!album) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/albums/${album.id}`, {
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
const response = await fetch(`/api/albums/${album.id}`, {
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
albumMedia = data.media || []
|
||||
|
|
@ -257,7 +133,7 @@
|
|||
location: formData.location || undefined,
|
||||
year: formData.year || undefined
|
||||
})
|
||||
_validationErrors = {}
|
||||
validationErrors = {}
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
|
|
@ -267,23 +143,22 @@
|
|||
errors[e.path[0].toString()] = e.message
|
||||
}
|
||||
})
|
||||
_validationErrors = errors
|
||||
validationErrors = errors
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function _handleSave() {
|
||||
async function handleSave() {
|
||||
if (!validateForm()) {
|
||||
toast.error('Please fix the validation errors')
|
||||
return
|
||||
}
|
||||
|
||||
isSaving = true
|
||||
const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} album...`)
|
||||
|
||||
try {
|
||||
_isSaving = true
|
||||
|
||||
const payload = {
|
||||
title: formData.title,
|
||||
slug: formData.slug,
|
||||
|
|
@ -292,7 +167,8 @@
|
|||
location: formData.location || null,
|
||||
showInUniverse: formData.showInUniverse,
|
||||
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'
|
||||
|
|
@ -366,7 +242,7 @@
|
|||
)
|
||||
console.error(err)
|
||||
} finally {
|
||||
_isSaving = false
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -399,23 +275,16 @@
|
|||
/>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{#if !isLoading}
|
||||
<AutoSaveStatus
|
||||
status={autoSave?.status ?? 'idle'}
|
||||
lastSavedAt={album?.updatedAt}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if draftRecovery.showPrompt}
|
||||
<DraftPrompt
|
||||
timeAgo={draftRecovery.draftTimeText}
|
||||
onRestore={draftRecovery.restore}
|
||||
onDismiss={draftRecovery.dismiss}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="admin-container">
|
||||
{#if isLoading}
|
||||
<div class="loading">Loading album...</div>
|
||||
|
|
@ -585,25 +454,6 @@
|
|||
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 {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
toggleChecked?: boolean
|
||||
toggleDisabled?: boolean
|
||||
showToggle?: boolean
|
||||
onToggleChange?: (checked: boolean) => void
|
||||
children?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
|
|
@ -14,6 +15,7 @@
|
|||
toggleChecked = $bindable(false),
|
||||
toggleDisabled = false,
|
||||
showToggle = true,
|
||||
onToggleChange,
|
||||
children
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
|
@ -22,7 +24,7 @@
|
|||
<header class="branding-section__header">
|
||||
<h2 class="branding-section__title">{title}</h2>
|
||||
{#if showToggle}
|
||||
<BrandingToggle bind:checked={toggleChecked} disabled={toggleDisabled} />
|
||||
<BrandingToggle bind:checked={toggleChecked} disabled={toggleDisabled} onchange={onToggleChange} />
|
||||
{/if}
|
||||
</header>
|
||||
<div class="branding-section__content">
|
||||
|
|
|
|||
|
|
@ -6,15 +6,8 @@
|
|||
import Button from './Button.svelte'
|
||||
import Input from './Input.svelte'
|
||||
import DropdownSelectField from './DropdownSelectField.svelte'
|
||||
import DraftPrompt from './DraftPrompt.svelte'
|
||||
import { toast } from '$lib/stores/toast'
|
||||
import { makeDraftKey, saveDraft, clearDraft } from '$lib/admin/draftStore'
|
||||
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
|
||||
import { useDraftRecovery } from '$lib/admin/useDraftRecovery.svelte'
|
||||
import { useFormGuards } from '$lib/admin/useFormGuards.svelte'
|
||||
import AutoSaveStatus from './AutoSaveStatus.svelte'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
import type { Post } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
postId?: number
|
||||
|
|
@ -32,9 +25,9 @@
|
|||
let { postId, initialData, mode }: Props = $props()
|
||||
|
||||
// State
|
||||
let hasLoaded = $state(mode === 'create') // Create mode loads immediately
|
||||
let hasLoaded = $state(mode === 'create')
|
||||
let isSaving = $state(false)
|
||||
let activeTab = $state('metadata')
|
||||
let updatedAt = $state<string | undefined>(initialData?.updatedAt)
|
||||
|
||||
// Form data
|
||||
let title = $state(initialData?.title || '')
|
||||
|
|
@ -47,61 +40,6 @@
|
|||
// Ref to the editor component
|
||||
let editorRef: { save: () => Promise<JSONContent> } | undefined
|
||||
|
||||
// Draft key for autosave fallback
|
||||
const draftKey = $derived(mode === 'edit' && postId ? makeDraftKey('post', postId) : null)
|
||||
|
||||
function buildPayload() {
|
||||
return {
|
||||
title,
|
||||
slug,
|
||||
type: 'essay',
|
||||
status,
|
||||
content,
|
||||
tags,
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
// Autosave store (edit mode only)
|
||||
const 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 =
|
||||
typeof saved.updatedAt === 'string' ? saved.updatedAt : saved.updatedAt.toISOString()
|
||||
prime(buildPayload())
|
||||
if (draftKey) clearDraft(draftKey)
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
// Draft recovery helper
|
||||
const draftRecovery = useDraftRecovery<ReturnType<typeof buildPayload>>({
|
||||
draftKey: () => draftKey,
|
||||
onRestore: (payload) => {
|
||||
title = payload.title ?? title
|
||||
slug = payload.slug ?? slug
|
||||
status = payload.status ?? status
|
||||
content = payload.content ?? content
|
||||
tags = payload.tags ?? tags
|
||||
}
|
||||
})
|
||||
|
||||
// Form guards (navigation protection, Cmd+S, beforeunload)
|
||||
useFormGuards(autoSave)
|
||||
|
||||
const tabOptions = [
|
||||
{ value: 'metadata', label: 'Metadata' },
|
||||
{ value: 'content', label: 'Content' }
|
||||
|
|
@ -130,39 +68,13 @@
|
|||
}
|
||||
})
|
||||
|
||||
// Prime autosave on initial load (edit mode only)
|
||||
// Mark as loaded for edit mode
|
||||
$effect(() => {
|
||||
if (mode === 'edit' && initialData && !hasLoaded && autoSave) {
|
||||
autoSave.prime(buildPayload())
|
||||
if (mode === 'edit' && initialData && !hasLoaded) {
|
||||
hasLoaded = true
|
||||
}
|
||||
})
|
||||
|
||||
// Trigger autosave when form data changes
|
||||
$effect(() => {
|
||||
void title; void slug; void status; void content; void tags; void activeTab
|
||||
if (hasLoaded && autoSave) {
|
||||
autoSave.schedule()
|
||||
}
|
||||
})
|
||||
|
||||
// Save draft only when autosave fails
|
||||
$effect(() => {
|
||||
if (hasLoaded && autoSave && draftKey) {
|
||||
const saveStatus = autoSave.status
|
||||
if (saveStatus === 'error' || saveStatus === 'offline') {
|
||||
saveDraft(draftKey, buildPayload())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup autosave on unmount
|
||||
$effect(() => {
|
||||
if (autoSave) {
|
||||
return () => autoSave.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function addTag() {
|
||||
if (tagInput && !tags.includes(tagInput)) {
|
||||
tags = [...tags, tagInput]
|
||||
|
|
@ -192,16 +104,18 @@
|
|||
return
|
||||
}
|
||||
|
||||
isSaving = true
|
||||
const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} essay...`)
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
title,
|
||||
slug,
|
||||
type: 'essay', // No mapping needed anymore
|
||||
type: 'essay',
|
||||
status,
|
||||
content,
|
||||
tags
|
||||
tags,
|
||||
updatedAt: mode === 'edit' ? initialData?.updatedAt : undefined
|
||||
}
|
||||
|
||||
const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts'
|
||||
|
|
@ -227,8 +141,7 @@
|
|||
const savedPost = await response.json()
|
||||
|
||||
toast.dismiss(loadingToastId)
|
||||
toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`)
|
||||
clearDraft(draftKey)
|
||||
toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`)
|
||||
|
||||
if (mode === 'create') {
|
||||
goto(`/admin/posts/${savedPost.id}/edit`)
|
||||
|
|
@ -237,9 +150,10 @@
|
|||
toast.dismiss(loadingToastId)
|
||||
toast.error(`Failed to ${mode === 'edit' ? 'save' : 'create'} essay`)
|
||||
console.error(err)
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<AdminPage>
|
||||
|
|
@ -255,24 +169,16 @@
|
|||
/>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{#if mode === 'edit' && autoSave}
|
||||
<AutoSaveStatus
|
||||
status={autoSave.status}
|
||||
error={autoSave.lastError}
|
||||
lastSavedAt={initialData?.updatedAt}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if draftRecovery.showPrompt}
|
||||
<DraftPrompt
|
||||
timeAgo={draftRecovery.draftTimeText}
|
||||
onRestore={draftRecovery.restore}
|
||||
onDismiss={draftRecovery.dismiss}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="admin-container">
|
||||
<div class="tab-panels">
|
||||
<!-- Metadata Panel -->
|
||||
|
|
@ -402,77 +308,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.save-actions {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
|
|
@ -494,26 +329,6 @@
|
|||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -9,10 +9,9 @@
|
|||
interface Props {
|
||||
formData: ProjectFormData
|
||||
validationErrors: Record<string, string>
|
||||
onSave?: () => Promise<void>
|
||||
}
|
||||
|
||||
let { formData = $bindable(), validationErrors, onSave }: Props = $props()
|
||||
let { formData = $bindable(), validationErrors }: Props = $props()
|
||||
|
||||
// ===== Media State Management =====
|
||||
// Convert logoUrl string to Media object for ImageUploader
|
||||
|
|
@ -91,16 +90,47 @@
|
|||
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 =====
|
||||
function handleFeaturedImageUpload(media: Media) {
|
||||
formData.featuredImage = media.url
|
||||
featuredImageMedia = media
|
||||
}
|
||||
|
||||
async function handleFeaturedImageRemove() {
|
||||
function handleFeaturedImageRemove() {
|
||||
formData.featuredImage = ''
|
||||
featuredImageMedia = null
|
||||
if (onSave) await onSave()
|
||||
}
|
||||
|
||||
function handleLogoUpload(media: Media) {
|
||||
|
|
@ -108,10 +138,9 @@
|
|||
logoMedia = media
|
||||
}
|
||||
|
||||
async function handleLogoRemove() {
|
||||
function handleLogoRemove() {
|
||||
formData.logoUrl = ''
|
||||
logoMedia = null
|
||||
if (onSave) await onSave()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,19 +3,13 @@
|
|||
import { api } from '$lib/admin/api'
|
||||
import AdminPage from './AdminPage.svelte'
|
||||
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import Composer from './composer'
|
||||
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
|
||||
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
|
||||
import AutoSaveStatus from './AutoSaveStatus.svelte'
|
||||
import DraftPrompt from './DraftPrompt.svelte'
|
||||
import { toast } from '$lib/stores/toast'
|
||||
import type { Project } from '$lib/types/project'
|
||||
import { createAutoSaveStore } from '$lib/admin/autoSave.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'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -31,42 +25,12 @@
|
|||
// UI state
|
||||
let isLoading = $state(mode === 'edit')
|
||||
let hasLoaded = $state(mode === 'create')
|
||||
let isSaving = $state(false)
|
||||
let activeTab = $state('metadata')
|
||||
let error = $state<string | null>(null)
|
||||
let successMessage = $state<string | null>(null)
|
||||
|
||||
// Ref to the editor component
|
||||
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 = [
|
||||
{ value: 'metadata', label: 'Metadata' },
|
||||
{ value: 'branding', label: 'Branding' },
|
||||
|
|
@ -77,40 +41,11 @@
|
|||
$effect(() => {
|
||||
if (project && mode === 'edit' && !hasLoaded) {
|
||||
formStore.populateFromProject(project)
|
||||
if (autoSave) {
|
||||
autoSave.prime(formStore.buildPayload())
|
||||
}
|
||||
isLoading = false
|
||||
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) {
|
||||
formStore.setField('caseStudyContent', content)
|
||||
}
|
||||
|
|
@ -129,6 +64,7 @@
|
|||
return
|
||||
}
|
||||
|
||||
isSaving = true
|
||||
const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} project...`)
|
||||
|
||||
try {
|
||||
|
|
@ -138,6 +74,12 @@
|
|||
updatedAt: mode === 'edit' ? project?.updatedAt : undefined
|
||||
}
|
||||
|
||||
console.log('[ProjectForm] Saving with payload:', {
|
||||
showFeaturedImageInHeader: payload.showFeaturedImageInHeader,
|
||||
showBackgroundColorInHeader: payload.showBackgroundColorInHeader,
|
||||
showLogoInHeader: payload.showLogoInHeader
|
||||
})
|
||||
|
||||
let savedProject: Project
|
||||
if (mode === 'edit') {
|
||||
savedProject = await api.put(`/api/projects/${project?.id}`, payload) as Project
|
||||
|
|
@ -152,6 +94,7 @@
|
|||
goto(`/admin/projects/${savedProject.id}/edit`)
|
||||
} else {
|
||||
project = savedProject
|
||||
formStore.populateFromProject(savedProject)
|
||||
}
|
||||
} catch (err) {
|
||||
toast.dismiss(loadingToastId)
|
||||
|
|
@ -161,10 +104,10 @@
|
|||
toast.error(`Failed to ${mode === 'edit' ? 'save' : 'create'} project`)
|
||||
}
|
||||
console.error(err)
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<AdminPage>
|
||||
|
|
@ -180,36 +123,20 @@
|
|||
/>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{#if !isLoading && mode === 'edit' && autoSave}
|
||||
<AutoSaveStatus
|
||||
status={autoSave.status}
|
||||
error={autoSave.lastError}
|
||||
lastSavedAt={project?.updatedAt}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if draftRecovery.showPrompt}
|
||||
<DraftPrompt
|
||||
timeAgo={draftRecovery.draftTimeText}
|
||||
onRestore={draftRecovery.restore}
|
||||
onDismiss={draftRecovery.dismiss}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="admin-container">
|
||||
{#if isLoading}
|
||||
<div class="loading">Loading project...</div>
|
||||
{:else}
|
||||
{#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'}>
|
||||
|
|
@ -220,7 +147,7 @@
|
|||
handleSave()
|
||||
}}
|
||||
>
|
||||
<ProjectMetadataForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
|
||||
<ProjectMetadataForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -234,7 +161,7 @@
|
|||
handleSave()
|
||||
}}
|
||||
>
|
||||
<ProjectBrandingForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
|
||||
<ProjectBrandingForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -295,25 +222,6 @@
|
|||
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 {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
|
|
@ -346,37 +254,12 @@
|
|||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: $unit-6x;
|
||||
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 {
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x;
|
||||
|
|
|
|||
|
|
@ -1,26 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { goto, beforeNavigate } from '$app/navigation'
|
||||
import { goto } from '$app/navigation'
|
||||
import AdminPage from './AdminPage.svelte'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
import type { Post } from '@prisma/client'
|
||||
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'
|
||||
|
||||
// Payload type for saving posts
|
||||
interface PostPayload {
|
||||
type: string
|
||||
status: string
|
||||
content: JSONContent
|
||||
updatedAt?: string
|
||||
title?: string
|
||||
link_url?: string
|
||||
linkDescription?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
postType: 'post'
|
||||
|
|
@ -36,13 +21,11 @@
|
|||
mode: 'create' | 'edit'
|
||||
}
|
||||
|
||||
let { postType, postId, initialData, mode }: Props = $props()
|
||||
let { postType, postId, initialData, mode }: Props = $props()
|
||||
|
||||
// State
|
||||
let isSaving = $state(false)
|
||||
let hasLoaded = $state(mode === 'create')
|
||||
let status = $state<'draft' | 'published'>(initialData?.status || 'draft')
|
||||
let updatedAt = $state<string | undefined>(initialData?.updatedAt)
|
||||
|
||||
// Form data
|
||||
let content = $state<JSONContent>(initialData?.content || { type: 'doc', content: [] })
|
||||
|
|
@ -50,7 +33,7 @@ let { postType, postId, initialData, mode }: Props = $props()
|
|||
let linkDescription = $state(initialData?.linkDescription || '')
|
||||
let title = $state(initialData?.title || '')
|
||||
|
||||
// Character count for posts
|
||||
// Character count for posts
|
||||
const maxLength = 280
|
||||
const textContent = $derived.by(() => {
|
||||
if (!content.content) return ''
|
||||
|
|
@ -67,179 +50,11 @@ let { postType, postId, initialData, mode }: Props = $props()
|
|||
const isOverLimit = $derived(charCount > maxLength)
|
||||
|
||||
// Check if form has content
|
||||
const hasContent = $derived.by(() => {
|
||||
// For posts, check if either content exists or it's a link with URL
|
||||
const hasContent = $derived.by(() => {
|
||||
const hasTextContent = textContent.trim().length > 0
|
||||
const hasLinkContent = linkUrl && linkUrl.trim().length > 0
|
||||
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 =
|
||||
typeof saved.updatedAt === 'string' ? saved.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') {
|
||||
if (isOverLimit) {
|
||||
|
|
@ -247,26 +62,24 @@ $effect(() => {
|
|||
return
|
||||
}
|
||||
|
||||
// For link posts, URL is required
|
||||
if (linkUrl && !linkUrl.trim()) {
|
||||
toast.error('Link URL is required')
|
||||
return
|
||||
}
|
||||
|
||||
isSaving = true
|
||||
const loadingToastId = toast.loading(
|
||||
`${publishStatus === 'published' ? 'Publishing' : 'Saving'} post...`
|
||||
)
|
||||
|
||||
try {
|
||||
isSaving = true
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
type: 'post', // Use simplified post type
|
||||
type: 'post',
|
||||
status: publishStatus,
|
||||
content: content
|
||||
content: content,
|
||||
updatedAt: mode === 'edit' ? initialData?.updatedAt : undefined
|
||||
}
|
||||
|
||||
// Add link fields if they're provided
|
||||
if (linkUrl && linkUrl.trim()) {
|
||||
payload.title = title || linkUrl
|
||||
payload.link_url = linkUrl
|
||||
|
|
@ -293,13 +106,11 @@ $effect(() => {
|
|||
throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} post`)
|
||||
}
|
||||
|
||||
await response.json()
|
||||
await response.json()
|
||||
|
||||
toast.dismiss(loadingToastId)
|
||||
toast.success(`Post ${publishStatus === 'published' ? 'published' : 'saved'} successfully!`)
|
||||
clearDraft(draftKey)
|
||||
toast.dismiss(loadingToastId)
|
||||
toast.success(`Post ${publishStatus === 'published' ? 'published' : 'saved'} successfully!`)
|
||||
|
||||
// Redirect back to posts list after creation
|
||||
goto('/admin/posts')
|
||||
} catch (err) {
|
||||
toast.dismiss(loadingToastId)
|
||||
|
|
@ -334,36 +145,19 @@ $effect(() => {
|
|||
</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{#if mode === 'edit' && autoSave}
|
||||
<AutoSaveStatus status={autoSave.status} error={autoSave.lastError} />
|
||||
{/if}
|
||||
<Button variant="secondary" onclick={() => handleSave('draft')} disabled={isSaving}>
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={() => handleSave('published')}
|
||||
disabled={isSaving || !hasContent() || (postType === 'microblog' && isOverLimit)}
|
||||
disabled={isSaving || !hasContent || (postType === 'microblog' && isOverLimit)}
|
||||
>
|
||||
Post
|
||||
</Button>
|
||||
</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="composer-container">
|
||||
<div class="composer">
|
||||
{#if postType === 'microblog'}
|
||||
|
|
@ -444,15 +238,6 @@ $effect(() => {
|
|||
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 {
|
||||
background: white;
|
||||
border-radius: $unit-2x;
|
||||
|
|
@ -561,103 +346,4 @@ $effect(() => {
|
|||
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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue