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:
parent
94e13f1129
commit
056e8927ee
8 changed files with 362 additions and 52 deletions
250
docs/eslint-cleanup-plan.md
Normal file
250
docs/eslint-cleanup-plan.md
Normal 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
|
||||||
|
|
@ -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')
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue