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:
@@ -78,31 +78,119 @@ function HighlightedTextarea({ value, onChange, wordMap, rows = 3, placeholder,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── PairForm ─────────────────────────────────────────────────────────────────
|
// ─── WordSearch (for word pickers) ───────────────────────────────────────────
|
||||||
function PairForm({ objectId, onPairSaved, onCancel }) {
|
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 lang = getUserLang();
|
||||||
const [answerType, setAnswerType] = useState('word');
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
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 [question, setQuestion] = useState('');
|
||||||
const [positive, setPositive] = useState('');
|
const [positive, setPositive] = useState('');
|
||||||
const [negative, setNegative] = useState('');
|
const [negative, setNegative] = useState('');
|
||||||
const [wordMap, setWordMap] = useState({}); // { 'hund': {id, titel_de, ...} }
|
const [wordMap, setWordMap] = useState({});
|
||||||
const [selection, setSelection] = useState('');
|
const [selection, setSelection] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [creatingWord, setCreatingWord] = useState(false);
|
const [creatingWord, setCreatingWord] = useState(false);
|
||||||
|
|
||||||
// Auto-detect words in all three fields combined
|
// Yes/No answer
|
||||||
const allText = `${question} ${positive} ${negative}`;
|
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(() => {
|
useEffect(() => {
|
||||||
|
if (!allText.trim()) { setWordMap({}); return; }
|
||||||
const t = setTimeout(async () => {
|
const t = setTimeout(async () => {
|
||||||
const words = [...new Set(
|
const tokens = [...new Set(allText.split(/[\s.,!?;:()\[\]"']+/).filter(w => w.length >= 2))];
|
||||||
allText.split(/[\s.,!?;:()\[\]"']+/).filter(w => w.length >= 2)
|
if (!tokens.length) return;
|
||||||
)];
|
|
||||||
if (!words.length) return;
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
words.map(w =>
|
tokens.map(w =>
|
||||||
apiFetch(`/words?titel_${lang}=${encodeURIComponent(w)}&limit=1`)
|
apiFetch(`/words?titel_${lang}=${encodeURIComponent(w)}&limit=1`)
|
||||||
.then(d => Array.isArray(d) && d.length ? { key: w.toLowerCase(), word: d[0] } : null)
|
.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;
|
if (!selection.trim()) return;
|
||||||
setCreatingWord(true);
|
setCreatingWord(true);
|
||||||
try {
|
try {
|
||||||
const body = { [`titel_${lang}`]: selection.trim() };
|
const w = await apiPost('/words', { [`titel_${lang}`]: selection.trim() });
|
||||||
const w = await apiPost('/words', body);
|
|
||||||
setWordMap(prev => ({ ...prev, [selection.trim().toLowerCase()]: w }));
|
setWordMap(prev => ({ ...prev, [selection.trim().toLowerCase()]: w }));
|
||||||
} catch (e) { alert('Fehler: ' + e.message); }
|
} catch (e) { alert('Fehler: ' + e.message); }
|
||||||
finally { setCreatingWord(false); }
|
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() {
|
async function handleSave() {
|
||||||
if (!question.trim() || !positive.trim() || !negative.trim()) {
|
if (!canSave) return;
|
||||||
alert('Bitte alle drei Felder ausfüllen.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const qField = `sentence_${lang}`;
|
const answerTypes = [
|
||||||
const posProcField = `positive_sentence_${lang}`;
|
...(typeText ? ['text'] : []),
|
||||||
const negProcField = `negative_sentence_${lang}`;
|
...(typeYesNo ? ['yes_no'] : []),
|
||||||
|
...(typeWord ? ['word'] : []),
|
||||||
|
];
|
||||||
|
|
||||||
// Insert word placeholders
|
// Question (only if Text + question text filled)
|
||||||
const qProcessed = withPlaceholders(question, wordMap);
|
let questionId = null;
|
||||||
const posProcessed = withPlaceholders(positive, wordMap);
|
if (typeText && question.trim()) {
|
||||||
const negProcessed = withPlaceholders(negative, wordMap);
|
const q = await apiPost('/questions', {
|
||||||
|
[`sentence_${lang}`]: withPlaceholders(question, wordMap),
|
||||||
|
status: 'draft',
|
||||||
|
});
|
||||||
|
questionId = q.id;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Create question
|
// Positive statement
|
||||||
const q = await apiPost('/questions', { [qField]: qProcessed, status: 'draft' });
|
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', posBody);
|
||||||
const posStmt = await apiPost('/statements', { [posProcField]: posProcessed, status: 'draft' });
|
|
||||||
|
|
||||||
// 3. Create negative statement (separate record)
|
// Link positive words to positive statement
|
||||||
const negStmt = await apiPost('/statements', { [negProcField]: negProcessed, status: 'draft' });
|
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', {
|
const pair = await apiPost('/pairs', {
|
||||||
answer_type: answerType,
|
answer_type: answerTypes,
|
||||||
question_id: q.id,
|
question_id: questionId,
|
||||||
positive_statement_id: posStmt.id,
|
positive_statement_id: posStmt.id,
|
||||||
negative_statement_id: negStmt.id,
|
negative_statement_id: negStmtId,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Link pair to object
|
|
||||||
await apiLink(`/objects/${objectId}/pairs/${pair.id}`);
|
await apiLink(`/objects/${objectId}/pairs/${pair.id}`);
|
||||||
|
|
||||||
// Reset
|
reset();
|
||||||
setQuestion(''); setPositive(''); setNegative('');
|
onPairSaved({ ...pair, question: questionId ? { [`sentence_${lang}`]: question } : null,
|
||||||
setWordMap({}); setShowForm(false);
|
positive_statement: posStmt, negative_statement: null });
|
||||||
onPairSaved({ ...pair, question: q, positive_statement: posStmt, negative_statement: negStmt });
|
|
||||||
} catch (e) { alert('Fehler: ' + e.message); }
|
} catch (e) { alert('Fehler: ' + e.message); }
|
||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
{/* Answer type + toggle button */}
|
{/* Toggle button */}
|
||||||
<div className="flex items-center gap-2">
|
<button
|
||||||
<select
|
onClick={() => setShowForm(s => !s)}
|
||||||
value={answerType}
|
className="w-full py-1.5 text-xs font-medium rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white transition-colors"
|
||||||
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"
|
{showForm ? '✕ Abbrechen' : '+ Add new pair'}
|
||||||
>
|
</button>
|
||||||
<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>
|
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="border border-indigo-100 rounded-xl p-3 bg-indigo-50/40 space-y-3">
|
<div className="border border-indigo-100 rounded-xl p-3 bg-indigo-50/30 space-y-3">
|
||||||
{/* "Als Wort erstellen" — floating */}
|
|
||||||
{selection && (
|
{/* ── Type checkboxes ── */}
|
||||||
<div className="flex items-center gap-2 bg-white border border-indigo-200 rounded-lg px-3 py-2">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-xs text-slate-600 flex-1">
|
{[['typeText', typeText, setTypeText, 'Text'],
|
||||||
Ausgewählt: <strong className="text-indigo-700">„{selection}"</strong>
|
['typeYesNo', typeYesNo, setTypeYesNo, 'Ja / Nein'],
|
||||||
</span>
|
['typeWord', typeWord, setTypeWord, 'Wort'],
|
||||||
<button
|
].map(([key, val, setter, label]) => (
|
||||||
onClick={handleCreateWord}
|
<label key={key} className="flex items-center gap-1.5 text-xs font-medium text-slate-700 cursor-pointer select-none">
|
||||||
disabled={creatingWord}
|
<input
|
||||||
className="text-xs bg-indigo-600 hover:bg-indigo-700 text-white px-2.5 py-1 rounded-lg font-medium disabled:opacity-50"
|
type="checkbox"
|
||||||
>
|
checked={val}
|
||||||
{creatingWord ? 'Erstelle…' : 'Als Wort erstellen'}
|
onChange={e => setter(e.target.checked)}
|
||||||
</button>
|
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
{/* ── Ja/Nein section ── */}
|
||||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Frage</label>
|
{typeYesNo && (
|
||||||
<HighlightedTextarea
|
<div className="pt-2 border-t border-indigo-100">
|
||||||
value={question}
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Antwort</p>
|
||||||
onChange={setQuestion}
|
<div className="flex gap-2">
|
||||||
wordMap={wordMap}
|
{[
|
||||||
rows={2}
|
[null, '— Offen', 'bg-slate-100 text-slate-600 border-slate-200'],
|
||||||
placeholder="Was ist das?"
|
[true, '✓ Ja', 'bg-green-50 text-green-700 border-green-300'],
|
||||||
onSelectionChange={setSelection}
|
[false, '✗ Nein', 'bg-red-50 text-red-600 border-red-300'],
|
||||||
/>
|
].map(([val, label, cls]) => (
|
||||||
</div>
|
<button key={String(val)}
|
||||||
|
onClick={() => setYesNoAnswer(val)}
|
||||||
<div>
|
className={`flex-1 py-1.5 text-xs font-medium rounded-lg border transition-all
|
||||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Positive Aussage</label>
|
${yesNoAnswer === val ? `${cls} ring-2 ring-offset-1 ring-indigo-400` : 'bg-white text-slate-500 border-slate-200 hover:border-slate-300'}`}
|
||||||
<HighlightedTextarea
|
>
|
||||||
value={positive}
|
{label}
|
||||||
onChange={setPositive}
|
</button>
|
||||||
wordMap={wordMap}
|
))}
|
||||||
rows={2}
|
</div>
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
{/* ── Wort section ── */}
|
||||||
onClick={handleSave}
|
{typeWord && (
|
||||||
disabled={saving || !question.trim() || !positive.trim() || !negative.trim()}
|
<div className="pt-2 border-t border-indigo-100 space-y-2.5">
|
||||||
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"
|
<div>
|
||||||
>
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">Positive Wörter</p>
|
||||||
{saving ? 'Speichern…' : '✓ Pair speichern'}
|
<div className="flex flex-wrap gap-1 mb-1.5">
|
||||||
</button>
|
{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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user