fix: replace any types with proper types in admin components

- add GalleryItem type for media/gallery item unions
- add EdraCommand import for editor command types
- add Post, Media imports from Prisma
- add BlockContent, DraftPayload, PostPayload, PhotoPayload types
- replace any with proper types in form handlers and callbacks
- use unknown for truly dynamic data, Record types for object props
This commit is contained in:
Justin Edmund 2025-11-23 05:00:59 -08:00
parent 94e13f1129
commit 056e8927ee
8 changed files with 362 additions and 52 deletions

250
docs/eslint-cleanup-plan.md Normal file
View file

@ -0,0 +1,250 @@
# ESLint Cleanup Plan
**Status:** 613 errors → Target: 0 errors
**Generated:** 2025-11-23
## Executive Summary
The codebase currently has 613 ESLint errors across 180 files. This document provides a systematic approach to eliminate all errors, organized by priority and error type.
## Error Breakdown by Rule
| Count | % of Total | Files | Rule |
|-------|------------|-------|------|
| 277 | 45.2% | 99 | `@typescript-eslint/no-explicit-any` |
| 139 | 22.7% | 79 | `@typescript-eslint/no-unused-vars` |
| 109 | 17.8% | 44 | `svelte/valid-compile` |
| 26 | 4.2% | 6 | `@typescript-eslint/no-unused-expressions` |
| 22 | 3.6% | 1 | `svelte/no-dupe-style-properties` |
| 10 | 1.6% | 9 | `svelte/no-at-html-tags` |
| 7 | 1.1% | 6 | `prefer-const` |
| 6 | 1.0% | 6 | Parsing errors |
| 5 | 0.8% | 2 | `no-undef` |
| 22 | 3.6% | — | Other (various) |
## Top Files Requiring Attention
1. **AvatarSVG.svelte** - 22 errors (duplicate style properties)
2. **posts/[id]/edit/+page.svelte** - 20 errors (mixed)
3. **admin/EssayForm.svelte** - 18 errors (mixed)
4. **admin/GalleryUploader.svelte** - 18 errors (mixed)
5. **admin/InlineComposerModal.svelte** - 17 errors (mixed)
## Execution Plan
### Phase 1: Critical Blockers (6 errors) 🔴
**Priority:** CRITICAL - These prevent proper linting of affected files
**Parsing Errors to Fix:**
- `src/routes/+layout.svelte:33` - Parsing error
- `routes/albums/[slug]/+page.svelte:140` - Parsing error
- `routes/labs/[slug]/+page.svelte:77` - Parsing error
- `routes/photos/[id]/+page.svelte:361` - Parsing error
- `routes/universe/[slug]/+page.svelte:85` - Parsing error
- `routes/work/[slug]/+page.svelte:115` - Parsing error
**Action:** Investigate and fix TypeScript/Svelte syntax issues in these route files.
### Phase 2: Low-Hanging Fruit (148 errors) 🟢
**Priority:** HIGH - Automatically fixable, quick wins
**Auto-fixable errors:**
- 139 unused imports/variables (`@typescript-eslint/no-unused-vars`)
- 7 `prefer-const` violations
- 2 empty blocks (`no-empty`)
**Action:** Run `npx eslint . --fix`
**Expected Result:** Reduces error count by ~24% with zero risk.
### Phase 3: Type Safety (277 errors) 🟡
**Priority:** HIGH - Improves code quality and type safety
Replace `any` types with proper TypeScript types, organized by subsystem:
#### Batch 1: Admin Components (~50 errors in 11 files)
- AdminFilters.svelte
- AdminHeader.svelte
- AdminNavBar.svelte
- AlbumForm.svelte
- AlbumListItem.svelte
- EssayForm.svelte
- FormField.svelte
- GalleryUploader.svelte
- SimplePostForm.svelte
- PhotoPostForm.svelte
- ProjectForm.svelte
#### Batch 2: API Routes (~80 errors in 20 files)
- `/api/admin/*` endpoints
- `/api/lastfm/*` endpoints
- `/api/media/*` endpoints
- `/api/posts/*` endpoints
- `/api/universe/*` endpoints
- `/rss/*` endpoints
#### Batch 3: Frontend Components (~70 errors in 30 files)
- AppleMusicSearchModal.svelte
- DebugPanel.svelte
- DynamicPostContent.svelte
- GeoCard.svelte
- PhotoMetadata.svelte
- ProjectPasswordProtection.svelte
- UniverseCard.svelte
- Other frontend components
#### Batch 4: Server Utilities (~40 errors in 20 files)
- `lib/server/apple-music-client.ts`
- `lib/server/logger.ts` (10 errors)
- `lib/utils/metadata.ts` (10 errors)
- `lib/utils/content.ts`
- Other server utilities
#### Batch 5: Remaining Files (~37 errors in 18 files)
- `global.d.ts` (2 errors)
- `lib/admin/autoSave.svelte.ts`
- `lib/admin/autoSaveLifecycle.ts`
- Other miscellaneous files
### Phase 4: Svelte 5 Migration (109 errors) 🟡
**Priority:** MEDIUM - Required for Svelte 5 compliance
#### Batch 1: Reactive State Declarations (~20 errors in 15 files)
Variables not declared with `$state()`:
- `searchModal` (DebugPanel.svelte)
- `cardElement` (LabCard.svelte)
- `logoElement` (ProjectItem.svelte)
- `dropdownElement` (DropdownMenu.svelte)
- `metadataButtonRef` (2 files)
- `editorInstance`, `essayTitle`, `essaySlug`, etc. (EssayForm.svelte)
- And 8 more files
**Action:** Wrap reactive variables in `$state()` declarations.
#### Batch 2: Event Handler Migration (~12 errors in 6 files)
Deprecated `on:*` handlers to migrate:
- `on:click``onclick` (3 occurrences in 2 files)
- `on:mousemove``onmousemove` (2 occurrences)
- `on:mouseenter``onmouseenter` (2 occurrences)
- `on:mouseleave``onmouseleave` (2 occurrences)
- `on:keydown``onkeydown` (1 occurrence)
**Files:**
- BaseModal.svelte
- LabCard.svelte
#### Batch 3: Accessibility Issues (~40 errors in 22 files)
**A11y fixes needed:**
- 15 instances: Click events need keyboard handlers
- 10 instances: Form labels need associated controls
- 8 instances: Elements with click handlers need ARIA roles
- 3 instances: Non-interactive elements with tabindex
- 2 instances: Elements need ARIA labels
**Common patterns:**
- Add `role="button"` and `onkeydown` handlers to clickable divs
- Associate labels with form controls using `for` attribute
- Add `tabindex="-1"` or remove unnecessary tabindex
#### Batch 4: Deprecated Component Syntax (~10 errors in 6 files)
**Issues:**
- `<svelte:self>` → Use self-imports instead (DropdownMenu.svelte)
- `<svelte:component>` → Components are dynamic by default in runes mode
- Self-closing non-void elements (3 files, e.g., `<textarea />`)
#### Batch 5: Custom Element Props (6 files)
**Issue:** Rest props with `$props()` need explicit destructuring or `customElement.props` config
**Files:**
- admin/Button.svelte
- stories/Button.svelte
- And 4 more component files
#### Batch 6: Miscellaneous Svelte Issues
- State referenced locally warnings (5 occurrences)
- Video elements missing captions (1 occurrence)
- Unused CSS selectors (2 occurrences)
- Image redundant alt text (1 occurrence)
### Phase 5: Remaining Issues (73 errors) 🟡
**Priority:** MEDIUM-LOW
#### AvatarSVG.svelte (22 errors)
- 22 duplicate style properties in SVG gradient definitions
- **Action:** Consolidate duplicate `fill` and `stop-color` properties
#### XSS Warnings (10 errors)
- 10 `{@html}` usage warnings in various components
- **Action:** Review each instance, ensure content is sanitized, or suppress with eslint-disable if safe
#### Code Quality Issues
- 5 `no-undef` errors (undefined variables)
- 26 `@typescript-eslint/no-unused-expressions` errors
- 4 `no-case-declarations` errors
- 3 `@typescript-eslint/no-empty-object-type` errors
- 3 `no-useless-escape` errors
## Recommended Execution Strategy
### For Manual Cleanup
1. ✅ **Work sequentially** - Complete phases in order
2. ✅ **Batch similar fixes** - Process files with same error pattern together
3. ✅ **Track progress** - Use todo list to check off completed items
4. ✅ **Verify continuously** - Run `npx eslint .` after each batch to confirm progress
5. ✅ **Commit frequently** - Commit after each batch for easy rollback if needed
### For LLM-Assisted Cleanup
1. **Process in phases** - Don't jump between phases
2. **One batch at a time** - Complete each batch before moving to next
3. **Verify after each batch** - Check error count decreases as expected
4. **Ask for clarification** - If error pattern is unclear, investigate before mass-fixing
5. **Preserve functionality** - Don't break working code while fixing lint errors
## Commands Reference
```bash
# Check all errors
npx eslint .
# Auto-fix what's possible
npx eslint . --fix
# Check specific file
npx eslint src/path/to/file.svelte
# Output to JSON for analysis
npx eslint . --format json > eslint-output.json
# Count errors by rule
npx eslint . 2>&1 | grep "error" | wc -l
```
## Success Metrics
- **Phase 1 Complete:** No parsing errors
- **Phase 2 Complete:** ~465 errors remaining (25% reduction)
- **Phase 3 Complete:** ~188 errors remaining (69% reduction)
- **Phase 4 Complete:** ~79 errors remaining (87% reduction)
- **Phase 5 Complete:** 0 errors (100% clean)
## Notes
- Prettier formatting issues (93 files) are separate from ESLint and should be fixed with `npm run format`
- Sass `@import` deprecation warnings are informational only and don't count toward the 613 errors
- Some `{@html}` warnings may be acceptable if content is trusted/sanitized
---
**Last Updated:** 2025-11-23
**Next Review:** After Phase 1 completion

View file

@ -2,7 +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(autoSave: AutoSaveStore<any, any> | null) { export function useFormGuards(autoSave: AutoSaveStore<unknown, 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
@ -13,7 +13,7 @@ export function useFormGuards(autoSave: AutoSaveStore<any, any> | null) {
// Otherwise flush pending changes // Otherwise flush pending changes
try { try {
await autoSave.flush() await autoSave.flush()
} catch (error: any) { } 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')
} }
@ -40,7 +40,7 @@ export function useFormGuards(autoSave: AutoSaveStore<any, any> | null) {
if (isModifier && key === 's') { if (isModifier && key === 's') {
event.preventDefault() event.preventDefault()
autoSave!.flush().catch((error: any) => { 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')
}) })

View file

@ -6,12 +6,15 @@
import UnifiedMediaModal from './UnifiedMediaModal.svelte' import UnifiedMediaModal from './UnifiedMediaModal.svelte'
import MediaDetailsModal from './MediaDetailsModal.svelte' import MediaDetailsModal from './MediaDetailsModal.svelte'
// Gallery items can be either Media objects or objects with a mediaId reference
type GalleryItem = Media | (Partial<Media> & { mediaId?: number })
interface Props { interface Props {
label: string label: string
value?: any[] // Changed from Media[] to any[] to be more flexible value?: GalleryItem[]
onUpload: (media: any[]) => void onUpload: (media: GalleryItem[]) => void
onReorder?: (media: any[]) => void onReorder?: (media: GalleryItem[]) => void
onRemove?: (item: any, index: number) => void // New callback for removals onRemove?: (item: GalleryItem, index: number) => void
maxItems?: number maxItems?: number
allowAltText?: boolean allowAltText?: boolean
required?: boolean required?: boolean
@ -50,7 +53,7 @@
let draggedOverIndex = $state<number | null>(null) let draggedOverIndex = $state<number | null>(null)
let isMediaLibraryOpen = $state(false) let isMediaLibraryOpen = $state(false)
let isImageModalOpen = $state(false) let isImageModalOpen = $state(false)
let selectedImage = $state<any | null>(null) let selectedImage = $state<Media | null>(null)
// Computed properties // Computed properties
const hasImages = $derived(value && value.length > 0) const hasImages = $derived(value && value.length > 0)
@ -316,7 +319,7 @@
isMediaLibraryOpen = true isMediaLibraryOpen = true
} }
function handleMediaSelect(selectedMedia: any | any[]) { function handleMediaSelect(selectedMedia: Media | Media[]) {
// For gallery mode, selectedMedia will be an array // For gallery mode, selectedMedia will be an array
const mediaArray = Array.isArray(selectedMedia) ? selectedMedia : [selectedMedia] const mediaArray = Array.isArray(selectedMedia) ? selectedMedia : [selectedMedia]
@ -357,10 +360,10 @@
} }
// Handle clicking on an image to open details modal // Handle clicking on an image to open details modal
function handleImageClick(media: any) { function handleImageClick(media: GalleryItem) {
// Convert to Media format if needed // Convert to Media format if needed
selectedImage = { selectedImage = {
id: media.mediaId || media.id, id: ('mediaId' in media && media.mediaId) || media.id!,
filename: media.filename, filename: media.filename,
originalName: media.originalName || media.filename, originalName: media.originalName || media.filename,
mimeType: media.mimeType || 'image/jpeg', mimeType: media.mimeType || 'image/jpeg',
@ -381,9 +384,9 @@
} }
// Handle updates from the media details modal // Handle updates from the media details modal
function handleImageUpdate(updatedMedia: any) { function handleImageUpdate(updatedMedia: Media) {
// Update the media in our value array // Update the media in our value array
const index = value.findIndex((m) => (m.mediaId || m.id) === updatedMedia.id) const index = value.findIndex((m) => (('mediaId' in m && m.mediaId) || m.id) === updatedMedia.id)
if (index !== -1) { if (index !== -1) {
value[index] = { value[index] = {
...value[index], ...value[index],

View file

@ -12,8 +12,8 @@
placeholder?: string placeholder?: string
rows?: number rows?: number
helpText?: string helpText?: string
component?: any // For custom components component?: unknown // For custom components
props?: any // Additional props for custom components props?: Record<string, unknown> // Additional props for custom components
} }
export interface MetadataConfig { export interface MetadataConfig {
@ -27,9 +27,9 @@
type Props = { type Props = {
config: MetadataConfig config: MetadataConfig
data: any data: Record<string, unknown>
triggerElement: HTMLElement triggerElement: HTMLElement
onUpdate?: (key: string, value: any) => void onUpdate?: (key: string, value: unknown) => void
onAddTag?: () => void onAddTag?: () => void
onRemoveTag?: (tag: string) => void onRemoveTag?: (tag: string) => void
onClose?: () => void onClose?: () => void
@ -110,7 +110,7 @@
popoverElement.style.zIndex = '1200' popoverElement.style.zIndex = '1200'
} }
function handleFieldUpdate(key: string, value: any) { function handleFieldUpdate(key: string, value: unknown) {
data[key] = value data[key] = value
onUpdate(key, value) onUpdate(key, value)
} }

View file

@ -10,7 +10,20 @@
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte' import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte' import AutoSaveStatus from './AutoSaveStatus.svelte'
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
import type { Media } from '@prisma/client' import type { Media, Post } from '@prisma/client'
import type { Editor } from '@tiptap/core'
// Payload type for photo posts
interface PhotoPayload {
title: string
slug: string
type: string
status: string
content: JSONContent
featuredImage: string | null
tags: string[]
updatedAt?: string
}
interface Props { interface Props {
postId?: number postId?: number
@ -40,7 +53,7 @@
let tags = $state(initialData?.tags?.join(', ') || '') let tags = $state(initialData?.tags?.join(', ') || '')
// Editor ref // Editor ref
let editorRef: any let editorRef: Editor | undefined
// Draft backup // Draft backup
const draftKey = $derived(makeDraftKey('post', postId ?? 'new')) const draftKey = $derived(makeDraftKey('post', postId ?? 'new'))
@ -49,7 +62,7 @@
let timeTicker = $state(0) let timeTicker = $state(0)
const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null)) const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
function buildPayload() { function buildPayload(): PhotoPayload {
return { return {
title: title.trim(), title: title.trim(),
slug: createSlug(title), slug: createSlug(title),
@ -83,8 +96,8 @@ let autoSave = mode === 'edit' && postId
if (!response.ok) throw new Error('Failed to save') if (!response.ok) throw new Error('Failed to save')
return await response.json() return await response.json()
}, },
onSaved: (saved: any, { prime }) => { onSaved: (saved: Post, { prime }) => {
updatedAt = saved.updatedAt updatedAt = saved.updatedAt.toISOString()
prime(buildPayload()) prime(buildPayload())
if (draftKey) clearDraft(draftKey) if (draftKey) clearDraft(draftKey)
} }
@ -118,7 +131,7 @@ let autoSave = mode === 'edit' && postId
}) })
$effect(() => { $effect(() => {
const draft = loadDraft<any>(draftKey) const draft = loadDraft<PhotoPayload>(draftKey)
if (draft) { if (draft) {
showDraftPrompt = true showDraftPrompt = true
draftTimestamp = draft.ts draftTimestamp = draft.ts
@ -126,7 +139,7 @@ $effect(() => {
}) })
function restoreDraft() { function restoreDraft() {
const draft = loadDraft<any>(draftKey) const draft = loadDraft<PhotoPayload>(draftKey)
if (!draft) return if (!draft) return
const p = draft.payload const p = draft.payload
title = p.title ?? title title = p.title ?? title

View file

@ -2,6 +2,7 @@
import { goto, beforeNavigate } 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'
@ -10,6 +11,17 @@
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte' import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
import AutoSaveStatus from './AutoSaveStatus.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'
postId?: number postId?: number
@ -43,7 +55,12 @@ let { postType, postId, initialData, mode }: Props = $props()
const textContent = $derived.by(() => { const textContent = $derived.by(() => {
if (!content.content) return '' if (!content.content) return ''
return content.content return content.content
.map((node: any) => node.content?.map((n: any) => n.text || '').join('') || '') .map((node) => {
if (node.content) {
return node.content.map((n) => ('text' in n ? n.text : '') || '').join('')
}
return ''
})
.join('\n') .join('\n')
}) })
const charCount = $derived(textContent.length) const charCount = $derived(textContent.length)
@ -64,8 +81,8 @@ let draftTimestamp = $state<number | null>(null)
let timeTicker = $state(0) let timeTicker = $state(0)
const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null)) const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
function buildPayload() { function buildPayload(): PostPayload {
const payload: any = { const payload: PostPayload = {
type: 'post', type: 'post',
status, status,
content, content,
@ -97,8 +114,8 @@ let autoSave = mode === 'edit' && postId
if (!response.ok) throw new Error('Failed to save') if (!response.ok) throw new Error('Failed to save')
return await response.json() return await response.json()
}, },
onSaved: (saved: any, { prime }) => { onSaved: (saved: Post, { prime }) => {
updatedAt = saved.updatedAt updatedAt = saved.updatedAt.toISOString()
prime(buildPayload()) prime(buildPayload())
if (draftKey) clearDraft(draftKey) if (draftKey) clearDraft(draftKey)
} }
@ -132,7 +149,7 @@ $effect(() => {
}) })
$effect(() => { $effect(() => {
const draft = loadDraft<any>(draftKey) const draft = loadDraft<PostPayload>(draftKey)
if (draft) { if (draft) {
showDraftPrompt = true showDraftPrompt = true
draftTimestamp = draft.ts draftTimestamp = draft.ts
@ -140,7 +157,7 @@ $effect(() => {
}) })
function restoreDraft() { function restoreDraft() {
const draft = loadDraft<any>(draftKey) const draft = loadDraft<PostPayload>(draftKey)
if (!draft) return if (!draft) return
const p = draft.payload const p = draft.payload
status = p.status ?? status status = p.status ?? status

View file

@ -1,12 +1,13 @@
import type { Editor } from '@tiptap/core' import type { Editor } from '@tiptap/core'
import type { ComposerVariant, ComposerFeatures } from './types' import type { ComposerVariant, ComposerFeatures } from './types'
import type { EdraCommand } from '$lib/components/edra/commands/types'
import { commands } from '$lib/components/edra/commands/commands.js' import { commands } from '$lib/components/edra/commands/commands.js'
export interface FilteredCommands { export interface FilteredCommands {
[key: string]: { [key: string]: {
name: string name: string
label: string label: string
commands: any[] commands: EdraCommand[]
} }
} }
@ -59,20 +60,20 @@ export function getFilteredCommands(
// Reorganize text formatting for toolbar // Reorganize text formatting for toolbar
if (filtered['text-formatting']) { if (filtered['text-formatting']) {
const allCommands = filtered['text-formatting'].commands const allCommands = filtered['text-formatting'].commands
const basicFormatting: any[] = [] const basicFormatting: EdraCommand[] = []
const advancedFormatting: any[] = [] const advancedFormatting: EdraCommand[] = []
// Group basic formatting first // Group basic formatting first
const basicOrder = ['bold', 'italic', 'underline', 'strike'] const basicOrder = ['bold', 'italic', 'underline', 'strike']
basicOrder.forEach((name) => { basicOrder.forEach((name) => {
const cmd = allCommands.find((c: any) => c.name === name) const cmd = allCommands.find((c) => c.name === name)
if (cmd) basicFormatting.push(cmd) if (cmd) basicFormatting.push(cmd)
}) })
// Then link and code // Then link and code
const advancedOrder = ['link', 'code'] const advancedOrder = ['link', 'code']
advancedOrder.forEach((name) => { advancedOrder.forEach((name) => {
const cmd = allCommands.find((c: any) => c.name === name) const cmd = allCommands.find((c) => c.name === name)
if (cmd) advancedFormatting.push(cmd) if (cmd) advancedFormatting.push(cmd)
}) })
@ -97,7 +98,7 @@ export function getFilteredCommands(
} }
// Get media commands, but filter out based on features // Get media commands, but filter out based on features
export function getMediaCommands(features: ComposerFeatures): any[] { export function getMediaCommands(features: ComposerFeatures): EdraCommand[] {
if (!commands.media) return [] if (!commands.media) return []
let mediaCommands = [...commands.media.commands] let mediaCommands = [...commands.media.commands]
@ -111,12 +112,12 @@ export function getMediaCommands(features: ComposerFeatures): any[] {
} }
// Get color commands // Get color commands
export function getColorCommands(): any[] { export function getColorCommands(): EdraCommand[] {
return commands.colors?.commands || [] return commands.colors?.commands || []
} }
// Get commands for bubble menu // Get commands for bubble menu
export function getBubbleMenuCommands(): any[] { export function getBubbleMenuCommands(): EdraCommand[] {
const textFormattingCommands = commands['text-formatting']?.commands || [] const textFormattingCommands = commands['text-formatting']?.commands || []
// Return only the essential formatting commands for bubble menu // Return only the essential formatting commands for bubble menu
return textFormattingCommands.filter((cmd) => return textFormattingCommands.filter((cmd) =>

View file

@ -14,8 +14,34 @@ import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/ad
import { createAutoSaveStore } from '$lib/admin/autoSave.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'
let post = $state<any>(null) // Type for the old blocks format from database
interface BlockContent {
blocks: Array<{
type: string
content?: string | Array<{ content?: string } | string>
level?: number
language?: string
src?: string
alt?: string
caption?: string
}>
}
// Type for draft payload
interface DraftPayload {
title: string | null
slug: string
type: string
status: string
content: JSONContent | null
excerpt?: string
tags: string[]
updatedAt?: Date
}
let post = $state<Post | null>(null)
let loading = $state(true) let loading = $state(true)
let hasLoaded = $state(false) let hasLoaded = $state(false)
let saving = $state(false) let saving = $state(false)
@ -68,7 +94,7 @@ const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(d
const saved = await api.put(`/api/posts/${$page.params.id}`, payload, { signal }) const saved = await api.put(`/api/posts/${$page.params.id}`, payload, { signal })
return saved return saved
}, },
onSaved: (saved: any, { prime }) => { onSaved: (saved: Post, { prime }) => {
post = saved post = saved
prime({ prime({
title: config?.showTitle ? title : null, title: config?.showTitle ? title : null,
@ -85,12 +111,12 @@ const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(d
}) })
// Convert blocks format (from database) to Tiptap format // Convert blocks format (from database) to Tiptap format
function convertBlocksToTiptap(blocksContent: any): JSONContent { function convertBlocksToTiptap(blocksContent: BlockContent): JSONContent {
if (!blocksContent || !blocksContent.blocks) { if (!blocksContent || !blocksContent.blocks) {
return { type: 'doc', content: [] } return { type: 'doc', content: [] }
} }
const tiptapContent = blocksContent.blocks.map((block: any) => { const tiptapContent = blocksContent.blocks.map((block) => {
switch (block.type) { switch (block.type) {
case 'paragraph': case 'paragraph':
return { return {
@ -109,30 +135,30 @@ const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(d
case 'ul': case 'ul':
return { return {
type: 'bulletList', type: 'bulletList',
content: (block.content || []).map((item: any) => ({ content: Array.isArray(block.content) ? block.content.map((item) => ({
type: 'listItem', type: 'listItem',
content: [ content: [
{ {
type: 'paragraph', type: 'paragraph',
content: [{ type: 'text', text: item.content || item }] content: [{ type: 'text', text: (typeof item === 'object' && item.content) || String(item) }]
} }
] ]
})) })) : []
} }
case 'orderedList': case 'orderedList':
case 'ol': case 'ol':
return { return {
type: 'orderedList', type: 'orderedList',
content: (block.content || []).map((item: any) => ({ content: Array.isArray(block.content) ? block.content.map((item) => ({
type: 'listItem', type: 'listItem',
content: [ content: [
{ {
type: 'paragraph', type: 'paragraph',
content: [{ type: 'text', text: item.content || item }] content: [{ type: 'text', text: (typeof item === 'object' && item.content) || String(item) }]
} }
] ]
})) })) : []
} }
case 'blockquote': case 'blockquote':
@ -187,7 +213,7 @@ 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<any>(draftKey) const draft = loadDraft<DraftPayload>(draftKey)
if (draft) { if (draft) {
showDraftPrompt = true showDraftPrompt = true
draftTimestamp = draft.ts draftTimestamp = draft.ts
@ -311,7 +337,7 @@ onMount(async () => {
} }
function restoreDraft() { function restoreDraft() {
const draft = loadDraft<any>(draftKey) const draft = loadDraft<DraftPayload>(draftKey)
if (!draft) return if (!draft) return
const p = draft.payload const p = draft.payload
// Apply payload fields to form // Apply payload fields to form