From e1d539038660243f7ad83c27dc0c686088651265 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 23 May 2026 21:53:23 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Content=20Verwaltung=20=E2=80=94=20Cont?= =?UTF-8?q?entHub=20+=20Object=20Creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a full Content Management section: - Nav link "Content" in header (all pages) - ContentHub at /content with 3 tiles (Object/Statement/Content Creation) - ObjectCreation at /content/objects: - Top bar: ← → pagination through all "uploaded" pictures - Left panel (1/5): existing objects per picture with their words, + button to start draw mode - Center: image on dark bg with canvas overlay for polygon drawing - Right words panel (1/5): picture words + new object words (each with search/create) - Right toolbar (1/5): draw instructions, "Auswahl hinzufügen", numbered selections list, "Objekt speichern" (requires ≥1 selection + ≥1 object word) - Canvas drawing: click=add point, dblclick=close polygon, live preview line to mouse cursor - Selections stored as [{points:[{x,y},...]}] (relative 0-1 coords) in objects.selections JSONB - Object saved with status "draft", linked picture + words Co-Authored-By: Claude Sonnet 4.6 --- src/App.jsx | 4 + src/components/Layout.jsx | 13 +- src/pages/ContentHub.jsx | 64 ++++ src/pages/ObjectCreation.jsx | 697 +++++++++++++++++++++++++++++++++++ 4 files changed, 774 insertions(+), 4 deletions(-) create mode 100644 src/pages/ContentHub.jsx create mode 100644 src/pages/ObjectCreation.jsx diff --git a/src/App.jsx b/src/App.jsx index ae633d5..db934ac 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,6 +4,8 @@ import Login from './pages/Login'; import Dashboard from './pages/Dashboard'; import DatabaseAdmin from './pages/DatabaseAdmin'; import TableView from './pages/TableView'; +import ContentHub from './pages/ContentHub'; +import ObjectCreation from './pages/ObjectCreation'; function RequireAuth({ children }) { const user = getUser(); @@ -19,6 +21,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 97915b4..725c183 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -1,7 +1,7 @@ import { useNavigate } from 'react-router-dom'; import { getUser, logout } from '../lib/api'; -export default function Layout({ children, back }) { +export default function Layout({ children, back, fullHeight = false }) { const navigate = useNavigate(); const user = getUser(); @@ -11,8 +11,8 @@ export default function Layout({ children, back }) { } return ( -
-
+
+
{back && (
+
{user?.email}
-
{children}
+
{children}
); } diff --git a/src/pages/ContentHub.jsx b/src/pages/ContentHub.jsx new file mode 100644 index 0000000..6e69ebe --- /dev/null +++ b/src/pages/ContentHub.jsx @@ -0,0 +1,64 @@ +import { useNavigate } from 'react-router-dom'; +import Layout from '../components/Layout'; + +const TOOLS = [ + { + key: 'objects', + title: 'Object Creation', + icon: '🖼️', + description: 'Bilder durchblättern, Bereiche einzeichnen und Objekte mit Wörtern verknüpfen.', + path: '/content/objects', + ready: true, + }, + { + key: 'statements', + title: 'Statement Creation', + icon: '💬', + description: 'Aussagen erstellen und mit Wörtern verknüpfen.', + path: '/content/statements', + ready: false, + }, + { + key: 'content', + title: 'Content Creation', + icon: '✨', + description: 'Inhalte zusammenstellen und veröffentlichen.', + path: '/content/creation', + ready: false, + }, +]; + +export default function ContentHub() { + const navigate = useNavigate(); + + return ( + +
+

Content Verwaltung

+

Wähle ein Tool um loszulegen.

+
+ {TOOLS.map(tool => ( + + ))} +
+
+
+ ); +} diff --git a/src/pages/ObjectCreation.jsx b/src/pages/ObjectCreation.jsx new file mode 100644 index 0000000..a30f37b --- /dev/null +++ b/src/pages/ObjectCreation.jsx @@ -0,0 +1,697 @@ +import { useEffect, useState, useRef, useCallback } from 'react'; +import Layout from '../components/Layout'; +import { apiFetch, apiPost, apiLink } from '../lib/api'; +import { STATUS_COLORS } from '../lib/tables'; + +// ─── Word Search ───────────────────────────────────────────────────────────── +function WordSearch({ existingIds = [], onAdd, placeholder = 'Wort suchen…' }) { + 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 onClickOut(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); } + document.addEventListener('mousedown', onClickOut); + return () => document.removeEventListener('mousedown', onClickOut); + }, []); + + async function handleCreate() { + setCreating(true); + try { + const w = await apiPost('/words', { titel_de: q.trim() }); + onAdd(w); + setQ(''); setResults([]); setOpen(false); + } catch (e) { alert('Fehler: ' + e.message); } + finally { setCreating(false); } + } + + return ( +
+ { 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() && ( +
+ {results.map(w => ( + + ))} + {results.length === 0 && ( + + )} +
+ )} +
+ ); +} + +// ─── WordTag ───────────────────────────────────────────────────────────────── +function WordTag({ word, onRemove }) { + return ( + + {word.titel_de || word.id} + {onRemove && ( + + )} + + ); +} + +// ─── Left panel: Objects ────────────────────────────────────────────────────── +function ObjectsPanel({ objects, loadingObjects, drawMode, onStartDraw }) { + return ( + + ); +} + +// ─── Right panel: Words ─────────────────────────────────────────────────────── +function WordsPanel({ + pictureWords, onAddPictureWord, onRemovePictureWord, + objectWords, onAddObjectWord, onRemoveObjectWord, + drawMode, +}) { + return ( + + ); +} + +// ─── Right toolbar: Draw ────────────────────────────────────────────────────── +function DrawToolbar({ drawMode, currentPolygon, savedSelections, onAddSelection, onSaveObject, canSave, saving, onCancel }) { + const polygonClosed = currentPolygon.length >= 3; + + return ( + + ); +} + +// ─── Image + Canvas ─────────────────────────────────────────────────────────── +function ImageCanvas({ picture, drawMode, currentPolygon, savedSelections, mousePos, onImageClick, onImageDblClick, onMouseMove }) { + const imgRef = useRef(null); + const canvasRef = useRef(null); + const containerRef = useRef(null); + + // Sync canvas size to rendered image size + const syncCanvas = useCallback(() => { + const img = imgRef.current; + const canvas = canvasRef.current; + if (!img || !canvas) return; + const rect = img.getBoundingClientRect(); + if (rect.width === 0) 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 polygons on canvas + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + + const W = canvas.width; + const H = canvas.height; + + // Draw completed selections + savedSelections.forEach((sel, i) => { + if (!sel.points.length) return; + 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 = 'rgba(99,102,241,0.15)'; + ctx.fill(); + ctx.strokeStyle = '#6366f1'; + ctx.lineWidth = 2; + ctx.stroke(); + // 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 = '#6366f1'; + ctx.font = 'bold 13px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(String(i + 1), cx * W, cy * H + 5); + }); + + // Draw current polygon being drawn + if (currentPolygon.length > 0) { + ctx.beginPath(); + currentPolygon.forEach((p, j) => { + if (j === 0) ctx.moveTo(p.x * W, p.y * H); + else ctx.lineTo(p.x * W, p.y * H); + }); + // Preview line to mouse + if (mousePos) ctx.lineTo(mousePos.x * W, mousePos.y * H); + ctx.strokeStyle = '#f59e0b'; + ctx.lineWidth = 2; + ctx.setLineDash([5, 3]); + ctx.stroke(); + ctx.setLineDash([]); + + // Dots + currentPolygon.forEach(p => { + ctx.beginPath(); + ctx.arc(p.x * W, p.y * H, 4, 0, Math.PI * 2); + ctx.fillStyle = '#f59e0b'; + ctx.fill(); + }); + } + }, [savedSelections, currentPolygon, mousePos]); + + function toRelative(e) { + const img = imgRef.current; + if (!img) return null; + const rect = img.getBoundingClientRect(); + return { + x: Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)), + y: Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)), + }; + } + + function handleClick(e) { + if (e.detail === 2) return; // ignore — dblclick handles this + const pt = toRelative(e); + if (pt) onImageClick(pt); + } + + function handleDblClick(e) { + const pt = toRelative(e); + if (pt) onImageDblClick(pt); + } + + function handleMouseMove(e) { + if (!drawMode) return; + const pt = toRelative(e); + if (pt) onMouseMove(pt); + } + + if (!picture) { + return ( +
+

Keine Bilder mit Status „uploaded" gefunden.

+
+ ); + } + + return ( +
+ {picture.picture_link ? ( + {picture.design + ) : ( +
+ 🖼️ + Kein Bild hochgeladen + {picture.design} +
+ )} + +
+ ); +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── +export default function ObjectCreation() { + // Pictures + const [pictures, setPictures] = useState([]); + const [pictureIndex, setPictureIndex] = useState(0); + const [loadingPictures, setLoadingPictures] = useState(true); + + // Per-picture data + const [pictureWords, setPictureWords] = useState([]); + const [pictureObjects, setPictureObjects] = useState([]); + const [loadingObjects, setLoadingObjects] = useState(false); + + // Draw state + const [drawMode, setDrawMode] = useState(false); + const [currentPolygon, setCurrentPolygon] = useState([]); + const [savedSelections, setSavedSelections] = useState([]); + const [objectWords, setObjectWords] = useState([]); + const [mousePos, setMousePos] = useState(null); + const [saving, setSaving] = useState(false); + + const currentPicture = pictures[pictureIndex] || null; + + // Load all uploaded pictures once + useEffect(() => { + setLoadingPictures(true); + apiFetch('/pictures?status=uploaded&limit=500&offset=0') + .then(data => setPictures(Array.isArray(data) ? data : [])) + .catch(() => setPictures([])) + .finally(() => setLoadingPictures(false)); + }, []); + + // Load picture words + objects whenever picture changes + useEffect(() => { + if (!currentPicture) return; + const id = currentPicture.id; + + // Words + apiFetch(`/pictures/${id}/words`) + .then(data => setPictureWords(Array.isArray(data) ? data : [])) + .catch(() => setPictureWords([])); + + // Objects + setLoadingObjects(true); + apiFetch(`/objects?picture_id=${id}&limit=100`) + .then(async data => { + const objs = Array.isArray(data) ? data : []; + // Fetch words for each object in parallel + 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: [] }; } + }) + ); + setPictureObjects(withWords); + }) + .catch(() => setPictureObjects([])) + .finally(() => setLoadingObjects(false)); + + // Reset draw state + setDrawMode(false); + setCurrentPolygon([]); + setSavedSelections([]); + setObjectWords([]); + }, [currentPicture?.id]); + + // ── Picture word handlers + async function handleAddPictureWord(word) { + if (pictureWords.some(w => w.id === word.id)) return; + try { + await apiLink(`/pictures/${currentPicture.id}/words/${word.id}`); + setPictureWords(prev => [...prev, word]); + } catch (e) { alert('Fehler: ' + e.message); } + } + + async function handleRemovePictureWord(wordId) { + try { + await apiFetch(`/pictures/${currentPicture.id}/words/${wordId}`, { method: 'DELETE' }); + setPictureWords(prev => prev.filter(w => w.id !== wordId)); + } catch (e) { alert('Fehler: ' + e.message); } + } + + // ── Object word handlers (pending new object, no API call yet) + function handleAddObjectWord(word) { + setObjectWords(prev => prev.some(w => w.id === word.id) ? prev : [...prev, word]); + } + function handleRemoveObjectWord(wordId) { + setObjectWords(prev => prev.filter(w => w.id !== wordId)); + } + + // ── Drawing handlers + function handleStartDraw() { + setDrawMode(true); + setCurrentPolygon([]); + setSavedSelections([]); + setObjectWords([]); + } + + function handleImageClick(pt) { + setCurrentPolygon(prev => [...prev, pt]); + } + + function handleImageDblClick(pt) { + // Close polygon: need ≥3 points + setCurrentPolygon(prev => { + const next = [...prev, pt]; + if (next.length >= 3) { + setSavedSelections(s => [...s, { points: next }]); + return []; // reset for next polygon + } + return next; + }); + } + + function handleAddSelection() { + if (currentPolygon.length < 3) return; + setSavedSelections(prev => [...prev, { points: currentPolygon }]); + setCurrentPolygon([]); + } + + function handleCancelDraw() { + setDrawMode(false); + setCurrentPolygon([]); + setSavedSelections([]); + setObjectWords([]); + } + + // ── Save object + async function handleSaveObject() { + if (!canSave || !currentPicture) return; + setSaving(true); + try { + // 1. Create object with selections + const obj = await apiPost('/objects', { + selections: savedSelections, + status: 'draft', + }); + + // 2. Link picture + await apiLink(`/objects/${obj.id}/pictures/${currentPicture.id}`); + + // 3. Link words + await Promise.all(objectWords.map(w => apiLink(`/objects/${obj.id}/words/${w.id}`))); + + // 4. Add to local objects list + setPictureObjects(prev => [...prev, { ...obj, _words: objectWords }]); + + // 5. Reset draw mode + setDrawMode(false); + setCurrentPolygon([]); + setSavedSelections([]); + setObjectWords([]); + } catch (e) { + alert('Fehler beim Speichern: ' + e.message); + } finally { + setSaving(false); + } + } + + const canSave = savedSelections.length > 0 && objectWords.length > 0; + + // ── Navigation + function goPrev() { setPictureIndex(i => Math.max(0, i - 1)); } + function goNext() { setPictureIndex(i => Math.min(pictures.length - 1, i + 1)); } + + return ( + +
+ + {/* Top bar */} +
+
+ Object Creation +
+ + {/* Picture navigation */} +
+ + + {loadingPictures ? 'Lade…' : pictures.length === 0 ? 'Keine Bilder' : `${pictureIndex + 1} / ${pictures.length}`} + + +
+ + {/* Picture info */} +
+ {currentPicture && ( + <> + {currentPicture.id?.slice(0, 8)}… + {currentPicture.design && {currentPicture.design}} + + {currentPicture.status} + + + )} +
+
+ + {/* Main 4-column layout */} +
+ + + + + + + +
+
+
+ ); +}