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:
@@ -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
|
||||
===================================================== */
|
||||
|
||||
@@ -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 (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<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} />
|
||||
|
||||
Reference in New Issue
Block a user