Files
hejyou_content_creation/frontend/src/pages/GenerateIt.tsx
admin f4b082329e feat: blurhash placeholder while image loads
- 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>
2026-05-10 08:09:09 +02:00

461 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}