feat: lesbares Placeholder-Format {1.Hund} in Pair-Textfeldern

- Textarea zeigt {1.Hund} (Index + Wort), Directus speichert {uuid.uuid}
- displayToStorage / storageToDisplay Konvertierung bei Save und Edit
- PreviewText-Komponente: Placeholder farblich hervorgehoben (blau)
- Chip-Klick fügt {1.Hund} an Cursor-Position ein
- startEdit konvertiert gespeicherte UUIDs zurück zu lesbarem Format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 21:08:41 +02:00
parent 9a32e4a39b
commit ceaa7eff3c

View File

@@ -29,6 +29,44 @@ const ChevronRightIcon = () => (
</svg> </svg>
) )
// ── Placeholder helpers ───────────────────────────────────────────────────────
// Display format: {1.Hund} ←→ Storage format: {objectUUID.wordUUID}
function displayToStorage(text: string, chips: ObjectChip[]): string {
return text.replace(/\{(\d+)\.([^}]+)\}/g, (match, indexStr, label) => {
const chip = chips.find(c => c.objectIndex === parseInt(indexStr) && c.label === label)
return chip ? `{${chip.objectId}.${chip.wordId}}` : match
})
}
function storageToDisplay(text: string, chips: ObjectChip[]): string {
// UUIDs are long (36 chars with hyphens); display indexes are short numbers
return text.replace(/\{([0-9a-f-]{30,})\.([0-9a-f-]{30,})\}/g, (match, objectId, wordId) => {
const chip = chips.find(c => c.objectId === objectId && c.wordId === wordId)
return chip ? `{${chip.objectIndex}.${chip.label}}` : match
})
}
// Renders text with {index.label} placeholders highlighted in blue
function PreviewText({ text, chips }: { text: string; chips: ObjectChip[] }) {
const display = storageToDisplay(text, chips)
const parts = display.split(/(\{\d+\.[^}]+\})/g)
return (
<span style={{ fontSize: 11, color: 'var(--text-2)', fontStyle: 'italic' }}>
{parts.map((part, i) => {
const m = part.match(/^\{(\d+)\.([^}]+)\}$/)
if (m) return (
<mark key={i} style={{ background: '#dbeafe', color: '#1e40af', borderRadius: 3, padding: '0 2px', fontStyle: 'normal', fontWeight: 600 }}>
{m[2]}
</mark>
)
return <span key={i}>{part}</span>
})}
</span>
)
}
// ── PairForm ────────────────────────────────────────────────────────────────── // ── PairForm ──────────────────────────────────────────────────────────────────
interface PairFormProps { interface PairFormProps {
@@ -41,8 +79,8 @@ interface PairFormProps {
function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormProps) { function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormProps) {
const [level, setLevel] = useState(50) const [level, setLevel] = useState(50)
const [questionDe, setQuestionDe] = useState('') const [questionDe, setQuestionDe] = useState('') // display format: {1.Hund}
const [statementDe, setStatementDe] = useState('') const [statementDe, setStatementDe] = useState('') // display format: {1.Hund}
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -50,19 +88,14 @@ function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormP
const statementRef = useRef<HTMLTextAreaElement>(null) const statementRef = useRef<HTMLTextAreaElement>(null)
const lastFocusedRef = useRef<'question' | 'statement'>('statement') const lastFocusedRef = useRef<'question' | 'statement'>('statement')
const resolveTemplate = (text: string): string => // Insert display-format placeholder at cursor: {1.Hund}
text.replace(/\{([^.}]+)\.([^}]+)\}/g, (_, oid, wid) => { const insertAtCursor = (chip: ObjectChip) => {
const chip = objectChips.find(c => c.objectId === oid && c.wordId === wid)
return chip ? chip.label : '{?}'
})
const insertAtCursor = (oid: string, wordId: string) => {
const ref = lastFocusedRef.current === 'question' ? questionRef : statementRef const ref = lastFocusedRef.current === 'question' ? questionRef : statementRef
const ta = ref.current const ta = ref.current
if (!ta) return if (!ta) return
const start = ta.selectionStart ?? ta.value.length const start = ta.selectionStart ?? ta.value.length
const end = ta.selectionEnd ?? ta.value.length const end = ta.selectionEnd ?? ta.value.length
const placeholder = `{${oid}.${wordId}}` const placeholder = `{${chip.objectIndex}.${chip.label}}`
const newVal = ta.value.slice(0, start) + placeholder + ta.value.slice(end) const newVal = ta.value.slice(0, start) + placeholder + ta.value.slice(end)
if (lastFocusedRef.current === 'question') setQuestionDe(newVal) if (lastFocusedRef.current === 'question') setQuestionDe(newVal)
else setStatementDe(newVal) else setStatementDe(newVal)
@@ -73,13 +106,14 @@ function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormP
} }
const handleSave = async () => { const handleSave = async () => {
if (!statementDe.trim()) { setError('Aussage (statement_de) ist Pflicht.'); return } if (!statementDe.trim()) { setError('Aussage ist Pflicht.'); return }
setSaving(true) setSaving(true)
setError('') setError('')
try { try {
// Convert display format → storage format (UUIDs) before saving
await createDbPair(objectId, { await createDbPair(objectId, {
question_de: questionDe.trim() || undefined, question_de: questionDe.trim() ? displayToStorage(questionDe.trim(), objectChips) : undefined,
statement_de: statementDe.trim(), statement_de: displayToStorage(statementDe.trim(), objectChips),
level, level,
words: [], words: [],
}, token) }, token)
@@ -112,7 +146,7 @@ function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormP
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{objectChips.map(chip => ( {objectChips.map(chip => (
<button key={`${chip.objectId}.${chip.wordId}`} type="button" <button key={`${chip.objectId}.${chip.wordId}`} type="button"
onClick={() => insertAtCursor(chip.objectId, chip.wordId)} onClick={() => insertAtCursor(chip)}
style={{ style={{
padding: '2px 7px', borderRadius: 'var(--r-full)', fontSize: 11, padding: '2px 7px', borderRadius: 'var(--r-full)', fontSize: 11,
border: '1px solid #93c5fd', cursor: 'pointer', border: '1px solid #93c5fd', cursor: 'pointer',
@@ -137,7 +171,7 @@ function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormP
onChange={e => setQuestionDe(e.target.value)} onChange={e => setQuestionDe(e.target.value)}
onFocus={() => { lastFocusedRef.current = 'question' }} onFocus={() => { lastFocusedRef.current = 'question' }}
rows={2} rows={2}
placeholder="question_de…" placeholder="z.B. Was ist das? oder: Was macht {1.Hund}?"
style={{ style={{
width: '100%', resize: 'vertical', padding: '6px 8px', width: '100%', resize: 'vertical', padding: '6px 8px',
borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)',
@@ -145,11 +179,7 @@ function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormP
fontFamily: 'var(--font)', fontSize: 12, boxSizing: 'border-box', fontFamily: 'var(--font)', fontSize: 12, boxSizing: 'border-box',
}} }}
/> />
{questionDe && ( {questionDe && <div style={{ marginTop: 3 }}><PreviewText text={questionDe} chips={objectChips} /></div>}
<p style={{ fontSize: 11, color: 'var(--text-2)', marginTop: 2, fontStyle: 'italic' }}>
{resolveTemplate(questionDe)}
</p>
)}
</div> </div>
{/* Statement (required) */} {/* Statement (required) */}
@@ -163,7 +193,7 @@ function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormP
onChange={e => setStatementDe(e.target.value)} onChange={e => setStatementDe(e.target.value)}
onFocus={() => { lastFocusedRef.current = 'statement' }} onFocus={() => { lastFocusedRef.current = 'statement' }}
rows={2} rows={2}
placeholder="statement_de…" placeholder="z.B. Das ist ein {1.Hund}."
style={{ style={{
width: '100%', resize: 'vertical', padding: '6px 8px', width: '100%', resize: 'vertical', padding: '6px 8px',
borderRadius: 'var(--r-sm)', border: `1px solid ${statementDe.trim() ? 'var(--border)' : 'var(--danger)'}`, borderRadius: 'var(--r-sm)', border: `1px solid ${statementDe.trim() ? 'var(--border)' : 'var(--danger)'}`,
@@ -171,11 +201,7 @@ function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormP
fontFamily: 'var(--font)', fontSize: 12, boxSizing: 'border-box', fontFamily: 'var(--font)', fontSize: 12, boxSizing: 'border-box',
}} }}
/> />
{statementDe && ( {statementDe && <div style={{ marginTop: 3 }}><PreviewText text={statementDe} chips={objectChips} /></div>}
<p style={{ fontSize: 11, color: 'var(--text-2)', marginTop: 2, fontStyle: 'italic' }}>
{resolveTemplate(statementDe)}
</p>
)}
</div> </div>
{error && <div style={{ fontSize: 11, color: 'var(--danger)' }}>{error}</div>} {error && <div style={{ fontSize: 11, color: 'var(--danger)' }}>{error}</div>}
@@ -218,19 +244,14 @@ function PairsList({ pairs, loading, objectId, token, objectChips, onRefresh }:
const editStatementRef = useRef<HTMLTextAreaElement>(null) const editStatementRef = useRef<HTMLTextAreaElement>(null)
const editLastFocusedRef = useRef<'question' | 'statement'>('statement') const editLastFocusedRef = useRef<'question' | 'statement'>('statement')
const resolveTemplate = (text: string): string => // Insert display-format placeholder at cursor: {1.Hund}
text.replace(/\{([^.}]+)\.([^}]+)\}/g, (_, oid, wid) => { const insertAtCursor = (chip: ObjectChip) => {
const chip = objectChips.find(c => c.objectId === oid && c.wordId === wid)
return chip ? chip.label : '{?}'
})
const insertAtCursor = (oid: string, wordId: string) => {
const ref = editLastFocusedRef.current === 'question' ? editQuestionRef : editStatementRef const ref = editLastFocusedRef.current === 'question' ? editQuestionRef : editStatementRef
const ta = ref.current const ta = ref.current
if (!ta) return if (!ta) return
const start = ta.selectionStart ?? ta.value.length const start = ta.selectionStart ?? ta.value.length
const end = ta.selectionEnd ?? ta.value.length const end = ta.selectionEnd ?? ta.value.length
const placeholder = `{${oid}.${wordId}}` const placeholder = `{${chip.objectIndex}.${chip.label}}`
const newVal = ta.value.slice(0, start) + placeholder + ta.value.slice(end) const newVal = ta.value.slice(0, start) + placeholder + ta.value.slice(end)
if (editLastFocusedRef.current === 'question') setEditQuestion(newVal) if (editLastFocusedRef.current === 'question') setEditQuestion(newVal)
else setEditStatement(newVal) else setEditStatement(newVal)
@@ -243,8 +264,9 @@ function PairsList({ pairs, loading, objectId, token, objectChips, onRefresh }:
const startEdit = (pair: DbPair) => { const startEdit = (pair: DbPair) => {
setEditingId(pair.id) setEditingId(pair.id)
setEditLevel(pair.level) setEditLevel(pair.level)
setEditStatement(pair.statements[0]?.statement_de ?? '') // Convert storage format (UUIDs) → display format ({1.Hund}) for editing
setEditQuestion(pair.questions[0]?.question_de ?? '') setEditStatement(storageToDisplay(pair.statements[0]?.statement_de ?? '', objectChips))
setEditQuestion(storageToDisplay(pair.questions[0]?.question_de ?? '', objectChips))
} }
const saveEdit = async () => { const saveEdit = async () => {
@@ -253,8 +275,9 @@ function PairsList({ pairs, loading, objectId, token, objectChips, onRefresh }:
try { try {
await updateDbPair(editingId, { await updateDbPair(editingId, {
level: editLevel, level: editLevel,
statement_de: editStatement, // Convert display format → storage format (UUIDs) before saving
question_de: editQuestion, statement_de: displayToStorage(editStatement, objectChips),
question_de: editQuestion.trim() ? displayToStorage(editQuestion, objectChips) : '',
}, token) }, token)
setEditingId(null) setEditingId(null)
onRefresh() onRefresh()
@@ -302,7 +325,7 @@ function PairsList({ pairs, loading, objectId, token, objectChips, onRefresh }:
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{objectChips.map(chip => ( {objectChips.map(chip => (
<button key={`${chip.objectId}.${chip.wordId}`} type="button" <button key={`${chip.objectId}.${chip.wordId}`} type="button"
onClick={() => insertAtCursor(chip.objectId, chip.wordId)} onClick={() => insertAtCursor(chip)}
style={{ style={{
padding: '2px 7px', borderRadius: 'var(--r-full)', fontSize: 11, padding: '2px 7px', borderRadius: 'var(--r-full)', fontSize: 11,
border: '1px solid #93c5fd', cursor: 'pointer', border: '1px solid #93c5fd', cursor: 'pointer',
@@ -325,11 +348,7 @@ function PairsList({ pairs, loading, objectId, token, objectChips, onRefresh }:
placeholder="Aussage (Pflicht)…" placeholder="Aussage (Pflicht)…"
style={{ width: '100%', padding: '5px 8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-1)', fontSize: 12, resize: 'vertical', boxSizing: 'border-box' }} style={{ width: '100%', padding: '5px 8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-1)', fontSize: 12, resize: 'vertical', boxSizing: 'border-box' }}
/> />
{editStatement && ( {editStatement && <div style={{ marginTop: 2 }}><PreviewText text={editStatement} chips={objectChips} /></div>}
<p style={{ fontSize: 11, color: 'var(--text-2)', marginTop: -4, fontStyle: 'italic' }}>
{resolveTemplate(editStatement)}
</p>
)}
<textarea <textarea
ref={editQuestionRef} ref={editQuestionRef}
value={editQuestion} value={editQuestion}
@@ -339,11 +358,7 @@ function PairsList({ pairs, loading, objectId, token, objectChips, onRefresh }:
placeholder="Frage (optional)…" placeholder="Frage (optional)…"
style={{ width: '100%', padding: '5px 8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-1)', fontSize: 12, resize: 'vertical', boxSizing: 'border-box' }} style={{ width: '100%', padding: '5px 8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-1)', fontSize: 12, resize: 'vertical', boxSizing: 'border-box' }}
/> />
{editQuestion && ( {editQuestion && <div style={{ marginTop: 2 }}><PreviewText text={editQuestion} chips={objectChips} /></div>}
<p style={{ fontSize: 11, color: 'var(--text-2)', marginTop: -4, fontStyle: 'italic' }}>
{resolveTemplate(editQuestion)}
</p>
)}
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
<button className="btn-primary btn-sm" style={{ flex: 1 }} onClick={saveEdit} disabled={saving || !editStatement.trim()}> <button className="btn-primary btn-sm" style={{ flex: 1 }} onClick={saveEdit} disabled={saving || !editStatement.trim()}>
@@ -371,12 +386,14 @@ function PairsList({ pairs, loading, objectId, token, objectChips, onRefresh }:
</div> </div>
{pair.statements.map(s => ( {pair.statements.map(s => (
<div key={s.id} style={{ fontSize: 12, color: 'var(--text-1)', marginBottom: 3, paddingLeft: 4 }}> <div key={s.id} style={{ fontSize: 12, color: 'var(--text-1)', marginBottom: 3, paddingLeft: 4 }}>
<span style={{ fontSize: 10, color: 'var(--text-2)', marginRight: 4 }}>Aussage:</span>{resolveTemplate(s.statement_de)} <span style={{ fontSize: 10, color: 'var(--text-2)', marginRight: 4 }}>Aussage:</span>
<PreviewText text={s.statement_de} chips={objectChips} />
</div> </div>
))} ))}
{pair.questions.map(q => ( {pair.questions.map(q => (
<div key={q.id} style={{ fontSize: 12, color: 'var(--text-2)', fontStyle: 'italic', paddingLeft: 4 }}> <div key={q.id} style={{ fontSize: 12, paddingLeft: 4 }}>
<span style={{ fontSize: 10, marginRight: 4, fontStyle: 'normal' }}>Frage:</span>{resolveTemplate(q.question_de)} <span style={{ fontSize: 10, color: 'var(--text-2)', marginRight: 4 }}>Frage:</span>
<PreviewText text={q.question_de} chips={objectChips} />
</div> </div>
))} ))}
</> </>