refactor: migrate to new db_* Directus collections
- DrawIt: load db_pictures (status=draft), create db_objects/db_words with blurhash placeholder, finish sets status=objects_created - GenerateIt: load db_pictures (status=objects_created), right panel replaced with manual QA pairs (db_pairs + db_question + db_statement) - Backend: new routes for db_pictures, db_objects, db_words, db_pairs - Types/API: full db_* type definitions and API helpers - Directus: user_notes field in db_objects, M2M db_words<->db_pictures Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,149 +1,297 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
|
||||
import GenerateObjectsList from '../components/GenerateObjectsList'
|
||||
|
||||
import Topbar from '../components/Topbar'
|
||||
import {
|
||||
getDirectusPictures,
|
||||
getDbPictures,
|
||||
getDbObjects,
|
||||
getDbObjectPairs,
|
||||
createDbPair,
|
||||
directusAssetUrl,
|
||||
type DirectusPicture,
|
||||
getDirectusObjects,
|
||||
generateQuestions,
|
||||
publishQuestions,
|
||||
getObjectQuestions,
|
||||
getObjectWords,
|
||||
deleteQuestion,
|
||||
deleteWord,
|
||||
purgeOrphans,
|
||||
purgeAllOrphans,
|
||||
type GenerateStats,
|
||||
type ObjectQuestion,
|
||||
type ObjectWord,
|
||||
} from '../api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import type { DirectusObject, CanvasObject } from '../types'
|
||||
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>
|
||||
)
|
||||
|
||||
const GenerateIcon = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
||||
</svg>
|
||||
)
|
||||
// ── PairForm ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const PublishIcon = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 19V5M5 12l7-7 7 7" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SaveIcon = () => (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||
<polyline points="17 21 17 13 7 13 7 21" />
|
||||
<polyline points="7 3 7 8 15 8" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ── Prompt layout system ──────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_PROMPT = `Du bist ein erfahrener Sprachlernexperte. Du erhältst die Beschreibung eines Objekts aus einem Bild (Titel, Position, Zustand, Aktion) sowie ggf. dessen Elternobjekt als Kontext. Deine Aufgabe: Erstelle ausschließlich für das genannte Objekt (nicht für das Elternobjekt) Sprachlernfragen auf 10 Niveaustufen (1–10):
|
||||
|
||||
* Stufe 1–2 (Anfänger): Einfachste Erkennungs- oder Ja/Nein-Fragen, z.B. „Kannst du den Hund sehen?"
|
||||
* Stufe 3–5 (Grundstufe): Beschreibende Fragen zu Farbe, Form, Position
|
||||
* Stufe 6–8 (Mittelstufe): Fragen zu Funktion, Vergleich oder Kontext
|
||||
* Stufe 9–10 (Fortgeschritten): Sprachlich anspruchsvolle, kreative oder erklärende Fragen – die Komplexität liegt in Grammatik, Wortschatz und Satzbau, nicht im abstrakten Denken
|
||||
|
||||
Regeln:
|
||||
|
||||
* Jede Frage muss sich direkt auf das Objekt beziehen
|
||||
* \`words\`: Enthält alle einzigartigen Tokens aus Frage UND Antwort zusammen (Satzzeichen ausgenommen, keine Duplikate)
|
||||
* \`short_answer\`: Ein einzelnes treffendes Wort als Kurzantwort (z.B. „Ja", „schwarz", „wendig")
|
||||
* \`distractor_words\`: Genau 5 Wörter, die thematisch passen, aber NICHT in Frage oder Antwort vorkommen und NICHT die Antwort sind
|
||||
* Gib ausschließlich valides JSON aus – kein Text, kein Markdown
|
||||
|
||||
Ausgabeformat:
|
||||
{
|
||||
"levels": [
|
||||
{
|
||||
"level": 1,
|
||||
"question": "Kannst du den Hund sehen?",
|
||||
"answer": "Ja, ich kann den Hund sehen.",
|
||||
"short_answer": "Ja",
|
||||
"words": ["Kannst", "du", "den", "Hund", "sehen", "Ja", "ich", "kann"],
|
||||
"distractor_words": ["Nein", "vielleicht", "Katze", "hören", "groß"]
|
||||
}
|
||||
]
|
||||
interface PendingWord {
|
||||
titel_de: string
|
||||
level: number
|
||||
}
|
||||
|
||||
Informationen: {user-notes_object}
|
||||
Elternobjekt: {user-notes_parentobject}`
|
||||
|
||||
interface PromptLayout {
|
||||
name: string
|
||||
prompt: string
|
||||
interface PairFormProps {
|
||||
objectId: string
|
||||
token: string
|
||||
onSaved: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'cm_prompt_layouts'
|
||||
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)
|
||||
|
||||
function loadLayouts(): PromptLayout[] {
|
||||
const defaultLayout: PromptLayout = { name: 'Standard', prompt: DEFAULT_PROMPT }
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (!stored) return [defaultLayout]
|
||||
const parsed = JSON.parse(stored) as PromptLayout[]
|
||||
const hasDefault = parsed.some(l => l.name === 'Standard')
|
||||
return hasDefault ? parsed : [defaultLayout, ...parsed]
|
||||
} catch {
|
||||
return [defaultLayout]
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function persistLayouts(layouts: PromptLayout[]): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(layouts))
|
||||
// ── 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)
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
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<DirectusPicture[]>([])
|
||||
const [pictureList, setPictureList] = useState<DbPicture[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||
const [directusObjects, setDirectusObjects] = useState<DirectusObject[]>([])
|
||||
const [dbObjects, setDbObjects] = useState<DbObject[]>([])
|
||||
const [selectedObjId, setSelectedObjId] = useState<string | null>(null)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [isPublishing, setIsPublishing] = useState(false)
|
||||
const [generateResult, setGenerateResult] = useState<GenerateStats | null>(null)
|
||||
const [generateError, setGenerateError] = useState<string | null>(null)
|
||||
const [publishResult, setPublishResult] = useState<{ q: number; w: number } | null>(null)
|
||||
const [questions, setQuestions] = useState<ObjectQuestion[]>([])
|
||||
const [objWords, setObjWords] = useState<ObjectWord[]>([])
|
||||
const [pairs, setPairs] = useState<DbPair[]>([])
|
||||
const [pairsLoading, setPairsLoading] = useState(false)
|
||||
|
||||
// Prompt layouts
|
||||
const [layouts, setLayouts] = useState<PromptLayout[]>(loadLayouts)
|
||||
const [selectedLayoutName, setSelectedLayoutName] = useState('Standard')
|
||||
const [promptText, setPromptText] = useState(() => loadLayouts()[0]?.prompt ?? DEFAULT_PROMPT)
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||
const [newLayoutName, setNewLayoutName] = useState('')
|
||||
const [promptOpen, setPromptOpen] = useState(false)
|
||||
|
||||
const currentPicture: DirectusPicture | null =
|
||||
const currentPicture: DbPicture | null =
|
||||
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
|
||||
|
||||
const canvasObjects: CanvasObject[] = directusObjects.map((obj, i) => ({
|
||||
const canvasObjects: CanvasObject[] = dbObjects.map((obj, i) => ({
|
||||
id: obj.id,
|
||||
visible: true,
|
||||
selections: obj.selections,
|
||||
@@ -151,219 +299,65 @@ export default function GenerateIt() {
|
||||
hierarchy: 1,
|
||||
}))
|
||||
|
||||
// Load db_pictures with status=objects_created
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
getDirectusPictures(token, 'drawing_created')
|
||||
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) {
|
||||
setDirectusObjects([]); setSelectedObjId(null)
|
||||
setGenerateResult(null); setPublishResult(null); setGenerateError(null)
|
||||
setQuestions([]); setObjWords([])
|
||||
setDbObjects([]); setSelectedObjId(null); setPairs([])
|
||||
return
|
||||
}
|
||||
getDirectusObjects(currentPicture.id, token)
|
||||
getDbObjects(currentPicture.id, token)
|
||||
.then(objs => {
|
||||
setDirectusObjects(objs)
|
||||
if (objs.length > 0) setSelectedObjId(objs[0].id)
|
||||
else setSelectedObjId(null)
|
||||
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) { setQuestions([]); setObjWords([]); return }
|
||||
getObjectQuestions(selectedObjId, token).then(setQuestions).catch(console.error)
|
||||
getObjectWords(selectedObjId, token).then(setObjWords).catch(console.error)
|
||||
if (!selectedObjId || !token) { setPairs([]); return }
|
||||
setPairsLoading(true)
|
||||
getDbObjectPairs(selectedObjId, token)
|
||||
.then(setPairs)
|
||||
.catch(console.error)
|
||||
.finally(() => setPairsLoading(false))
|
||||
}, [selectedObjId, token])
|
||||
|
||||
const reloadQW = async (objId: string) => {
|
||||
if (!token) return
|
||||
await purgeOrphans(objId, token).catch(console.error)
|
||||
const [qs, ws] = await Promise.all([
|
||||
getObjectQuestions(objId, token).catch(() => [] as ObjectQuestion[]),
|
||||
getObjectWords(objId, token).catch(() => [] as ObjectWord[]),
|
||||
])
|
||||
setQuestions(qs)
|
||||
setObjWords(ws)
|
||||
}
|
||||
|
||||
const handleDeleteQuestion = async (qId: string) => {
|
||||
if (!token) return
|
||||
await deleteQuestion(qId, token)
|
||||
setQuestions(qs => qs.filter(q => q.id !== qId))
|
||||
}
|
||||
|
||||
const handleDeleteWord = async (wId: string) => {
|
||||
if (!token) return
|
||||
await deleteWord(wId, token)
|
||||
setObjWords(ws => ws.filter(w => w.id !== wId))
|
||||
}
|
||||
|
||||
const handleGenerate = async () => {
|
||||
const refreshPairs = () => {
|
||||
if (!selectedObjId || !token) return
|
||||
setIsGenerating(true)
|
||||
setGenerateResult(null)
|
||||
setGenerateError(null)
|
||||
setPublishResult(null)
|
||||
try {
|
||||
const res = await generateQuestions(selectedObjId, promptText, token)
|
||||
setGenerateResult(res.stats)
|
||||
reloadQW(selectedObjId)
|
||||
} catch (e) {
|
||||
setGenerateError(e instanceof Error ? e.message : 'Fehler beim Generieren')
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!selectedObjId || !token) return
|
||||
setIsPublishing(true)
|
||||
setPublishResult(null)
|
||||
try {
|
||||
const res = await publishQuestions(selectedObjId, token)
|
||||
setPublishResult({ q: res.published_questions, w: res.published_words })
|
||||
} catch (e) {
|
||||
setGenerateError(e instanceof Error ? e.message : 'Fehler beim Veröffentlichen')
|
||||
} finally {
|
||||
setIsPublishing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Layout handlers
|
||||
const handleSelectLayout = (name: string) => {
|
||||
const layout = layouts.find(l => l.name === name)
|
||||
if (layout) { setSelectedLayoutName(name); setPromptText(layout.prompt) }
|
||||
}
|
||||
|
||||
const handleSaveLayout = () => {
|
||||
const trimmed = newLayoutName.trim()
|
||||
if (!trimmed) return
|
||||
const newLayout: PromptLayout = { name: trimmed, prompt: promptText }
|
||||
const updated = [...layouts.filter(l => l.name !== trimmed), newLayout]
|
||||
setLayouts(updated)
|
||||
persistLayouts(updated)
|
||||
setSelectedLayoutName(trimmed)
|
||||
setNewLayoutName('')
|
||||
setShowSaveDialog(false)
|
||||
}
|
||||
|
||||
const handleDeleteLayout = (name: string) => {
|
||||
if (name === 'Standard') return
|
||||
const updated = layouts.filter(l => l.name !== name)
|
||||
setLayouts(updated)
|
||||
persistLayouts(updated)
|
||||
if (selectedLayoutName === name) {
|
||||
const first = updated[0]
|
||||
setSelectedLayoutName(first.name)
|
||||
setPromptText(first.prompt)
|
||||
}
|
||||
setPairsLoading(true)
|
||||
getDbObjectPairs(selectedObjId, token)
|
||||
.then(setPairs)
|
||||
.catch(console.error)
|
||||
.finally(() => setPairsLoading(false))
|
||||
}
|
||||
|
||||
const imageNav = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<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>
|
||||
|
||||
<div style={{ width: 1, height: 24, background: 'var(--border)' }} />
|
||||
|
||||
<button
|
||||
className="btn-ghost btn-sm"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || isPublishing || !selectedObjId}
|
||||
style={isGenerating ? { opacity: 0.7 } : undefined}
|
||||
>
|
||||
<GenerateIcon />
|
||||
{isGenerating ? 'Generiere…' : 'Generate it'}
|
||||
<div className="image-nav">
|
||||
<button className="btn-icon" onClick={() => setCurrentIndex(i => i - 1)} disabled={currentIndex <= 0}>
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost btn-sm"
|
||||
onClick={handlePublish}
|
||||
disabled={isPublishing || isGenerating || !selectedObjId || !generateResult}
|
||||
title={!generateResult ? 'Erst Generate it ausführen' : undefined}
|
||||
>
|
||||
<PublishIcon />
|
||||
{isPublishing ? 'Veröffentliche…' : 'Publish it'}
|
||||
</button>
|
||||
|
||||
{generateResult && !isGenerating && (
|
||||
<span style={{ fontSize: 11, color: 'var(--success)', background: 'var(--success-bg)', border: '1px solid var(--success)', borderRadius: 'var(--r-full)', padding: '2px 8px' }}>
|
||||
✓ {generateResult.questions_created}F +{generateResult.words_created}W neu
|
||||
</span>
|
||||
)}
|
||||
{publishResult && (
|
||||
<span style={{ fontSize: 11, color: 'var(--primary-muted-fg)', background: 'var(--primary-muted)', border: '1px solid var(--primary)', borderRadius: 'var(--r-full)', padding: '2px 8px' }}>
|
||||
↑ {publishResult.q}F {publishResult.w}W
|
||||
</span>
|
||||
)}
|
||||
{generateError && (
|
||||
<span style={{ fontSize: 11, color: 'var(--danger)', background: 'var(--danger-bg)', border: '1px solid var(--danger)', borderRadius: 'var(--r-full)', padding: '2px 8px', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{generateError}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div style={{ width: 1, height: 24, background: 'var(--border)' }} />
|
||||
|
||||
<button
|
||||
className="btn-ghost btn-sm"
|
||||
title="Directus-Schema einmalig konfigurieren (M2M-Relationen)"
|
||||
onClick={async () => {
|
||||
if (!token) return
|
||||
const h = { Authorization: `Bearer ${token}` }
|
||||
const [r1, r2] = await Promise.all([
|
||||
fetch('/api/setup-schema', { method: 'POST', headers: h }),
|
||||
fetch('/api/fix-distractor-field', { method: 'POST', headers: h }),
|
||||
])
|
||||
const [d1, d2] = await Promise.all([r1.json(), r2.json()])
|
||||
alert(`Setup: ${d1.total} Schritte, ${d1.failed} Fehler\nDistractor-Fix: ${d2.ok ? 'OK' : 'Fehler'}`)
|
||||
console.log('[setup-schema]', d1, '[fix-distractor]', d2)
|
||||
}}
|
||||
>
|
||||
⚙ Schema
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-ghost btn-sm"
|
||||
title="Alle verwaisten Junction-Einträge global bereinigen"
|
||||
onClick={async () => {
|
||||
if (!token) return
|
||||
if (!confirm('Alle verwaisten Junction-Einträge (gelöschte Fragen/Wörter) global bereinigen?')) return
|
||||
try {
|
||||
const r = await purgeAllOrphans(token)
|
||||
const det = (r as {orphans_removed: number; details?: {collection:string; junction_rows?:number; orphans_removed?:number; items_found?:number; status_breakdown?:Record<string,number>}[]}).details
|
||||
const detStr = det ? '\n\n' + det.map(d =>
|
||||
`${d.collection}: ${d.junction_rows ?? 0} Zeilen, ${d.items_found ?? 0} Items gefunden, ${d.orphans_removed ?? 0} entfernt` +
|
||||
(d.status_breakdown ? ' | Status: ' + JSON.stringify(d.status_breakdown) : '')
|
||||
).join('\n') : ''
|
||||
alert(`Bereinigt: ${r.orphans_removed} verwaiste Einträge entfernt${detStr}`)
|
||||
if (selectedObjId) await reloadQW(selectedObjId)
|
||||
} catch (e: unknown) {
|
||||
alert(`Fehler: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
🧹 Bereinigen
|
||||
<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>
|
||||
)
|
||||
@@ -378,176 +372,27 @@ export default function GenerateIt() {
|
||||
<div className="sidebar-panel" style={{ flex: 1 }}>
|
||||
<h3 className="sidebar-heading">
|
||||
Objekte
|
||||
{directusObjects.length > 0 && <span className="badge">{directusObjects.length}</span>}
|
||||
{dbObjects.length > 0 && <span className="badge">{dbObjects.length}</span>}
|
||||
</h3>
|
||||
<GenerateObjectsList
|
||||
objects={directusObjects}
|
||||
selectedId={selectedObjId}
|
||||
onSelect={id => setSelectedObjId(id)}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Center: Canvas + Prompt Bar */}
|
||||
<main className="canvas-area canvas-area--relative">
|
||||
<div className="canvas-frame">
|
||||
<DrawCanvas
|
||||
ref={canvasRef}
|
||||
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.media, token) : null}
|
||||
objects={canvasObjects}
|
||||
selectedObjectId={selectedObjId}
|
||||
mode="rect"
|
||||
onHasSelection={() => {}}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Collapsible Prompt Bar */}
|
||||
<div className={`prompt-bar${promptOpen ? ' prompt-bar--open' : ''}`}>
|
||||
<div className="prompt-bar-header" onClick={() => setPromptOpen(o => !o)}>
|
||||
<span className="prompt-bar-chevron">{promptOpen ? '▾' : '▸'}</span>
|
||||
<span className="prompt-bar-title">Prompt</span>
|
||||
<span className="prompt-bar-layout-name">{selectedLayoutName}</span>
|
||||
<div className="prompt-bar-actions" onClick={e => e.stopPropagation()}>
|
||||
<select
|
||||
className="prompt-layout-select"
|
||||
value={selectedLayoutName}
|
||||
onChange={e => handleSelectLayout(e.target.value)}
|
||||
>
|
||||
{layouts.map(l => (
|
||||
<option key={l.name} value={l.name}>{l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedLayoutName !== 'Standard' && (
|
||||
<button
|
||||
className="btn-ghost btn-sm btn-danger"
|
||||
onClick={() => handleDeleteLayout(selectedLayoutName)}
|
||||
title="Layout löschen"
|
||||
>✕</button>
|
||||
)}
|
||||
<button
|
||||
className="btn-ghost btn-sm"
|
||||
onClick={() => { setNewLayoutName(''); setShowSaveDialog(s => !s) }}
|
||||
>
|
||||
<SaveIcon />
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{promptOpen && (
|
||||
<div className="prompt-bar-body">
|
||||
{showSaveDialog && (
|
||||
<div className="prompt-save-dialog">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Layout-Name"
|
||||
value={newLayoutName}
|
||||
onChange={e => setNewLayoutName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveLayout(); if (e.key === 'Escape') setShowSaveDialog(false) }}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="btn-ghost btn-sm" onClick={handleSaveLayout} disabled={!newLayoutName.trim()}>
|
||||
Speichern
|
||||
</button>
|
||||
<button className="btn-ghost btn-sm" onClick={() => setShowSaveDialog(false)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
className="prompt-textarea"
|
||||
value={promptText}
|
||||
onChange={e => setPromptText(e.target.value)}
|
||||
placeholder="Prompt eingeben…"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Words sidebar */}
|
||||
<aside className="sidebar sidebar--words">
|
||||
<div className="sidebar-panel" style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<h3 className="sidebar-heading" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
Wörter
|
||||
{objWords.length > 0 && <span className="badge">{objWords.length}</span>}
|
||||
{selectedObjId && (
|
||||
<button onClick={() => reloadQW(selectedObjId)} style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--muted)', padding: '0 2px' }} title="Neu laden">↺</button>
|
||||
)}
|
||||
</h3>
|
||||
{objWords.length === 0 ? (
|
||||
<div className="empty-state">–</div>
|
||||
{dbObjects.length === 0 ? (
|
||||
<div className="empty-state">Keine Objekte.</div>
|
||||
) : (
|
||||
<div style={{ overflowY: 'auto', flex: 1, padding: '4px 8px', display: 'flex', flexWrap: 'wrap', gap: 4, alignContent: 'flex-start' }}>
|
||||
{objWords.map(w => {
|
||||
const isInvalid = /[\s,;]/.test(w.title_de)
|
||||
return (
|
||||
<span key={w.id} className="word-chip" style={{ display: 'inline-flex', alignItems: 'center', gap: 3, ...(isInvalid ? { background: 'var(--warning-bg, #fff3cd)', border: '1px solid var(--warning, #f0a500)', color: 'var(--warning-fg, #7a4f00)' } : {}) }} title={isInvalid ? 'Mehrwortiger Eintrag – bitte löschen' : undefined}>
|
||||
{w.title_de}
|
||||
<button
|
||||
onClick={() => handleDeleteWord(w.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, lineHeight: 1, color: 'var(--muted)', fontSize: 10, marginLeft: 1 }}
|
||||
title="Löschen"
|
||||
>✕</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Questions sidebar */}
|
||||
<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 }}>
|
||||
Fragen
|
||||
{questions.length > 0 && <span className="badge">{questions.length}</span>}
|
||||
{selectedObjId && (
|
||||
<button onClick={() => reloadQW(selectedObjId)} style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--muted)', padding: '0 2px' }} title="Neu laden">↺</button>
|
||||
)}
|
||||
</h3>
|
||||
{questions.length === 0 ? (
|
||||
<div className="empty-state">Klicke „Generate it".</div>
|
||||
) : (
|
||||
<div style={{ overflowY: 'auto', flex: 1 }}>
|
||||
{questions.map(q => (
|
||||
<div key={q.id} style={{ padding: '7px 10px', borderBottom: '1px solid var(--border)', fontSize: 12 }}>
|
||||
{/* Header: Level + Status + Delete */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 3 }}>
|
||||
<span style={{ fontWeight: 700, color: 'var(--muted)', fontSize: 10 }}>
|
||||
L{q.level}
|
||||
{q.status === 'published' && <span style={{ marginLeft: 4, color: 'var(--success)' }}>↑</span>}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDeleteQuestion(q.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--muted)', fontSize: 11 }}
|
||||
title="Frage löschen"
|
||||
>✕</button>
|
||||
</div>
|
||||
{/* Frage */}
|
||||
<div style={{ color: 'var(--fg)', marginBottom: 2 }}>{q.question_de}</div>
|
||||
{/* Antwort */}
|
||||
<div style={{ color: 'var(--muted)', fontStyle: 'italic', marginBottom: q.short_answer_de || q.distractor_words?.length ? 4 : 0 }}>{q.answer_de}</div>
|
||||
{/* Short Answer */}
|
||||
{q.short_answer_de && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 3 }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)', flexShrink: 0 }}>Kurz:</span>
|
||||
<span style={{ background: 'var(--primary-muted, #e8f0fe)', color: 'var(--primary)', borderRadius: 4, padding: '1px 6px', fontSize: 11, fontWeight: 600 }}>
|
||||
{q.short_answer_de}
|
||||
</span>
|
||||
<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>
|
||||
)}
|
||||
{/* Distractor Words */}
|
||||
{q.distractor_words?.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)', alignSelf: 'center', flexShrink: 0 }}>Ablenker:</span>
|
||||
{q.distractor_words.map(dw => (
|
||||
<span key={dw} style={{ background: 'var(--bg-muted, #f5f5f5)', border: '1px solid var(--border)', borderRadius: 4, padding: '1px 5px', fontSize: 10 }}>
|
||||
{dw}
|
||||
</span>
|
||||
))}
|
||||
</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>
|
||||
@@ -556,6 +401,47 @@ export default function GenerateIt() {
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Center: Canvas */}
|
||||
<main className="canvas-area">
|
||||
<div className="canvas-frame">
|
||||
<DrawCanvas
|
||||
ref={canvasRef}
|
||||
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.picture, token) : null}
|
||||
objects={canvasObjects}
|
||||
selectedObjectId={selectedObjId}
|
||||
mode="rect"
|
||||
onHasSelection={() => {}}
|
||||
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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user