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