From 469e8dc3852d5313bf57f531a12ef78106ed4eba Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 25 Apr 2026 22:37:02 +0200 Subject: [PATCH] =?UTF-8?q?Generieren-Seite:=20Objekte-Sidebar,=20Prompt-E?= =?UTF-8?q?ditor=20mit=20Layouts,=20W=C3=B6rter-Spalte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/index.css | 104 ++++++++++++++ frontend/src/pages/GenerateIt.tsx | 224 ++++++++++++++++++++++++++++-- 2 files changed, 313 insertions(+), 15 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index 060ee04..61e661e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 ===================================================== */ diff --git a/frontend/src/pages/GenerateIt.tsx b/frontend/src/pages/GenerateIt.tsx index 90b562b..469b4b8 100644 --- a/frontend/src/pages/GenerateIt.tsx +++ b/frontend/src/pages/GenerateIt.tsx @@ -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 = () => ( ) -function directusObjToMeta(obj: DirectusObject, index: number): ObjectMeta { - const first = obj.selections?.[0] +const SaveIcon = () => ( + + + + + +) + +// ── 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): 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([]) const [currentIndex, setCurrentIndex] = useState(-1) - const [directusObjects, setDirectusObjects] = useState([]) const [objects, setObjects] = useState([]) const [selectedObj, setSelectedObj] = useState(null) const [sentences, setSentences] = useState([]) const [isGeneratingDetails, setIsGeneratingDetails] = useState(false) const [isGeneratingSentence, setIsGeneratingSentence] = useState(false) + // Prompt layouts + const [layouts, setLayouts] = useState(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 = (
@@ -179,27 +301,99 @@ export default function GenerateIt() {
- {/* Center: Image + Details */} + {/* Center: Image + Prompt Editor */}
{currentPicture && token && ( Bild )} -
-
- + + {/* Prompt Editor */} +
+
+ + {selectedLayoutName !== 'Standard' && ( + + )} +
+ + {showSaveDialog && ( +
+ setNewLayoutName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleSaveLayout(); if (e.key === 'Escape') setShowSaveDialog(false) }} + autoFocus + /> + + +
+ )} + +