Merge pull request #18 from jedmund/cleanup/linter
Linter cleanup Part 1
This commit is contained in:
commit
b06842bcab
80 changed files with 901 additions and 238 deletions
349
docs/eslint-cleanup-plan.md
Normal file
349
docs/eslint-cleanup-plan.md
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
# ESLint Cleanup Plan
|
||||
|
||||
**Status:** 622 errors → 105 errors remaining (83% complete) ✨
|
||||
**Generated:** 2025-11-23
|
||||
**Last Updated:** 2025-11-23
|
||||
|
||||
## Progress Summary
|
||||
|
||||
| Phase | Status | Errors Fixed | Notes |
|
||||
|-------|--------|--------------|-------|
|
||||
| Phase 1: Critical Blockers | ✅ Complete | 6 | All parsing errors resolved |
|
||||
| Phase 2: Auto-fixable | ✅ Complete | 148 | Ran `eslint --fix` |
|
||||
| Phase 3: Type Safety | 🔄 In Progress | 363/277* | *More errors found during cleanup |
|
||||
| Phase 4: Svelte 5 Migration | ⏳ Pending | 0/109 | Not started |
|
||||
| Phase 5: Remaining Issues | ⏳ Pending | 0/73 | Not started |
|
||||
|
||||
**Total Progress:** 517/622 errors fixed (83% complete)
|
||||
|
||||
### Phase 3 Detailed Progress
|
||||
|
||||
| Batch | Status | Errors Fixed | Files |
|
||||
|-------|--------|--------------|-------|
|
||||
| Batch 1: Admin Components | ✅ Complete | 44 | 11 files |
|
||||
| Batch 2: API Routes | ✅ Complete | 26 | 20 files |
|
||||
| Batch 3: Frontend Components | ✅ Complete | 80 | 46 files |
|
||||
| Batch 4: Server Utilities | 🔄 In Progress | 9/88 | 21 files |
|
||||
| Batch 5: Remaining Files | ⏳ Pending | 0 | TBD |
|
||||
|
||||
**Commits:**
|
||||
- `94e13f1` - Auto-fix linting issues with eslint --fix
|
||||
- `8ec4c58` - Eliminate remaining any types in API routes
|
||||
- `9c746d5` - Replace any types in frontend components (batch 1)
|
||||
- `3d77922` - Replace more any types in components (batch 2)
|
||||
- `9379557` - Complete frontend component any type cleanup
|
||||
- `6408e7f` - Start fixing server utility any types (WIP)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The codebase initially had 622 ESLint errors across 180 files. Through systematic cleanup, we've reduced this to 105 errors (83% complete). This document tracks progress and provides a systematic approach to eliminate all remaining errors.
|
||||
|
||||
## 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) ✅ COMPLETE
|
||||
|
||||
**Status:** ✅ All parsing errors resolved
|
||||
|
||||
**Parsing Errors Fixed:**
|
||||
- `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 ✅
|
||||
|
||||
**Result:** All files now properly lintable.
|
||||
|
||||
### Phase 2: Low-Hanging Fruit (148 errors) ✅ COMPLETE
|
||||
|
||||
**Status:** ✅ Auto-fixes applied successfully
|
||||
|
||||
**Errors Fixed:**
|
||||
- 139 unused imports/variables (`@typescript-eslint/no-unused-vars`) ✅
|
||||
- 7 `prefer-const` violations ✅
|
||||
- 2 empty blocks (`no-empty`) ✅
|
||||
|
||||
**Action Taken:** Ran `npx eslint . --fix`
|
||||
|
||||
**Result:** 148 errors eliminated automatically (24% reduction).
|
||||
|
||||
### Phase 3: Type Safety (277+ errors) 🔄 IN PROGRESS
|
||||
|
||||
**Priority:** HIGH - Improves code quality and type safety
|
||||
**Status:** 150/~363 errors fixed (41% complete)
|
||||
|
||||
Replace `any` types with proper TypeScript types, organized by subsystem:
|
||||
|
||||
#### Batch 1: Admin Components ✅ COMPLETE
|
||||
**Status:** ✅ 44 errors fixed in 11 files
|
||||
|
||||
**Key Improvements:**
|
||||
- Added Prisma types (Post, Project, Media, Album)
|
||||
- Created specific payload interfaces (DraftPayload, PhotoPayload, etc.)
|
||||
- Replaced `any` with `unknown` and proper type guards
|
||||
- Fixed editor ref types with JSONContent interfaces
|
||||
|
||||
**Files Fixed:**
|
||||
- GalleryUploader.svelte (9 errors)
|
||||
- editorConfig.ts (8 errors)
|
||||
- posts/[id]/edit/+page.svelte (8 errors)
|
||||
- SimplePostForm.svelte (7 errors)
|
||||
- GenericMetadataPopover.svelte (5 errors)
|
||||
- PhotoPostForm.svelte (5 errors)
|
||||
- useFormGuards.svelte.ts (4 errors)
|
||||
|
||||
#### Batch 2: API Routes ✅ COMPLETE
|
||||
**Status:** ✅ 26 errors fixed in 20 files (all API/RSS routes now have 0 `any` errors)
|
||||
|
||||
**Key Improvements:**
|
||||
- Used `Prisma.JsonValue` for JSON column types
|
||||
- Added `Prisma.[Model]WhereInput` for where clauses
|
||||
- Added `Prisma.[Model]UpdateInput` for update operations
|
||||
- Created interfaces for complex data structures (ExifData, PhotoMedia, etc.)
|
||||
- Used proper type guards (Array.isArray checks)
|
||||
|
||||
**Files Fixed:**
|
||||
- api/media/bulk-delete/+server.ts (10 errors)
|
||||
- rss/+server.ts (8 errors)
|
||||
- api/universe/+server.ts (4 errors)
|
||||
- rss/universe/+server.ts (4 errors)
|
||||
- Plus 16 more API/RSS route files
|
||||
|
||||
#### Batch 3: Frontend Components ✅ COMPLETE
|
||||
**Status:** ✅ 80 errors fixed in 46 files (all components now have 0 `any` errors)
|
||||
|
||||
**Key Improvements:**
|
||||
- Used Leaflet types (L.Map, L.Marker, L.LeafletEvent) for map components
|
||||
- Used Svelte 5 `Snippet` type for render functions
|
||||
- Used `Component` type for Svelte component parameters
|
||||
- Used `EditorView` type for TipTap/ProseMirror views
|
||||
- Added proper error handling with type guards
|
||||
|
||||
**Files Fixed:**
|
||||
- All edra/headless placeholder components (7 files, 14 errors)
|
||||
- Map components with Leaflet types (3 files, 9 errors)
|
||||
- Form components with Prisma types (12 files, 24 errors)
|
||||
- Editor extensions and utilities (6 files, 12 errors)
|
||||
- Plus 18 more component files
|
||||
|
||||
#### Batch 4: Server Utilities 🔄 IN PROGRESS
|
||||
**Status:** 🔄 9/88 errors fixed in 21 files
|
||||
|
||||
**Currently Working On:**
|
||||
- `lib/utils/content.ts` (15 → 6 errors remaining)
|
||||
- Added ContentNode interface for content rendering
|
||||
- Replaced function parameters with proper types
|
||||
- Fixed content traversal and mapping functions
|
||||
|
||||
**Remaining Files:**
|
||||
- `lib/server/apple-music-client.ts` (10 errors)
|
||||
- `lib/server/logger.ts` (10 errors)
|
||||
- `lib/utils/metadata.ts` (10 errors)
|
||||
- `lib/server/cloudinary-audit.ts` (6 errors)
|
||||
- Plus 17 more server/utility files
|
||||
|
||||
#### Batch 5: Remaining Files ⏳ PENDING
|
||||
**Status:** ⏳ Not started
|
||||
|
||||
**Files to Fix:**
|
||||
- `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 (6 fixed)
|
||||
- **Phase 2 Complete:** ✅ 468 errors remaining (24% reduction, 154 fixed)
|
||||
- **Phase 3 In Progress:** 🔄 105 errors remaining (83% reduction, 517 total fixed)
|
||||
- Batch 1-3 Complete: 150 `any` types eliminated
|
||||
- Batch 4 In Progress: 9/88 errors fixed
|
||||
- **Phase 4 Pending:** ~109 Svelte 5 errors to fix
|
||||
- **Phase 5 Pending:** ~73 miscellaneous errors to fix
|
||||
- **Target:** 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
|
||||
|
||||
## Key Learnings
|
||||
|
||||
### Type System Patterns Established
|
||||
|
||||
1. **Prisma Types:** Always use generated Prisma types for database models
|
||||
- `import type { Post, Project, Media, Album } from '@prisma/client'`
|
||||
- Use `Prisma.JsonValue` for JSON columns
|
||||
- Use `Prisma.[Model]WhereInput` and `Prisma.[Model]UpdateInput`
|
||||
|
||||
2. **Content Handling:** Create structured interfaces for complex nested data
|
||||
- ContentNode interface for TipTap/BlockNote content
|
||||
- Type guards for safe traversal (Array.isArray, typeof checks)
|
||||
|
||||
3. **Component Types:** Use Svelte 5 and framework-specific types
|
||||
- `Snippet` for render functions
|
||||
- `Component` for component references
|
||||
- Specific editor types (Editor, EditorView, JSONContent)
|
||||
|
||||
4. **Error Handling:** Use type guards instead of `any` casts
|
||||
- `err && typeof err === 'object' && 'status' in err`
|
||||
- `Record<string, unknown>` for truly dynamic objects
|
||||
- `unknown` instead of `any` when type is genuinely unknown
|
||||
|
||||
### Commit Strategy
|
||||
|
||||
- Commits grouped by logical batches (admin components, API routes, etc.)
|
||||
- Terse, informal commit messages focusing on impact
|
||||
- Frequent commits for easy rollback if needed
|
||||
- No mention of tooling (Claude Code) in commit messages
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-11-23
|
||||
**Next Review:** After Phase 3 Batch 4 completion
|
||||
**Estimated Completion:** Phase 3 in progress, ~105 errors remaining
|
||||
|
|
@ -2,7 +2,7 @@ import { beforeNavigate } from '$app/navigation'
|
|||
import { toast } from '$lib/stores/toast'
|
||||
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
|
||||
|
||||
// Navigation guard: flush autosave before route change
|
||||
|
|
@ -13,7 +13,7 @@ export function useFormGuards(autoSave: AutoSaveStore<any, any> | null) {
|
|||
// Otherwise flush pending changes
|
||||
try {
|
||||
await autoSave.flush()
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('Autosave flush failed:', error)
|
||||
toast.error('Failed to save changes')
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ export function useFormGuards(autoSave: AutoSaveStore<any, any> | null) {
|
|||
|
||||
if (isModifier && key === 's') {
|
||||
event.preventDefault()
|
||||
autoSave!.flush().catch((error: any) => {
|
||||
autoSave!.flush().catch((error) => {
|
||||
console.error('Autosave flush failed:', error)
|
||||
toast.error('Failed to save changes')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
let searchQuery = $state('')
|
||||
let storefront = $state('us')
|
||||
let isSearching = $state(false)
|
||||
let searchResults = $state<any>(null)
|
||||
let searchResults = $state<unknown>(null)
|
||||
let searchError = $state<string | null>(null)
|
||||
let responseTime = $state<number>(0)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
import { formatDate } from '$lib/utils/date'
|
||||
import { renderEdraContent } from '$lib/utils/content'
|
||||
|
||||
let { post }: { post: any } = $props()
|
||||
import type { Post } from '@prisma/client'
|
||||
|
||||
let { post }: { post: Post } = $props()
|
||||
|
||||
const renderedContent = $derived(post.content ? renderEdraContent(post.content) : '')
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@
|
|||
}: Props = $props()
|
||||
|
||||
let mapContainer: HTMLDivElement
|
||||
let map: any
|
||||
let marker: any
|
||||
let leaflet: any
|
||||
let map: L.Map | null = null
|
||||
let marker: L.Marker | null = null
|
||||
let leaflet: typeof L | null = null
|
||||
|
||||
// Load Leaflet dynamically
|
||||
async function loadLeaflet() {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
title?: string
|
||||
caption?: string
|
||||
description?: string
|
||||
exifData?: any
|
||||
exifData?: Record<string, unknown>
|
||||
createdAt?: string
|
||||
backHref?: string
|
||||
backLabel?: string
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { Project } from '@prisma/client'
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import Button from '$lib/components/admin/Button.svelte'
|
||||
import BackButton from './BackButton.svelte'
|
||||
import { onMount } from 'svelte'
|
||||
|
|
@ -7,7 +9,7 @@
|
|||
projectSlug: string
|
||||
correctPassword: string
|
||||
projectType?: 'work' | 'labs'
|
||||
children?: any
|
||||
children?: Snippet
|
||||
}
|
||||
|
||||
let { projectSlug, correctPassword, projectType = 'work', children }: Props = $props()
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
interface UniverseItem {
|
||||
slug: string
|
||||
publishedAt: string
|
||||
[key: string]: any
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
left?: any
|
||||
right?: any
|
||||
left?: Snippet
|
||||
right?: Snippet
|
||||
}
|
||||
|
||||
let { left, right }: Props = $props()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
actions?: any
|
||||
actions?: unknown
|
||||
}
|
||||
|
||||
let { title, actions }: Props = $props()
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@
|
|||
|
||||
const currentPath = $derived($page.url.pathname)
|
||||
|
||||
import type { Component } from 'svelte'
|
||||
|
||||
interface NavItem {
|
||||
text: string
|
||||
href: string
|
||||
icon: any
|
||||
icon: Component
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@
|
|||
let isSaving = $state(false)
|
||||
let validationErrors = $state<Record<string, string>>({})
|
||||
let showBulkAlbumModal = $state(false)
|
||||
let albumMedia = $state<any[]>([])
|
||||
let editorInstance = $state<any>()
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { Album } from '@prisma/client'
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import AdminByline from './AdminByline.svelte'
|
||||
|
|
@ -23,7 +24,7 @@
|
|||
createdAt: string
|
||||
updatedAt: string
|
||||
photos: Photo[]
|
||||
content?: any
|
||||
content?: unknown
|
||||
_count: {
|
||||
media: number
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
|
||||
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
|
||||
import AutoSaveStatus from './AutoSaveStatus.svelte'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
import type { JSONContent, Editor as TipTapEditor } from '@tiptap/core'
|
||||
import type { Post } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
postId?: number
|
||||
|
|
@ -43,7 +44,7 @@
|
|||
let tagInput = $state('')
|
||||
|
||||
// Ref to the editor component
|
||||
let editorRef: any
|
||||
let editorRef: { save: () => Promise<JSONContent> } | undefined
|
||||
|
||||
// Draft backup
|
||||
const draftKey = $derived(makeDraftKey('post', postId ?? 'new'))
|
||||
|
|
@ -80,8 +81,8 @@ let autoSave = mode === 'edit' && postId
|
|||
if (!response.ok) throw new Error('Failed to save')
|
||||
return await response.json()
|
||||
},
|
||||
onSaved: (saved: any, { prime }) => {
|
||||
updatedAt = saved.updatedAt
|
||||
onSaved: (saved: Post, { prime }) => {
|
||||
updatedAt = saved.updatedAt.toISOString()
|
||||
prime(buildPayload())
|
||||
if (draftKey) clearDraft(draftKey)
|
||||
}
|
||||
|
|
@ -144,7 +145,7 @@ $effect(() => {
|
|||
|
||||
// Show restore prompt if a draft exists
|
||||
$effect(() => {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
const draft = loadDraft<ReturnType<typeof buildPayload>>(draftKey)
|
||||
if (draft) {
|
||||
showDraftPrompt = true
|
||||
draftTimestamp = draft.ts
|
||||
|
|
@ -152,7 +153,7 @@ $effect(() => {
|
|||
})
|
||||
|
||||
function restoreDraft() {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
const draft = loadDraft<ReturnType<typeof buildPayload>>(draftKey)
|
||||
if (!draft) return
|
||||
const p = draft.payload
|
||||
title = p.title ?? title
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
name?: string
|
||||
type?: string
|
||||
value?: any
|
||||
value?: string | number
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
error?: string
|
||||
helpText?: string
|
||||
disabled?: boolean
|
||||
onchange?: (e: Event) => void
|
||||
children?: any
|
||||
children?: Snippet
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@
|
|||
import UnifiedMediaModal from './UnifiedMediaModal.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 {
|
||||
label: string
|
||||
value?: any[] // Changed from Media[] to any[] to be more flexible
|
||||
onUpload: (media: any[]) => void
|
||||
onReorder?: (media: any[]) => void
|
||||
onRemove?: (item: any, index: number) => void // New callback for removals
|
||||
value?: GalleryItem[]
|
||||
onUpload: (media: GalleryItem[]) => void
|
||||
onReorder?: (media: GalleryItem[]) => void
|
||||
onRemove?: (item: GalleryItem, index: number) => void
|
||||
maxItems?: number
|
||||
allowAltText?: boolean
|
||||
required?: boolean
|
||||
|
|
@ -50,7 +53,7 @@
|
|||
let draggedOverIndex = $state<number | null>(null)
|
||||
let isMediaLibraryOpen = $state(false)
|
||||
let isImageModalOpen = $state(false)
|
||||
let selectedImage = $state<any | null>(null)
|
||||
let selectedImage = $state<Media | null>(null)
|
||||
|
||||
// Computed properties
|
||||
const hasImages = $derived(value && value.length > 0)
|
||||
|
|
@ -316,7 +319,7 @@
|
|||
isMediaLibraryOpen = true
|
||||
}
|
||||
|
||||
function handleMediaSelect(selectedMedia: any | any[]) {
|
||||
function handleMediaSelect(selectedMedia: Media | Media[]) {
|
||||
// For gallery mode, selectedMedia will be an array
|
||||
const mediaArray = Array.isArray(selectedMedia) ? selectedMedia : [selectedMedia]
|
||||
|
||||
|
|
@ -357,10 +360,10 @@
|
|||
}
|
||||
|
||||
// Handle clicking on an image to open details modal
|
||||
function handleImageClick(media: any) {
|
||||
function handleImageClick(media: GalleryItem) {
|
||||
// Convert to Media format if needed
|
||||
selectedImage = {
|
||||
id: media.mediaId || media.id,
|
||||
id: ('mediaId' in media && media.mediaId) || media.id!,
|
||||
filename: media.filename,
|
||||
originalName: media.originalName || media.filename,
|
||||
mimeType: media.mimeType || 'image/jpeg',
|
||||
|
|
@ -381,9 +384,9 @@
|
|||
}
|
||||
|
||||
// Handle updates from the media details modal
|
||||
function handleImageUpdate(updatedMedia: any) {
|
||||
function handleImageUpdate(updatedMedia: Media) {
|
||||
// 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) {
|
||||
value[index] = {
|
||||
...value[index],
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@
|
|||
placeholder?: string
|
||||
rows?: number
|
||||
helpText?: string
|
||||
component?: any // For custom components
|
||||
props?: any // Additional props for custom components
|
||||
component?: unknown // For custom components
|
||||
props?: Record<string, unknown> // Additional props for custom components
|
||||
}
|
||||
|
||||
export interface MetadataConfig {
|
||||
|
|
@ -27,9 +27,9 @@
|
|||
|
||||
type Props = {
|
||||
config: MetadataConfig
|
||||
data: any
|
||||
data: Record<string, unknown>
|
||||
triggerElement: HTMLElement
|
||||
onUpdate?: (key: string, value: any) => void
|
||||
onUpdate?: (key: string, value: unknown) => void
|
||||
onAddTag?: () => void
|
||||
onRemoveTag?: (tag: string) => void
|
||||
onClose?: () => void
|
||||
|
|
@ -110,7 +110,7 @@
|
|||
popoverElement.style.zIndex = '1200'
|
||||
}
|
||||
|
||||
function handleFieldUpdate(key: string, value: any) {
|
||||
function handleFieldUpdate(key: string, value: unknown) {
|
||||
data[key] = value
|
||||
onUpdate(key, value)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@
|
|||
content: [{ type: 'paragraph' }]
|
||||
}
|
||||
let characterCount = 0
|
||||
let editorInstance: any
|
||||
let editorInstance: { save: () => Promise<JSONContent>; clear: () => void } | undefined
|
||||
|
||||
// Essay metadata
|
||||
let essayTitle = ''
|
||||
|
|
@ -183,7 +183,7 @@
|
|||
if (!hasContent() && postType !== 'essay') return
|
||||
if (postType === 'essay' && !essayTitle) return
|
||||
|
||||
let postData: any = {
|
||||
let postData: Record<string, unknown> = {
|
||||
content,
|
||||
status: 'published',
|
||||
attachedPhotos: attachedPhotos.map((photo) => photo.id)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import Input from './Input.svelte'
|
||||
import type { Post } from '@prisma/client'
|
||||
|
||||
type Props = {
|
||||
post: any
|
||||
post: Post
|
||||
postType: 'post' | 'essay'
|
||||
slug: string
|
||||
excerpt: string
|
||||
|
|
|
|||
|
|
@ -10,7 +10,20 @@
|
|||
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
|
||||
import AutoSaveStatus from './AutoSaveStatus.svelte'
|
||||
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 {
|
||||
postId?: number
|
||||
|
|
@ -40,7 +53,7 @@
|
|||
let tags = $state(initialData?.tags?.join(', ') || '')
|
||||
|
||||
// Editor ref
|
||||
let editorRef: any
|
||||
let editorRef: Editor | undefined
|
||||
|
||||
// Draft backup
|
||||
const draftKey = $derived(makeDraftKey('post', postId ?? 'new'))
|
||||
|
|
@ -49,7 +62,7 @@
|
|||
let timeTicker = $state(0)
|
||||
const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
|
||||
|
||||
function buildPayload() {
|
||||
function buildPayload(): PhotoPayload {
|
||||
return {
|
||||
title: title.trim(),
|
||||
slug: createSlug(title),
|
||||
|
|
@ -83,8 +96,8 @@ let autoSave = mode === 'edit' && postId
|
|||
if (!response.ok) throw new Error('Failed to save')
|
||||
return await response.json()
|
||||
},
|
||||
onSaved: (saved: any, { prime }) => {
|
||||
updatedAt = saved.updatedAt
|
||||
onSaved: (saved: Post, { prime }) => {
|
||||
updatedAt = saved.updatedAt.toISOString()
|
||||
prime(buildPayload())
|
||||
if (draftKey) clearDraft(draftKey)
|
||||
}
|
||||
|
|
@ -118,7 +131,7 @@ let autoSave = mode === 'edit' && postId
|
|||
})
|
||||
|
||||
$effect(() => {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
const draft = loadDraft<PhotoPayload>(draftKey)
|
||||
if (draft) {
|
||||
showDraftPrompt = true
|
||||
draftTimestamp = draft.ts
|
||||
|
|
@ -126,7 +139,7 @@ $effect(() => {
|
|||
})
|
||||
|
||||
function restoreDraft() {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
const draft = loadDraft<PhotoPayload>(draftKey)
|
||||
if (!draft) return
|
||||
const p = draft.payload
|
||||
title = p.title ?? title
|
||||
|
|
@ -149,7 +162,7 @@ $effect(() => {
|
|||
usedIn: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
} as any
|
||||
} as unknown
|
||||
}
|
||||
showDraftPrompt = false
|
||||
clearDraft(draftKey)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { Post } from '@prisma/client'
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { onMount } from 'svelte'
|
||||
|
|
@ -72,14 +73,14 @@
|
|||
|
||||
if (typeof post.content === 'object' && post.content.content) {
|
||||
// BlockNote/TipTap format
|
||||
function extractText(node: any): string {
|
||||
if (node.text) return node.text
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
return node.content.map(extractText).join(' ')
|
||||
function extractText(node: Record<string, unknown>): string {
|
||||
if (typeof node.text === 'string') return node.text
|
||||
if (Array.isArray(node.content)) {
|
||||
return node.content.map((n) => extractText(n as Record<string, unknown>)).join(' ')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
textContent = extractText(post.content)
|
||||
textContent = extractText(post.content as Record<string, unknown>)
|
||||
} else if (typeof post.content === 'string') {
|
||||
textContent = post.content
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
<script lang="ts">
|
||||
import GenericMetadataPopover, { type MetadataConfig } from './GenericMetadataPopover.svelte'
|
||||
import type { Post } from '@prisma/client'
|
||||
|
||||
type Props = {
|
||||
post: any
|
||||
post: Post
|
||||
postType: 'post' | 'essay'
|
||||
slug: string
|
||||
tags: string[]
|
||||
|
|
@ -12,7 +13,7 @@
|
|||
onRemoveTag: (tag: string) => void
|
||||
onDelete: () => void
|
||||
onClose?: () => void
|
||||
onFieldUpdate?: (key: string, value: any) => void
|
||||
onFieldUpdate?: (key: string, value: unknown) => void
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -29,11 +30,11 @@
|
|||
onFieldUpdate
|
||||
}: Props = $props()
|
||||
|
||||
function handleFieldUpdate(key: string, value: any) {
|
||||
if (key === 'slug') {
|
||||
function handleFieldUpdate(key: string, value: unknown) {
|
||||
if (key === 'slug' && typeof value === 'string') {
|
||||
slug = value
|
||||
onFieldUpdate?.(key, value)
|
||||
} else if (key === 'tagInput') {
|
||||
} else if (key === 'tagInput' && typeof value === 'string') {
|
||||
tagInput = value
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
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 {
|
||||
project?: Project | null
|
||||
|
|
@ -37,7 +38,7 @@
|
|||
let successMessage = $state<string | null>(null)
|
||||
|
||||
// Ref to the editor component
|
||||
let editorRef: any
|
||||
let editorRef: { save: () => Promise<JSONContent> } | undefined
|
||||
|
||||
// Draft key for autosave fallback
|
||||
const draftKey = $derived(mode === 'edit' && project ? makeDraftKey('project', project.id) : null)
|
||||
|
|
@ -50,7 +51,7 @@
|
|||
save: async (payload, { signal }) => {
|
||||
return await api.put(`/api/projects/${project?.id}`, payload, { signal })
|
||||
},
|
||||
onSaved: (savedProject: any, { prime }) => {
|
||||
onSaved: (savedProject: Project, { prime }) => {
|
||||
project = savedProject
|
||||
formStore.populateFromProject(savedProject)
|
||||
prime(formStore.buildPayload())
|
||||
|
|
@ -112,7 +113,7 @@
|
|||
}
|
||||
})
|
||||
|
||||
function handleEditorChange(content: any) {
|
||||
function handleEditorChange(content: JSONContent) {
|
||||
formStore.setField('caseStudyContent', content)
|
||||
}
|
||||
|
||||
|
|
@ -158,7 +159,7 @@
|
|||
}
|
||||
} catch (err) {
|
||||
toast.dismiss(loadingToastId)
|
||||
if ((err as any)?.status === 409) {
|
||||
if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 409) {
|
||||
toast.error('This project has changed in another tab. Please reload.')
|
||||
} else {
|
||||
toast.error(`Failed to ${mode === 'edit' ? 'save' : 'create'} project`)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { goto, beforeNavigate } 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'
|
||||
|
|
@ -10,6 +11,17 @@
|
|||
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'
|
||||
postId?: number
|
||||
|
|
@ -43,7 +55,12 @@ let { postType, postId, initialData, mode }: Props = $props()
|
|||
const textContent = $derived.by(() => {
|
||||
if (!content.content) return ''
|
||||
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')
|
||||
})
|
||||
const charCount = $derived(textContent.length)
|
||||
|
|
@ -64,8 +81,8 @@ let draftTimestamp = $state<number | null>(null)
|
|||
let timeTicker = $state(0)
|
||||
const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
|
||||
|
||||
function buildPayload() {
|
||||
const payload: any = {
|
||||
function buildPayload(): PostPayload {
|
||||
const payload: PostPayload = {
|
||||
type: 'post',
|
||||
status,
|
||||
content,
|
||||
|
|
@ -97,8 +114,8 @@ let autoSave = mode === 'edit' && postId
|
|||
if (!response.ok) throw new Error('Failed to save')
|
||||
return await response.json()
|
||||
},
|
||||
onSaved: (saved: any, { prime }) => {
|
||||
updatedAt = saved.updatedAt
|
||||
onSaved: (saved: Post, { prime }) => {
|
||||
updatedAt = saved.updatedAt.toISOString()
|
||||
prime(buildPayload())
|
||||
if (draftKey) clearDraft(draftKey)
|
||||
}
|
||||
|
|
@ -132,7 +149,7 @@ $effect(() => {
|
|||
})
|
||||
|
||||
$effect(() => {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
const draft = loadDraft<PostPayload>(draftKey)
|
||||
if (draft) {
|
||||
showDraftPrompt = true
|
||||
draftTimestamp = draft.ts
|
||||
|
|
@ -140,7 +157,7 @@ $effect(() => {
|
|||
})
|
||||
|
||||
function restoreDraft() {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
const draft = loadDraft<PostPayload>(draftKey)
|
||||
if (!draft) return
|
||||
const p = draft.payload
|
||||
status = p.status ?? status
|
||||
|
|
@ -242,7 +259,7 @@ $effect(() => {
|
|||
try {
|
||||
isSaving = true
|
||||
|
||||
const payload: any = {
|
||||
const payload: Record<string, unknown> = {
|
||||
type: 'post', // Use simplified post type
|
||||
status: publishStatus,
|
||||
content: content
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
editor: Editor
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
features: any
|
||||
features: { textStyles?: boolean; colors?: boolean; [key: string]: unknown }
|
||||
}
|
||||
|
||||
const { editor, isOpen, onClose, features }: Props = $props()
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@
|
|||
}
|
||||
|
||||
// Update content when editor changes
|
||||
function handleUpdate({ editor: updatedEditor, transaction }: any) {
|
||||
function handleUpdate({ editor: updatedEditor, transaction }: { editor: Editor; transaction: unknown }) {
|
||||
// Dismiss link menus on typing
|
||||
linkManagerRef?.dismissOnTyping(transaction)
|
||||
|
||||
|
|
|
|||
|
|
@ -133,10 +133,10 @@
|
|||
}
|
||||
|
||||
// Dismiss dropdowns on typing
|
||||
export function dismissOnTyping(transaction: any) {
|
||||
export function dismissOnTyping(transaction: unknown) {
|
||||
if (showUrlConvertDropdown && transaction.docChanged) {
|
||||
const hasTextChange = transaction.steps.some(
|
||||
(step: any) =>
|
||||
(step: unknown) =>
|
||||
step.toJSON().stepType === 'replace' || step.toJSON().stepType === 'replaceAround'
|
||||
)
|
||||
if (hasTextChange) {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
editor: Editor
|
||||
variant: ComposerVariant
|
||||
currentTextStyle: string
|
||||
filteredCommands: any
|
||||
colorCommands: any[]
|
||||
filteredCommands: unknown
|
||||
colorCommands: unknown[]
|
||||
excludedCommands: string[]
|
||||
showMediaLibrary: boolean
|
||||
onTextStyleDropdownToggle: () => void
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import type { Editor } from '@tiptap/core'
|
||||
import type { ComposerVariant, ComposerFeatures } from './types'
|
||||
import type { EdraCommand } from '$lib/components/edra/commands/types'
|
||||
import { commands } from '$lib/components/edra/commands/commands.js'
|
||||
|
||||
export interface FilteredCommands {
|
||||
[key: string]: {
|
||||
name: string
|
||||
label: string
|
||||
commands: any[]
|
||||
commands: EdraCommand[]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -59,20 +60,20 @@ export function getFilteredCommands(
|
|||
// Reorganize text formatting for toolbar
|
||||
if (filtered['text-formatting']) {
|
||||
const allCommands = filtered['text-formatting'].commands
|
||||
const basicFormatting: any[] = []
|
||||
const advancedFormatting: any[] = []
|
||||
const basicFormatting: EdraCommand[] = []
|
||||
const advancedFormatting: EdraCommand[] = []
|
||||
|
||||
// Group basic formatting first
|
||||
const basicOrder = ['bold', 'italic', 'underline', 'strike']
|
||||
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)
|
||||
})
|
||||
|
||||
// Then link and code
|
||||
const advancedOrder = ['link', 'code']
|
||||
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)
|
||||
})
|
||||
|
||||
|
|
@ -97,7 +98,7 @@ export function getFilteredCommands(
|
|||
}
|
||||
|
||||
// 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 []
|
||||
|
||||
let mediaCommands = [...commands.media.commands]
|
||||
|
|
@ -111,12 +112,12 @@ export function getMediaCommands(features: ComposerFeatures): any[] {
|
|||
}
|
||||
|
||||
// Get color commands
|
||||
export function getColorCommands(): any[] {
|
||||
export function getColorCommands(): EdraCommand[] {
|
||||
return commands.colors?.commands || []
|
||||
}
|
||||
|
||||
// Get commands for bubble menu
|
||||
export function getBubbleMenuCommands(): any[] {
|
||||
export function getBubbleMenuCommands(): EdraCommand[] {
|
||||
const textFormattingCommands = commands['text-formatting']?.commands || []
|
||||
// Return only the essential formatting commands for bubble menu
|
||||
return textFormattingCommands.filter((cmd) =>
|
||||
|
|
|
|||
|
|
@ -33,10 +33,12 @@ export interface DropdownPosition {
|
|||
left: number
|
||||
}
|
||||
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
export interface MediaSelectionOptions {
|
||||
mode: 'single' | 'multiple'
|
||||
fileType?: 'image' | 'video' | 'audio' | 'all'
|
||||
albumId?: number
|
||||
onSelect: (media: any) => void
|
||||
onSelect: (media: Media | Media[]) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Editor } from '@tiptap/core'
|
||||
import type { Editor, EditorView } from '@tiptap/core'
|
||||
import type { ComposerMediaHandler } from './ComposerMediaHandler.svelte'
|
||||
import { focusEditor } from '$lib/components/edra/utils'
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ export interface UseComposerEventsOptions {
|
|||
|
||||
export function useComposerEvents(options: UseComposerEventsOptions) {
|
||||
// Handle paste events
|
||||
function handlePaste(view: any, event: ClipboardEvent): boolean {
|
||||
function handlePaste(view: EditorView, event: ClipboardEvent): boolean {
|
||||
const clipboardData = event.clipboardData
|
||||
if (!clipboardData) return false
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ export function useComposerEvents(options: UseComposerEventsOptions) {
|
|||
event.preventDefault()
|
||||
|
||||
// Use editor commands to insert HTML content
|
||||
const editorInstance = (view as any).editor
|
||||
const editorInstance = options.editor
|
||||
if (editorInstance) {
|
||||
editorInstance
|
||||
.chain()
|
||||
|
|
@ -66,7 +66,7 @@ export function useComposerEvents(options: UseComposerEventsOptions) {
|
|||
}
|
||||
|
||||
// Handle drag and drop for images
|
||||
function handleDrop(view: any, event: DragEvent): boolean {
|
||||
function handleDrop(view: EditorView, event: DragEvent): boolean {
|
||||
if (!options.features.imageUpload || !options.mediaHandler) return false
|
||||
|
||||
const files = event.dataTransfer?.files
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@
|
|||
}
|
||||
|
||||
// Block manipulation functions
|
||||
function convertBlockType(type: string, attrs?: any) {
|
||||
function convertBlockType(type: string, attrs?: Record<string, unknown>) {
|
||||
console.log('convertBlockType called:', type, attrs)
|
||||
// Use menuNode which was captured when menu was opened
|
||||
const nodeToConvert = menuNode || currentNode
|
||||
|
|
@ -486,10 +486,11 @@
|
|||
// Find the existing drag handle created by the plugin and add click listener
|
||||
const checkForDragHandle = setInterval(() => {
|
||||
const existingDragHandle = document.querySelector('.drag-handle')
|
||||
if (existingDragHandle && !(existingDragHandle as any).__menuListener) {
|
||||
const element = existingDragHandle as HTMLElement & { __menuListener?: boolean }
|
||||
if (existingDragHandle && !element.__menuListener) {
|
||||
console.log('Found drag handle, adding click listener')
|
||||
existingDragHandle.addEventListener('click', handleMenuClick)
|
||||
;(existingDragHandle as any).__menuListener = true
|
||||
element.__menuListener = true
|
||||
|
||||
// Update our reference to use the existing drag handle
|
||||
dragHandleContainer = existingDragHandle as HTMLElement
|
||||
|
|
|
|||
|
|
@ -43,11 +43,13 @@ import SlashCommandList from './headless/components/SlashCommandList.svelte'
|
|||
// Create lowlight instance
|
||||
const lowlight = createLowlight(all)
|
||||
|
||||
import type { Component } from 'svelte'
|
||||
|
||||
export interface EditorExtensionOptions {
|
||||
showSlashCommands?: boolean
|
||||
onShowUrlConvertDropdown?: (pos: number, url: string) => void
|
||||
onShowLinkContextMenu?: (pos: number, url: string, coords: { x: number; y: number }) => void
|
||||
imagePlaceholderComponent?: any // Allow custom image placeholder component
|
||||
imagePlaceholderComponent?: Component // Allow custom image placeholder component
|
||||
}
|
||||
|
||||
export function getEditorExtensions(options: EditorExtensionOptions = {}): Extensions {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { Component } from 'svelte'
|
|||
import type { NodeViewProps } from '@tiptap/core'
|
||||
|
||||
export interface GalleryOptions {
|
||||
HTMLAttributes: Record<string, any>
|
||||
HTMLAttributes: Record<string, unknown>
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import type { Component } from 'svelte'
|
|||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||
|
||||
export interface GalleryPlaceholderOptions {
|
||||
HTMLAttributes: Record<string, object>
|
||||
onSelectImages: (images: any[], editor: Editor) => void
|
||||
HTMLAttributes: Record<string, unknown>
|
||||
onSelectImages: (images: Array<Record<string, unknown>>, editor: Editor) => void
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Component } from 'svelte'
|
|||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||
|
||||
export interface GeolocationExtendedOptions {
|
||||
HTMLAttributes: Record<string, any>
|
||||
HTMLAttributes: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const GeolocationExtended = (
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@
|
|||
let { node, updateAttributes }: Props = $props()
|
||||
|
||||
let mapContainer: HTMLDivElement
|
||||
let map: any
|
||||
let marker: any
|
||||
let leaflet: any
|
||||
let map: L.Map | null = null
|
||||
let marker: L.Marker | null = null
|
||||
let leaflet: typeof L | null = null
|
||||
let isEditing = $state(false)
|
||||
|
||||
// Extract attributes
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@
|
|||
// Map picker state
|
||||
let showMapPicker = $state(false)
|
||||
let mapContainer: HTMLDivElement
|
||||
let pickerMap: any
|
||||
let pickerMarker: any
|
||||
let leaflet: any
|
||||
let pickerMap: L.Map | null = null
|
||||
let pickerMarker: L.Marker | null = null
|
||||
let leaflet: typeof L | null = null
|
||||
|
||||
// Load Leaflet for map picker
|
||||
async function loadLeaflet() {
|
||||
|
|
@ -77,15 +77,15 @@
|
|||
.addTo(pickerMap)
|
||||
|
||||
// Update coordinates on marker drag
|
||||
pickerMarker.on('dragend', (e: any) => {
|
||||
const position = e.target.getLatLng()
|
||||
pickerMarker.on('dragend', (e: L.LeafletEvent) => {
|
||||
const position = (e.target as L.Marker).getLatLng()
|
||||
latitude = position.lat.toFixed(6)
|
||||
longitude = position.lng.toFixed(6)
|
||||
})
|
||||
|
||||
// Update marker on map click
|
||||
pickerMap.on('click', (e: any) => {
|
||||
pickerMarker.setLatLng(e.latlng)
|
||||
pickerMap.on('click', (e: L.LeafletMouseEvent) => {
|
||||
pickerMarker!.setLatLng(e.latlng)
|
||||
latitude = e.latlng.lat.toFixed(6)
|
||||
longitude = e.latlng.lng.toFixed(6)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import GeolocationPlaceholder from './geolocation-placeholder.svelte'
|
|||
import GeolocationExtended from './geolocation-extended.svelte'
|
||||
|
||||
export interface GeolocationOptions {
|
||||
HTMLAttributes: Record<string, any>
|
||||
HTMLAttributes: Record<string, unknown>
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
|
||||
export interface UrlEmbedOptions {
|
||||
HTMLAttributes: Record<string, any>
|
||||
HTMLAttributes: Record<string, unknown>
|
||||
onShowDropdown?: (pos: number, url: string) => void
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/core'
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||
import type { Component } from 'svelte'
|
||||
|
||||
export const UrlEmbedExtended = (component: any) =>
|
||||
export const UrlEmbedExtended = (component: Component) =>
|
||||
Node.create({
|
||||
name: 'urlEmbed',
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/core'
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||
import type { Component } from 'svelte'
|
||||
|
||||
export const UrlEmbedPlaceholder = (component: any) =>
|
||||
export const UrlEmbedPlaceholder = (component: Component) =>
|
||||
Node.create({
|
||||
name: 'urlEmbedPlaceholder',
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
const { editor, deleteNode, getPos }: NodeViewProps = $props()
|
||||
|
||||
// Get album context if available
|
||||
const editorContext = getContext<any>('editorContext') || {}
|
||||
const editorContext = getContext<{ albumId?: number; [key: string]: unknown }>('editorContext') || {}
|
||||
const albumId = $derived(editorContext.albumId)
|
||||
|
||||
// Generate unique pane ID based on node position
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleClick(e as any)
|
||||
handleClick(e as unknown as MouseEvent)
|
||||
} else if (e.key === 'Escape') {
|
||||
if (showPane) {
|
||||
paneManager.close()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
const { editor, deleteNode, getPos }: NodeViewProps = $props()
|
||||
|
||||
// Get album context if available
|
||||
const editorContext = getContext<any>('editorContext') || {}
|
||||
const editorContext = getContext<{ albumId?: number; [key: string]: unknown }>('editorContext') || {}
|
||||
const albumId = $derived(editorContext.albumId)
|
||||
|
||||
// Generate unique pane ID based on node position
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleClick(e as any)
|
||||
handleClick(e as unknown as MouseEvent)
|
||||
} else if (e.key === 'Escape') {
|
||||
if (showPane) {
|
||||
paneManager.close()
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@
|
|||
updateAttributes({ images: newImages })
|
||||
} else {
|
||||
// Add to existing images
|
||||
const existingImages = node.attrs.images || []
|
||||
const currentIds = existingImages.map((img: any) => img.id)
|
||||
const existingImages = (node.attrs.images || []) as Array<{ id: number; [key: string]: unknown }>
|
||||
const currentIds = existingImages.map((img) => img.id)
|
||||
const uniqueNewImages = newImages.filter((img) => !currentIds.includes(img.id))
|
||||
updateAttributes({ images: [...existingImages, ...uniqueNewImages] })
|
||||
}
|
||||
|
|
@ -54,8 +54,8 @@
|
|||
}
|
||||
|
||||
function removeImage(imageId: number) {
|
||||
const currentImages = node.attrs.images || []
|
||||
const updatedImages = currentImages.filter((img: any) => img.id !== imageId)
|
||||
const currentImages = (node.attrs.images || []) as Array<{ id: number; [key: string]: unknown }>
|
||||
const updatedImages = currentImages.filter((img) => img.id !== imageId)
|
||||
updateAttributes({ images: updatedImages })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
const { editor, deleteNode, getPos }: NodeViewProps = $props()
|
||||
|
||||
// Get album context if available
|
||||
const editorContext = getContext<any>('editorContext') || {}
|
||||
const editorContext = getContext<{ albumId?: number; [key: string]: unknown }>('editorContext') || {}
|
||||
const albumId = $derived(editorContext.albumId)
|
||||
|
||||
// Generate unique pane ID based on node position
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleClick(e as any)
|
||||
handleClick(e as unknown as MouseEvent)
|
||||
} else if (e.key === 'Escape') {
|
||||
if (showPane) {
|
||||
paneManager.close()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
const { editor, deleteNode, getPos }: NodeViewProps = $props()
|
||||
|
||||
// Get album context if available
|
||||
const editorContext = getContext<any>('editorContext') || {}
|
||||
const editorContext = getContext<{ albumId?: number; [key: string]: unknown }>('editorContext') || {}
|
||||
const albumId = $derived(editorContext.albumId)
|
||||
|
||||
// Generate unique pane ID based on node position
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleClick(e as any)
|
||||
handleClick(e as unknown as MouseEvent)
|
||||
} else if (e.key === 'Escape') {
|
||||
if (showPane) {
|
||||
paneManager.close()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
const { editor, deleteNode, getPos }: NodeViewProps = $props()
|
||||
|
||||
// Get album context if available
|
||||
const editorContext = getContext<any>('editorContext') || {}
|
||||
const editorContext = getContext<{ albumId?: number; [key: string]: unknown }>('editorContext') || {}
|
||||
const albumId = $derived(editorContext.albumId)
|
||||
|
||||
// Generate unique pane ID based on node position
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleClick(e as any)
|
||||
handleClick(e as unknown as MouseEvent)
|
||||
} else if (e.key === 'Escape') {
|
||||
if (showPane) {
|
||||
paneManager.close()
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@
|
|||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleBrowseLibrary(e as any)
|
||||
handleBrowseLibrary(e as unknown as MouseEvent)
|
||||
} else if (e.key === 'Escape') {
|
||||
deleteNode()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
const { editor, deleteNode, getPos }: NodeViewProps = $props()
|
||||
|
||||
// Get album context if available
|
||||
const editorContext = getContext<any>('editorContext') || {}
|
||||
const editorContext = getContext<{ albumId?: number; [key: string]: unknown }>('editorContext') || {}
|
||||
const albumId = $derived(editorContext.albumId)
|
||||
|
||||
// Generate unique pane ID based on node position
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleClick(e as any)
|
||||
handleClick(e as unknown as MouseEvent)
|
||||
} else if (e.key === 'Escape') {
|
||||
if (showPane) {
|
||||
paneManager.close()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
const { editor, deleteNode, getPos }: NodeViewProps = $props()
|
||||
|
||||
// Get album context if available
|
||||
const editorContext = getContext<any>('editorContext') || {}
|
||||
const editorContext = getContext<{ albumId?: number; [key: string]: unknown }>('editorContext') || {}
|
||||
const albumId = $derived(editorContext.albumId)
|
||||
|
||||
// Generate unique pane ID based on node position
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleClick(e as any)
|
||||
handleClick(e as unknown as MouseEvent)
|
||||
} else if (e.key === 'Escape') {
|
||||
if (showPane) {
|
||||
paneManager.close()
|
||||
|
|
|
|||
|
|
@ -263,7 +263,7 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
|
|||
}
|
||||
|
||||
// Try different matching strategies in order of preference
|
||||
let match = albums.find((a) => {
|
||||
const match = albums.find((a) => {
|
||||
const albumName = a.attributes?.name || ''
|
||||
const artistName = a.attributes?.artistName || ''
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
// Content node types for rendering
|
||||
interface ContentNode {
|
||||
type: string
|
||||
attrs?: Record<string, unknown>
|
||||
content?: ContentNode[] | string
|
||||
text?: string
|
||||
level?: number
|
||||
src?: string
|
||||
alt?: string
|
||||
caption?: string
|
||||
language?: string
|
||||
mediaId?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// Render Edra/BlockNote JSON content to HTML
|
||||
export const renderEdraContent = (content: any, options: { albumSlug?: string } = {}): string => {
|
||||
export const renderEdraContent = (content: unknown, options: { albumSlug?: string } = {}): string => {
|
||||
if (!content) return ''
|
||||
|
||||
// Handle Tiptap format first (has type: 'doc')
|
||||
|
|
@ -8,10 +23,11 @@ export const renderEdraContent = (content: any, options: { albumSlug?: string }
|
|||
}
|
||||
|
||||
// Handle both { blocks: [...] } and { content: [...] } formats
|
||||
const blocks = content.blocks || content.content || []
|
||||
const contentObj = content as Record<string, unknown>
|
||||
const blocks = (contentObj.blocks || contentObj.content || []) as ContentNode[]
|
||||
if (!Array.isArray(blocks)) return ''
|
||||
|
||||
const renderBlock = (block: any): string => {
|
||||
const renderBlock = (block: ContentNode): string => {
|
||||
switch (block.type) {
|
||||
case 'heading': {
|
||||
const level = block.attrs?.level || block.level || 1
|
||||
|
|
@ -28,7 +44,7 @@ export const renderEdraContent = (content: any, options: { albumSlug?: string }
|
|||
case 'bulletList':
|
||||
case 'ul': {
|
||||
const listItems = (block.content || [])
|
||||
.map((item: any) => {
|
||||
.map((item) => {
|
||||
const itemText = item.content || item.text || ''
|
||||
return `<li>${itemText}</li>`
|
||||
})
|
||||
|
|
@ -39,7 +55,7 @@ export const renderEdraContent = (content: any, options: { albumSlug?: string }
|
|||
case 'orderedList':
|
||||
case 'ol': {
|
||||
const orderedItems = (block.content || [])
|
||||
.map((item: any) => {
|
||||
.map((item) => {
|
||||
const itemText = item.content || item.text || ''
|
||||
return `<li>${itemText}</li>`
|
||||
})
|
||||
|
|
@ -97,10 +113,10 @@ export const renderEdraContent = (content: any, options: { albumSlug?: string }
|
|||
}
|
||||
|
||||
// Render Tiptap JSON content to HTML
|
||||
function renderTiptapContent(doc: any): string {
|
||||
function renderTiptapContent(doc: Record<string, unknown>): string {
|
||||
if (!doc || !doc.content) return ''
|
||||
|
||||
const renderNode = (node: any): string => {
|
||||
const renderNode = (node: ContentNode): string => {
|
||||
switch (node.type) {
|
||||
case 'paragraph': {
|
||||
const content = renderInlineContent(node.content || [])
|
||||
|
|
@ -116,8 +132,8 @@ function renderTiptapContent(doc: any): string {
|
|||
|
||||
case 'bulletList': {
|
||||
const items = (node.content || [])
|
||||
.map((item: any) => {
|
||||
const itemContent = item.content?.map(renderNode).join('') || ''
|
||||
.map((item) => {
|
||||
const itemContent = item.content ? (item.content as ContentNode[]).map(renderNode) : [].join('') || ''
|
||||
return `<li>${itemContent}</li>`
|
||||
})
|
||||
.join('')
|
||||
|
|
@ -126,8 +142,8 @@ function renderTiptapContent(doc: any): string {
|
|||
|
||||
case 'orderedList': {
|
||||
const items = (node.content || [])
|
||||
.map((item: any) => {
|
||||
const itemContent = item.content?.map(renderNode).join('') || ''
|
||||
.map((item) => {
|
||||
const itemContent = item.content ? (item.content as ContentNode[]).map(renderNode) : [].join('') || ''
|
||||
return `<li>${itemContent}</li>`
|
||||
})
|
||||
.join('')
|
||||
|
|
@ -266,7 +282,7 @@ function renderTiptapContent(doc: any): string {
|
|||
default: {
|
||||
// For any unknown block types, try to render their content
|
||||
if (node.content) {
|
||||
return node.content.map(renderNode).join('')
|
||||
return Array.isArray(node.content) ? node.content.map(renderNode as (node: unknown) => string) : [].join('')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
|
@ -346,7 +362,7 @@ export const getContentExcerpt = (content: any, maxLength = 200): string => {
|
|||
const blocks = content.blocks || content.content || []
|
||||
if (!Array.isArray(blocks)) return ''
|
||||
|
||||
const extractText = (node: any): string => {
|
||||
const extractText = (node: ContentNode): string => {
|
||||
// For block-level content
|
||||
if (node.type && node.content && typeof node.content === 'string') {
|
||||
return node.content
|
||||
|
|
|
|||
|
|
@ -26,11 +26,16 @@
|
|||
]
|
||||
})
|
||||
)
|
||||
|
||||
const personJsonLdScript = $derived(
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
`<script type="application/ld+json">${JSON.stringify(personJsonLd)}<\/script>`
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<!-- Site-wide JSON-LD -->
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(personJsonLd)}</script>`}
|
||||
{@html personJsonLdScript}
|
||||
</svelte:head>
|
||||
|
||||
<div class="layout-wrapper" class:admin-route={isAdminRoute}>
|
||||
|
|
|
|||
|
|
@ -14,8 +14,34 @@ import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/ad
|
|||
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
|
||||
import AutoSaveStatus from '$lib/components/admin/AutoSaveStatus.svelte'
|
||||
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 hasLoaded = $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 })
|
||||
return saved
|
||||
},
|
||||
onSaved: (saved: any, { prime }) => {
|
||||
onSaved: (saved: Post, { prime }) => {
|
||||
post = saved
|
||||
prime({
|
||||
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
|
||||
function convertBlocksToTiptap(blocksContent: any): JSONContent {
|
||||
function convertBlocksToTiptap(blocksContent: BlockContent): JSONContent {
|
||||
if (!blocksContent || !blocksContent.blocks) {
|
||||
return { type: 'doc', content: [] }
|
||||
}
|
||||
|
||||
const tiptapContent = blocksContent.blocks.map((block: any) => {
|
||||
const tiptapContent = blocksContent.blocks.map((block) => {
|
||||
switch (block.type) {
|
||||
case 'paragraph':
|
||||
return {
|
||||
|
|
@ -109,30 +135,30 @@ const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(d
|
|||
case 'ul':
|
||||
return {
|
||||
type: 'bulletList',
|
||||
content: (block.content || []).map((item: any) => ({
|
||||
content: Array.isArray(block.content) ? block.content.map((item) => ({
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: item.content || item }]
|
||||
content: [{ type: 'text', text: (typeof item === 'object' && item.content) || String(item) }]
|
||||
}
|
||||
]
|
||||
}))
|
||||
})) : []
|
||||
}
|
||||
|
||||
case 'orderedList':
|
||||
case 'ol':
|
||||
return {
|
||||
type: 'orderedList',
|
||||
content: (block.content || []).map((item: any) => ({
|
||||
content: Array.isArray(block.content) ? block.content.map((item) => ({
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: item.content || item }]
|
||||
content: [{ type: 'text', text: (typeof item === 'object' && item.content) || String(item) }]
|
||||
}
|
||||
]
|
||||
}))
|
||||
})) : []
|
||||
}
|
||||
|
||||
case 'blockquote':
|
||||
|
|
@ -187,7 +213,7 @@ onMount(async () => {
|
|||
// Wait a tick to ensure page params are loaded
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await loadPost()
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
const draft = loadDraft<DraftPayload>(draftKey)
|
||||
if (draft) {
|
||||
showDraftPrompt = true
|
||||
draftTimestamp = draft.ts
|
||||
|
|
@ -311,7 +337,7 @@ onMount(async () => {
|
|||
}
|
||||
|
||||
function restoreDraft() {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
const draft = loadDraft<DraftPayload>(draftKey)
|
||||
if (!draft) return
|
||||
const p = draft.payload
|
||||
// Apply payload fields to form
|
||||
|
|
|
|||
|
|
@ -116,6 +116,11 @@
|
|||
|
||||
// Generate image gallery JSON-LD
|
||||
const galleryJsonLd = $derived(album ? generateAlbumJsonLd(album, pageUrl) : null)
|
||||
|
||||
const galleryJsonLdScript = $derived(
|
||||
// eslint-disable-next-line no-useless-escape -- Escape required for Svelte parser
|
||||
galleryJsonLd ? `<script type="application/ld+json">${JSON.stringify(galleryJsonLd)}<\/script>` : null
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -136,8 +141,8 @@
|
|||
<link rel="canonical" href={metaTags.other.canonical} />
|
||||
|
||||
<!-- JSON-LD -->
|
||||
{#if galleryJsonLd}
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(galleryJsonLd)}</script>`}
|
||||
{#if galleryJsonLdScript}
|
||||
{@html galleryJsonLdScript}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import {
|
||||
jsonResponse,
|
||||
|
|
@ -89,7 +90,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||
coverPhotoId?: number
|
||||
status?: string
|
||||
showInUniverse?: boolean
|
||||
content?: any
|
||||
content?: Prisma.JsonValue
|
||||
}>(event.request)
|
||||
|
||||
if (!body) {
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||
}>(event.request)
|
||||
|
||||
// Also support legacy photoId parameter
|
||||
const mediaId = body?.mediaId || (body as any)?.photoId
|
||||
const mediaId = body?.mediaId || (body as Record<string, unknown>)?.photoId
|
||||
if (!mediaId || body?.displayOrder === undefined) {
|
||||
return errorResponse('Media ID and display order are required', 400)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,30 @@ interface TrackPlayInfo {
|
|||
durationMs?: number
|
||||
}
|
||||
|
||||
// Type for Apple Music data
|
||||
interface AppleMusicTrack {
|
||||
name: string
|
||||
previewUrl?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
interface AppleMusicData {
|
||||
tracks?: AppleMusicTrack[]
|
||||
artwork?: {
|
||||
url: string
|
||||
width: number
|
||||
height: number
|
||||
bgColor?: string
|
||||
textColor1?: string
|
||||
textColor2?: string
|
||||
textColor3?: string
|
||||
textColor4?: string
|
||||
}
|
||||
previewUrl?: string
|
||||
appleMusicUrl?: string
|
||||
releaseDate?: string
|
||||
}
|
||||
|
||||
let recentTracks: TrackPlayInfo[] = []
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
|
|
@ -80,7 +104,7 @@ async function getRecentAlbums(
|
|||
recentTracksResponse = JSON.parse(cached)
|
||||
// Convert date strings back to Date objects
|
||||
if (recentTracksResponse.tracks) {
|
||||
recentTracksResponse.tracks = recentTracksResponse.tracks.map((track: any) => ({
|
||||
recentTracksResponse.tracks = recentTracksResponse.tracks.map((track) => ({
|
||||
...track,
|
||||
date: track.date ? new Date(track.date) : undefined
|
||||
}))
|
||||
|
|
@ -310,7 +334,7 @@ function transformImages(images: LastfmImage[]): AlbumImages {
|
|||
return transformedImages
|
||||
}
|
||||
|
||||
function checkNowPlaying(album: Album, appleMusicData: any): Album {
|
||||
function checkNowPlaying(album: Album, appleMusicData: AppleMusicData | null): Album {
|
||||
// Don't override if already marked as now playing by Last.fm
|
||||
if (album.isNowPlaying) {
|
||||
return album
|
||||
|
|
@ -324,8 +348,8 @@ function checkNowPlaying(album: Album, appleMusicData: any): Album {
|
|||
if (trackInfo.albumName !== album.name) continue
|
||||
|
||||
// Find the track duration from Apple Music data
|
||||
const trackData = appleMusicData.tracks?.find(
|
||||
(t: any) => t.name.toLowerCase() === trackInfo.trackName.toLowerCase()
|
||||
const trackData = appleMusicData?.tracks?.find(
|
||||
(t) => t.name.toLowerCase() === trackInfo.trackName.toLowerCase()
|
||||
)
|
||||
|
||||
if (trackData?.durationMs) {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const GET: RequestHandler = async ({ request }) => {
|
|||
let intervalId: NodeJS.Timeout | null = null
|
||||
let isClosed = false
|
||||
let currentInterval = UPDATE_INTERVAL
|
||||
let isPlaying = false
|
||||
const isPlaying = false
|
||||
|
||||
// Send initial connection message
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import {
|
||||
jsonResponse,
|
||||
|
|
@ -29,7 +30,7 @@ export const GET: RequestHandler = async (event) => {
|
|||
const albumId = event.url.searchParams.get('albumId')
|
||||
|
||||
// Build where clause
|
||||
const whereConditions: any[] = []
|
||||
const whereConditions: Prisma.MediaWhereInput[] = []
|
||||
|
||||
// Handle mime type filtering
|
||||
if (mimeType && mimeType !== 'all') {
|
||||
|
|
@ -149,7 +150,7 @@ export const GET: RequestHandler = async (event) => {
|
|||
: {}
|
||||
|
||||
// Build orderBy clause based on sort parameter
|
||||
let orderBy: any = { createdAt: 'desc' } // default to newest
|
||||
let orderBy: Prisma.MediaOrderByWithRelationInput = { createdAt: 'desc' } // default to newest
|
||||
|
||||
switch (sort) {
|
||||
case 'oldest':
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ export const DELETE: RequestHandler = async (event) => {
|
|||
}
|
||||
|
||||
// Check if media is in use
|
||||
if (media.usedIn && (media.usedIn as any[]).length > 0) {
|
||||
if (media.usedIn && Array.isArray(media.usedIn) && media.usedIn.length > 0) {
|
||||
return errorResponse('Cannot delete media that is in use', 409)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import {
|
||||
jsonResponse,
|
||||
|
|
@ -11,6 +12,20 @@ import { removeMediaUsage, extractMediaIds } from '$lib/server/media-usage.js'
|
|||
import { deleteFile, extractPublicId, isCloudinaryConfigured } from '$lib/server/cloudinary'
|
||||
import { deleteFileLocally } from '$lib/server/local-storage'
|
||||
|
||||
// Type for content node structure
|
||||
interface ContentNode {
|
||||
type: string
|
||||
attrs?: Record<string, unknown>
|
||||
content?: ContentNode[]
|
||||
}
|
||||
|
||||
// Type for gallery item in JSON fields
|
||||
interface GalleryItem {
|
||||
id?: number
|
||||
mediaId?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// DELETE /api/media/bulk-delete - Delete multiple media files and clean up references
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
|
|
@ -165,7 +180,7 @@ async function cleanupMediaReferences(mediaIds: number[]) {
|
|||
|
||||
for (const project of projects) {
|
||||
let needsUpdate = false
|
||||
const updateData: any = {}
|
||||
const updateData: Prisma.ProjectUpdateInput = {}
|
||||
|
||||
// Check featured image
|
||||
if (project.featuredImage && urlsToRemove.includes(project.featuredImage)) {
|
||||
|
|
@ -181,9 +196,9 @@ async function cleanupMediaReferences(mediaIds: number[]) {
|
|||
|
||||
// Check gallery
|
||||
if (project.gallery && Array.isArray(project.gallery)) {
|
||||
const filteredGallery = project.gallery.filter((item: any) => {
|
||||
const itemId = typeof item === 'object' ? item.id : parseInt(item)
|
||||
return !mediaIds.includes(itemId)
|
||||
const filteredGallery = (project.gallery as GalleryItem[]).filter((item) => {
|
||||
const itemId = item.id || item.mediaId
|
||||
return itemId ? !mediaIds.includes(Number(itemId)) : true
|
||||
})
|
||||
if (filteredGallery.length !== project.gallery.length) {
|
||||
updateData.gallery = filteredGallery.length > 0 ? filteredGallery : null
|
||||
|
|
@ -221,7 +236,7 @@ async function cleanupMediaReferences(mediaIds: number[]) {
|
|||
|
||||
for (const post of posts) {
|
||||
let needsUpdate = false
|
||||
const updateData: any = {}
|
||||
const updateData: Prisma.PostUpdateInput = {}
|
||||
|
||||
// Check featured image
|
||||
if (post.featuredImage && urlsToRemove.includes(post.featuredImage)) {
|
||||
|
|
@ -231,9 +246,9 @@ async function cleanupMediaReferences(mediaIds: number[]) {
|
|||
|
||||
// Check attachments
|
||||
if (post.attachments && Array.isArray(post.attachments)) {
|
||||
const filteredAttachments = post.attachments.filter((item: any) => {
|
||||
const itemId = typeof item === 'object' ? item.id : parseInt(item)
|
||||
return !mediaIds.includes(itemId)
|
||||
const filteredAttachments = (post.attachments as GalleryItem[]).filter((item) => {
|
||||
const itemId = item.id || item.mediaId
|
||||
return itemId ? !mediaIds.includes(Number(itemId)) : true
|
||||
})
|
||||
if (filteredAttachments.length !== post.attachments.length) {
|
||||
updateData.attachments = filteredAttachments.length > 0 ? filteredAttachments : null
|
||||
|
|
@ -263,27 +278,30 @@ async function cleanupMediaReferences(mediaIds: number[]) {
|
|||
/**
|
||||
* Remove media references from rich text content
|
||||
*/
|
||||
function cleanContentFromMedia(content: any, mediaIds: number[], urlsToRemove: string[]): any {
|
||||
function cleanContentFromMedia(content: Prisma.JsonValue, mediaIds: number[], urlsToRemove: string[]): Prisma.JsonValue {
|
||||
if (!content || typeof content !== 'object') return content
|
||||
|
||||
function cleanNode(node: any): any {
|
||||
function cleanNode(node: ContentNode | null): ContentNode | null {
|
||||
if (!node) return node
|
||||
|
||||
// Remove image nodes that reference deleted media
|
||||
if (node.type === 'image' && node.attrs?.src) {
|
||||
const shouldRemove = urlsToRemove.some((url) => node.attrs.src.includes(url))
|
||||
const shouldRemove = urlsToRemove.some((url) => String(node.attrs?.src).includes(url))
|
||||
if (shouldRemove) {
|
||||
return null // Mark for removal
|
||||
}
|
||||
}
|
||||
|
||||
// Clean gallery nodes
|
||||
if (node.type === 'gallery' && node.attrs?.images) {
|
||||
const filteredImages = node.attrs.images.filter((image: any) => !mediaIds.includes(image.id))
|
||||
if (node.type === 'gallery' && node.attrs?.images && Array.isArray(node.attrs.images)) {
|
||||
const filteredImages = (node.attrs.images as GalleryItem[]).filter((image) => {
|
||||
const imageId = image.id || image.mediaId
|
||||
return imageId ? !mediaIds.includes(Number(imageId)) : true
|
||||
})
|
||||
|
||||
if (filteredImages.length === 0) {
|
||||
return null // Remove empty gallery
|
||||
} else if (filteredImages.length !== node.attrs.images.length) {
|
||||
} else if (filteredImages.length !== (node.attrs.images as unknown[]).length) {
|
||||
return {
|
||||
...node,
|
||||
attrs: {
|
||||
|
|
@ -296,7 +314,7 @@ function cleanContentFromMedia(content: any, mediaIds: number[], urlsToRemove: s
|
|||
|
||||
// Recursively clean child nodes
|
||||
if (node.content) {
|
||||
const cleanedContent = node.content.map(cleanNode).filter((child: any) => child !== null)
|
||||
const cleanedContent = node.content.map(cleanNode).filter((child): child is ContentNode => child !== null)
|
||||
|
||||
return {
|
||||
...node,
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ async function extractExifData(file: File) {
|
|||
if (!exif) return null
|
||||
|
||||
// Format EXIF data
|
||||
const formattedExif: any = {}
|
||||
const formattedExif: ExifData = {}
|
||||
|
||||
// Camera info
|
||||
if (exif.Make && exif.Model) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import { uploadFile, isCloudinaryConfigured } from '$lib/server/cloudinary'
|
||||
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
|
||||
|
|
@ -6,8 +7,27 @@ import { logger } from '$lib/server/logger'
|
|||
import { dev } from '$app/environment'
|
||||
import exifr from 'exifr'
|
||||
|
||||
// Type for formatted EXIF data
|
||||
interface ExifData {
|
||||
camera?: string
|
||||
lens?: string
|
||||
focalLength?: string
|
||||
aperture?: string
|
||||
shutterSpeed?: string
|
||||
iso?: number
|
||||
dateTaken?: string
|
||||
gps?: {
|
||||
latitude: number
|
||||
longitude: number
|
||||
altitude?: number
|
||||
}
|
||||
orientation?: number
|
||||
colorSpace?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// Helper function to extract and format EXIF data
|
||||
async function extractExifData(file: File): Promise<any> {
|
||||
async function extractExifData(file: File): Promise<ExifData | null> {
|
||||
try {
|
||||
const buffer = await file.arrayBuffer()
|
||||
const exif = await exifr.parse(buffer, {
|
||||
|
|
@ -33,7 +53,7 @@ async function extractExifData(file: File): Promise<any> {
|
|||
if (!exif) return null
|
||||
|
||||
// Format the data into a more usable structure
|
||||
const formattedExif: any = {}
|
||||
const formattedExif: ExifData = {}
|
||||
|
||||
if (exif.Make && exif.Model) {
|
||||
formattedExif.camera = `${exif.Make} ${exif.Model}`.trim()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,30 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import { jsonResponse, errorResponse } from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import type { PhotoItem, PhotoAlbum, Photo } from '$lib/types/photos'
|
||||
|
||||
// Type for media with photo fields
|
||||
interface PhotoMedia {
|
||||
id: number
|
||||
photoSlug: string | null
|
||||
filename: string
|
||||
url: string
|
||||
thumbnailUrl: string | null
|
||||
width: number | null
|
||||
height: number | null
|
||||
dominantColor: string | null
|
||||
colors: Prisma.JsonValue
|
||||
aspectRatio: number | null
|
||||
photoCaption: string | null
|
||||
photoTitle: string | null
|
||||
photoDescription: string | null
|
||||
createdAt: Date
|
||||
photoPublishedAt: Date | null
|
||||
exifData: Prisma.JsonValue
|
||||
}
|
||||
|
||||
// GET /api/photos - Get individual photos only (albums excluded from collection)
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
try {
|
||||
|
|
@ -39,11 +60,11 @@ export const GET: RequestHandler = async (event) => {
|
|||
})
|
||||
|
||||
// Helper function to extract date from EXIF data
|
||||
const getPhotoDate = (media: any): Date => {
|
||||
const getPhotoDate = (media: PhotoMedia): Date => {
|
||||
// Try to get date from EXIF data
|
||||
if (media.exifData && typeof media.exifData === 'object') {
|
||||
// Check for common EXIF date fields
|
||||
const exif = media.exifData as any
|
||||
const exif = media.exifData as Record<string, unknown>
|
||||
const dateTaken = exif.DateTimeOriginal || exif.DateTime || exif.dateTaken
|
||||
if (dateTaken) {
|
||||
// Parse EXIF date format (typically "YYYY:MM:DD HH:MM:SS")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import {
|
||||
jsonResponse,
|
||||
|
|
@ -29,7 +30,7 @@ export const GET: RequestHandler = async (event) => {
|
|||
const postType = event.url.searchParams.get('postType')
|
||||
|
||||
// Build where clause
|
||||
const where: any = {}
|
||||
const where: Prisma.PostWhereInput = {}
|
||||
if (status) {
|
||||
where.status = status
|
||||
}
|
||||
|
|
@ -98,7 +99,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
}
|
||||
|
||||
// Use content as-is (no special handling needed)
|
||||
let postContent = data.content
|
||||
const postContent = data.content
|
||||
|
||||
const post = await prisma.post.create({
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
|
|
@ -75,8 +76,8 @@ export const PUT: RequestHandler = async (event) => {
|
|||
}
|
||||
|
||||
// Use content as-is (no special handling needed)
|
||||
let featuredImageId = data.featuredImage
|
||||
let postContent = data.content
|
||||
const featuredImageId = data.featuredImage
|
||||
const postContent = data.content
|
||||
|
||||
const post = await prisma.post.update({
|
||||
where: { id },
|
||||
|
|
@ -175,7 +176,7 @@ export const PATCH: RequestHandler = async (event) => {
|
|||
}
|
||||
}
|
||||
|
||||
const updateData: any = {}
|
||||
const updateData: Prisma.PostUpdateInput = {}
|
||||
|
||||
if (data.status !== undefined) {
|
||||
updateData.status = data.status
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import {
|
||||
jsonResponse,
|
||||
|
|
@ -16,6 +17,28 @@ import {
|
|||
type MediaUsageReference
|
||||
} from '$lib/server/media-usage.js'
|
||||
|
||||
// Type for project creation request body
|
||||
interface ProjectCreateBody {
|
||||
title: string
|
||||
subtitle?: string
|
||||
description?: string
|
||||
year: number
|
||||
client?: string
|
||||
role?: string
|
||||
featuredImage?: string
|
||||
logoUrl?: string
|
||||
gallery?: Prisma.JsonValue
|
||||
externalUrl?: string
|
||||
caseStudyContent?: Prisma.JsonValue
|
||||
backgroundColor?: string
|
||||
highlightColor?: string
|
||||
projectType?: string
|
||||
displayOrder?: number
|
||||
status?: string
|
||||
password?: string | null
|
||||
slug?: string
|
||||
}
|
||||
|
||||
// GET /api/projects - List all projects
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
try {
|
||||
|
|
@ -33,7 +56,7 @@ export const GET: RequestHandler = async (event) => {
|
|||
event.url.searchParams.get('includePasswordProtected') === 'true'
|
||||
|
||||
// Build where clause
|
||||
const where: any = {}
|
||||
const where: Prisma.ProjectWhereInput = {}
|
||||
|
||||
if (status) {
|
||||
where.status = status
|
||||
|
|
@ -90,7 +113,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
}
|
||||
|
||||
try {
|
||||
const body = await parseRequestBody<any>(event.request)
|
||||
const body = await parseRequestBody<ProjectCreateBody>(event.request)
|
||||
if (!body) {
|
||||
return errorResponse('Invalid request body', 400)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import {
|
||||
jsonResponse,
|
||||
|
|
@ -15,6 +16,28 @@ import {
|
|||
type MediaUsageReference
|
||||
} from '$lib/server/media-usage.js'
|
||||
|
||||
// Type for project update request body (partial of ProjectCreateBody)
|
||||
interface ProjectUpdateBody {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
description?: string
|
||||
year?: number
|
||||
client?: string
|
||||
role?: string
|
||||
featuredImage?: string
|
||||
logoUrl?: string
|
||||
gallery?: Prisma.JsonValue
|
||||
externalUrl?: string
|
||||
caseStudyContent?: Prisma.JsonValue
|
||||
backgroundColor?: string
|
||||
highlightColor?: string
|
||||
projectType?: string
|
||||
displayOrder?: number
|
||||
status?: string
|
||||
password?: string | null
|
||||
slug?: string
|
||||
}
|
||||
|
||||
// GET /api/projects/[id] - Get a single project
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const id = parseInt(event.params.id)
|
||||
|
|
@ -51,7 +74,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||
}
|
||||
|
||||
try {
|
||||
const body = await parseRequestBody<any>(event.request)
|
||||
const body = await parseRequestBody<ProjectUpdateBody>(event.request)
|
||||
if (!body) {
|
||||
return errorResponse('Invalid request body', 400)
|
||||
}
|
||||
|
|
@ -191,7 +214,7 @@ export const PATCH: RequestHandler = async (event) => {
|
|||
}
|
||||
|
||||
try {
|
||||
const body = await parseRequestBody<any>(event.request)
|
||||
const body = await parseRequestBody<ProjectUpdateBody>(event.request)
|
||||
if (!body) {
|
||||
return errorResponse('Invalid request body', 400)
|
||||
}
|
||||
|
|
@ -214,7 +237,7 @@ export const PATCH: RequestHandler = async (event) => {
|
|||
}
|
||||
|
||||
// Build update data object with only provided fields
|
||||
const updateData: any = {}
|
||||
const updateData: Prisma.ProjectUpdateInput = {}
|
||||
|
||||
// Handle status update specially
|
||||
if (body.status !== undefined) {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ async function authorize(npsso: string): Promise<AuthTokensResponse> {
|
|||
|
||||
async function getSerializedGames(psnId: string): Promise<SerializableGameInfo[]> {
|
||||
// Authorize with PSN and get games sorted by last played time
|
||||
let authorization = await authorize(PSN_NPSSO_TOKEN || '')
|
||||
const authorization = await authorize(PSN_NPSSO_TOKEN || '')
|
||||
const response = await getUserPlayedTime(authorization, PSN_ID, {
|
||||
limit: 5,
|
||||
categories: ['ps4_game', 'ps5_native_game']
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ async function getSerializedGames(steamId: string): Promise<SerializableGameInfo
|
|||
)
|
||||
|
||||
// Map the games to a serializable format that the frontend understands.
|
||||
let games: SerializableGameInfo[] = extendedGames.map((game) => ({
|
||||
const games: SerializableGameInfo[] = extendedGames.map((game) => ({
|
||||
id: game.game.id,
|
||||
name: game.game.name,
|
||||
playtime: game.minutes,
|
||||
|
|
|
|||
|
|
@ -1,20 +1,31 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import { jsonResponse, errorResponse } from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
|
||||
// Type for photo in album
|
||||
interface AlbumPhoto {
|
||||
id: number
|
||||
url: string
|
||||
thumbnailUrl: string | null
|
||||
photoCaption: string | null
|
||||
width: number | null
|
||||
height: number | null
|
||||
}
|
||||
|
||||
export interface UniverseItem {
|
||||
id: number
|
||||
type: 'post' | 'album'
|
||||
slug: string
|
||||
title?: string
|
||||
content?: any
|
||||
content?: Prisma.JsonValue
|
||||
publishedAt: string
|
||||
createdAt: string
|
||||
|
||||
// Post-specific fields
|
||||
postType?: string
|
||||
attachments?: any
|
||||
attachments?: Prisma.JsonValue
|
||||
featuredImage?: string
|
||||
|
||||
// Album-specific fields
|
||||
|
|
@ -22,8 +33,8 @@ export interface UniverseItem {
|
|||
location?: string
|
||||
date?: string
|
||||
photosCount?: number
|
||||
coverPhoto?: any
|
||||
photos?: any[]
|
||||
coverPhoto?: AlbumPhoto
|
||||
photos?: AlbumPhoto[]
|
||||
hasContent?: boolean
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,11 @@
|
|||
})
|
||||
: null
|
||||
)
|
||||
|
||||
const projectJsonLdScript = $derived(
|
||||
// eslint-disable-next-line no-useless-escape -- Escape required for Svelte parser
|
||||
projectJsonLd ? `<script type="application/ld+json">${JSON.stringify(projectJsonLd)}<\/script>` : null
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -73,8 +78,8 @@
|
|||
{/if}
|
||||
|
||||
<!-- JSON-LD -->
|
||||
{#if projectJsonLd}
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(projectJsonLd)}</script>`}
|
||||
{#if projectJsonLdScript}
|
||||
{@html projectJsonLdScript}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,11 @@
|
|||
: null
|
||||
)
|
||||
|
||||
const photoJsonLdScript = $derived(
|
||||
// eslint-disable-next-line no-useless-escape -- Escape required for Svelte parser
|
||||
photoJsonLd ? `<script type="application/ld+json">${JSON.stringify(photoJsonLd)}<\/script>` : null
|
||||
)
|
||||
|
||||
// Parse EXIF data if available
|
||||
const exifData = $derived(
|
||||
photo?.exifData && typeof photo.exifData === 'object' ? photo.exifData : null
|
||||
|
|
@ -357,8 +362,8 @@
|
|||
<link rel="canonical" href={metaTags.other.canonical} />
|
||||
|
||||
<!-- JSON-LD -->
|
||||
{#if photoJsonLd}
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(photoJsonLd)}</script>`}
|
||||
{#if photoJsonLdScript}
|
||||
{@html photoJsonLdScript}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,32 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { renderEdraContent } from '$lib/utils/content'
|
||||
|
||||
// Content node types for TipTap/Edra content
|
||||
interface TextNode {
|
||||
type: 'text'
|
||||
text: string
|
||||
marks?: unknown[]
|
||||
}
|
||||
|
||||
interface ParagraphNode {
|
||||
type: 'paragraph'
|
||||
content?: (TextNode | ContentNode)[]
|
||||
}
|
||||
|
||||
interface ContentNode {
|
||||
type: string
|
||||
content?: ContentNode[]
|
||||
attrs?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface DocContent {
|
||||
type: 'doc'
|
||||
content?: ContentNode[]
|
||||
}
|
||||
|
||||
// Helper function to escape XML special characters
|
||||
function escapeXML(str: string): string {
|
||||
if (!str) return ''
|
||||
|
|
@ -16,7 +40,7 @@ function escapeXML(str: string): string {
|
|||
|
||||
// Helper function to convert content to HTML for full content
|
||||
// Uses the same rendering logic as the website for consistency
|
||||
function convertContentToHTML(content: any): string {
|
||||
function convertContentToHTML(content: Prisma.JsonValue): string {
|
||||
if (!content) return ''
|
||||
|
||||
// Handle legacy content format (if it's just a string)
|
||||
|
|
@ -30,7 +54,7 @@ function convertContentToHTML(content: any): string {
|
|||
}
|
||||
|
||||
// Helper function to extract text summary from content
|
||||
function extractTextSummary(content: any, maxLength: number = 300): string {
|
||||
function extractTextSummary(content: Prisma.JsonValue, maxLength: number = 300): string {
|
||||
if (!content) return ''
|
||||
|
||||
let text = ''
|
||||
|
|
@ -40,20 +64,21 @@ function extractTextSummary(content: any, maxLength: number = 300): string {
|
|||
text = content
|
||||
}
|
||||
// Handle TipTap/Edra format
|
||||
else if (content.type === 'doc' && content.content && Array.isArray(content.content)) {
|
||||
text = content.content
|
||||
.filter((node: any) => node.type === 'paragraph')
|
||||
.map((node: any) => {
|
||||
else if (typeof content === 'object' && content !== null && 'type' in content && content.type === 'doc' && 'content' in content && Array.isArray(content.content)) {
|
||||
const docContent = content as DocContent
|
||||
text = docContent.content
|
||||
?.filter((node): node is ParagraphNode => node.type === 'paragraph')
|
||||
.map((node) => {
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
return node.content
|
||||
.filter((child: any) => child.type === 'text')
|
||||
.map((child: any) => child.text || '')
|
||||
.filter((child): child is TextNode => 'type' in child && child.type === 'text')
|
||||
.map((child) => child.text || '')
|
||||
.join('')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
.filter((t: string) => t)
|
||||
.join(' ')
|
||||
.filter((t) => t.length > 0)
|
||||
.join(' ') || ''
|
||||
}
|
||||
|
||||
// Clean up and truncate
|
||||
|
|
@ -79,8 +104,8 @@ export const GET: RequestHandler = async (event) => {
|
|||
})
|
||||
|
||||
// TODO: Re-enable albums once database schema is updated
|
||||
const universeAlbums: any[] = []
|
||||
const photoAlbums: any[] = []
|
||||
const universeAlbums: never[] = []
|
||||
const photoAlbums: never[] = []
|
||||
|
||||
// Combine all content types
|
||||
const items = [
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { renderEdraContent } from '$lib/utils/content'
|
||||
|
||||
// Type for legacy block content format
|
||||
interface BlockContent {
|
||||
blocks: Array<{
|
||||
type: string
|
||||
content?: string
|
||||
}>
|
||||
}
|
||||
|
||||
// Helper function to escape XML special characters
|
||||
function escapeXML(str: string): string {
|
||||
if (!str) return ''
|
||||
|
|
@ -16,7 +25,7 @@ function escapeXML(str: string): string {
|
|||
|
||||
// Helper function to convert content to HTML for full content
|
||||
// Uses the same rendering logic as the website for consistency
|
||||
function convertContentToHTML(content: any): string {
|
||||
function convertContentToHTML(content: Prisma.JsonValue): string {
|
||||
if (!content) return ''
|
||||
|
||||
// Use the existing renderEdraContent function which properly handles TipTap marks
|
||||
|
|
@ -25,12 +34,19 @@ function convertContentToHTML(content: any): string {
|
|||
}
|
||||
|
||||
// Helper function to extract text summary from content
|
||||
function extractTextSummary(content: any, maxLength: number = 300): string {
|
||||
if (!content || !content.blocks) return ''
|
||||
function extractTextSummary(content: Prisma.JsonValue, maxLength: number = 300): string {
|
||||
if (!content) return ''
|
||||
|
||||
// Type guard for block content
|
||||
const isBlockContent = (val: unknown): val is BlockContent => {
|
||||
return typeof val === 'object' && val !== null && 'blocks' in val && Array.isArray((val as BlockContent).blocks)
|
||||
}
|
||||
|
||||
if (!isBlockContent(content)) return ''
|
||||
|
||||
const text = content.blocks
|
||||
.filter((block: any) => block.type === 'paragraph' && block.content)
|
||||
.map((block: any) => block.content)
|
||||
.filter((block) => block.type === 'paragraph' && block.content)
|
||||
.map((block) => block.content || '')
|
||||
.join(' ')
|
||||
|
||||
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text
|
||||
|
|
|
|||
|
|
@ -56,6 +56,11 @@
|
|||
})
|
||||
: null
|
||||
)
|
||||
|
||||
const articleJsonLdScript = $derived(
|
||||
// eslint-disable-next-line no-useless-escape -- Escape required for Svelte parser
|
||||
articleJsonLd ? `<script type="application/ld+json">${JSON.stringify(articleJsonLd)}<\/script>` : null
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -81,8 +86,8 @@
|
|||
{/if}
|
||||
|
||||
<!-- JSON-LD -->
|
||||
{#if articleJsonLd}
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(articleJsonLd)}</script>`}
|
||||
{#if articleJsonLdScript}
|
||||
{@html articleJsonLdScript}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,11 @@
|
|||
: null
|
||||
)
|
||||
|
||||
const projectJsonLdScript = $derived(
|
||||
// eslint-disable-next-line no-useless-escape -- Escape required for Svelte parser
|
||||
projectJsonLd ? `<script type="application/ld+json">${JSON.stringify(projectJsonLd)}<\/script>` : null
|
||||
)
|
||||
|
||||
let headerContainer = $state<HTMLElement | null>(null)
|
||||
|
||||
// Spring with aggressive bounce settings
|
||||
|
|
@ -111,8 +116,8 @@
|
|||
{/if}
|
||||
|
||||
<!-- JSON-LD -->
|
||||
{#if projectJsonLd}
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(projectJsonLd)}</script>`}
|
||||
{#if projectJsonLdScript}
|
||||
{@html projectJsonLdScript}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ describe('createAutoSaveStore', () => {
|
|||
})
|
||||
|
||||
it('skips save when payload matches primed baseline', async () => {
|
||||
let value = 0
|
||||
const value = 0
|
||||
let saveCalls = 0
|
||||
|
||||
const controller = createAutoSaveController<{ value: number }>({
|
||||
|
|
|
|||
Loading…
Reference in a new issue