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