- Add BlurhashCanvas component (decodes hash → canvas pixel data) - DrawCanvas: expose onImageLoad callback prop - DrawIt + GenerateIt: show blurhash layer until real image is ready, reset imageLoaded state on picture navigation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
461 lines
17 KiB
TypeScript
461 lines
17 KiB
TypeScript
import { useState, useEffect, useRef } from 'react'
|
||
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
|
||
import BlurhashCanvas from '../components/BlurhashCanvas'
|
||
import Topbar from '../components/Topbar'
|
||
import {
|
||
getDbPictures,
|
||
getDbObjects,
|
||
getDbObjectPairs,
|
||
createDbPair,
|
||
directusAssetUrl,
|
||
} from '../api'
|
||
import { useAuth } from '../context/AuthContext'
|
||
import type { DbPicture, DbObject, DbPair, CanvasObject } from '../types'
|
||
|
||
const ChevronLeftIcon = () => (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="15 18 9 12 15 6" />
|
||
</svg>
|
||
)
|
||
const ChevronRightIcon = () => (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="9 18 15 12 9 6" />
|
||
</svg>
|
||
)
|
||
|
||
// ── PairForm ──────────────────────────────────────────────────────────────────
|
||
|
||
interface PendingWord {
|
||
titel_de: string
|
||
level: number
|
||
}
|
||
|
||
interface PairFormProps {
|
||
objectId: string
|
||
token: string
|
||
onSaved: () => void
|
||
onCancel: () => void
|
||
}
|
||
|
||
function PairForm({ objectId, token, onSaved, onCancel }: PairFormProps) {
|
||
const [level, setLevel] = useState(5)
|
||
const [questionDe, setQuestionDe] = useState('')
|
||
const [statementDe, setStatementDe] = useState('')
|
||
const [words, setWords] = useState<PendingWord[]>([])
|
||
const [wordInput, setWordInput] = useState('')
|
||
const [wordLevel, setWordLevel] = useState(50)
|
||
const [saving, setSaving] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const wordInputRef = useRef<HTMLInputElement>(null)
|
||
|
||
const addWord = () => {
|
||
const t = wordInput.trim()
|
||
if (!t || words.some(w => w.titel_de === t)) { setWordInput(''); return }
|
||
setWords(prev => [...prev, { titel_de: t, level: wordLevel }])
|
||
setWordInput('')
|
||
setWordLevel(50)
|
||
wordInputRef.current?.focus()
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
if (!statementDe.trim()) { setError('Aussage (statement_de) ist Pflicht.'); return }
|
||
setSaving(true)
|
||
setError('')
|
||
try {
|
||
await createDbPair(objectId, {
|
||
question_de: questionDe.trim() || undefined,
|
||
statement_de: statementDe.trim(),
|
||
level,
|
||
words,
|
||
}, token)
|
||
onSaved()
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '10px 0' }}>
|
||
{/* Level slider */}
|
||
<div>
|
||
<label style={{ fontSize: 11, color: 'var(--text-2)', display: 'block', marginBottom: 4 }}>
|
||
Level: <strong>{level}</strong>
|
||
</label>
|
||
<input
|
||
type="range" min={1} max={10} value={level}
|
||
onChange={e => setLevel(Number(e.target.value))}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
|
||
{/* Question (optional) */}
|
||
<div>
|
||
<label style={{ fontSize: 11, color: 'var(--text-2)', display: 'block', marginBottom: 4 }}>
|
||
Frage (optional)
|
||
</label>
|
||
<textarea
|
||
value={questionDe}
|
||
onChange={e => setQuestionDe(e.target.value)}
|
||
rows={2}
|
||
placeholder="question_de…"
|
||
style={{
|
||
width: '100%', resize: 'vertical', padding: '6px 8px',
|
||
borderRadius: 'var(--r-sm)', border: '1px solid var(--border)',
|
||
background: 'var(--surface-2)', color: 'var(--text-1)',
|
||
fontFamily: 'var(--font)', fontSize: 12, boxSizing: 'border-box',
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Statement (required) */}
|
||
<div>
|
||
<label style={{ fontSize: 11, color: 'var(--text-2)', display: 'block', marginBottom: 4 }}>
|
||
Aussage <span style={{ color: 'var(--danger)' }}>*</span>
|
||
</label>
|
||
<textarea
|
||
value={statementDe}
|
||
onChange={e => setStatementDe(e.target.value)}
|
||
rows={2}
|
||
placeholder="statement_de…"
|
||
style={{
|
||
width: '100%', resize: 'vertical', padding: '6px 8px',
|
||
borderRadius: 'var(--r-sm)', border: `1px solid ${statementDe.trim() ? 'var(--border)' : 'var(--danger)'}`,
|
||
background: 'var(--surface-2)', color: 'var(--text-1)',
|
||
fontFamily: 'var(--font)', fontSize: 12, boxSizing: 'border-box',
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Words input */}
|
||
<div>
|
||
<label style={{ fontSize: 11, color: 'var(--text-2)', display: 'block', marginBottom: 4 }}>
|
||
Wörter (werden an Frage + Aussage verknüpft)
|
||
</label>
|
||
<div style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||
<input
|
||
ref={wordInputRef}
|
||
value={wordInput}
|
||
onChange={e => setWordInput(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') addWord() }}
|
||
placeholder="titel_de…"
|
||
style={{
|
||
flex: 1, padding: '4px 8px', borderRadius: 'var(--r-sm)',
|
||
border: '1px solid var(--border)', background: 'var(--surface-2)',
|
||
color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12,
|
||
}}
|
||
/>
|
||
<input
|
||
type="number" min={1} max={100} value={wordLevel}
|
||
onChange={e => setWordLevel(Math.min(100, Math.max(1, Number(e.target.value))))}
|
||
style={{
|
||
width: 50, padding: '4px 4px', borderRadius: 'var(--r-sm)',
|
||
border: '1px solid var(--border)', background: 'var(--surface-2)',
|
||
color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12, textAlign: 'center',
|
||
}}
|
||
/>
|
||
<button className="btn-ghost btn-sm" onClick={addWord}>+</button>
|
||
</div>
|
||
{/* Word chips */}
|
||
{words.length > 0 && (
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||
{words.map((w, i) => (
|
||
<span key={i} style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||
padding: '2px 6px', borderRadius: 'var(--r-full)',
|
||
background: 'var(--primary-muted)', border: '1px solid color-mix(in srgb, var(--primary) 30%, transparent)',
|
||
fontSize: 11, color: 'var(--primary)',
|
||
}}>
|
||
{w.titel_de} <span style={{ color: 'var(--text-2)', fontSize: 10 }}>L{w.level}</span>
|
||
<button
|
||
onClick={() => setWords(prev => prev.filter((_, j) => j !== i))}
|
||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--primary)', padding: 0, lineHeight: 1, fontSize: 12 }}
|
||
>×</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{error && <div style={{ fontSize: 11, color: 'var(--danger)' }}>{error}</div>}
|
||
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
<button
|
||
className="btn-primary btn-sm"
|
||
style={{ flex: 1 }}
|
||
onClick={handleSave}
|
||
disabled={saving || !statementDe.trim()}
|
||
>
|
||
{saving ? 'Speichere…' : 'Speichern'}
|
||
</button>
|
||
<button className="btn-ghost btn-sm" onClick={onCancel}>Abbrechen</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── PairsList ─────────────────────────────────────────────────────────────────
|
||
|
||
interface PairsListProps {
|
||
pairs: DbPair[]
|
||
loading: boolean
|
||
objectId: string | null
|
||
token: string
|
||
onRefresh: () => void
|
||
}
|
||
|
||
function PairsList({ pairs, loading, objectId, token, onRefresh }: PairsListProps) {
|
||
const [showForm, setShowForm] = useState(false)
|
||
|
||
const handleSaved = () => {
|
||
setShowForm(false)
|
||
onRefresh()
|
||
}
|
||
|
||
if (loading) return <div className="empty-state">Lade…</div>
|
||
|
||
if (!objectId) return <div className="empty-state">Kein Objekt gewählt.</div>
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||
{pairs.length === 0 && !showForm && (
|
||
<div style={{ padding: '12px 0', textAlign: 'center' }}>
|
||
<div className="empty-state" style={{ marginBottom: 8 }}>Noch keine Pairs.</div>
|
||
<button className="btn-ghost btn-sm" onClick={() => setShowForm(true)}>+ Pair hinzufügen</button>
|
||
</div>
|
||
)}
|
||
|
||
{pairs.map(pair => (
|
||
<div key={pair.id} style={{ padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
|
||
{/* Level badge */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||
<span style={{
|
||
fontSize: 10, fontWeight: 700, color: 'var(--primary)',
|
||
background: 'var(--primary-muted)', borderRadius: 'var(--r-full)',
|
||
padding: '1px 6px', border: '1px solid color-mix(in srgb, var(--primary) 30%, transparent)',
|
||
}}>L{pair.level}</span>
|
||
<span style={{ fontSize: 10, color: 'var(--text-2)' }}>{pair.status}</span>
|
||
</div>
|
||
|
||
{/* Statements */}
|
||
{pair.statements.map(s => (
|
||
<div key={s.id} style={{ fontSize: 12, color: 'var(--text-1)', marginBottom: 3, paddingLeft: 4 }}>
|
||
<span style={{ fontSize: 10, color: 'var(--text-2)', marginRight: 4 }}>Aussage:</span>
|
||
{s.statement_de}
|
||
</div>
|
||
))}
|
||
|
||
{/* Questions */}
|
||
{pair.questions.map(q => (
|
||
<div key={q.id} style={{ fontSize: 12, color: 'var(--text-2)', fontStyle: 'italic', paddingLeft: 4 }}>
|
||
<span style={{ fontSize: 10, marginRight: 4, fontStyle: 'normal' }}>Frage:</span>
|
||
{q.question_de}
|
||
</div>
|
||
))}
|
||
</div>
|
||
))}
|
||
|
||
{!showForm && pairs.length > 0 && (
|
||
<div style={{ paddingTop: 8 }}>
|
||
<button className="btn-ghost btn-sm btn-block" onClick={() => setShowForm(true)}>+ Pair hinzufügen</button>
|
||
</div>
|
||
)}
|
||
|
||
{showForm && objectId && (
|
||
<div style={{ paddingTop: 8 }}>
|
||
<PairForm
|
||
objectId={objectId}
|
||
token={token}
|
||
onSaved={handleSaved}
|
||
onCancel={() => setShowForm(false)}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── GenerateIt ────────────────────────────────────────────────────────────────
|
||
|
||
export default function GenerateIt() {
|
||
const { token } = useAuth()
|
||
const canvasRef = useRef<DrawCanvasHandle>(null)
|
||
|
||
const [pictureList, setPictureList] = useState<DbPicture[]>([])
|
||
const [currentIndex, setCurrentIndex] = useState(-1)
|
||
const [dbObjects, setDbObjects] = useState<DbObject[]>([])
|
||
const [selectedObjId, setSelectedObjId] = useState<string | null>(null)
|
||
const [imageLoaded, setImageLoaded] = useState(false)
|
||
const [pairs, setPairs] = useState<DbPair[]>([])
|
||
const [pairsLoading, setPairsLoading] = useState(false)
|
||
|
||
const currentPicture: DbPicture | null =
|
||
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
|
||
|
||
const canvasObjects: CanvasObject[] = dbObjects.map((obj, i) => ({
|
||
id: obj.id,
|
||
visible: true,
|
||
selections: obj.selections,
|
||
index: i + 1,
|
||
hierarchy: 1,
|
||
}))
|
||
|
||
// Load db_pictures with status=objects_created
|
||
useEffect(() => {
|
||
if (!token) return
|
||
getDbPictures(token, 'objects_created')
|
||
.then(pics => { setPictureList(pics); setCurrentIndex(pics.length > 0 ? 0 : -1) })
|
||
.catch(console.error)
|
||
}, [token])
|
||
|
||
// Load db_objects when picture changes
|
||
useEffect(() => {
|
||
if (!currentPicture || !token) {
|
||
setDbObjects([]); setSelectedObjId(null); setPairs([]); setImageLoaded(false)
|
||
return
|
||
}
|
||
getDbObjects(currentPicture.id, token)
|
||
.then(objs => {
|
||
setDbObjects(objs)
|
||
setSelectedObjId(objs.length > 0 ? objs[0].id : null)
|
||
})
|
||
.catch(console.error)
|
||
}, [currentPicture?.id, token])
|
||
|
||
// Load pairs when selected object changes
|
||
useEffect(() => {
|
||
if (!selectedObjId || !token) { setPairs([]); return }
|
||
setPairsLoading(true)
|
||
getDbObjectPairs(selectedObjId, token)
|
||
.then(setPairs)
|
||
.catch(console.error)
|
||
.finally(() => setPairsLoading(false))
|
||
}, [selectedObjId, token])
|
||
|
||
const refreshPairs = () => {
|
||
if (!selectedObjId || !token) return
|
||
setPairsLoading(true)
|
||
getDbObjectPairs(selectedObjId, token)
|
||
.then(setPairs)
|
||
.catch(console.error)
|
||
.finally(() => setPairsLoading(false))
|
||
}
|
||
|
||
const imageNav = (
|
||
<div className="image-nav">
|
||
<button className="btn-icon" onClick={() => setCurrentIndex(i => i - 1)} disabled={currentIndex <= 0}>
|
||
<ChevronLeftIcon />
|
||
</button>
|
||
<span className="image-counter">
|
||
{pictureList.length > 0 ? (
|
||
<>
|
||
<span className="image-counter-num">{currentIndex + 1}</span>
|
||
<span className="image-counter-sep">/</span>
|
||
<span className="image-counter-total">{pictureList.length}</span>
|
||
</>
|
||
) : (
|
||
<span className="image-counter-empty">Keine Bilder</span>
|
||
)}
|
||
</span>
|
||
<button className="btn-icon" onClick={() => setCurrentIndex(i => i + 1)} disabled={currentIndex >= pictureList.length - 1}>
|
||
<ChevronRightIcon />
|
||
</button>
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<div className="app-shell">
|
||
<Topbar page="generate" center={imageNav} />
|
||
|
||
<div className="workspace">
|
||
{/* Left: Objects */}
|
||
<aside className="sidebar">
|
||
<div className="sidebar-panel" style={{ flex: 1 }}>
|
||
<h3 className="sidebar-heading">
|
||
Objekte
|
||
{dbObjects.length > 0 && <span className="badge">{dbObjects.length}</span>}
|
||
</h3>
|
||
{dbObjects.length === 0 ? (
|
||
<div className="empty-state">Keine Objekte.</div>
|
||
) : (
|
||
<div className="objects-list">
|
||
{dbObjects.map((obj, i) => (
|
||
<div
|
||
key={obj.id}
|
||
className={`object-item${selectedObjId === obj.id ? ' selected' : ''}`}
|
||
onClick={() => setSelectedObjId(obj.id)}
|
||
>
|
||
<div className="object-item-header">
|
||
<div className="object-item-text">
|
||
<strong>Objekt {i + 1}</strong>
|
||
<span>{obj.selections?.length ? `${obj.selections.length} Auswahl(en)` : '–'}</span>
|
||
</div>
|
||
</div>
|
||
{obj.user_notes && (
|
||
<div style={{ padding: '4px 8px 6px 12px', fontSize: 11.5, color: 'var(--text-2)', lineHeight: 1.4, borderTop: '1px solid var(--border)' }}>
|
||
{obj.user_notes}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
|
||
{/* Center: Canvas */}
|
||
<main className="canvas-area">
|
||
<div className="canvas-frame" style={{ position: 'relative', background: 'var(--surface-2)' }}>
|
||
{/* Blurhash-Platzhalter: sichtbar solange das echte Bild noch lädt */}
|
||
{currentPicture?.blurhash && !imageLoaded && (
|
||
<BlurhashCanvas
|
||
hash={currentPicture.blurhash}
|
||
width={32}
|
||
height={32}
|
||
style={{ zIndex: 1 }}
|
||
/>
|
||
)}
|
||
<DrawCanvas
|
||
ref={canvasRef}
|
||
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.picture, token) : null}
|
||
objects={canvasObjects}
|
||
selectedObjectId={selectedObjId}
|
||
mode="rect"
|
||
onHasSelection={() => {}}
|
||
onImageLoad={() => setImageLoaded(true)}
|
||
readOnly
|
||
/>
|
||
</div>
|
||
</main>
|
||
|
||
{/* Right: QA Pairs */}
|
||
<aside className="sidebar sidebar--right" style={{ width: 300, minWidth: 240 }}>
|
||
<div className="sidebar-panel" style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||
<h3 className="sidebar-heading" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
QA Pairs
|
||
{pairs.length > 0 && <span className="badge">{pairs.length}</span>}
|
||
{selectedObjId && (
|
||
<button
|
||
onClick={refreshPairs}
|
||
style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--muted)', padding: '0 2px' }}
|
||
title="Neu laden"
|
||
>↺</button>
|
||
)}
|
||
</h3>
|
||
<div style={{ overflowY: 'auto', flex: 1 }}>
|
||
<PairsList
|
||
pairs={pairs}
|
||
loading={pairsLoading}
|
||
objectId={selectedObjId}
|
||
token={token ?? ''}
|
||
onRefresh={refreshPairs}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|