feat: rework PairForm — 3 checkboxes, validation logic, word pickers

Replaces the answer_type dropdown with 3 independent checkboxes:
- Text: positive statement required; negative requires question+positive;
  question is optional standalone; auto-detects words + {{uuid}} placeholders
- Ja/Nein: addon, adds answer field (null/true/false) to positive statement
- Wort: opens positive/negative word pickers → linked via statement_positive/negative_words

Validation rules:
- At least one type must be checked
- Text: positive statement non-empty
- Negative statement only allowed when question + positive both filled

Word creation: highlight text in any field → 'Als Wort erstellen' button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 20:50:18 +02:00
parent c92191db63
commit 25c31adbdf

View File

@@ -78,31 +78,119 @@ function HighlightedTextarea({ value, onChange, wordMap, rows = 3, placeholder,
);
}
// ─── PairForm ─────────────────────────────────────────────────────────────────
function PairForm({ objectId, onPairSaved, onCancel }) {
// ─── WordSearch (for word pickers) ───────────────────────────────────────────
function WordSearch({ existingIds = [], onAdd, placeholder = 'Wort suchen…' }) {
const lang = getUserLang();
const [q, setQ] = useState('');
const [results, setResults] = useState([]);
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!q.trim()) { setResults([]); return; }
const t = setTimeout(async () => {
try {
const data = await apiFetch(`/words?search=${encodeURIComponent(q.trim())}&limit=10`);
setResults(Array.isArray(data) ? data.filter(w => !existingIds.includes(w.id)) : []);
} catch {}
}, 300);
return () => clearTimeout(t);
}, [q, existingIds]);
useEffect(() => {
function out(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
document.addEventListener('mousedown', out);
return () => document.removeEventListener('mousedown', out);
}, []);
async function handleCreate() {
setCreating(true);
try {
const w = await apiPost('/words', { [`titel_${lang}`]: q.trim() });
onAdd(w); setQ(''); setResults([]); setOpen(false);
} catch (e) { alert('Fehler: ' + e.message); }
finally { setCreating(false); }
}
return (
<div ref={ref} className="relative">
<input
type="text" value={q}
onChange={e => { setQ(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
placeholder={placeholder}
className="w-full border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-indigo-400 bg-white"
/>
{open && q.trim() && (
<div className="absolute z-30 left-0 right-0 mt-1 bg-white border border-slate-200 rounded-lg shadow-lg overflow-hidden max-h-36 overflow-y-auto">
{results.map(w => (
<button key={w.id} onMouseDown={e => e.preventDefault()}
onClick={() => { onAdd(w); setQ(''); setResults([]); setOpen(false); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-indigo-50 hover:text-indigo-700 border-b border-slate-100 last:border-0"
>
{w.titel_de || w.id}{w.titel_en && <span className="text-slate-400 ml-1">({w.titel_en})</span>}
</button>
))}
{results.length === 0 && (
<button onMouseDown={e => e.preventDefault()} onClick={handleCreate} disabled={creating}
className="w-full text-left px-3 py-1.5 text-xs text-indigo-600 hover:bg-indigo-50 font-medium"
>
{creating ? 'Erstelle…' : `+ „${q.trim()}" neu erstellen`}
</button>
)}
</div>
)}
</div>
);
}
function WordTag({ word, onRemove }) {
return (
<span className="inline-flex items-center gap-1 text-xs bg-indigo-50 text-indigo-700 rounded-full pl-2 pr-1 py-0.5 font-medium">
{word.titel_de || word.id}
<button onClick={() => onRemove(word.id)}
className="text-indigo-300 hover:text-red-500 rounded-full w-3.5 h-3.5 flex items-center justify-center">×</button>
</span>
);
}
// ─── PairForm ─────────────────────────────────────────────────────────────────
function PairForm({ objectId, onPairSaved }) {
const lang = getUserLang();
const [answerType, setAnswerType] = useState('word');
const [showForm, setShowForm] = useState(false);
// Type checkboxes
const [typeText, setTypeText] = useState(false);
const [typeYesNo, setTypeYesNo] = useState(false);
const [typeWord, setTypeWord] = useState(false);
// Text fields
const [question, setQuestion] = useState('');
const [positive, setPositive] = useState('');
const [negative, setNegative] = useState('');
const [wordMap, setWordMap] = useState({}); // { 'hund': {id, titel_de, ...} }
const [wordMap, setWordMap] = useState({});
const [selection, setSelection] = useState('');
const [saving, setSaving] = useState(false);
const [creatingWord, setCreatingWord] = useState(false);
// Auto-detect words in all three fields combined
const allText = `${question} ${positive} ${negative}`;
// Yes/No answer
const [yesNoAnswer, setYesNoAnswer] = useState(null); // null | true | false
// Word pickers
const [positiveWords, setPositiveWords] = useState([]);
const [negativeWords, setNegativeWords] = useState([]);
const [saving, setSaving] = useState(false);
// Auto-detect words from text fields (only when Text type active)
const allText = typeText ? `${question} ${positive} ${negative}` : '';
useEffect(() => {
if (!allText.trim()) { setWordMap({}); return; }
const t = setTimeout(async () => {
const words = [...new Set(
allText.split(/[\s.,!?;:()\[\]"']+/).filter(w => w.length >= 2)
)];
if (!words.length) return;
const tokens = [...new Set(allText.split(/[\s.,!?;:()\[\]"']+/).filter(w => w.length >= 2))];
if (!tokens.length) return;
const results = await Promise.allSettled(
words.map(w =>
tokens.map(w =>
apiFetch(`/words?titel_${lang}=${encodeURIComponent(w)}&limit=1`)
.then(d => Array.isArray(d) && d.length ? { key: w.toLowerCase(), word: d[0] } : null)
)
@@ -118,151 +206,251 @@ function PairForm({ objectId, onPairSaved, onCancel }) {
if (!selection.trim()) return;
setCreatingWord(true);
try {
const body = { [`titel_${lang}`]: selection.trim() };
const w = await apiPost('/words', body);
const w = await apiPost('/words', { [`titel_${lang}`]: selection.trim() });
setWordMap(prev => ({ ...prev, [selection.trim().toLowerCase()]: w }));
} catch (e) { alert('Fehler: ' + e.message); }
finally { setCreatingWord(false); }
}
// Validation
const atLeastOne = typeText || typeYesNo || typeWord;
const textOk = !typeText || positive.trim().length > 0;
const negativeOk = !negative.trim() || (question.trim().length > 0 && positive.trim().length > 0);
const canSave = atLeastOne && textOk && negativeOk;
function validationHint() {
if (!atLeastOne) return 'Wähle mindestens einen Typ (Text / Ja·Nein / Wort).';
if (!textOk) return 'Text: Positive Aussage ist Pflicht.';
if (!negativeOk) return 'Negative Aussage erfordert Frage + Positive Aussage.';
return '';
}
function reset() {
setTypeText(false); setTypeYesNo(false); setTypeWord(false);
setQuestion(''); setPositive(''); setNegative('');
setWordMap({}); setYesNoAnswer(null);
setPositiveWords([]); setNegativeWords([]);
setShowForm(false);
}
async function handleSave() {
if (!question.trim() || !positive.trim() || !negative.trim()) {
alert('Bitte alle drei Felder ausfüllen.');
return;
}
if (!canSave) return;
setSaving(true);
try {
const qField = `sentence_${lang}`;
const posProcField = `positive_sentence_${lang}`;
const negProcField = `negative_sentence_${lang}`;
const answerTypes = [
...(typeText ? ['text'] : []),
...(typeYesNo ? ['yes_no'] : []),
...(typeWord ? ['word'] : []),
];
// Insert word placeholders
const qProcessed = withPlaceholders(question, wordMap);
const posProcessed = withPlaceholders(positive, wordMap);
const negProcessed = withPlaceholders(negative, wordMap);
// Question (only if Text + question text filled)
let questionId = null;
if (typeText && question.trim()) {
const q = await apiPost('/questions', {
[`sentence_${lang}`]: withPlaceholders(question, wordMap),
status: 'draft',
});
questionId = q.id;
}
// 1. Create question
const q = await apiPost('/questions', { [qField]: qProcessed, status: 'draft' });
// Positive statement
const posBody = { status: 'draft' };
if (typeText && positive.trim())
posBody[`positive_sentence_${lang}`] = withPlaceholders(positive, wordMap);
if (typeYesNo && yesNoAnswer !== null)
posBody.answer = yesNoAnswer;
// 2. Create positive statement (separate record)
const posStmt = await apiPost('/statements', { [posProcField]: posProcessed, status: 'draft' });
const posStmt = await apiPost('/statements', posBody);
// 3. Create negative statement (separate record)
const negStmt = await apiPost('/statements', { [negProcField]: negProcessed, status: 'draft' });
// Link positive words to positive statement
if (typeWord && positiveWords.length)
await Promise.all(positiveWords.map(w => apiLink(`/statements/${posStmt.id}/positive-words/${w.id}`)));
// 4. Create pair
// Negative statement (Text + negative text, OR Word + negativeWords)
let negStmtId = null;
const hasNegText = typeText && negative.trim();
const hasNegWords = typeWord && negativeWords.length > 0;
if (hasNegText || hasNegWords) {
const negBody = { status: 'draft' };
if (hasNegText)
negBody[`negative_sentence_${lang}`] = withPlaceholders(negative, wordMap);
const negStmt = await apiPost('/statements', negBody);
negStmtId = negStmt.id;
if (hasNegWords)
await Promise.all(negativeWords.map(w => apiLink(`/statements/${negStmt.id}/negative-words/${w.id}`)));
}
// Pair
const pair = await apiPost('/pairs', {
answer_type: answerType,
question_id: q.id,
answer_type: answerTypes,
question_id: questionId,
positive_statement_id: posStmt.id,
negative_statement_id: negStmt.id,
negative_statement_id: negStmtId,
status: 'draft',
});
// 5. Link pair to object
await apiLink(`/objects/${objectId}/pairs/${pair.id}`);
// Reset
setQuestion(''); setPositive(''); setNegative('');
setWordMap({}); setShowForm(false);
onPairSaved({ ...pair, question: q, positive_statement: posStmt, negative_statement: negStmt });
reset();
onPairSaved({ ...pair, question: questionId ? { [`sentence_${lang}`]: question } : null,
positive_statement: posStmt, negative_statement: null });
} catch (e) { alert('Fehler: ' + e.message); }
finally { setSaving(false); }
}
return (
<div className="space-y-3">
{/* Answer type + toggle button */}
<div className="flex items-center gap-2">
<select
value={answerType}
onChange={e => setAnswerType(e.target.value)}
className="border border-slate-300 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-indigo-400 bg-white"
>
<option value="word">Wort</option>
<option value="yes_no">Ja / Nein</option>
<option value="text">Text</option>
</select>
<button
onClick={() => setShowForm(s => !s)}
className="flex-1 py-1.5 text-xs font-medium rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white transition-colors"
>
{showForm ? '✕ Abbrechen' : '+ Add new pair'}
</button>
</div>
<div className="space-y-2">
{/* Toggle button */}
<button
onClick={() => setShowForm(s => !s)}
className="w-full py-1.5 text-xs font-medium rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white transition-colors"
>
{showForm ? '✕ Abbrechen' : '+ Add new pair'}
</button>
{showForm && (
<div className="border border-indigo-100 rounded-xl p-3 bg-indigo-50/40 space-y-3">
{/* "Als Wort erstellen" — floating */}
{selection && (
<div className="flex items-center gap-2 bg-white border border-indigo-200 rounded-lg px-3 py-2">
<span className="text-xs text-slate-600 flex-1">
Ausgewählt: <strong className="text-indigo-700">{selection}"</strong>
</span>
<button
onClick={handleCreateWord}
disabled={creatingWord}
className="text-xs bg-indigo-600 hover:bg-indigo-700 text-white px-2.5 py-1 rounded-lg font-medium disabled:opacity-50"
>
{creatingWord ? 'Erstelle…' : 'Als Wort erstellen'}
</button>
<div className="border border-indigo-100 rounded-xl p-3 bg-indigo-50/30 space-y-3">
{/* ── Type checkboxes ── */}
<div className="flex items-center gap-4">
{[['typeText', typeText, setTypeText, 'Text'],
['typeYesNo', typeYesNo, setTypeYesNo, 'Ja / Nein'],
['typeWord', typeWord, setTypeWord, 'Wort'],
].map(([key, val, setter, label]) => (
<label key={key} className="flex items-center gap-1.5 text-xs font-medium text-slate-700 cursor-pointer select-none">
<input
type="checkbox"
checked={val}
onChange={e => setter(e.target.checked)}
className="w-3.5 h-3.5 accent-indigo-600"
/>
{label}
</label>
))}
</div>
{/* ── Text section ── */}
{typeText && (
<div className="space-y-2.5 pt-1 border-t border-indigo-100">
{/* "Als Wort erstellen" banner */}
{selection && (
<div className="flex items-center gap-2 bg-white border border-indigo-200 rounded-lg px-2.5 py-1.5">
<span className="text-xs text-slate-600 flex-1 truncate">
<strong className="text-indigo-700">{selection}</strong>"
</span>
<button onClick={handleCreateWord} disabled={creatingWord}
className="text-xs bg-indigo-600 hover:bg-indigo-700 text-white px-2 py-0.5 rounded font-medium disabled:opacity-50 whitespace-nowrap">
{creatingWord ? '…' : 'Als Wort erstellen'}
</button>
</div>
)}
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
Positive Aussage <span className="text-red-400">*</span>
</label>
<HighlightedTextarea value={positive} onChange={setPositive} wordMap={wordMap}
rows={2} placeholder="Das ist ein Hund." onSelectionChange={setSelection} />
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
Frage <span className="text-slate-300">(optional)</span>
</label>
<HighlightedTextarea value={question} onChange={setQuestion} wordMap={wordMap}
rows={2} placeholder="Was ist das?" onSelectionChange={setSelection} />
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
Negative Aussage
<span className="text-slate-300 ml-1">(optional — braucht Frage + Positiv)</span>
</label>
<HighlightedTextarea value={negative} onChange={setNegative} wordMap={wordMap}
rows={2} placeholder="Das ist keine Katze." onSelectionChange={setSelection} />
{negative.trim() && !negativeOk && (
<p className="text-xs text-red-500 mt-0.5">Frage + Positive Aussage erforderlich.</p>
)}
</div>
{Object.keys(wordMap).length > 0 && (
<div className="flex flex-wrap gap-1">
<span className="text-xs text-slate-400 w-full">Erkannte Wörter:</span>
{Object.entries(wordMap).map(([title, w]) => (
<span key={w.id} className="text-xs bg-indigo-100 text-indigo-700 rounded px-1.5 py-0.5">{title}</span>
))}
</div>
)}
</div>
)}
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Frage</label>
<HighlightedTextarea
value={question}
onChange={setQuestion}
wordMap={wordMap}
rows={2}
placeholder="Was ist das?"
onSelectionChange={setSelection}
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Positive Aussage</label>
<HighlightedTextarea
value={positive}
onChange={setPositive}
wordMap={wordMap}
rows={2}
placeholder="Das ist ein Hund."
onSelectionChange={setSelection}
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Negative Aussage</label>
<HighlightedTextarea
value={negative}
onChange={setNegative}
wordMap={wordMap}
rows={2}
placeholder="Das ist keine Katze."
onSelectionChange={setSelection}
/>
</div>
{Object.keys(wordMap).length > 0 && (
<div className="flex flex-wrap gap-1">
<span className="text-xs text-slate-400">Erkannte Wörter:</span>
{Object.entries(wordMap).map(([title, w]) => (
<span key={w.id} className="text-xs bg-indigo-100 text-indigo-700 rounded px-1.5 py-0.5 font-medium">
{title}
</span>
))}
{/* ── Ja/Nein section ── */}
{typeYesNo && (
<div className="pt-2 border-t border-indigo-100">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Antwort</p>
<div className="flex gap-2">
{[
[null, '— Offen', 'bg-slate-100 text-slate-600 border-slate-200'],
[true, '✓ Ja', 'bg-green-50 text-green-700 border-green-300'],
[false, '✗ Nein', 'bg-red-50 text-red-600 border-red-300'],
].map(([val, label, cls]) => (
<button key={String(val)}
onClick={() => setYesNoAnswer(val)}
className={`flex-1 py-1.5 text-xs font-medium rounded-lg border transition-all
${yesNoAnswer === val ? `${cls} ring-2 ring-offset-1 ring-indigo-400` : 'bg-white text-slate-500 border-slate-200 hover:border-slate-300'}`}
>
{label}
</button>
))}
</div>
</div>
)}
<button
onClick={handleSave}
disabled={saving || !question.trim() || !positive.trim() || !negative.trim()}
className="w-full py-2 text-xs font-medium rounded-lg bg-green-600 hover:bg-green-700 disabled:opacity-40 text-white transition-colors"
>
{saving ? 'Speichern…' : '✓ Pair speichern'}
</button>
{/* ── Wort section ── */}
{typeWord && (
<div className="pt-2 border-t border-indigo-100 space-y-2.5">
<div>
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">Positive Wörter</p>
<div className="flex flex-wrap gap-1 mb-1.5">
{positiveWords.map(w => (
<WordTag key={w.id} word={w} onRemove={id => setPositiveWords(prev => prev.filter(x => x.id !== id))} />
))}
</div>
<WordSearch
existingIds={positiveWords.map(w => w.id)}
onAdd={w => setPositiveWords(prev => prev.some(x => x.id === w.id) ? prev : [...prev, w])}
placeholder="Positives Wort suchen"
/>
</div>
<div>
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">Negative Wörter</p>
<div className="flex flex-wrap gap-1 mb-1.5">
{negativeWords.map(w => (
<WordTag key={w.id} word={w} onRemove={id => setNegativeWords(prev => prev.filter(x => x.id !== id))} />
))}
</div>
<WordSearch
existingIds={negativeWords.map(w => w.id)}
onAdd={w => setNegativeWords(prev => prev.some(x => x.id === w.id) ? prev : [...prev, w])}
placeholder="Negatives Wort suchen"
/>
</div>
</div>
)}
{/* ── Save ── */}
<div className="pt-2 border-t border-indigo-100 space-y-1.5">
{!canSave && <p className="text-xs text-amber-600">{validationHint()}</p>}
<button
onClick={handleSave}
disabled={saving || !canSave}
className="w-full py-2 text-xs font-medium rounded-lg bg-green-600 hover:bg-green-700 disabled:opacity-40 text-white transition-colors"
>
{saving ? 'Speichern…' : '✓ Pair speichern'}
</button>
</div>
</div>
)}
</div>