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>
)
// ── 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 ──────────────────────────────────────────────────────────────────
interface PairFormProps {
@@ -41,8 +79,8 @@ interface PairFormProps {
function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormProps) {
const [level, setLevel] = useState(50)
const [questionDe, setQuestionDe] = useState('')
const [statementDe, setStatementDe] = useState('')
const [questionDe, setQuestionDe] = useState('') // display format: {1.Hund}
const [statementDe, setStatementDe] = useState('') // display format: {1.Hund}
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
@@ -50,19 +88,14 @@ function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormP
const statementRef = useRef<HTMLTextAreaElement>(null)
const lastFocusedRef = useRef<'question' | 'statement'>('statement')
const resolveTemplate = (text: string): string =>
text.replace(/\{([^.}]+)\.([^}]+)\}/g, (_, oid, wid) => {
const chip = objectChips.find(c => c.objectId === oid && c.wordId === wid)
return chip ? chip.label : '{?}'
})
const insertAtCursor = (oid: string, wordId: string) => {
// Insert display-format placeholder at cursor: {1.Hund}
const insertAtCursor = (chip: ObjectChip) => {
const ref = lastFocusedRef.current === 'question' ? questionRef : statementRef
const ta = ref.current
if (!ta) return
const start = ta.selectionStart ?? 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)
if (lastFocusedRef.current === 'question') setQuestionDe(newVal)
else setStatementDe(newVal)
@@ -73,13 +106,14 @@ function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormP
}
const handleSave = async () => {
if (!statementDe.trim()) { setError('Aussage (statement_de) ist Pflicht.'); return }
if (!statementDe.trim()) { setError('Aussage ist Pflicht.'); return }
setSaving(true)
setError('')
try {
// Convert display format → storage format (UUIDs) before saving
await createDbPair(objectId, {
question_de: questionDe.trim() || undefined,
statement_de: statementDe.trim(),
question_de: questionDe.trim() ? displayToStorage(questionDe.trim(), objectChips) : undefined,
statement_de: displayToStorage(statementDe.trim(), objectChips),
level,
words: [],
}, token)
@@ -112,7 +146,7 @@ function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormP
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{objectChips.map(chip => (
<button key={`${chip.objectId}.${chip.wordId}`} type="button"
onClick={() => insertAtCursor(chip.objectId, chip.wordId)}
onClick={() => insertAtCursor(chip)}
style={{
padding: '2px 7px', borderRadius: 'var(--r-full)', fontSize: 11,
border: '1px solid #93c5fd', cursor: 'pointer',
@@ -137,7 +171,7 @@ function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormP
onChange={e => setQuestionDe(e.target.value)}
onFocus={() => { lastFocusedRef.current = 'question' }}
rows={2}
placeholder="question_de…"
placeholder="z.B. Was ist das? oder: Was macht {1.Hund}?"
style={{
width: '100%', resize: 'vertical', padding: '6px 8px',
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',
}}
/>
{questionDe && (
<p style={{ fontSize: 11, color: 'var(--text-2)', marginTop: 2, fontStyle: 'italic' }}>
{resolveTemplate(questionDe)}
</p>
)}
{questionDe && <div style={{ marginTop: 3 }}><PreviewText text={questionDe} chips={objectChips} /></div>}
</div>
{/* Statement (required) */}
@@ -163,7 +193,7 @@ function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormP
onChange={e => setStatementDe(e.target.value)}
onFocus={() => { lastFocusedRef.current = 'statement' }}
rows={2}
placeholder="statement_de…"
placeholder="z.B. Das ist ein {1.Hund}."
style={{
width: '100%', resize: 'vertical', padding: '6px 8px',
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',
}}
/>
{statementDe && (
<p style={{ fontSize: 11, color: 'var(--text-2)', marginTop: 2, fontStyle: 'italic' }}>
{resolveTemplate(statementDe)}
</p>
)}
{statementDe && <div style={{ marginTop: 3 }}><PreviewText text={statementDe} chips={objectChips} /></div>}
</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 editLastFocusedRef = useRef<'question' | 'statement'>('statement')
const resolveTemplate = (text: string): string =>
text.replace(/\{([^.}]+)\.([^}]+)\}/g, (_, oid, wid) => {
const chip = objectChips.find(c => c.objectId === oid && c.wordId === wid)
return chip ? chip.label : '{?}'
})
const insertAtCursor = (oid: string, wordId: string) => {
// Insert display-format placeholder at cursor: {1.Hund}
const insertAtCursor = (chip: ObjectChip) => {
const ref = editLastFocusedRef.current === 'question' ? editQuestionRef : editStatementRef
const ta = ref.current
if (!ta) return
const start = ta.selectionStart ?? 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)
if (editLastFocusedRef.current === 'question') setEditQuestion(newVal)
else setEditStatement(newVal)
@@ -243,8 +264,9 @@ function PairsList({ pairs, loading, objectId, token, objectChips, onRefresh }:
const startEdit = (pair: DbPair) => {
setEditingId(pair.id)
setEditLevel(pair.level)
setEditStatement(pair.statements[0]?.statement_de ?? '')
setEditQuestion(pair.questions[0]?.question_de ?? '')
// Convert storage format (UUIDs) → display format ({1.Hund}) for editing
setEditStatement(storageToDisplay(pair.statements[0]?.statement_de ?? '', objectChips))
setEditQuestion(storageToDisplay(pair.questions[0]?.question_de ?? '', objectChips))
}
const saveEdit = async () => {
@@ -253,8 +275,9 @@ function PairsList({ pairs, loading, objectId, token, objectChips, onRefresh }:
try {
await updateDbPair(editingId, {
level: editLevel,
statement_de: editStatement,
question_de: editQuestion,
// Convert display format → storage format (UUIDs) before saving
statement_de: displayToStorage(editStatement, objectChips),
question_de: editQuestion.trim() ? displayToStorage(editQuestion, objectChips) : '',
}, token)
setEditingId(null)
onRefresh()
@@ -302,7 +325,7 @@ function PairsList({ pairs, loading, objectId, token, objectChips, onRefresh }:
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{objectChips.map(chip => (
<button key={`${chip.objectId}.${chip.wordId}`} type="button"
onClick={() => insertAtCursor(chip.objectId, chip.wordId)}
onClick={() => insertAtCursor(chip)}
style={{
padding: '2px 7px', borderRadius: 'var(--r-full)', fontSize: 11,
border: '1px solid #93c5fd', cursor: 'pointer',
@@ -325,11 +348,7 @@ function PairsList({ pairs, loading, objectId, token, objectChips, onRefresh }:
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' }}
/>
{editStatement && (
<p style={{ fontSize: 11, color: 'var(--text-2)', marginTop: -4, fontStyle: 'italic' }}>
{resolveTemplate(editStatement)}
</p>
)}
{editStatement && <div style={{ marginTop: 2 }}><PreviewText text={editStatement} chips={objectChips} /></div>}
<textarea
ref={editQuestionRef}
value={editQuestion}
@@ -339,11 +358,7 @@ function PairsList({ pairs, loading, objectId, token, objectChips, onRefresh }:
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' }}
/>
{editQuestion && (
<p style={{ fontSize: 11, color: 'var(--text-2)', marginTop: -4, fontStyle: 'italic' }}>
{resolveTemplate(editQuestion)}
</p>
)}
{editQuestion && <div style={{ marginTop: 2 }}><PreviewText text={editQuestion} chips={objectChips} /></div>}
<div style={{ display: 'flex', gap: 4 }}>
<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>
{pair.statements.map(s => (
<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>
))}
{pair.questions.map(q => (
<div key={q.id} style={{ fontSize: 12, color: 'var(--text-2)', fontStyle: 'italic', paddingLeft: 4 }}>
<span style={{ fontSize: 10, marginRight: 4, fontStyle: 'normal' }}>Frage:</span>{resolveTemplate(q.question_de)}
<div key={q.id} style={{ fontSize: 12, paddingLeft: 4 }}>
<span style={{ fontSize: 10, color: 'var(--text-2)', marginRight: 4 }}>Frage:</span>
<PreviewText text={q.question_de} chips={objectChips} />
</div>
))}
</>