Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions frontend/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { PythonSetup } from './components/PythonSetup'
import { SettingsModal, type SettingsTabId } from './components/SettingsModal'
import { LogViewer } from './components/LogViewer'
import { ApiGatewayModal, type ApiGatewaySection } from './components/ApiGatewayModal'
import { ErrorBoundary } from './components/ErrorBoundary'
import { Button } from './components/ui/button'

type SetupState = 'loading' | { needsSetup: boolean; needsLicense: boolean }
Expand Down Expand Up @@ -545,13 +546,15 @@ function AppContent() {

export default function App() {
return (
<ProjectProvider>
<KeyboardShortcutsProvider>
<AppSettingsProvider>
<AppContent />
<KeyboardShortcutsModal />
</AppSettingsProvider>
</KeyboardShortcutsProvider>
</ProjectProvider>
<ErrorBoundary fallbackTitle="App error">
<ProjectProvider>
<KeyboardShortcutsProvider>
<AppSettingsProvider>
<AppContent />
<KeyboardShortcutsModal />
</AppSettingsProvider>
</KeyboardShortcutsProvider>
</ProjectProvider>
</ErrorBoundary>
)
}
86 changes: 86 additions & 0 deletions frontend/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { AlertCircle } from 'lucide-react'
import { Button } from './ui/button'

interface Props {
children: ReactNode
/** Optional label for the fallback (e.g. "Something went wrong") */
fallbackTitle?: string
}

interface State {
error: Error | null
errorInfo: ErrorInfo | null
}

/**
* Catches React render errors and shows the message (and stack) on screen
* so we're not blind when the app crashes (e.g. without DevTools).
*/
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null, errorInfo: null }

static getDerivedStateFromError(error: Error): Partial<State> {
return { error }
}

componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
this.setState({ errorInfo })
if (typeof window !== 'undefined' && window.electronAPI?.sendRendererLog) {
window.electronAPI.sendRendererLog('error', '[ErrorBoundary]', error?.message, error?.stack, errorInfo.componentStack)
}
}

render(): ReactNode {
const { error, errorInfo } = this.state
if (!error) return this.props.children

const title = this.props.fallbackTitle ?? 'Something went wrong'
const message = error.message ?? String(error)
const stack = error.stack ?? ''
const componentStack = errorInfo?.componentStack ?? ''

return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-zinc-950 p-4">
<div className="w-full max-w-2xl max-h-[90vh] overflow-auto rounded-xl border border-red-900/50 bg-zinc-900 p-5 text-zinc-100 shadow-xl">
<div className="flex items-start gap-3">
<AlertCircle className="h-6 w-6 shrink-0 text-red-400" />
<div className="min-w-0 flex-1">
<h2 className="text-lg font-semibold text-red-300">{title}</h2>
<p className="mt-2 font-mono text-sm text-zinc-300 break-words">{message}</p>
{stack && (
<pre className="mt-3 overflow-x-auto rounded bg-zinc-800 p-3 text-xs text-zinc-400 whitespace-pre-wrap break-words">
{stack}
</pre>
)}
{componentStack && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-zinc-500 hover:text-zinc-400">Component stack</summary>
<pre className="mt-1 overflow-x-auto rounded bg-zinc-800 p-2 text-xs text-zinc-500 whitespace-pre-wrap">
{componentStack}
</pre>
</details>
)}
<div className="mt-4 flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => this.setState({ error: null, errorInfo: null })}
>
Dismiss
</Button>
<Button
variant="outline"
size="sm"
onClick={() => window.location.reload()}
>
Reload app
</Button>
</div>
</div>
</div>
</div>
</div>
)
}
}
45 changes: 34 additions & 11 deletions frontend/views/Characters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,26 @@ import { LtxLogo } from '../components/LtxLogo'
import { Button } from '../components/ui/button'
import { logger } from '../lib/logger'

/** Matches API: reference_image_paths are filesystem paths; we convert to file:// for <img src> */
interface Character {
id: string
name: string
role: string
description: string
reference_images: string[]
reference_image_paths: string[]
created_at: string
}

function pathToFileUrl(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/')
return normalized.startsWith('/') ? `file://${normalized}` : `file:///${normalized}`
}

function safeImagePaths(raw: unknown): string[] {
if (!Array.isArray(raw)) return []
return raw.filter((x): x is string => typeof x === 'string' && x.length > 0)
}

export function Characters() {
const { goHome } = useProjects()
const [characters, setCharacters] = useState<Character[]>([])
Expand All @@ -34,8 +45,20 @@ export function Characters() {
const backendUrl = await window.electronAPI.getBackendUrl()
const res = await fetch(`${backendUrl}/api/library/characters`)
if (!res.ok) throw new Error(`Failed to fetch characters: ${res.status}`)
const data = (await res.json()) as { characters: Character[] }
setCharacters(data.characters ?? [])
const data = (await res.json()) as { characters: unknown[] }
setCharacters(
(data.characters ?? []).map((c: unknown) => {
const row = c as Record<string, unknown>
return {
id: String(row.id ?? ''),
name: String(row.name ?? ''),
role: String(row.role ?? ''),
description: String(row.description ?? ''),
reference_image_paths: safeImagePaths(row.reference_image_paths ?? row.reference_images ?? []),
created_at: String(row.created_at ?? ''),
}
})
)
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to load characters'
logger.error(msg)
Expand Down Expand Up @@ -63,7 +86,7 @@ export function Characters() {
setFormName(char.name)
setFormRole(char.role)
setFormDescription(char.description)
setFormImages([...char.reference_images])
setFormImages([...char.reference_image_paths])
setIsModalOpen(true)
}

Expand All @@ -76,7 +99,7 @@ export function Characters() {
name: formName.trim(),
role: formRole.trim(),
description: formDescription.trim(),
reference_images: formImages,
reference_image_paths: formImages,
}
if (editingCharacter) {
const res = await fetch(`${backendUrl}/api/library/characters/${editingCharacter.id}`, {
Expand Down Expand Up @@ -182,14 +205,14 @@ export function Characters() {
key={char.id}
className="group bg-zinc-900 rounded-lg border border-zinc-800 hover:border-zinc-600 transition-all overflow-hidden"
>
{/* Reference images */}
{/* Reference images (paths → file:// for renderer) */}
<div className="aspect-video bg-zinc-800 flex items-center justify-center overflow-hidden">
{char.reference_images.length > 0 ? (
{char.reference_image_paths.length > 0 ? (
<div className="grid grid-cols-2 w-full h-full">
{char.reference_images.slice(0, 4).map((img, i) => (
{char.reference_image_paths.slice(0, 4).map((path, i) => (
<img
key={i}
src={img}
src={pathToFileUrl(path)}
alt={`${char.name} ref ${i + 1}`}
className="w-full h-full object-cover"
/>
Expand Down Expand Up @@ -283,9 +306,9 @@ export function Characters() {
<div>
<label className="text-xs text-zinc-500 uppercase tracking-wider font-semibold mb-1.5 block">Reference Images</label>
<div className="flex flex-wrap gap-2 mb-2">
{formImages.map((img, i) => (
{formImages.map((path, i) => (
<div key={i} className="relative w-16 h-16 rounded-lg overflow-hidden border border-zinc-700">
<img src={img} alt="" className="w-full h-full object-cover" />
<img src={pathToFileUrl(path)} alt="" className="w-full h-full object-cover" />
<button
onClick={() => setFormImages(prev => prev.filter((_, idx) => idx !== i))}
className="absolute top-0.5 right-0.5 bg-black/70 rounded p-0.5"
Expand Down
42 changes: 32 additions & 10 deletions frontend/views/References.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,21 @@ import { logger } from '../lib/logger'

type Category = 'all' | 'people' | 'places' | 'props' | 'other'

/** API uses image_path (filesystem path); we convert to file:// for <img src>. */
interface Reference {
id: string
name: string
category: Exclude<Category, 'all'>
image_url: string
image_path: string
created_at: string
}

function pathToFileUrl(filePath: string): string {
if (!filePath || typeof filePath !== 'string') return ''
const normalized = filePath.replace(/\\/g, '/')
return normalized.startsWith('/') ? `file://${normalized}` : `file:///${normalized}`
}

export function References() {
const { goHome } = useProjects()
const [references, setReferences] = useState<Reference[]>([])
Expand All @@ -35,8 +42,19 @@ export function References() {
const query = category !== 'all' ? `?category=${category}` : ''
const res = await fetch(`${backendUrl}/api/library/references${query}`)
if (!res.ok) throw new Error(`Failed to fetch references: ${res.status}`)
const data = (await res.json()) as { references: Reference[] }
setReferences(data.references ?? [])
const data = (await res.json()) as { references: unknown[] }
setReferences(
(data.references ?? []).map((r: unknown) => {
const row = r as Record<string, unknown>
return {
id: String(row.id ?? ''),
name: String(row.name ?? ''),
category: (typeof row.category === 'string' ? row.category : 'other') as Exclude<Category, 'all'>,
image_path: String(row.image_path ?? row.image_url ?? ''),
created_at: String(row.created_at ?? ''),
}
})
)
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to load references'
logger.error(msg)
Expand Down Expand Up @@ -68,7 +86,7 @@ export function References() {
body: JSON.stringify({
name: formName.trim(),
category: formCategory,
image_url: formImage,
image_path: formImage,
}),
})
if (!res.ok) throw new Error(`Create failed: ${res.status}`)
Expand Down Expand Up @@ -195,11 +213,15 @@ export function References() {
className="group relative bg-zinc-900 rounded-lg border border-zinc-800 hover:border-zinc-600 transition-all overflow-hidden"
>
<div className="aspect-square bg-zinc-800 flex items-center justify-center overflow-hidden">
<img
src={ref.image_url}
alt={ref.name}
className="w-full h-full object-cover"
/>
{ref.image_path ? (
<img
src={pathToFileUrl(ref.image_path)}
alt={ref.name}
className="w-full h-full object-cover"
/>
) : (
<ImageIcon className="h-12 w-12 text-zinc-600" />
)}
</div>

<div className="p-2.5">
Expand Down Expand Up @@ -268,7 +290,7 @@ export function References() {
<label className="text-xs text-zinc-500 uppercase tracking-wider font-semibold mb-1.5 block">Image</label>
{formImage ? (
<div className="relative w-full aspect-video rounded-lg overflow-hidden border border-zinc-700 mb-2">
<img src={formImage} alt="" className="w-full h-full object-cover" />
<img src={pathToFileUrl(formImage)} alt="" className="w-full h-full object-cover" />
<button
onClick={() => setFormImage('')}
className="absolute top-2 right-2 bg-black/70 rounded p-1"
Expand Down
34 changes: 26 additions & 8 deletions frontend/views/Styles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,21 @@ import { LtxLogo } from '../components/LtxLogo'
import { Button } from '../components/ui/button'
import { logger } from '../lib/logger'

/** API uses reference_image_path (filesystem path); we convert to file:// for <img src>. */
interface Style {
id: string
name: string
description: string
reference_image?: string
reference_image_path: string
created_at: string
}

function pathToFileUrl(filePath: string): string {
if (!filePath || typeof filePath !== 'string') return ''
const normalized = filePath.replace(/\\/g, '/')
return normalized.startsWith('/') ? `file://${normalized}` : `file:///${normalized}`
}

export function Styles() {
const { goHome } = useProjects()
const [styles, setStyles] = useState<Style[]>([])
Expand All @@ -32,8 +39,19 @@ export function Styles() {
const backendUrl = await window.electronAPI.getBackendUrl()
const res = await fetch(`${backendUrl}/api/library/styles`)
if (!res.ok) throw new Error(`Failed to fetch styles: ${res.status}`)
const data = (await res.json()) as { styles: Style[] }
setStyles(data.styles ?? [])
const data = (await res.json()) as { styles: unknown[] }
setStyles(
(data.styles ?? []).map((s: unknown) => {
const row = s as Record<string, unknown>
return {
id: String(row.id ?? ''),
name: String(row.name ?? ''),
description: String(row.description ?? ''),
reference_image_path: String(row.reference_image_path ?? row.reference_image ?? ''),
created_at: String(row.created_at ?? ''),
}
})
)
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to load styles'
logger.error(msg)
Expand All @@ -59,7 +77,7 @@ export function Styles() {
setEditingStyle(style)
setFormName(style.name)
setFormDescription(style.description)
setFormImage(style.reference_image ?? '')
setFormImage(style.reference_image_path ?? '')
setIsModalOpen(true)
}

Expand All @@ -71,7 +89,7 @@ export function Styles() {
const body = {
name: formName.trim(),
description: formDescription.trim(),
reference_image: formImage || undefined,
reference_image_path: formImage || '',
}
if (editingStyle) {
const res = await fetch(`${backendUrl}/api/library/styles/${editingStyle.id}`, {
Expand Down Expand Up @@ -178,9 +196,9 @@ export function Styles() {
className="group bg-zinc-900 rounded-lg border border-zinc-800 hover:border-zinc-600 transition-all overflow-hidden"
>
<div className="aspect-video bg-zinc-800 flex items-center justify-center overflow-hidden">
{style.reference_image ? (
{style.reference_image_path ? (
<img
src={style.reference_image}
src={pathToFileUrl(style.reference_image_path)}
alt={style.name}
className="w-full h-full object-cover"
/>
Expand Down Expand Up @@ -257,7 +275,7 @@ export function Styles() {
<label className="text-xs text-zinc-500 uppercase tracking-wider font-semibold mb-1.5 block">Reference Image</label>
{formImage ? (
<div className="relative w-full aspect-video rounded-lg overflow-hidden border border-zinc-700 mb-2">
<img src={formImage} alt="" className="w-full h-full object-cover" />
<img src={pathToFileUrl(formImage)} alt="" className="w-full h-full object-cover" />
<button
onClick={() => setFormImage('')}
className="absolute top-2 right-2 bg-black/70 rounded p-1"
Expand Down