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:
2026-05-10 08:03:23 +02:00
parent 5b99bef765
commit 7c983a7460
5 changed files with 811 additions and 564 deletions

View File

@@ -325,3 +325,131 @@ export async function purgeAllOrphans(token: string): Promise<{ orphans_removed:
if (!res.ok) throw new Error('Fehler beim globalen Bereinigen')
return data
}
// ── DB Pictures ───────────────────────────────────────────────────────────────
import type { DbPicture, DbObject, DbWord, DbPair } from './types'
export async function getDbPictures(token: string, status = 'draft'): Promise<DbPicture[]> {
const res = await fetch(`/api/directus/db-pictures?status=${status}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Fehler beim Laden der db_pictures')
const data = await res.json()
return data.data as DbPicture[]
}
export async function updateDbPictureStatus(pictureId: string, status: string, token: string): Promise<void> {
const res = await fetch(`/api/directus/db-pictures/${pictureId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ status }),
})
if (!res.ok) throw new Error('Fehler beim Aktualisieren des Bild-Status')
}
// ── DB Objects ────────────────────────────────────────────────────────────────
export async function getDbObjects(pictureId: string, token: string): Promise<DbObject[]> {
const res = await fetch(`/api/directus/db-objects?picture_id=${pictureId}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Fehler beim Laden der db_objects')
const data = await res.json()
return data.data as DbObject[]
}
export async function createDbObject(payload: {
picture: string
selections: import('./types').Selection[] | null
user_notes: string | null
}, token: string): Promise<DbObject> {
const res = await fetch('/api/directus/db-objects', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ ...payload, status: 'draft' }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Fehler beim Erstellen des Objekts')
return data.data as DbObject
}
export async function updateDbObject(
objId: string,
payload: Partial<Pick<DbObject, 'user_notes'>>,
token: string
): Promise<DbObject> {
const res = await fetch(`/api/directus/db-objects/${objId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(payload),
})
const data = await res.json()
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Fehler beim Aktualisieren')
return data.data as DbObject
}
export async function deleteDbObject(objId: string, token: string): Promise<void> {
const res = await fetch(`/api/directus/db-objects/${objId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Fehler beim Löschen des Objekts')
}
// ── DB Words for a picture ────────────────────────────────────────────────────
export async function getDbPictureWords(pictureId: string, token: string): Promise<DbWord[]> {
const res = await fetch(`/api/directus/db-pictures/${pictureId}/words`, {
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
if (!res.ok) throw new Error('Fehler beim Laden der Wörter')
return data.data as DbWord[]
}
export async function saveDbPictureWords(
pictureId: string,
words: { titel_de: string; level: number }[],
token: string
): Promise<{ saved: number }> {
const res = await fetch(`/api/directus/db-pictures/${pictureId}/words`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ words }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Fehler beim Speichern der Wörter')
return data
}
// ── DB Pairs for an object ────────────────────────────────────────────────────
export async function getDbObjectPairs(objectId: string, token: string): Promise<DbPair[]> {
const res = await fetch(`/api/directus/db-objects/${objectId}/pairs`, {
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
if (!res.ok) throw new Error('Fehler beim Laden der Pairs')
return data.data as DbPair[]
}
export async function createDbPair(
objectId: string,
payload: {
question_de?: string
statement_de: string
level: number
words: { titel_de: string; level: number }[]
},
token: string
): Promise<{ ok: boolean; pair_id: string; statement_id: string; question_id: string | null }> {
const res = await fetch(`/api/directus/db-objects/${objectId}/pairs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(payload),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Fehler beim Erstellen des Pairs')
return data
}

View File

@@ -2,12 +2,18 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
import Topbar from '../components/Topbar'
import {
getDirectusPictures, directusAssetUrl, type DirectusPicture,
getDirectusObjects, createDirectusObject, updateDirectusObject, deleteDirectusObject,
updatePictureStatus, getPictureWords, savePictureWords,
getDbPictures,
updateDbPictureStatus,
getDbObjects,
createDbObject,
updateDbObject,
deleteDbObject,
getDbPictureWords,
saveDbPictureWords,
directusAssetUrl,
} from '../api'
import { useAuth } from '../context/AuthContext'
import type { DirectusObject, Selection, CanvasObject, PictureWord } from '../types'
import type { DbPicture, DbObject, DbWord, Selection, 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">
@@ -28,73 +34,45 @@ const TrashIcon = () => (
export default function DrawIt() {
const { token } = useAuth()
const [pictureList, setPictureList] = useState<DirectusPicture[]>([])
const [pictureList, setPictureList] = useState<DbPicture[]>([])
const [currentIndex, setCurrentIndex] = useState(-1)
const [debouncedIndex, setDebouncedIndex] = useState(-1)
const [objects, setObjects] = useState<DirectusObject[]>([])
const [objects, setObjects] = useState<DbObject[]>([])
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
const [userNotes, setUserNotes] = useState('')
const [safeWords, setSafeWords] = useState<{ title: string; level: number }[]>([])
const [safeWordInput, setSafeWordInput] = useState('')
const [safeWordLevel, setSafeWordLevel] = useState(50)
const [safeWordInputVisible, setSafeWordInputVisible] = useState(false)
const safeWordInputRef = useRef<HTMLInputElement>(null)
const [pictureWords, setPictureWords] = useState<PictureWord[]>([])
// pending words (not yet saved)
const [pendingWords, setPendingWords] = useState<{ titel_de: string; level: number }[]>([])
const [wordInput, setWordInput] = useState('')
const [wordLevel, setWordLevel] = useState(50)
const [wordInputVisible, setWordInputVisible] = useState(false)
const wordInputRef = useRef<HTMLInputElement>(null)
// saved words from Directus
const [pictureWords, setPictureWords] = useState<DbWord[]>([])
const [savingWords, setSavingWords] = useState(false)
const [parentId, setParentId] = useState<string | null>(null)
const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null)
const [mode, setMode] = useState<'rect' | 'polygon'>('polygon')
const [hasSelection, setHasSelection] = useState(false)
const [saving, setSaving] = useState(false)
const [finishing, setFinishing] = useState(false)
const [status, setStatus] = useState('')
const [statusMsg, setStatusMsg] = useState('')
const [statusError, setStatusError] = useState(false)
const canvasRef = useRef<DrawCanvasHandle>(null)
// Debounce: Bild erst laden wenn 350ms keine weitere Navigation
// Debounce: only load picture data after 350ms of no navigation
useEffect(() => {
const t = setTimeout(() => setDebouncedIndex(currentIndex), 350)
return () => clearTimeout(t)
}, [currentIndex])
useEffect(() => {
if (safeWordInputVisible) safeWordInputRef.current?.focus()
}, [safeWordInputVisible])
if (wordInputVisible) wordInputRef.current?.focus()
}, [wordInputVisible])
const addSafeWord = () => {
const title = safeWordInput.trim()
if (!title || safeWords.some(w => w.title === title) || pictureWords.some(w => w.title_de === title)) {
setSafeWordInput(''); return
}
setSafeWords(prev => [...prev, { title, level: safeWordLevel }])
setSafeWordInput('')
setSafeWordLevel(50)
setSafeWordInputVisible(false)
}
const saveSafeWords = async () => {
if (!currentPicture || !token || safeWords.length === 0) return
setSavingWords(true)
try {
await savePictureWords(currentPicture.id, safeWords.map(w => ({ title_de: w.title, level: w.level })), token)
const updated = await getPictureWords(currentPicture.id, token)
setPictureWords(updated)
setSafeWords([])
showStatus('Wörter gespeichert.')
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern der Wörter.', true)
} finally {
setSavingWords(false)
}
}
// currentPicture folgt dem debouncedIndex → lädt erst wenn Navigation pausiert
const currentPicture: DirectusPicture | null =
const currentPicture: DbPicture | null =
debouncedIndex >= 0 && debouncedIndex < pictureList.length ? pictureList[debouncedIndex] : null
// Map DirectusObject → CanvasObject for rendering
const canvasObjects: CanvasObject[] = objects.map((obj, i) => ({
id: obj.id,
visible: obj.visible !== false,
@@ -103,33 +81,62 @@ export default function DrawIt() {
hierarchy: 1,
}))
// Load db_pictures with status=draft
useEffect(() => {
if (!token) return
getDirectusPictures(token)
getDbPictures(token, 'draft')
.then(pics => { setPictureList(pics); setCurrentIndex(pics.length > 0 ? 0 : -1) })
.catch(console.error)
}, [token])
// Load objects + words when picture changes
useEffect(() => {
if (!currentPicture || !token) {
setObjects([]); setSelectedObjectId(null)
setPictureWords([]); setSafeWords([])
setPictureWords([]); setPendingWords([])
return
}
getDirectusObjects(currentPicture.id, token)
getDbObjects(currentPicture.id, token)
.then(objs => { setObjects(objs.map(o => ({ ...o, visible: true }))); setSelectedObjectId(null) })
.catch(console.error)
getPictureWords(currentPicture.id, token)
getDbPictureWords(currentPicture.id, token)
.then(setPictureWords)
.catch(console.error)
}, [currentPicture?.id, token])
const showStatus = (msg: string, isError = false) => {
setStatus(msg); setStatusError(isError)
setStatusMsg(msg); setStatusError(isError)
}
const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), [])
const addWord = () => {
const titel = wordInput.trim()
if (!titel || pendingWords.some(w => w.titel_de === titel) || pictureWords.some(w => w.titel_de === titel)) {
setWordInput(''); return
}
setPendingWords(prev => [...prev, { titel_de: titel, level: wordLevel }])
setWordInput('')
setWordLevel(50)
setWordInputVisible(false)
}
const saveWords = async () => {
if (!currentPicture || !token || pendingWords.length === 0) return
setSavingWords(true)
try {
await saveDbPictureWords(currentPicture.id, pendingWords, token)
const updated = await getDbPictureWords(currentPicture.id, token)
setPictureWords(updated)
setPendingWords([])
showStatus('Wörter gespeichert.')
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern der Wörter.', true)
} finally {
setSavingWords(false)
}
}
const addSelection = () => {
const sel = canvasRef.current?.getCurrentSelection()
if (!sel) { showStatus('Bitte zuerst einen Bereich auswählen.', true); return }
@@ -142,16 +149,14 @@ export default function DrawIt() {
if (!currentPicture || !token || currentSelections.length === 0) return
setSaving(true)
try {
const obj = await createDirectusObject({
const obj = await createDbObject({
picture: currentPicture.id,
selections: currentSelections,
user_notes: userNotes.trim() || null,
parent: parentId,
}, token)
setObjects(prev => [...prev, { ...obj, visible: true }])
setCurrentSelections([])
setUserNotes('')
setParentId(null)
canvasRef.current?.resetSelection()
showStatus('Objekt gespeichert.')
} catch (e) {
@@ -161,11 +166,12 @@ export default function DrawIt() {
}
}
// Mark picture as objects_created and remove from list
const finishPicture = async () => {
if (!currentPicture || !token) return
setFinishing(true)
try {
await updatePictureStatus(currentPicture.id, 'drawing_created', token)
await updateDbPictureStatus(currentPicture.id, 'objects_created', token)
setPictureList(prev => prev.filter(p => p.id !== currentPicture.id))
setCurrentIndex(i => Math.max(0, i - 1))
setObjects([])
@@ -180,7 +186,7 @@ export default function DrawIt() {
const saveNoteEdit = async () => {
if (!editingNotes || !token) return
try {
await updateDirectusObject(editingNotes.id, { user_notes: editingNotes.notes }, token)
await updateDbObject(editingNotes.id, { user_notes: editingNotes.notes }, token)
setObjects(prev => prev.map(o => o.id === editingNotes.id ? { ...o, user_notes: editingNotes.notes } : o))
setEditingNotes(null)
showStatus('Notizen gespeichert.')
@@ -192,7 +198,7 @@ export default function DrawIt() {
const deleteObject = async (objId: string) => {
if (!token) return
try {
await deleteDirectusObject(objId, token)
await deleteDbObject(objId, token)
setObjects(prev => prev.filter(o => o.id !== objId))
if (selectedObjectId === objId) setSelectedObjectId(null)
showStatus('Objekt gelöscht.')
@@ -250,7 +256,6 @@ export default function DrawIt() {
<div className="object-item-text">
<strong>Objekt {i + 1}</strong>
<span>{obj.selections?.length ? `${obj.selections.length} Auswahl(en)` : ''}</span>
{obj.parent && <span style={{ color: 'var(--primary-muted-fg)', fontSize: 11 }}> Kind von #{objects.findIndex(o => o.id === obj.parent) + 1}</span>}
</div>
<button
className="object-icon-button"
@@ -308,10 +313,13 @@ export default function DrawIt() {
{/* Center: Canvas */}
<main className="canvas-area">
<div className="canvas-frame">
<div
className="canvas-frame"
style={currentPicture ? { background: 'var(--surface-2)' } : undefined}
>
<DrawCanvas
ref={canvasRef}
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.media, token) : null}
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.picture, token) : null}
objects={canvasObjects}
selectedObjectId={selectedObjectId}
mode={mode}
@@ -369,21 +377,6 @@ export default function DrawIt() {
/>
</div>
<div className="sidebar-panel">
<h3 className="sidebar-heading">Parent-Objekt</h3>
<select
value={parentId ?? ''}
onChange={e => setParentId(e.target.value || null)}
>
<option value=""> kein Parent </option>
{objects.map((obj, i) => (
<option key={obj.id} value={obj.id}>
Objekt {i + 1}{obj.user_notes ? ` ${obj.user_notes.slice(0, 30)}` : ''}
</option>
))}
</select>
</div>
<div className="sidebar-panel">
<h3 className="sidebar-heading">
Auswahlen
@@ -422,7 +415,7 @@ export default function DrawIt() {
Alle löschen
</button>
</div>
{status && <div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{status}</div>}
{statusMsg && <div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{statusMsg}</div>}
</div>
</aside>
@@ -430,26 +423,31 @@ export default function DrawIt() {
<aside className="sidebar sidebar--words">
<div className="sidebar-panel">
<h3 className="sidebar-heading" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>Words{(pictureWords.length + safeWords.length) > 0 && <span className="badge" style={{ marginLeft: 6 }}>{pictureWords.length + safeWords.length}</span>}</span>
<span>
Words
{(pictureWords.length + pendingWords.length) > 0 && (
<span className="badge" style={{ marginLeft: 6 }}>{pictureWords.length + pendingWords.length}</span>
)}
</span>
<button
className="btn-icon"
style={{ width: 22, height: 22, borderRadius: 'var(--r-sm)', fontSize: 16, lineHeight: 1, padding: 0 }}
onClick={() => setSafeWordInputVisible(v => !v)}
onClick={() => setWordInputVisible(v => !v)}
title="Word hinzufügen"
>+</button>
</h3>
{safeWordInputVisible && (
{wordInputVisible && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
<input
ref={safeWordInputRef}
value={safeWordInput}
onChange={e => setSafeWordInput(e.target.value)}
ref={wordInputRef}
value={wordInput}
onChange={e => setWordInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') addSafeWord()
if (e.key === 'Escape') { setSafeWordInputVisible(false); setSafeWordInput('') }
if (e.key === 'Enter') addWord()
if (e.key === 'Escape') { setWordInputVisible(false); setWordInput('') }
}}
placeholder="Wort…"
placeholder="Wort (titel_de)…"
style={{
width: '100%', padding: '5px 8px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', background: 'var(--surface-2)',
@@ -462,22 +460,22 @@ export default function DrawIt() {
<input
type="number"
min={1} max={100}
value={safeWordLevel}
onChange={e => setSafeWordLevel(Math.min(100, Math.max(1, Number(e.target.value))))}
value={wordLevel}
onChange={e => setWordLevel(Math.min(100, Math.max(1, Number(e.target.value))))}
style={{
flex: 1, padding: '4px 6px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', background: 'var(--surface-2)',
color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12,
}}
/>
<button className="btn-primary btn-sm" style={{ padding: '4px 8px' }} onClick={addSafeWord}></button>
<button className="btn-primary btn-sm" style={{ padding: '4px 8px' }} onClick={addWord}></button>
</div>
</div>
)}
{/* Saved words from Directus */}
{/* Saved words from db_words_db_pictures */}
{pictureWords.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: safeWords.length > 0 ? 8 : 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: pendingWords.length > 0 ? 8 : 0 }}>
{pictureWords.map(w => (
<div key={w.word_id} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
@@ -485,7 +483,7 @@ export default function DrawIt() {
background: 'var(--surface-2)', border: '1px solid var(--border)',
}}>
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-1)', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{w.title_de}
{w.titel_de}
</span>
<span style={{ fontSize: 11, color: 'var(--text-2)', marginLeft: 6, flexShrink: 0 }}>L{w.level}</span>
</div>
@@ -493,23 +491,23 @@ export default function DrawIt() {
</div>
)}
{/* Pending new words */}
{safeWords.length > 0 && (
{/* Pending new words (not yet saved) */}
{pendingWords.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{safeWords.map((w, i) => (
{pendingWords.map((w, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '4px 8px', borderRadius: 'var(--r-sm)',
background: 'var(--primary-muted)', border: '1px solid color-mix(in srgb, var(--primary) 30%, transparent)',
}}>
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--primary)', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{w.title}
{w.titel_de}
</span>
<input
type="number"
min={1} max={100}
value={w.level}
onChange={e => setSafeWords(prev => prev.map((x, j) => j === i ? { ...x, level: Math.min(100, Math.max(1, Number(e.target.value))) } : x))}
onChange={e => setPendingWords(prev => prev.map((x, j) => j === i ? { ...x, level: Math.min(100, Math.max(1, Number(e.target.value))) } : x))}
style={{
width: 44, padding: '2px 4px', borderRadius: 'var(--r-sm)',
border: '1px solid color-mix(in srgb, var(--primary) 40%, transparent)',
@@ -518,7 +516,7 @@ export default function DrawIt() {
}}
/>
<button
onClick={() => setSafeWords(prev => prev.filter((_, j) => j !== i))}
onClick={() => setPendingWords(prev => prev.filter((_, j) => j !== i))}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--primary)', padding: 0, lineHeight: 1, fontSize: 14, flexShrink: 0 }}
title="Entfernen"
>×</button>
@@ -527,7 +525,7 @@ export default function DrawIt() {
</div>
)}
{pictureWords.length === 0 && safeWords.length === 0 && (
{pictureWords.length === 0 && pendingWords.length === 0 && (
<div className="empty-state" style={{ fontSize: 12 }}>Noch keine Words</div>
)}
</div>
@@ -535,10 +533,10 @@ export default function DrawIt() {
<div className="sidebar-panel">
<button
className="btn-primary btn-sm btn-block"
onClick={saveSafeWords}
disabled={safeWords.length === 0 || savingWords || !currentPicture}
onClick={saveWords}
disabled={pendingWords.length === 0 || savingWords || !currentPicture}
>
{savingWords ? 'Speichere…' : `Save${safeWords.length > 0 ? ` (${safeWords.length})` : ''}`}
{savingWords ? 'Speichere…' : `Save${pendingWords.length > 0 ? ` (${pendingWords.length})` : ''}`}
</button>
</div>
</aside>

View File

@@ -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 (110):
* Stufe 12 (Anfänger): Einfachste Erkennungs- oder Ja/Nein-Fragen, z.B. „Kannst du den Hund sehen?"
* Stufe 35 (Grundstufe): Beschreibende Fragen zu Farbe, Form, Position
* Stufe 68 (Mittelstufe): Fragen zu Funktion, Vergleich oder Kontext
* Stufe 910 (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>
)

View File

@@ -54,6 +54,54 @@ export interface PictureWord {
status: string
}
// ── db_* Collection types ─────────────────────────────────────────────────────
export interface DbPicture {
id: string
picture: string // UUID → directus_files (für asset URL)
blurhash: string | null
status: string
}
export interface DbObject {
id: string
status: string
picture: string // → DbPicture.id
selections: Selection[] | null
user_notes: string | null
visible?: boolean // nur UI-State
}
export interface DbWord {
junction_id?: string | number
word_id: string
titel_de: string
level: number
status: string
}
export interface DbPairQuestion {
id: string
question_de: string
level: number
status: string
}
export interface DbPairStatement {
id: string
statement_de: string
level: number
status: string
}
export interface DbPair {
id: string
level: number
status: string
questions: DbPairQuestion[]
statements: DbPairStatement[]
}
// Legacy — still used by GenerateIt
export interface ObjectMeta {
id: string