From c92191db6381b0b8e04bc8b0fbf816b027092786 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 24 May 2026 19:23:50 +0200 Subject: [PATCH] feat: Statement Creation + objects_created workflow + native language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/App.jsx | 2 + src/lib/api.js | 18 +- src/pages/ContentHub.jsx | 4 +- src/pages/ObjectCreation.jsx | 31 +- src/pages/StatementCreation.jsx | 613 ++++++++++++++++++++++++++++++++ 5 files changed, 662 insertions(+), 6 deletions(-) create mode 100644 src/pages/StatementCreation.jsx diff --git a/src/App.jsx b/src/App.jsx index db934ac..5817c6e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -6,6 +6,7 @@ import DatabaseAdmin from './pages/DatabaseAdmin'; import TableView from './pages/TableView'; import ContentHub from './pages/ContentHub'; import ObjectCreation from './pages/ObjectCreation'; +import StatementCreation from './pages/StatementCreation'; function RequireAuth({ children }) { const user = getUser(); @@ -23,6 +24,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/src/lib/api.js b/src/lib/api.js index 1389aac..e49c6f7 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -24,7 +24,9 @@ export async function login(email, password) { } const data = await res.json(); 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; } @@ -32,6 +34,20 @@ export function getUser() { 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 = {}) { const token = getToken(); const res = await fetch(`${API_URL}${path}`, { diff --git a/src/pages/ContentHub.jsx b/src/pages/ContentHub.jsx index 6e69ebe..b6fd7e6 100644 --- a/src/pages/ContentHub.jsx +++ b/src/pages/ContentHub.jsx @@ -14,9 +14,9 @@ const TOOLS = [ key: 'statements', title: 'Statement Creation', 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', - ready: false, + ready: true, }, { key: 'content', diff --git a/src/pages/ObjectCreation.jsx b/src/pages/ObjectCreation.jsx index a30f37b..dcf37f0 100644 --- a/src/pages/ObjectCreation.jsx +++ b/src/pages/ObjectCreation.jsx @@ -1,6 +1,6 @@ import { useEffect, useState, useRef, useCallback } from 'react'; 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'; // ─── Word Search ───────────────────────────────────────────────────────────── @@ -464,7 +464,7 @@ export default function ObjectCreation() { // Load all uploaded pictures once useEffect(() => { 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 : [])) .catch(() => setPictures([])) .finally(() => setLoadingPictures(false)); @@ -601,6 +601,23 @@ export default function ObjectCreation() { 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 function goPrev() { setPictureIndex(i => Math.max(0, i - 1)); } function goNext() { setPictureIndex(i => Math.min(pictures.length - 1, i + 1)); } @@ -636,7 +653,7 @@ export default function ObjectCreation() { - {/* Picture info */} + {/* Picture info + done button */}
{currentPicture && ( <> @@ -647,6 +664,14 @@ export default function ObjectCreation() { )} +
diff --git a/src/pages/StatementCreation.jsx b/src/pages/StatementCreation.jsx new file mode 100644 index 0000000..c5e1189 --- /dev/null +++ b/src/pages/StatementCreation.jsx @@ -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 ( +
+ {/* Highlight layer */} +
+ {tokens.map((tok, i) => + tok.word + ? {tok.text} + : {tok.text} + )} + {/* Trailing newline keeps height in sync */} + {'\n'} +
+ {/* Input layer */} +