Files
hejyou_content_creation/frontend/src/pages/GenerateIt.tsx
2026-04-26 10:53:04 +02:00

497 lines
20 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 GenerateObjectsList from '../components/GenerateObjectsList'
import Topbar from '../components/Topbar'
import {
getDirectusPictures,
directusAssetUrl,
type DirectusPicture,
getDirectusObjects,
generateQuestions,
publishQuestions,
getObjectQuestions,
getObjectWords,
deleteQuestion,
deleteWord,
type GenerateStats,
type ObjectQuestion,
type ObjectWord,
} from '../api'
import { useAuth } from '../context/AuthContext'
import type { DirectusObject, 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>
)
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ß"]
}
]
}
Informationen: {user-notes_object}
Elternobjekt: {user-notes_parentobject}`
interface PromptLayout {
name: string
prompt: string
}
const STORAGE_KEY = 'cm_prompt_layouts'
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]
}
}
function persistLayouts(layouts: PromptLayout[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(layouts))
}
// ── Component ─────────────────────────────────────────────────────────────────
export default function GenerateIt() {
const { token } = useAuth()
const canvasRef = useRef<DrawCanvasHandle>(null)
const [pictureList, setPictureList] = useState<DirectusPicture[]>([])
const [currentIndex, setCurrentIndex] = useState(-1)
const [directusObjects, setDirectusObjects] = useState<DirectusObject[]>([])
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[]>([])
// 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 =
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
const canvasObjects: CanvasObject[] = directusObjects.map((obj, i) => ({
id: obj.id,
visible: true,
selections: obj.selections,
index: i + 1,
hierarchy: 1,
}))
useEffect(() => {
if (!token) return
getDirectusPictures(token, 'drawing_created')
.then(pics => { setPictureList(pics); setCurrentIndex(pics.length > 0 ? 0 : -1) })
.catch(console.error)
}, [token])
useEffect(() => {
if (!currentPicture || !token) {
setDirectusObjects([]); setSelectedObjId(null)
setGenerateResult(null); setPublishResult(null); setGenerateError(null)
setQuestions([]); setObjWords([])
return
}
getDirectusObjects(currentPicture.id, token)
.then(objs => {
setDirectusObjects(objs)
if (objs.length > 0) setSelectedObjId(objs[0].id)
else setSelectedObjId(null)
})
.catch(console.error)
}, [currentPicture?.id, token])
useEffect(() => {
if (!selectedObjId || !token) { setQuestions([]); setObjWords([]); return }
getObjectQuestions(selectedObjId, token).then(setQuestions).catch(console.error)
getObjectWords(selectedObjId, token).then(setObjWords).catch(console.error)
}, [selectedObjId, token])
const reloadQW = (objId: string) => {
if (!token) return
getObjectQuestions(objId, token).then(setQuestions).catch(console.error)
getObjectWords(objId, token).then(setObjWords).catch(console.error)
}
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 () => {
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)
}
}
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'}
</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 res = await fetch('/api/setup-schema', { method: 'POST', headers: { Authorization: `Bearer ${token}` } })
const d = await res.json()
alert(`Setup: ${d.total} Schritte, ${d.failed} Fehler.\nSiehe Konsole für Details.`)
console.log('[setup-schema]', d)
}}
>
Schema
</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
{directusObjects.length > 0 && <span className="badge">{directusObjects.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">
Wörter
{objWords.length > 0 && <span className="badge">{objWords.length}</span>}
</h3>
{objWords.length === 0 ? (
<div className="empty-state"></div>
) : (
<div style={{ overflowY: 'auto', flex: 1, padding: '4px 8px', display: 'flex', flexWrap: 'wrap', gap: 4, alignContent: 'flex-start' }}>
{objWords.map(w => (
<span key={w.id} className="word-chip" style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
{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: 280, minWidth: 220 }}>
<div className="sidebar-panel" style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<h3 className="sidebar-heading">
Fragen
{questions.length > 0 && <span className="badge">{questions.length}</span>}
</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: '6px 10px', borderBottom: '1px solid var(--border)', fontSize: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 4 }}>
<span style={{ fontWeight: 600, color: 'var(--muted)', fontSize: 10, flexShrink: 0 }}>
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, lineHeight: 1, color: 'var(--muted)', fontSize: 11, flexShrink: 0 }}
title="Frage löschen"
></button>
</div>
<div style={{ marginTop: 2, color: 'var(--fg)' }}>{q.question_de}</div>
<div style={{ marginTop: 2, color: 'var(--muted)', fontStyle: 'italic' }}>{q.answer_de}</div>
</div>
))}
</div>
)}
</div>
</aside>
</div>
</div>
)
}