Generieren-Seite: Objekte-Sidebar, Prompt-Editor mit Layouts, Wörter-Spalte

- Details-Panel unter dem Bild entfernt
- Prompt-Editor unter dem Bild mit Layout-Speicherung (localStorage)
- Standard-Layout mit verbessertem Sprachlern-Prompt (10 Niveaus + Wortliste)
- Neue Wörter-Spalte (links neben Sätze) extrahiert Tokens aus Sätzen
- Sidebar --words CSS-Klasse ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 22:37:02 +02:00
parent a42fadef09
commit 469e8dc385
2 changed files with 313 additions and 15 deletions

View File

@@ -283,6 +283,12 @@ body {
border-left: 1px solid var(--border);
}
.sidebar--words {
width: 180px;
border-left: 1px solid var(--border);
border-right: none;
}
.sidebar::-webkit-scrollbar { width: 4px; }
.sidebar::-webkit-scrollbar-track { background: transparent; }
.sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
@@ -915,6 +921,104 @@ select:focus {
font-style: italic;
}
/* =====================================================
WORDS CLOUD
===================================================== */
.words-cloud {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.word-chip {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: var(--r-full);
background: var(--surface-2);
border: 1px solid var(--border);
font-size: 12px;
color: var(--text-1);
cursor: default;
transition: background 0.12s, border-color 0.12s;
}
.word-chip:hover {
background: var(--primary-muted);
border-color: var(--primary);
color: var(--primary-muted-fg);
}
/* =====================================================
PROMPT EDITOR
===================================================== */
.prompt-editor {
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 10px;
flex: 1;
min-height: 0;
}
.prompt-editor-toolbar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.prompt-layout-select {
width: auto !important;
min-width: 120px;
max-width: 200px;
font-size: 12.5px !important;
padding: 4px 8px !important;
}
.prompt-save-dialog {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-md);
}
.prompt-save-dialog input[type="text"] {
flex: 1;
padding: 5px 8px;
font-size: 12.5px;
}
.prompt-textarea {
width: 100%;
flex: 1;
min-height: 180px;
resize: vertical;
padding: 10px 12px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-md);
color: var(--text-1);
font-family: var(--font);
font-size: 12.5px;
line-height: 1.6;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.prompt-textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(92,108,246,.12);
background: var(--surface);
}
/* =====================================================
STATUS MESSAGE
===================================================== */

View File

@@ -1,11 +1,18 @@
import { useState, useEffect } from 'react'
import ObjectsList from '../components/ObjectsList'
import DetailsPanel from '../components/DetailsPanel'
import SentencesList from '../components/SentencesList'
import Topbar from '../components/Topbar'
import { getDirectusPictures, directusAssetUrl, type DirectusPicture, getDirectusObjects, generateDetails, generateSentence, getSentences } from '../api'
import {
getDirectusPictures,
directusAssetUrl,
type DirectusPicture,
getDirectusObjects,
generateDetails,
generateSentence,
getSentences,
} from '../api'
import { useAuth } from '../context/AuthContext'
import type { DirectusObject, Selection } from '../types'
import type { DirectusObject } from '../types'
import type { ObjectMeta, Sentence } from '../types'
const ChevronLeftIcon = () => (
@@ -32,8 +39,81 @@ const ChatIcon = () => (
</svg>
)
function directusObjToMeta(obj: DirectusObject, index: number): ObjectMeta {
const first = obj.selections?.[0]
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): Analytische, kreative oder erklärende Fragen
Regeln:
- Jede Frage muss sich direkt auf das Objekt beziehen
- Füge zu jedem Satz eine Wortliste (einzelne Tokens, Satzzeichen ausgenommen) hinzu
- 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.",
"words": ["Kannst", "du", "den", "Hund", "sehen"]
}
]
}`
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))
}
// ── Word extraction ───────────────────────────────────────────────────────────
function extractWords(sentences: Sentence[]): string[] {
const allText = sentences
.flatMap(s => [s.question_simple_en, s.answer_simple_en, s.question_advanced_en, s.answer_advanced_en])
.join(' ')
const words = allText
.split(/\s+/)
.map(w => w.replace(/[^a-zA-ZäöüÄÖÜßéèêàâùûîô'-]/g, ''))
.filter(w => w.length > 1)
return [...new Set(words)].sort((a, b) => a.localeCompare(b))
}
// ── Helper ────────────────────────────────────────────────────────────────────
function directusObjToMeta(obj: DirectusObject): ObjectMeta {
return {
id: obj.id,
image_file: '',
@@ -48,20 +128,30 @@ function directusObjToMeta(obj: DirectusObject, index: number): ObjectMeta {
} as unknown as ObjectMeta
}
// ── Component ─────────────────────────────────────────────────────────────────
export default function GenerateIt() {
const { token } = useAuth()
const [pictureList, setPictureList] = useState<DirectusPicture[]>([])
const [currentIndex, setCurrentIndex] = useState(-1)
const [directusObjects, setDirectusObjects] = useState<DirectusObject[]>([])
const [objects, setObjects] = useState<ObjectMeta[]>([])
const [selectedObj, setSelectedObj] = useState<ObjectMeta | null>(null)
const [sentences, setSentences] = useState<Sentence[]>([])
const [isGeneratingDetails, setIsGeneratingDetails] = useState(false)
const [isGeneratingSentence, setIsGeneratingSentence] = 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 currentPicture: DirectusPicture | null =
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
const words = extractWords(sentences)
useEffect(() => {
if (!token) return
getDirectusPictures(token, 'drawing_created')
@@ -70,11 +160,13 @@ export default function GenerateIt() {
}, [token])
useEffect(() => {
if (!currentPicture || !token) { setDirectusObjects([]); setObjects([]); setSelectedObj(null); setSentences([]); return }
if (!currentPicture || !token) {
setObjects([]); setSelectedObj(null); setSentences([])
return
}
getDirectusObjects(currentPicture.id, token)
.then(objs => {
setDirectusObjects(objs)
const metas = objs.map((o, i) => directusObjToMeta(o, i))
const metas = objs.map(directusObjToMeta)
setObjects(metas)
if (metas.length > 0) { setSelectedObj(metas[0]); loadSentences(metas[0].id) }
else { setSelectedObj(null); setSentences([]) }
@@ -117,6 +209,36 @@ export default function GenerateIt() {
}
}
// 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">
@@ -179,27 +301,99 @@ export default function GenerateIt() {
</div>
</aside>
{/* Center: Image + Details */}
{/* Center: Image + Prompt Editor */}
<main className="canvas-area" style={{ alignItems: 'flex-start', justifyContent: 'flex-start', flexDirection: 'column', gap: 12, padding: 16 }}>
{currentPicture && token && (
<img
src={directusAssetUrl(currentPicture.media, token)}
alt="Bild"
style={{ maxWidth: '100%', maxHeight: 320, borderRadius: 8, objectFit: 'contain', border: '1px solid var(--border)' }}
style={{ maxWidth: '100%', maxHeight: 280, borderRadius: 8, objectFit: 'contain', border: '1px solid var(--border)' }}
/>
)}
<div style={{ width: '100%', maxWidth: 520 }}>
<div className="sidebar-panel" style={{ background: 'var(--surface)', borderRadius: 'var(--r-lg)', border: '1px solid var(--border)' }}>
<DetailsPanel obj={selectedObj} objects={objects} sentences={sentences} />
{/* Prompt Editor */}
<div className="prompt-editor">
<div className="prompt-editor-toolbar">
<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"
style={{ marginLeft: 'auto' }}
onClick={() => { setNewLayoutName(''); setShowSaveDialog(s => !s) }}
>
<SaveIcon />
Als Layout speichern
</button>
</div>
{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>
</main>
{/* Words */}
<aside className="sidebar sidebar--words">
<div className="sidebar-panel" style={{ flex: 1 }}>
<h3 className="sidebar-heading">
Wörter
{words.length > 0 && <span className="badge">{words.length}</span>}
</h3>
{words.length === 0 ? (
<div className="empty-state">Noch keine Wörter.</div>
) : (
<div className="words-cloud">
{words.map(w => (
<span key={w} className="word-chip">{w}</span>
))}
</div>
)}
</div>
</aside>
{/* Right: Sentences */}
<aside className="sidebar sidebar--right">
<div className="sidebar-panel" style={{ flex: 1 }}>
<h3 className="sidebar-heading">
Alle Sätze
Sätze
{sentences.length > 0 && <span className="badge">{sentences.length}</span>}
</h3>
<SentencesList sentences={sentences} />