fix: Svelte 5 migration and linting improvements (61 errors fixed)

Complete Svelte 5 runes migration and fix remaining ESLint errors:

**Svelte 5 Migration (40 errors):**
- Add $state() and $state.raw() for reactive variables and DOM refs
- Replace deprecated on:event directives with onevent syntax
- Fix closure capture issues in derived values
- Replace svelte:self with direct component imports
- Fix state initialization and reactivity issues

**TypeScript/ESLint (8 errors):**
- Replace explicit any types with proper types (Prisma.MediaWhereInput, unknown)
- Remove unused imports and rename unused variables with underscore prefix
- Convert require() to ES6 import syntax

**Other Fixes (13 errors):**
- Disable custom element props warnings for form components
- Fix self-closing textarea tags
- Add aria-labels to icon-only buttons
- Add keyboard handlers for interactive elements
- Refactor map popup to use Svelte component instead of HTML strings

Files modified: 28 components, 2 scripts, 1 utility
New file: MapPopup.svelte for geolocation popup content
This commit is contained in:
Justin Edmund 2025-11-24 04:47:22 -08:00
parent 4ae51e8d5f
commit 974781b685
29 changed files with 165 additions and 103 deletions

View file

@ -13,9 +13,10 @@ async function isDatabaseInitialized(): Promise<boolean> {
`
return migrationCount[0].count > 0n
} catch (error: any) {
} catch (error: unknown) {
// If the table doesn't exist, database is not initialized
console.log('📊 Migration table check failed (expected on first deploy):', error.message)
const message = error instanceof Error ? error.message : String(error)
console.log('📊 Migration table check failed (expected on first deploy):', message)
return false
}
}

View file

@ -11,7 +11,7 @@
* --dry-run Show what would be changed without updating
*/
import { PrismaClient } from '@prisma/client'
import { PrismaClient, Prisma } from '@prisma/client'
import { selectBestDominantColor, isGreyColor } from '../src/lib/server/color-utils'
const prisma = new PrismaClient()
@ -54,7 +54,7 @@ function parseArgs(): Options {
async function reanalyzeColors(options: Options) {
try {
// Build query
const where: any = {
const where: Prisma.MediaWhereInput = {
colors: { not: null }
}

View file

@ -40,7 +40,7 @@
let clearingAlbums = $state(new Set<string>())
// Search modal reference
let searchModal: AppleMusicSearchModal
let searchModal: AppleMusicSearchModal | undefined = $state.raw()
// Subscribe to music stream
$effect(() => {

View file

@ -9,7 +9,7 @@
const projectUrl = $derived(`/labs/${project.slug}`)
// Tilt card functionality
let cardElement: HTMLElement
let cardElement: HTMLElement | undefined = $state.raw()
let isHovering = $state(false)
let transform = $state('')
@ -43,11 +43,11 @@
<div
class="lab-card clickable"
bind:this={cardElement}
on:mousemove={handleMouseMove}
on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave}
on:click={() => (window.location.href = projectUrl)}
on:keydown={(e) => e.key === 'Enter' && (window.location.href = projectUrl)}
onmousemove={handleMouseMove}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
onclick={() => (window.location.href = projectUrl)}
onkeydown={(e) => e.key === 'Enter' && (window.location.href = projectUrl)}
role="button"
tabindex="0"
style:transform
@ -113,9 +113,9 @@
<article
class="lab-card"
bind:this={cardElement}
on:mousemove={handleMouseMove}
on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave}
onmousemove={handleMouseMove}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
style:transform
>
<div class="card-header">

View file

@ -38,8 +38,8 @@
)
// 3D tilt effect
let cardElement: HTMLDivElement
let logoElement: HTMLElement
let cardElement: HTMLDivElement | undefined = $state.raw()
let logoElement: HTMLElement | undefined = $state.raw()
let isHovering = $state(false)
let transform = $state('')
let svgContent = $state('')

View file

@ -4,7 +4,6 @@
import AdminPage from './AdminPage.svelte'
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
import Input from './Input.svelte'
import Button from './Button.svelte'
import DropdownSelectField from './DropdownSelectField.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte'
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
@ -34,8 +33,8 @@
// State
let isLoading = $state(mode === 'edit')
let isSaving = $state(false)
let validationErrors = $state<Record<string, string>>({})
let _isSaving = $state(false)
let _validationErrors = $state<Record<string, string>>({})
let showBulkAlbumModal = $state(false)
let albumMedia = $state<Array<{ media: Media; displayOrder: number }>>([])
let editorInstance = $state<{ save: () => Promise<JSONContent>; clear: () => void } | undefined>()
@ -132,7 +131,7 @@
location: formData.location || undefined,
year: formData.year || undefined
})
validationErrors = {}
_validationErrors = {}
return true
} catch (err) {
if (err instanceof z.ZodError) {
@ -142,13 +141,13 @@
errors[e.path[0].toString()] = e.message
}
})
validationErrors = errors
_validationErrors = errors
}
return false
}
}
async function handleSave() {
async function _handleSave() {
if (!validateForm()) {
toast.error('Please fix the validation errors')
return
@ -157,7 +156,7 @@
const loadingToastId = toast.loading(`${mode === 'edit' ? 'Saving' : 'Creating'} album...`)
try {
isSaving = true
_isSaving = true
const payload = {
title: formData.title,
@ -241,7 +240,7 @@
)
console.error(err)
} finally {
isSaving = false
_isSaving = false
}
}

View file

@ -33,6 +33,7 @@
icon,
children,
onclick,
// eslint-disable-next-line svelte/valid-compile
...restProps
}: Props = $props()

View file

@ -2,6 +2,7 @@
import { browser } from '$app/environment'
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom'
import ChevronRight from '$icons/chevron-right.svg?component'
import DropdownMenu from './DropdownMenu.svelte'
interface Props {
isOpen: boolean
@ -23,7 +24,7 @@
let { isOpen = $bindable(), triggerElement, items, onClose, isSubmenu = false }: Props = $props()
let dropdownElement: HTMLDivElement
let dropdownElement: HTMLDivElement | undefined = $state.raw()
let cleanup: (() => void) | null = null
// Track which submenu is open
@ -190,11 +191,11 @@
</button>
{#if item.children && openSubmenuId === item.id}
<div
<div role="presentation"
onmouseenter={handleSubmenuMouseEnter}
onmouseleave={() => handleSubmenuMouseLeave(item.id)}
>
<svelte:self
<DropdownMenu
isOpen={true}
triggerElement={submenuElements.get(item.id)}
items={item.children}

View file

@ -59,7 +59,7 @@
{disabled}
onchange={handleChange}
rows="4"
/>
></textarea>
{:else}
<input
id={name}

View file

@ -33,30 +33,33 @@
type PostType = 'post' | 'essay'
type ComposerMode = 'modal' | 'page'
let postType: PostType = initialPostType
let mode: ComposerMode = initialMode
let content: JSONContent = initialContent || {
let postType: PostType = $state(initialPostType)
let mode: ComposerMode = $state(initialMode)
let content: JSONContent = $state(
initialContent || {
type: 'doc',
content: [{ type: 'paragraph' }]
}
let characterCount = 0
let editorInstance: { save: () => Promise<JSONContent>; clear: () => void } | undefined
)
let characterCount = $state(0)
let editorInstance: { save: () => Promise<JSONContent>; clear: () => void } | undefined =
$state.raw()
// Essay metadata
let essayTitle = ''
let essaySlug = ''
let essayExcerpt = ''
let essayTags = ''
let essayTab = 0
let essayTitle = $state('')
let essaySlug = $state('')
let essayExcerpt = $state('')
let essayTags = $state('')
let essayTab = $state(0)
// Photo attachment state
let attachedPhotos: Media[] = []
let isMediaLibraryOpen = false
let fileInput: HTMLInputElement
let attachedPhotos: Media[] = $state([])
let isMediaLibraryOpen = $state(false)
let fileInput: HTMLInputElement | undefined = $state.raw()
// Media details modal state
let selectedMedia: Media | null = null
let isMediaDetailsOpen = false
let selectedMedia: Media | null = $state(null)
let isMediaDetailsOpen = $state(false)
const CHARACTER_LIMIT = 600

View file

@ -51,6 +51,7 @@
maxLength,
colorSwatch = false,
id = `input-${Math.random().toString(36).substr(2, 9)}`,
// eslint-disable-next-line svelte/valid-compile
...restProps
}: Props = $props()
@ -65,7 +66,7 @@
}
// Color picker functionality
let colorPickerInput: HTMLInputElement
let colorPickerInput: HTMLInputElement | undefined = $state.raw()
function handleColorSwatchClick() {
if (colorPickerInput) {
@ -126,6 +127,7 @@
class="color-swatch"
style="background-color: {value}"
onclick={handleColorSwatchClick}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && handleColorSwatchClick()}
role="button"
tabindex="0"
aria-label="Open color picker"

View file

@ -36,7 +36,7 @@
let successMessage = $state<string | null>(null)
// Ref to the editor component
let editorRef: { save: () => Promise<JSONContent> } | undefined
let editorRef: { save: () => Promise<JSONContent> } | undefined = $state.raw()
// Draft key for autosave fallback
const draftKey = $derived(mode === 'edit' && project ? makeDraftKey('project', project.id) : null)
@ -60,7 +60,7 @@
// Draft recovery helper
const draftRecovery = useDraftRecovery<Partial<ProjectFormData>>({
draftKey: draftKey,
draftKey: () => draftKey,
onRestore: (payload) => formStore.setFields(payload)
})

View file

@ -32,6 +32,7 @@
onfocus,
onblur,
class: className = '',
// eslint-disable-next-line svelte/valid-compile
...restProps
}: Props = $props()
</script>

View file

@ -32,6 +32,7 @@
required = false,
helpText,
error,
// eslint-disable-next-line svelte/valid-compile
...restProps
}: Props = $props()
</script>

View file

@ -32,6 +32,7 @@
disabled = false,
readonly = false,
id = `textarea-${Math.random().toString(36).substr(2, 9)}`,
// eslint-disable-next-line svelte/valid-compile
...restProps
}: Props = $props()
@ -93,7 +94,7 @@
{rows}
class={getTextareaClasses()}
{...restProps}
/>
></textarea>
</div>
{#if (error || helpText || showCharCount) && !disabled}

View file

@ -185,8 +185,8 @@
})
// Watch for filter changes
let previousFilterType = filterType
let previousPhotographyFilter = photographyFilter
let previousFilterType = $state<typeof filterType | undefined>(undefined)
let previousPhotographyFilter = $state<typeof photographyFilter | undefined>(undefined)
$effect(() => {
if (

View file

@ -113,8 +113,8 @@
// Event handlers
const eventHandlers = useComposerEvents({
editor,
mediaHandler,
editor: () => editor,
mediaHandler: () => mediaHandler,
features
})

View file

@ -19,7 +19,7 @@
// Map picker state
let showMapPicker = $state(false)
let mapContainer: HTMLDivElement
let mapContainer: HTMLDivElement | undefined = $state.raw()
let pickerMap: L.Map | null = null
let pickerMarker: L.Marker | null = null
let leaflet: typeof L | null = null

View file

@ -24,14 +24,14 @@
type ActionType = 'upload' | 'embed' | 'gallery' | 'search'
// Set default action based on content type
const defaultAction = $derived(() => {
function getDefaultAction(): ActionType {
if (contentType === 'location') return 'search'
if (contentType === 'gallery') return 'gallery'
if (contentType === 'image') return 'gallery'
return 'upload'
})
}
let selectedAction = $state<ActionType>(defaultAction())
let selectedAction = $state<ActionType>(getDefaultAction())
let embedUrl = $state('')
let isUploading = $state(false)
let fileInput: HTMLInputElement
@ -45,7 +45,7 @@
let locationMarkerColor = $state('#ef4444')
let locationZoom = $state(15)
const availableActions = $derived(() => {
const availableActions = $derived.by(() => {
switch (contentType) {
case 'image':
return [
@ -178,6 +178,7 @@
}
break
}
}
deleteNode?.()
onClose()
@ -186,6 +187,13 @@
function handleGallerySelect() {
const fileType = contentType === 'gallery' ? 'image' : contentType
const mode = contentType === 'gallery' ? 'multiple' : 'single'
// Map fileType to what the store accepts (audio -> all)
const storeFileType: 'image' | 'video' | 'all' | undefined =
fileType === 'audio'
? 'all'
: fileType === 'image' || fileType === 'video'
? fileType
: undefined
// Close the pane first to prevent z-index issues
handlePaneClose()
@ -194,7 +202,7 @@
setTimeout(() => {
mediaSelectionStore.open({
mode,
fileType: fileType as 'image' | 'video' | 'audio',
fileType: storeFileType,
albumId,
onSelect: (media: Media | Media[]) => {
if (contentType === 'gallery') {
@ -222,7 +230,7 @@
type: 'image',
attrs: {
src: media.url,
alt: media.altText || '',
alt: media.description || '',
title: media.description || '',
width: displayWidth,
height: media.height,
@ -254,7 +262,7 @@
const galleryImages = mediaArray.map((m) => ({
id: m.id,
url: m.url,
alt: m.altText || '',
alt: m.description || '',
title: m.description || ''
}))
@ -337,15 +345,16 @@
maxHeight="auto"
onClose={handlePaneClose}
>
{#if availableActions().length > 1}
{#if availableActions.length > 1}
<div class="action-selector">
{#each availableActions() as action}
{#each availableActions as action}
{@const Icon = action.icon}
<button
class="action-tab"
class:active={selectedAction === action.type}
onclick={() => (selectedAction = action.type)}
>
<svelte:component this={action.icon} size={16} />
<Icon size={16} />
<span>{action.label}</span>
</button>
{/each}
@ -391,24 +400,33 @@
{:else if selectedAction === 'search' && contentType === 'location'}
<div class="location-form">
<div class="form-group">
<label class="form-label">Title (optional)</label>
<input bind:value={locationTitle} placeholder="Location name" class="form-input" />
<label for="location-title" class="form-label">Title (optional)</label>
<input
id="location-title"
bind:value={locationTitle}
placeholder="Location name"
class="form-input"
/>
</div>
<div class="form-group">
<label class="form-label">Description (optional)</label>
<label for="location-description" class="form-label">Description (optional)</label>
<textarea
id="location-description"
bind:value={locationDescription}
placeholder="About this location"
class="form-textarea"
rows="2"
/>
></textarea>
</div>
<div class="coordinates-group">
<div class="form-group">
<label class="form-label">Latitude <span class="required">*</span></label>
<label for="location-lat" class="form-label"
>Latitude <span class="required">*</span></label
>
<input
id="location-lat"
bind:value={locationLat}
placeholder="37.7749"
type="number"
@ -418,8 +436,11 @@
/>
</div>
<div class="form-group">
<label class="form-label">Longitude <span class="required">*</span></label>
<label for="location-lng" class="form-label"
>Longitude <span class="required">*</span></label
>
<input
id="location-lng"
bind:value={locationLng}
placeholder="-122.4194"
type="number"

View file

@ -2,7 +2,9 @@
import { type NodeViewProps } from '@tiptap/core'
import { NodeViewWrapper } from 'svelte-tiptap'
import { onMount } from 'svelte'
import { mount, unmount } from 'svelte'
import type L from 'leaflet'
import MapPopup from './MapPopup.svelte'
type Props = NodeViewProps
let { node, selected }: Props = $props()
@ -46,17 +48,26 @@
const marker = leaflet.marker([latitude, longitude], { icon }).addTo(map)
// Add popup if title or description exists
let popupComponent: ReturnType<typeof mount> | null = null
if (title || description) {
const popupContent = `
<div class="map-popup">
${title ? `<h4>${title}</h4>` : ''}
${description ? `<p>${description}</p>` : ''}
</div>
`
marker.bindPopup(popupContent)
// Create a container for the Svelte component
const popupContainer = document.createElement('div')
// Mount the Svelte component
popupComponent = mount(MapPopup, {
target: popupContainer,
props: { title, description }
})
// Bind the container to the marker
marker.bindPopup(popupContainer)
}
return () => {
// Clean up the popup component
if (popupComponent) {
unmount(popupComponent)
}
map?.remove()
}
})
@ -78,20 +89,6 @@
border: none;
}
:global(.map-popup) {
h4 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
}
p {
margin: 0;
font-size: 14px;
color: #4b5563;
}
}
.geolocation-node {
margin: 16px 0;
border-radius: 8px;

View file

@ -0,0 +1,33 @@
<script lang="ts">
interface Props {
title?: string
description?: string
}
let { title, description }: Props = $props()
</script>
<div class="map-popup">
{#if title}
<h4>{title}</h4>
{/if}
{#if description}
<p>{description}</p>
{/if}
</div>
<style lang="scss">
.map-popup {
h4 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
}
p {
margin: 0;
font-size: 14px;
color: #4b5563;
}
}
</style>

View file

@ -25,7 +25,7 @@
children
}: BasePaneProps = $props()
let paneElement: HTMLDivElement
let paneElement: HTMLDivElement | undefined = $state.raw()
// Handle escape key
$effect(() => {

View file

@ -1,4 +1,4 @@
import type { TiptapNode, EditorData } from '$lib/types/editor'
import type { EditorData } from '$lib/types/editor'
// Content node types for rendering
interface ContentNode {

View file

@ -16,8 +16,8 @@
const { data, form } = $props<{ data: PageData; form?: { message?: string } }>()
let showInlineComposer = true
let showDeleteConfirmation = false
let showInlineComposer = $state(true)
let showDeleteConfirmation = $state(false)
let postToDelete: AdminPost | null = null
const actionError = form?.message ?? ''

View file

@ -56,7 +56,7 @@ import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/ad
let tags = $state<string[]>([])
let tagInput = $state('')
let showMetadata = $state(false)
let metadataButtonRef: HTMLButtonElement
let metadataButtonRef: HTMLButtonElement | undefined = $state.raw()
let showDeleteConfirmation = $state(false)
// Draft backup
@ -477,7 +477,7 @@ $effect(() => {
<header slot="header">
{#if !loading && post}
<div class="header-left">
<button class="btn-icon" onclick={() => goto('/admin/posts')}>
<button class="btn-icon" onclick={() => goto('/admin/posts')} aria-label="Back to posts">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M12.5 15L7.5 10L12.5 5"

View file

@ -21,7 +21,7 @@ import { api } from '$lib/admin/api'
let tags = $state<string[]>([])
let tagInput = $state('')
let showMetadata = $state(false)
let metadataButtonRef: HTMLButtonElement
let metadataButtonRef: HTMLButtonElement | undefined = $state.raw()
// Auto-generate slug from title when title changes and slug hasn't been manually set
$effect(() => {
@ -109,7 +109,7 @@ import { api } from '$lib/admin/api'
<AdminPage>
<header slot="header">
<div class="header-left">
<button class="btn-icon" onclick={() => goto('/admin/posts')}>
<button class="btn-icon" onclick={() => goto('/admin/posts')} aria-label="Back to posts">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M12.5 15L7.5 10L12.5 5"

View file

@ -15,7 +15,7 @@
const { data, form } = $props<{ data: PageData; form?: { message?: string } }>()
let showDeleteModal = false
let showDeleteModal = $state(false)
let projectToDelete: AdminProject | null = null
const actionError = form?.message ?? ''

View file

@ -14,6 +14,7 @@
onclick?: () => void
}
// eslint-disable-next-line svelte/valid-compile
const { primary = false, backgroundColor, size = 'medium', label, ...props }: Props = $props()
let mode = $derived(primary ? 'storybook-button--primary' : 'storybook-button--secondary')

View file

@ -1,5 +1,5 @@
// Simple test to check if project edit page loads correctly
const puppeteer = require('puppeteer')
import puppeteer from 'puppeteer'
;(async () => {
const browser = await puppeteer.launch({ headless: false })