feat: Statement Creation + objects_created workflow + native language
ObjectCreation:
- Filter: only shows pictures where objects_created=false
- New '✓ Alle Objekte erstellt' button (enabled when ≥1 object linked)
→ PATCHes picture with objects_created:true + auto timestamp
→ removes picture from list immediately
ContentHub:
- Statement Creation tile enabled (was: grayed out)
api.js:
- getUserLang() → reads native_lang from stored user (default 'de')
- langField(suffix) → returns e.g. 'sentence_de' based on user lang
- login() stores native_lang in localStorage user object
StatementCreation (/content/statements):
- Shows pictures where objects_created=true
- Left 1/5: clickable objects list → highlights selections on canvas
- Center 2/5: image with canvas, draws selected object's polygons in color
- Right 2/5: PairsPanel
- answer_type dropdown + 'Add new pair' toggle
- PairForm: Question / Positive / Negative textareas
- HighlightedTextarea: overlay technique, auto-detects words from DB
(debounced GET /words?titel_de=, colored mark via rgba background)
- 'Als Wort erstellen' button when text is selected
- 'Save pair' → creates question + 2 separate statements + pair
→ sentences stored with {{uuid}} placeholders for matched words
→ pair linked to selected object
- List of existing pairs with question/positive/negative preview
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import DatabaseAdmin from './pages/DatabaseAdmin';
|
|||||||
import TableView from './pages/TableView';
|
import TableView from './pages/TableView';
|
||||||
import ContentHub from './pages/ContentHub';
|
import ContentHub from './pages/ContentHub';
|
||||||
import ObjectCreation from './pages/ObjectCreation';
|
import ObjectCreation from './pages/ObjectCreation';
|
||||||
|
import StatementCreation from './pages/StatementCreation';
|
||||||
|
|
||||||
function RequireAuth({ children }) {
|
function RequireAuth({ children }) {
|
||||||
const user = getUser();
|
const user = getUser();
|
||||||
@@ -23,6 +24,7 @@ export default function App() {
|
|||||||
<Route path="/db/:tableKey" element={<RequireAuth><TableView /></RequireAuth>} />
|
<Route path="/db/:tableKey" element={<RequireAuth><TableView /></RequireAuth>} />
|
||||||
<Route path="/content" element={<RequireAuth><ContentHub /></RequireAuth>} />
|
<Route path="/content" element={<RequireAuth><ContentHub /></RequireAuth>} />
|
||||||
<Route path="/content/objects" element={<RequireAuth><ObjectCreation /></RequireAuth>} />
|
<Route path="/content/objects" element={<RequireAuth><ObjectCreation /></RequireAuth>} />
|
||||||
|
<Route path="/content/statements" element={<RequireAuth><StatementCreation /></RequireAuth>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ export async function login(email, password) {
|
|||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
localStorage.setItem('cmt_token', data.token);
|
localStorage.setItem('cmt_token', data.token);
|
||||||
localStorage.setItem('cmt_user', JSON.stringify(data.user));
|
// Merge native_lang (may come top-level or inside user object)
|
||||||
|
const userWithLang = { ...data.user, native_lang: data.user.native_lang || data.native_lang || 'de' };
|
||||||
|
localStorage.setItem('cmt_user', JSON.stringify(userWithLang));
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +34,20 @@ export function getUser() {
|
|||||||
try { return JSON.parse(localStorage.getItem('cmt_user')); } catch { return null; }
|
try { return JSON.parse(localStorage.getItem('cmt_user')); } catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns ISO 639-1 code of the logged-in user's native language (default 'de') */
|
||||||
|
export function getUserLang() {
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(localStorage.getItem('cmt_user'));
|
||||||
|
return user?.native_lang || 'de';
|
||||||
|
} catch { return 'de'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map lang code → sentence field suffix for questions/statements */
|
||||||
|
export function langField(suffix) {
|
||||||
|
const lang = getUserLang();
|
||||||
|
return `${suffix}_${lang}`; // e.g. 'sentence_de', 'titel_de'
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiFetch(path, options = {}) {
|
export async function apiFetch(path, options = {}) {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const res = await fetch(`${API_URL}${path}`, {
|
const res = await fetch(`${API_URL}${path}`, {
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ const TOOLS = [
|
|||||||
key: 'statements',
|
key: 'statements',
|
||||||
title: 'Statement Creation',
|
title: 'Statement Creation',
|
||||||
icon: '💬',
|
icon: '💬',
|
||||||
description: 'Aussagen erstellen und mit Wörtern verknüpfen.',
|
description: 'Pairs mit Fragen und Aussagen erstellen, Wörter im Satz markieren.',
|
||||||
path: '/content/statements',
|
path: '/content/statements',
|
||||||
ready: false,
|
ready: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'content',
|
key: 'content',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import { apiFetch, apiPost, apiLink } from '../lib/api';
|
import { apiFetch, apiPost, apiLink, apiPatch } from '../lib/api';
|
||||||
import { STATUS_COLORS } from '../lib/tables';
|
import { STATUS_COLORS } from '../lib/tables';
|
||||||
|
|
||||||
// ─── Word Search ─────────────────────────────────────────────────────────────
|
// ─── Word Search ─────────────────────────────────────────────────────────────
|
||||||
@@ -464,7 +464,7 @@ export default function ObjectCreation() {
|
|||||||
// Load all uploaded pictures once
|
// Load all uploaded pictures once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoadingPictures(true);
|
setLoadingPictures(true);
|
||||||
apiFetch('/pictures?status=uploaded&limit=500&offset=0')
|
apiFetch('/pictures?status=uploaded&objects_created=false&limit=500&offset=0')
|
||||||
.then(data => setPictures(Array.isArray(data) ? data : []))
|
.then(data => setPictures(Array.isArray(data) ? data : []))
|
||||||
.catch(() => setPictures([]))
|
.catch(() => setPictures([]))
|
||||||
.finally(() => setLoadingPictures(false));
|
.finally(() => setLoadingPictures(false));
|
||||||
@@ -601,6 +601,23 @@ export default function ObjectCreation() {
|
|||||||
|
|
||||||
const canSave = savedSelections.length > 0 && objectWords.length > 0;
|
const canSave = savedSelections.length > 0 && objectWords.length > 0;
|
||||||
|
|
||||||
|
// ── "Alle Objekte erstellt" — mark picture and remove from list
|
||||||
|
const [markingDone, setMarkingDone] = useState(false);
|
||||||
|
async function handleMarkDone() {
|
||||||
|
if (!currentPicture || pictureObjects.length === 0) return;
|
||||||
|
setMarkingDone(true);
|
||||||
|
try {
|
||||||
|
await apiPatch('/pictures', currentPicture.id, { objects_created: true });
|
||||||
|
// Remove from local list
|
||||||
|
setPictures(prev => {
|
||||||
|
const next = prev.filter(p => p.id !== currentPicture.id);
|
||||||
|
setPictureIndex(i => Math.min(i, Math.max(0, next.length - 1)));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (e) { alert('Fehler: ' + e.message); }
|
||||||
|
finally { setMarkingDone(false); }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Navigation
|
// ── Navigation
|
||||||
function goPrev() { setPictureIndex(i => Math.max(0, i - 1)); }
|
function goPrev() { setPictureIndex(i => Math.max(0, i - 1)); }
|
||||||
function goNext() { setPictureIndex(i => Math.min(pictures.length - 1, i + 1)); }
|
function goNext() { setPictureIndex(i => Math.min(pictures.length - 1, i + 1)); }
|
||||||
@@ -636,7 +653,7 @@ export default function ObjectCreation() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Picture info */}
|
{/* Picture info + done button */}
|
||||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||||
{currentPicture && (
|
{currentPicture && (
|
||||||
<>
|
<>
|
||||||
@@ -647,6 +664,14 @@ export default function ObjectCreation() {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleMarkDone}
|
||||||
|
disabled={markingDone || !currentPicture || pictureObjects.length === 0}
|
||||||
|
className="ml-2 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors
|
||||||
|
bg-green-600 hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed text-white whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{markingDone ? 'Speichern…' : '✓ Alle Objekte erstellt'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
613
src/pages/StatementCreation.jsx
Normal file
613
src/pages/StatementCreation.jsx
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { apiFetch, apiPost, apiLink, getUserLang, langField } from '../lib/api';
|
||||||
|
import { STATUS_COLORS } from '../lib/tables';
|
||||||
|
|
||||||
|
// ─── Word map helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns regex-split array of {text, word?} parts for a given text + wordMap */
|
||||||
|
function tokenize(text, wordMap) {
|
||||||
|
const titles = Object.keys(wordMap);
|
||||||
|
if (!titles.length || !text) return [{ text }];
|
||||||
|
const escaped = titles
|
||||||
|
.sort((a, b) => b.length - a.length)
|
||||||
|
.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||||
|
const re = new RegExp(`(${escaped.join('|')})`, 'gi');
|
||||||
|
return text.split(re).filter(s => s !== '').map(part => ({
|
||||||
|
text: part,
|
||||||
|
word: wordMap[part.toLowerCase()] || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace matched words with {{uuid}} placeholders */
|
||||||
|
function withPlaceholders(text, wordMap) {
|
||||||
|
if (!text) return '';
|
||||||
|
let result = text;
|
||||||
|
Object.entries(wordMap)
|
||||||
|
.sort((a, b) => b[0].length - a[0].length)
|
||||||
|
.forEach(([title, w]) => {
|
||||||
|
const re = new RegExp(`\\b${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi');
|
||||||
|
result = result.replace(re, `{{${w.id}}}`);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HighlightedTextarea ──────────────────────────────────────────────────────
|
||||||
|
function HighlightedTextarea({ value, onChange, wordMap, rows = 3, placeholder, onSelectionChange }) {
|
||||||
|
const taRef = useRef(null);
|
||||||
|
|
||||||
|
function handleSelect() {
|
||||||
|
const ta = taRef.current;
|
||||||
|
if (!ta) return;
|
||||||
|
const sel = ta.value.slice(ta.selectionStart, ta.selectionEnd).trim();
|
||||||
|
onSelectionChange?.(sel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = useMemo(() => tokenize(value, wordMap), [value, wordMap]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative rounded-lg border border-slate-300 focus-within:ring-2 focus-within:ring-indigo-400 overflow-hidden">
|
||||||
|
{/* Highlight layer */}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 px-3 py-2 text-sm leading-normal whitespace-pre-wrap break-words pointer-events-none overflow-hidden"
|
||||||
|
style={{ color: 'transparent', fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{tokens.map((tok, i) =>
|
||||||
|
tok.word
|
||||||
|
? <mark key={i} style={{ background: 'rgba(99,102,241,0.22)', borderRadius: 3 }}>{tok.text}</mark>
|
||||||
|
: <span key={i}>{tok.text}</span>
|
||||||
|
)}
|
||||||
|
{/* Trailing newline keeps height in sync */}
|
||||||
|
{'\n'}
|
||||||
|
</div>
|
||||||
|
{/* Input layer */}
|
||||||
|
<textarea
|
||||||
|
ref={taRef}
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onKeyUp={handleSelect}
|
||||||
|
onMouseUp={handleSelect}
|
||||||
|
rows={rows}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="relative w-full px-3 py-2 text-sm leading-normal bg-transparent resize-none focus:outline-none"
|
||||||
|
style={{ caretColor: '#1e293b', fontFamily: 'inherit' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PairForm ─────────────────────────────────────────────────────────────────
|
||||||
|
function PairForm({ objectId, onPairSaved, onCancel }) {
|
||||||
|
const lang = getUserLang();
|
||||||
|
const [answerType, setAnswerType] = useState('word');
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [question, setQuestion] = useState('');
|
||||||
|
const [positive, setPositive] = useState('');
|
||||||
|
const [negative, setNegative] = useState('');
|
||||||
|
const [wordMap, setWordMap] = useState({}); // { 'hund': {id, titel_de, ...} }
|
||||||
|
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}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(async () => {
|
||||||
|
const words = [...new Set(
|
||||||
|
allText.split(/[\s.,!?;:()\[\]"']+/).filter(w => w.length >= 2)
|
||||||
|
)];
|
||||||
|
if (!words.length) return;
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
words.map(w =>
|
||||||
|
apiFetch(`/words?titel_${lang}=${encodeURIComponent(w)}&limit=1`)
|
||||||
|
.then(d => Array.isArray(d) && d.length ? { key: w.toLowerCase(), word: d[0] } : null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const map = {};
|
||||||
|
results.forEach(r => { if (r.status === 'fulfilled' && r.value) map[r.value.key] = r.value.word; });
|
||||||
|
setWordMap(map);
|
||||||
|
}, 600);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [allText, lang]);
|
||||||
|
|
||||||
|
async function handleCreateWord() {
|
||||||
|
if (!selection.trim()) return;
|
||||||
|
setCreatingWord(true);
|
||||||
|
try {
|
||||||
|
const body = { [`titel_${lang}`]: selection.trim() };
|
||||||
|
const w = await apiPost('/words', body);
|
||||||
|
setWordMap(prev => ({ ...prev, [selection.trim().toLowerCase()]: w }));
|
||||||
|
} catch (e) { alert('Fehler: ' + e.message); }
|
||||||
|
finally { setCreatingWord(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!question.trim() || !positive.trim() || !negative.trim()) {
|
||||||
|
alert('Bitte alle drei Felder ausfüllen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const qField = `sentence_${lang}`;
|
||||||
|
const posProcField = `positive_sentence_${lang}`;
|
||||||
|
const negProcField = `negative_sentence_${lang}`;
|
||||||
|
|
||||||
|
// Insert word placeholders
|
||||||
|
const qProcessed = withPlaceholders(question, wordMap);
|
||||||
|
const posProcessed = withPlaceholders(positive, wordMap);
|
||||||
|
const negProcessed = withPlaceholders(negative, wordMap);
|
||||||
|
|
||||||
|
// 1. Create question
|
||||||
|
const q = await apiPost('/questions', { [qField]: qProcessed, status: 'draft' });
|
||||||
|
|
||||||
|
// 2. Create positive statement (separate record)
|
||||||
|
const posStmt = await apiPost('/statements', { [posProcField]: posProcessed, status: 'draft' });
|
||||||
|
|
||||||
|
// 3. Create negative statement (separate record)
|
||||||
|
const negStmt = await apiPost('/statements', { [negProcField]: negProcessed, status: 'draft' });
|
||||||
|
|
||||||
|
// 4. Create pair
|
||||||
|
const pair = await apiPost('/pairs', {
|
||||||
|
answer_type: answerType,
|
||||||
|
question_id: q.id,
|
||||||
|
positive_statement_id: posStmt.id,
|
||||||
|
negative_statement_id: negStmt.id,
|
||||||
|
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 });
|
||||||
|
} 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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PairsPanel (right 2/5) ───────────────────────────────────────────────────
|
||||||
|
function PairsPanel({ selectedObject, objectPairs, loadingPairs, onPairSaved }) {
|
||||||
|
const lang = getUserLang();
|
||||||
|
|
||||||
|
if (!selectedObject) {
|
||||||
|
return (
|
||||||
|
<aside className="w-2/5 border-l border-slate-200 bg-white flex items-center justify-center">
|
||||||
|
<p className="text-xs text-slate-400 text-center px-4">
|
||||||
|
Wähle links ein Objekt aus um Pairs zu verwalten.
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-2/5 border-l border-slate-200 bg-white flex flex-col overflow-hidden">
|
||||||
|
<div className="px-3 py-2.5 border-b border-slate-100 bg-slate-50 flex-shrink-0">
|
||||||
|
<h2 className="text-xs font-bold text-slate-500 uppercase tracking-wider">
|
||||||
|
Pairs — Objekt #{selectedObject._index + 1}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add pair form */}
|
||||||
|
<div className="p-3 border-b border-slate-100 flex-shrink-0">
|
||||||
|
<PairForm
|
||||||
|
objectId={selectedObject.id}
|
||||||
|
onPairSaved={onPairSaved}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Existing pairs */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||||
|
{loadingPairs && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1,2].map(i => <div key={i} className="h-16 bg-slate-100 rounded animate-pulse" />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingPairs && objectPairs.length === 0 && (
|
||||||
|
<p className="text-xs text-slate-400 text-center mt-4">Noch keine Pairs</p>
|
||||||
|
)}
|
||||||
|
{objectPairs.map((pair, i) => (
|
||||||
|
<div key={pair.id} className="rounded-lg border border-slate-200 p-3 bg-slate-50 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-bold text-slate-400">#{i + 1}</span>
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${STATUS_COLORS[pair.status] || 'bg-slate-100 text-slate-600'}`}>
|
||||||
|
{pair.status}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs bg-slate-200 text-slate-600 rounded px-1.5 py-0.5">{pair.answer_type}</span>
|
||||||
|
</div>
|
||||||
|
{pair.question && (
|
||||||
|
<p className="text-xs text-slate-600">
|
||||||
|
<span className="font-semibold text-slate-400">F: </span>
|
||||||
|
{pair.question[`sentence_${lang}`] || pair.question.sentence_de || '—'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{pair.positive_statement && (
|
||||||
|
<p className="text-xs text-green-700">
|
||||||
|
<span className="font-semibold">+ </span>
|
||||||
|
{pair.positive_statement[`positive_sentence_${lang}`] || pair.positive_statement.positive_sentence_de || '—'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{pair.negative_statement && (
|
||||||
|
<p className="text-xs text-red-600">
|
||||||
|
<span className="font-semibold">− </span>
|
||||||
|
{pair.negative_statement[`negative_sentence_${lang}`] || pair.negative_statement.negative_sentence_de || '—'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ObjectSelectPanel (left 1/5) ─────────────────────────────────────────────
|
||||||
|
function ObjectSelectPanel({ objects, loadingObjects, selectedId, onSelect }) {
|
||||||
|
return (
|
||||||
|
<aside className="w-1/5 min-w-[180px] border-r border-slate-200 bg-white flex flex-col overflow-hidden">
|
||||||
|
<div className="px-3 py-2.5 border-b border-slate-100 bg-slate-50">
|
||||||
|
<h2 className="text-xs font-bold text-slate-500 uppercase tracking-wider">Objekte</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||||
|
{loadingObjects && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1,2].map(i => <div key={i} className="h-12 bg-slate-100 rounded animate-pulse" />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingObjects && objects.length === 0 && (
|
||||||
|
<p className="text-xs text-slate-400 text-center mt-4">Keine Objekte</p>
|
||||||
|
)}
|
||||||
|
{objects.map((obj, i) => (
|
||||||
|
<button
|
||||||
|
key={obj.id}
|
||||||
|
onClick={() => onSelect(obj.id === selectedId ? null : obj.id)}
|
||||||
|
className={`w-full text-left rounded-lg border p-2.5 transition-all
|
||||||
|
${obj.id === selectedId
|
||||||
|
? 'border-indigo-400 bg-indigo-50 ring-1 ring-indigo-300'
|
||||||
|
: 'border-slate-200 bg-slate-50 hover:border-indigo-200 hover:bg-indigo-50/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<span className="text-xs font-bold text-slate-400">#{i + 1}</span>
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${STATUS_COLORS[obj.status] || 'bg-slate-100 text-slate-600'}`}>
|
||||||
|
{obj.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(obj._words || []).slice(0, 4).map(w => (
|
||||||
|
<span key={w.id} className="text-xs bg-white border border-indigo-100 text-indigo-600 rounded px-1 py-0.5">
|
||||||
|
{w.titel_de || w.id}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Image + Canvas (read-only, shows selections for selected object) ──────────
|
||||||
|
function StatementCanvas({ picture, selectedObject }) {
|
||||||
|
const imgRef = useRef(null);
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
|
const syncCanvas = useCallback(() => {
|
||||||
|
const img = imgRef.current;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!img || !canvas) return;
|
||||||
|
const rect = img.getBoundingClientRect();
|
||||||
|
if (!rect.width) return;
|
||||||
|
canvas.width = rect.width;
|
||||||
|
canvas.height = rect.height;
|
||||||
|
canvas.style.left = `${img.offsetLeft}px`;
|
||||||
|
canvas.style.top = `${img.offsetTop}px`;
|
||||||
|
canvas.style.width = `${rect.width}px`;
|
||||||
|
canvas.style.height = `${rect.height}px`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
syncCanvas();
|
||||||
|
const ro = new ResizeObserver(syncCanvas);
|
||||||
|
if (containerRef.current) ro.observe(containerRef.current);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [syncCanvas, picture]);
|
||||||
|
|
||||||
|
// Draw selected object's polygons
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
if (!selectedObject) return;
|
||||||
|
|
||||||
|
const W = canvas.width;
|
||||||
|
const H = canvas.height;
|
||||||
|
const selections = selectedObject.selections;
|
||||||
|
if (!Array.isArray(selections)) return;
|
||||||
|
|
||||||
|
const COLORS = ['rgba(99,102,241', 'rgba(245,158,11', 'rgba(16,185,129', 'rgba(239,68,68'];
|
||||||
|
|
||||||
|
selections.forEach((sel, i) => {
|
||||||
|
if (!sel.points?.length) return;
|
||||||
|
const color = COLORS[i % COLORS.length];
|
||||||
|
ctx.beginPath();
|
||||||
|
sel.points.forEach((p, j) => {
|
||||||
|
if (j === 0) ctx.moveTo(p.x * W, p.y * H);
|
||||||
|
else ctx.lineTo(p.x * W, p.y * H);
|
||||||
|
});
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = `${color},0.2)`;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = `${color},0.9)`;
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.stroke();
|
||||||
|
// Number label
|
||||||
|
const cx = sel.points.reduce((s, p) => s + p.x, 0) / sel.points.length;
|
||||||
|
const cy = sel.points.reduce((s, p) => s + p.y, 0) / sel.points.length;
|
||||||
|
ctx.fillStyle = `${color},0.9)`;
|
||||||
|
ctx.font = 'bold 14px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(String(i + 1), cx * W, cy * H + 5);
|
||||||
|
});
|
||||||
|
}, [selectedObject]);
|
||||||
|
|
||||||
|
if (!picture) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center bg-slate-100">
|
||||||
|
<p className="text-slate-400 text-sm">Keine Bilder</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="w-2/5 flex items-center justify-center bg-slate-900 relative overflow-hidden">
|
||||||
|
{picture.picture_link ? (
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
src={picture.picture_link}
|
||||||
|
alt={picture.design}
|
||||||
|
className="max-h-full max-w-full object-contain select-none"
|
||||||
|
draggable={false}
|
||||||
|
onLoad={syncCanvas}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-2 text-slate-400">
|
||||||
|
<span className="text-5xl">🖼️</span>
|
||||||
|
<span className="text-sm">Kein Bild</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<canvas ref={canvasRef} className="absolute pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
export default function StatementCreation() {
|
||||||
|
const [pictures, setPictures] = useState([]);
|
||||||
|
const [pictureIndex, setPictureIndex] = useState(0);
|
||||||
|
const [loadingPictures, setLoadingPictures] = useState(true);
|
||||||
|
|
||||||
|
const [objects, setObjects] = useState([]);
|
||||||
|
const [loadingObjects, setLoadingObjects] = useState(false);
|
||||||
|
|
||||||
|
const [selectedObjectId, setSelectedObjectId] = useState(null);
|
||||||
|
const [objectPairs, setObjectPairs] = useState([]);
|
||||||
|
const [loadingPairs, setLoadingPairs] = useState(false);
|
||||||
|
|
||||||
|
const currentPicture = pictures[pictureIndex] || null;
|
||||||
|
const selectedObject = selectedObjectId
|
||||||
|
? objects.find(o => o.id === selectedObjectId) || null
|
||||||
|
: null;
|
||||||
|
const selectedObjectWithIndex = selectedObject
|
||||||
|
? { ...selectedObject, _index: objects.indexOf(selectedObject) }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Load all pictures where objects_created = true
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingPictures(true);
|
||||||
|
apiFetch('/pictures?objects_created=true&limit=500')
|
||||||
|
.then(data => setPictures(Array.isArray(data) ? data : []))
|
||||||
|
.catch(() => setPictures([]))
|
||||||
|
.finally(() => setLoadingPictures(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load objects for current picture
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentPicture) return;
|
||||||
|
setSelectedObjectId(null);
|
||||||
|
setObjectPairs([]);
|
||||||
|
setLoadingObjects(true);
|
||||||
|
apiFetch(`/objects?picture_id=${currentPicture.id}&limit=100`)
|
||||||
|
.then(async data => {
|
||||||
|
const objs = Array.isArray(data) ? data : [];
|
||||||
|
const withWords = await Promise.all(
|
||||||
|
objs.map(async obj => {
|
||||||
|
try {
|
||||||
|
const words = await apiFetch(`/objects/${obj.id}/words`);
|
||||||
|
return { ...obj, _words: Array.isArray(words) ? words : [] };
|
||||||
|
} catch { return { ...obj, _words: [] }; }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setObjects(withWords);
|
||||||
|
})
|
||||||
|
.catch(() => setObjects([]))
|
||||||
|
.finally(() => setLoadingObjects(false));
|
||||||
|
}, [currentPicture?.id]);
|
||||||
|
|
||||||
|
// Load pairs when object selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedObjectId) { setObjectPairs([]); return; }
|
||||||
|
setLoadingPairs(true);
|
||||||
|
apiFetch(`/objects/${selectedObjectId}/pairs`)
|
||||||
|
.then(data => setObjectPairs(Array.isArray(data) ? data : []))
|
||||||
|
.catch(() => setObjectPairs([]))
|
||||||
|
.finally(() => setLoadingPairs(false));
|
||||||
|
}, [selectedObjectId]);
|
||||||
|
|
||||||
|
function handlePairSaved(pair) {
|
||||||
|
setObjectPairs(prev => [pair, ...prev]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout back="/content" fullHeight>
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
|
||||||
|
{/* Top bar */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 bg-white border-b border-slate-200 flex-shrink-0">
|
||||||
|
<span className="font-semibold text-slate-700 text-sm">Statement Creation</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setPictureIndex(i => Math.max(0, i - 1))}
|
||||||
|
disabled={pictureIndex === 0 || loadingPictures}
|
||||||
|
className="p-1.5 rounded-lg border border-slate-300 hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>←</button>
|
||||||
|
<span className="text-sm text-slate-600 min-w-[120px] text-center">
|
||||||
|
{loadingPictures ? 'Lade…' : pictures.length === 0 ? 'Keine Bilder' : `${pictureIndex + 1} / ${pictures.length}`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPictureIndex(i => Math.min(pictures.length - 1, i + 1))}
|
||||||
|
disabled={pictureIndex >= pictures.length - 1 || loadingPictures}
|
||||||
|
className="p-1.5 rounded-lg border border-slate-300 hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>→</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||||
|
{currentPicture && (
|
||||||
|
<>
|
||||||
|
<span className="font-mono opacity-60">{currentPicture.id?.slice(0, 8)}…</span>
|
||||||
|
{currentPicture.design && <span>{currentPicture.design}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3-column layout */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<ObjectSelectPanel
|
||||||
|
objects={objects}
|
||||||
|
loadingObjects={loadingObjects}
|
||||||
|
selectedId={selectedObjectId}
|
||||||
|
onSelect={setSelectedObjectId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatementCanvas
|
||||||
|
picture={currentPicture}
|
||||||
|
selectedObject={selectedObject}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PairsPanel
|
||||||
|
selectedObject={selectedObjectWithIndex}
|
||||||
|
objectPairs={objectPairs}
|
||||||
|
loadingPairs={loadingPairs}
|
||||||
|
onPairSaved={handlePairSaved}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user