Fix white-page crash in PairForm word detection
- tokenize(): add try/catch + filter empty/null parts so a bad regex never propagates to the React render tree - Word detection useEffect: wrap entire async block and each per-token fetch in try/catch; filter null/malformed word objects out of the map before setWordMap() so the render never receives w.id === undefined - "Erkannte Wörter" sections in PairForm and EditPairForm: filter wordMap entries where w?.id is falsy; use (allObjects || []) defensively - handleCreateWord: only update wordMap when the API response contains w.id - After successful save in PairForm: reset all text/word state so the form starts clean for the next pair Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,15 +6,21 @@ import { STATUS_COLORS } from '../lib/tables';
|
|||||||
// ─── Word / placeholder helpers ───────────────────────────────────────────────
|
// ─── Word / placeholder helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
function tokenize(text, wordMap) {
|
function tokenize(text, wordMap) {
|
||||||
const titles = Object.keys(wordMap);
|
try {
|
||||||
if (!titles.length || !text) return [{ text }];
|
const titles = Object.keys(wordMap).filter(k => k && k.length > 0);
|
||||||
const escaped = titles.sort((a, b) => b.length - a.length)
|
if (!titles.length || !text) return [{ text: text || '' }];
|
||||||
.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
const escaped = titles.sort((a, b) => b.length - a.length)
|
||||||
const re = new RegExp(`(${escaped.join('|')})`, 'gi');
|
.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||||
return text.split(re).filter(s => s !== '').map(part => ({
|
.filter(e => e.length > 0);
|
||||||
text: part,
|
if (!escaped.length) return [{ text }];
|
||||||
word: wordMap[part.toLowerCase()] || null,
|
const re = new RegExp(`(${escaped.join('|')})`, 'gi');
|
||||||
}));
|
return text.split(re).filter(s => s != null && s !== '').map(part => ({
|
||||||
|
text: part,
|
||||||
|
word: wordMap[part.toLowerCase()] || null,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [{ text: text || '', word: null }];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function withPlaceholders(text, wordMap, objectAssignments = {}) {
|
function withPlaceholders(text, wordMap, objectAssignments = {}) {
|
||||||
@@ -206,19 +212,27 @@ function PairForm({ objectId, allObjects, onPairSaved }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!allText.trim()) { setWordMap({}); return; }
|
if (!allText.trim()) { setWordMap({}); return; }
|
||||||
const t = setTimeout(async () => {
|
const t = setTimeout(async () => {
|
||||||
const tokens = [...new Set(allText.split(/[\s.,!?;:()\[\]"']+/).filter(w => w.length >= 2))];
|
try {
|
||||||
if (!tokens.length) return;
|
const tokens = [...new Set(allText.split(/[\s.,!?;:()\[\]"']+/).filter(w => w.length >= 2))];
|
||||||
const results = await Promise.allSettled(
|
if (!tokens.length) return;
|
||||||
tokens.map(async w => {
|
const results = await Promise.allSettled(
|
||||||
const data = await apiFetch(`/words?search=${encodeURIComponent(w)}&limit=5`);
|
tokens.map(async w => {
|
||||||
const candidates = Array.isArray(data) ? data : [];
|
try {
|
||||||
const match = candidates.find(word => fuzzyMatch(w, word[`titel_${lang}`] || word.titel_de || ''));
|
const data = await apiFetch(`/words?search=${encodeURIComponent(w)}&limit=5`);
|
||||||
return match ? { key: w.toLowerCase(), word: match } : null;
|
const candidates = Array.isArray(data) ? data.filter(Boolean) : [];
|
||||||
})
|
const match = candidates.find(word => word && word.id && fuzzyMatch(w, word[`titel_${lang}`] || word.titel_de || ''));
|
||||||
);
|
return match ? { key: w.toLowerCase(), word: match } : null;
|
||||||
const map = {};
|
} catch { return null; }
|
||||||
results.forEach(r => { if (r.status === 'fulfilled' && r.value) map[r.value.key] = r.value.word; });
|
})
|
||||||
setWordMap(map);
|
);
|
||||||
|
const map = {};
|
||||||
|
results.forEach(r => {
|
||||||
|
if (r.status === 'fulfilled' && r.value && r.value.key && r.value.word?.id) {
|
||||||
|
map[r.value.key] = r.value.word;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setWordMap(map);
|
||||||
|
} catch { /* ignore word detection errors */ }
|
||||||
}, 600);
|
}, 600);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [allText, lang]);
|
}, [allText, lang]);
|
||||||
@@ -228,7 +242,7 @@ function PairForm({ objectId, allObjects, onPairSaved }) {
|
|||||||
setCreatingWord(true);
|
setCreatingWord(true);
|
||||||
try {
|
try {
|
||||||
const w = await apiPost('/words', { [`titel_${lang}`]: wordInput.trim() });
|
const w = await apiPost('/words', { [`titel_${lang}`]: wordInput.trim() });
|
||||||
setWordMap(prev => ({ ...prev, [selection.trim().toLowerCase()]: w }));
|
if (w?.id) setWordMap(prev => ({ ...prev, [selection.trim().toLowerCase()]: w }));
|
||||||
setWordInput('');
|
setWordInput('');
|
||||||
} catch (e) { alert('Fehler: ' + e.message); }
|
} catch (e) { alert('Fehler: ' + e.message); }
|
||||||
finally { setCreatingWord(false); }
|
finally { setCreatingWord(false); }
|
||||||
@@ -287,6 +301,9 @@ function PairForm({ objectId, allObjects, onPairSaved }) {
|
|||||||
});
|
});
|
||||||
await apiLink(`/objects/${objectId}/pairs/${pair.id}`);
|
await apiLink(`/objects/${objectId}/pairs/${pair.id}`);
|
||||||
setType(''); setYesNoAnswer(null);
|
setType(''); setYesNoAnswer(null);
|
||||||
|
setQuestion(''); setPositive(''); setNegative('');
|
||||||
|
setWordMap({}); setObjectAssignments({});
|
||||||
|
setPositiveWords([]); setNegativeWords([]);
|
||||||
setSavedFlash(true); setTimeout(() => setSavedFlash(false), 2000);
|
setSavedFlash(true); setTimeout(() => setSavedFlash(false), 2000);
|
||||||
onPairSaved(pair);
|
onPairSaved(pair);
|
||||||
} catch (e) { alert('Fehler: ' + e.message); }
|
} catch (e) { alert('Fehler: ' + e.message); }
|
||||||
@@ -384,8 +401,9 @@ function PairForm({ objectId, allObjects, onPairSaved }) {
|
|||||||
{Object.keys(wordMap).length > 0 && (
|
{Object.keys(wordMap).length > 0 && (
|
||||||
<div className="space-y-1.5 pt-1">
|
<div className="space-y-1.5 pt-1">
|
||||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Erkannte Wörter</span>
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Erkannte Wörter</span>
|
||||||
{Object.entries(wordMap).map(([title, w]) => {
|
{Object.entries(wordMap).filter(([, w]) => w?.id).map(([title, w]) => {
|
||||||
const matchingObjs = allObjects.filter(o => o._words?.some(ow => ow.id === w.id));
|
const safeObjects = Array.isArray(allObjects) ? allObjects : [];
|
||||||
|
const matchingObjs = safeObjects.filter(o => o._words?.some(ow => ow.id === w.id));
|
||||||
const assigned = objectAssignments[w.id] || '';
|
const assigned = objectAssignments[w.id] || '';
|
||||||
return (
|
return (
|
||||||
<div key={w.id} className="flex items-center gap-2">
|
<div key={w.id} className="flex items-center gap-2">
|
||||||
@@ -395,7 +413,7 @@ function PairForm({ objectId, allObjects, onPairSaved }) {
|
|||||||
className={`flex-1 text-xs border rounded px-1.5 py-0.5 bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400 ${assigned ? 'border-indigo-400 text-indigo-700 bg-indigo-50' : 'border-slate-200 text-slate-500'}`}>
|
className={`flex-1 text-xs border rounded px-1.5 py-0.5 bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400 ${assigned ? 'border-indigo-400 text-indigo-700 bg-indigo-50' : 'border-slate-200 text-slate-500'}`}>
|
||||||
<option value="">— nur Wort ({{wordId}})</option>
|
<option value="">— nur Wort ({{wordId}})</option>
|
||||||
{matchingObjs.map(obj => {
|
{matchingObjs.map(obj => {
|
||||||
const idx = allObjects.indexOf(obj);
|
const idx = safeObjects.indexOf(obj);
|
||||||
const labels = (obj._words || []).slice(0, 3).map(ow => ow.titel_de || ow.id).join(', ');
|
const labels = (obj._words || []).slice(0, 3).map(ow => ow.titel_de || ow.id).join(', ');
|
||||||
return <option key={obj.id} value={obj.id}>Objekt #{idx + 1}{labels ? ` — ${labels}` : ''}</option>;
|
return <option key={obj.id} value={obj.id}>Objekt #{idx + 1}{labels ? ` — ${labels}` : ''}</option>;
|
||||||
})}
|
})}
|
||||||
@@ -491,19 +509,27 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel, onDeleted }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!allText.trim()) { setWordMap({}); return; }
|
if (!allText.trim()) { setWordMap({}); return; }
|
||||||
const t = setTimeout(async () => {
|
const t = setTimeout(async () => {
|
||||||
const tokens = [...new Set(allText.split(/[\s.,!?;:()\[\]"']+/).filter(w => w.length >= 2))];
|
try {
|
||||||
if (!tokens.length) return;
|
const tokens = [...new Set(allText.split(/[\s.,!?;:()\[\]"']+/).filter(w => w.length >= 2))];
|
||||||
const results = await Promise.allSettled(
|
if (!tokens.length) return;
|
||||||
tokens.map(async w => {
|
const results = await Promise.allSettled(
|
||||||
const data = await apiFetch(`/words?search=${encodeURIComponent(w)}&limit=5`);
|
tokens.map(async w => {
|
||||||
const candidates = Array.isArray(data) ? data : [];
|
try {
|
||||||
const match = candidates.find(word => fuzzyMatch(w, word[`titel_${lang}`] || word.titel_de || ''));
|
const data = await apiFetch(`/words?search=${encodeURIComponent(w)}&limit=5`);
|
||||||
return match ? { key: w.toLowerCase(), word: match } : null;
|
const candidates = Array.isArray(data) ? data.filter(Boolean) : [];
|
||||||
})
|
const match = candidates.find(word => word && word.id && fuzzyMatch(w, word[`titel_${lang}`] || word.titel_de || ''));
|
||||||
);
|
return match ? { key: w.toLowerCase(), word: match } : null;
|
||||||
const map = {};
|
} catch { return null; }
|
||||||
results.forEach(r => { if (r.status === 'fulfilled' && r.value) map[r.value.key] = r.value.word; });
|
})
|
||||||
setWordMap(map);
|
);
|
||||||
|
const map = {};
|
||||||
|
results.forEach(r => {
|
||||||
|
if (r.status === 'fulfilled' && r.value && r.value.key && r.value.word?.id) {
|
||||||
|
map[r.value.key] = r.value.word;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setWordMap(map);
|
||||||
|
} catch { /* ignore word detection errors */ }
|
||||||
}, 600);
|
}, 600);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [allText, lang]);
|
}, [allText, lang]);
|
||||||
@@ -513,7 +539,7 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel, onDeleted }) {
|
|||||||
setCreatingWord(true);
|
setCreatingWord(true);
|
||||||
try {
|
try {
|
||||||
const w = await apiPost('/words', { [`titel_${lang}`]: wordInput.trim() });
|
const w = await apiPost('/words', { [`titel_${lang}`]: wordInput.trim() });
|
||||||
setWordMap(prev => ({ ...prev, [selection.trim().toLowerCase()]: w }));
|
if (w?.id) setWordMap(prev => ({ ...prev, [selection.trim().toLowerCase()]: w }));
|
||||||
setWordInput('');
|
setWordInput('');
|
||||||
} catch (e) { alert('Fehler: ' + e.message); }
|
} catch (e) { alert('Fehler: ' + e.message); }
|
||||||
finally { setCreatingWord(false); }
|
finally { setCreatingWord(false); }
|
||||||
@@ -663,8 +689,9 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel, onDeleted }) {
|
|||||||
{Object.keys(wordMap).length > 0 && (
|
{Object.keys(wordMap).length > 0 && (
|
||||||
<div className="space-y-1.5 pt-1">
|
<div className="space-y-1.5 pt-1">
|
||||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Erkannte Wörter</span>
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Erkannte Wörter</span>
|
||||||
{Object.entries(wordMap).map(([title, w]) => {
|
{Object.entries(wordMap).filter(([, w]) => w?.id).map(([title, w]) => {
|
||||||
const matchingObjs = allObjects.filter(o => o._words?.some(ow => ow.id === w.id));
|
const safeObjects = Array.isArray(allObjects) ? allObjects : [];
|
||||||
|
const matchingObjs = safeObjects.filter(o => o._words?.some(ow => ow.id === w.id));
|
||||||
const assigned = objectAssignments[w.id] || '';
|
const assigned = objectAssignments[w.id] || '';
|
||||||
return (
|
return (
|
||||||
<div key={w.id} className="flex items-center gap-2">
|
<div key={w.id} className="flex items-center gap-2">
|
||||||
@@ -674,7 +701,7 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel, onDeleted }) {
|
|||||||
className={`flex-1 text-xs border rounded px-1.5 py-0.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-400 ${assigned ? 'border-amber-400 text-amber-700 bg-amber-50' : 'border-slate-200 text-slate-500'}`}>
|
className={`flex-1 text-xs border rounded px-1.5 py-0.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-400 ${assigned ? 'border-amber-400 text-amber-700 bg-amber-50' : 'border-slate-200 text-slate-500'}`}>
|
||||||
<option value="">— nur Wort</option>
|
<option value="">— nur Wort</option>
|
||||||
{matchingObjs.map(obj => {
|
{matchingObjs.map(obj => {
|
||||||
const idx = allObjects.indexOf(obj);
|
const idx = safeObjects.indexOf(obj);
|
||||||
const labels = (obj._words || []).slice(0, 3).map(ow => ow.titel_de || ow.id).join(', ');
|
const labels = (obj._words || []).slice(0, 3).map(ow => ow.titel_de || ow.id).join(', ');
|
||||||
return <option key={obj.id} value={obj.id}>Objekt #{idx + 1}{labels ? ` — ${labels}` : ''}</option>;
|
return <option key={obj.id} value={obj.id}>Objekt #{idx + 1}{labels ? ` — ${labels}` : ''}</option>;
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user