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);
|
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 { width: 4px; }
|
||||||
.sidebar::-webkit-scrollbar-track { background: transparent; }
|
.sidebar::-webkit-scrollbar-track { background: transparent; }
|
||||||
.sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
.sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||||
@@ -915,6 +921,104 @@ select:focus {
|
|||||||
font-style: italic;
|
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
|
STATUS MESSAGE
|
||||||
===================================================== */
|
===================================================== */
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import ObjectsList from '../components/ObjectsList'
|
import ObjectsList from '../components/ObjectsList'
|
||||||
import DetailsPanel from '../components/DetailsPanel'
|
|
||||||
import SentencesList from '../components/SentencesList'
|
import SentencesList from '../components/SentencesList'
|
||||||
import Topbar from '../components/Topbar'
|
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 { useAuth } from '../context/AuthContext'
|
||||||
import type { DirectusObject, Selection } from '../types'
|
import type { DirectusObject } from '../types'
|
||||||
import type { ObjectMeta, Sentence } from '../types'
|
import type { ObjectMeta, Sentence } from '../types'
|
||||||
|
|
||||||
const ChevronLeftIcon = () => (
|
const ChevronLeftIcon = () => (
|
||||||
@@ -32,8 +39,81 @@ const ChatIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
function directusObjToMeta(obj: DirectusObject, index: number): ObjectMeta {
|
const SaveIcon = () => (
|
||||||
const first = obj.selections?.[0]
|
<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 {
|
return {
|
||||||
id: obj.id,
|
id: obj.id,
|
||||||
image_file: '',
|
image_file: '',
|
||||||
@@ -48,20 +128,30 @@ function directusObjToMeta(obj: DirectusObject, index: number): ObjectMeta {
|
|||||||
} as unknown as ObjectMeta
|
} as unknown as ObjectMeta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function GenerateIt() {
|
export default function GenerateIt() {
|
||||||
const { token } = useAuth()
|
const { token } = useAuth()
|
||||||
const [pictureList, setPictureList] = useState<DirectusPicture[]>([])
|
const [pictureList, setPictureList] = useState<DirectusPicture[]>([])
|
||||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||||
const [directusObjects, setDirectusObjects] = useState<DirectusObject[]>([])
|
|
||||||
const [objects, setObjects] = useState<ObjectMeta[]>([])
|
const [objects, setObjects] = useState<ObjectMeta[]>([])
|
||||||
const [selectedObj, setSelectedObj] = useState<ObjectMeta | null>(null)
|
const [selectedObj, setSelectedObj] = useState<ObjectMeta | null>(null)
|
||||||
const [sentences, setSentences] = useState<Sentence[]>([])
|
const [sentences, setSentences] = useState<Sentence[]>([])
|
||||||
const [isGeneratingDetails, setIsGeneratingDetails] = useState(false)
|
const [isGeneratingDetails, setIsGeneratingDetails] = useState(false)
|
||||||
const [isGeneratingSentence, setIsGeneratingSentence] = 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 =
|
const currentPicture: DirectusPicture | null =
|
||||||
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
|
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
|
||||||
|
|
||||||
|
const words = extractWords(sentences)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return
|
if (!token) return
|
||||||
getDirectusPictures(token, 'drawing_created')
|
getDirectusPictures(token, 'drawing_created')
|
||||||
@@ -70,11 +160,13 @@ export default function GenerateIt() {
|
|||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentPicture || !token) { setDirectusObjects([]); setObjects([]); setSelectedObj(null); setSentences([]); return }
|
if (!currentPicture || !token) {
|
||||||
|
setObjects([]); setSelectedObj(null); setSentences([])
|
||||||
|
return
|
||||||
|
}
|
||||||
getDirectusObjects(currentPicture.id, token)
|
getDirectusObjects(currentPicture.id, token)
|
||||||
.then(objs => {
|
.then(objs => {
|
||||||
setDirectusObjects(objs)
|
const metas = objs.map(directusObjToMeta)
|
||||||
const metas = objs.map((o, i) => directusObjToMeta(o, i))
|
|
||||||
setObjects(metas)
|
setObjects(metas)
|
||||||
if (metas.length > 0) { setSelectedObj(metas[0]); loadSentences(metas[0].id) }
|
if (metas.length > 0) { setSelectedObj(metas[0]); loadSentences(metas[0].id) }
|
||||||
else { setSelectedObj(null); setSentences([]) }
|
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 = (
|
const imageNav = (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<div className="image-nav">
|
<div className="image-nav">
|
||||||
@@ -179,27 +301,99 @@ export default function GenerateIt() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</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 }}>
|
<main className="canvas-area" style={{ alignItems: 'flex-start', justifyContent: 'flex-start', flexDirection: 'column', gap: 12, padding: 16 }}>
|
||||||
{currentPicture && token && (
|
{currentPicture && token && (
|
||||||
<img
|
<img
|
||||||
src={directusAssetUrl(currentPicture.media, token)}
|
src={directusAssetUrl(currentPicture.media, token)}
|
||||||
alt="Bild"
|
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)' }}>
|
{/* Prompt Editor */}
|
||||||
<DetailsPanel obj={selectedObj} objects={objects} sentences={sentences} />
|
<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>
|
</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>
|
</div>
|
||||||
</main>
|
</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 */}
|
{/* Right: Sentences */}
|
||||||
<aside className="sidebar sidebar--right">
|
<aside className="sidebar sidebar--right">
|
||||||
<div className="sidebar-panel" style={{ flex: 1 }}>
|
<div className="sidebar-panel" style={{ flex: 1 }}>
|
||||||
<h3 className="sidebar-heading">
|
<h3 className="sidebar-heading">
|
||||||
Alle Sätze
|
Sätze
|
||||||
{sentences.length > 0 && <span className="badge">{sentences.length}</span>}
|
{sentences.length > 0 && <span className="badge">{sentences.length}</span>}
|
||||||
</h3>
|
</h3>
|
||||||
<SentencesList sentences={sentences} />
|
<SentencesList sentences={sentences} />
|
||||||
|
|||||||
Reference in New Issue
Block a user