feat: Content Verwaltung — ContentHub + Object Creation
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 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,8 @@ import Login from './pages/Login';
|
|||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import DatabaseAdmin from './pages/DatabaseAdmin';
|
import DatabaseAdmin from './pages/DatabaseAdmin';
|
||||||
import TableView from './pages/TableView';
|
import TableView from './pages/TableView';
|
||||||
|
import ContentHub from './pages/ContentHub';
|
||||||
|
import ObjectCreation from './pages/ObjectCreation';
|
||||||
|
|
||||||
function RequireAuth({ children }) {
|
function RequireAuth({ children }) {
|
||||||
const user = getUser();
|
const user = getUser();
|
||||||
@@ -19,6 +21,8 @@ export default function App() {
|
|||||||
<Route path="/" element={<RequireAuth><Dashboard /></RequireAuth>} />
|
<Route path="/" element={<RequireAuth><Dashboard /></RequireAuth>} />
|
||||||
<Route path="/db" element={<RequireAuth><DatabaseAdmin /></RequireAuth>} />
|
<Route path="/db" element={<RequireAuth><DatabaseAdmin /></RequireAuth>} />
|
||||||
<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/objects" element={<RequireAuth><ObjectCreation /></RequireAuth>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getUser, logout } from '../lib/api';
|
import { getUser, logout } from '../lib/api';
|
||||||
|
|
||||||
export default function Layout({ children, back }) {
|
export default function Layout({ children, back, fullHeight = false }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = getUser();
|
const user = getUser();
|
||||||
|
|
||||||
@@ -11,8 +11,8 @@ export default function Layout({ children, back }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-100">
|
<div className={fullHeight ? 'h-screen flex flex-col bg-slate-100 overflow-hidden' : 'min-h-screen bg-slate-100'}>
|
||||||
<header className="bg-indigo-700 text-white px-6 py-3 flex items-center justify-between shadow">
|
<header className="bg-indigo-700 text-white px-6 py-3 flex items-center justify-between shadow flex-shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{back && (
|
{back && (
|
||||||
<button
|
<button
|
||||||
@@ -24,6 +24,11 @@ export default function Layout({ children, back }) {
|
|||||||
)}
|
)}
|
||||||
<span className="text-xl font-bold tracking-tight">🐟 snakkimo CMT</span>
|
<span className="text-xl font-bold tracking-tight">🐟 snakkimo CMT</span>
|
||||||
</div>
|
</div>
|
||||||
|
<nav className="flex items-center gap-1 text-sm">
|
||||||
|
<button onClick={() => navigate('/')} className="text-indigo-200 hover:text-white px-3 py-1 rounded-lg transition-colors">Dashboard</button>
|
||||||
|
<button onClick={() => navigate('/db')} className="text-indigo-200 hover:text-white px-3 py-1 rounded-lg transition-colors">Datenbank</button>
|
||||||
|
<button onClick={() => navigate('/content')} className="text-indigo-200 hover:text-white px-3 py-1 rounded-lg transition-colors">Content</button>
|
||||||
|
</nav>
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
<span className="text-indigo-200">{user?.email}</span>
|
<span className="text-indigo-200">{user?.email}</span>
|
||||||
<button
|
<button
|
||||||
@@ -34,7 +39,7 @@ export default function Layout({ children, back }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="p-6">{children}</main>
|
<main className={fullHeight ? 'flex-1 overflow-hidden' : 'p-6'}>{children}</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/pages/ContentHub.jsx
Normal file
64
src/pages/ContentHub.jsx
Normal file
@@ -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 (
|
||||||
|
<Layout back="/">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800 mb-2">Content Verwaltung</h1>
|
||||||
|
<p className="text-slate-500 mb-8">Wähle ein Tool um loszulegen.</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
|
||||||
|
{TOOLS.map(tool => (
|
||||||
|
<button
|
||||||
|
key={tool.key}
|
||||||
|
onClick={() => tool.ready && navigate(tool.path)}
|
||||||
|
className={`text-left rounded-2xl p-6 shadow-sm border transition-all
|
||||||
|
${tool.ready
|
||||||
|
? 'bg-white border-slate-200 hover:shadow-md hover:border-indigo-300 cursor-pointer'
|
||||||
|
: 'bg-slate-50 border-slate-100 opacity-50 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-3xl mb-3">{tool.icon}</div>
|
||||||
|
<h2 className="font-semibold text-slate-800 mb-1">{tool.title}</h2>
|
||||||
|
<p className="text-sm text-slate-500">{tool.description}</p>
|
||||||
|
{!tool.ready && (
|
||||||
|
<span className="inline-block mt-3 text-xs bg-slate-200 text-slate-500 rounded-full px-2 py-0.5">
|
||||||
|
Bald verfügbar
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
697
src/pages/ObjectCreation.jsx
Normal file
697
src/pages/ObjectCreation.jsx
Normal file
@@ -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 (
|
||||||
|
<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-40 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── WordTag ─────────────────────────────────────────────────────────────────
|
||||||
|
function WordTag({ word, onRemove }) {
|
||||||
|
return (
|
||||||
|
<span className="group 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}
|
||||||
|
{onRemove && (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Left panel: Objects ──────────────────────────────────────────────────────
|
||||||
|
function ObjectsPanel({ objects, loadingObjects, drawMode, onStartDraw }) {
|
||||||
|
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-3">
|
||||||
|
{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">Noch keine Objekte</p>
|
||||||
|
)}
|
||||||
|
{objects.map((obj, i) => (
|
||||||
|
<div key={obj.id} className="rounded-lg border border-slate-200 p-2.5 bg-slate-50">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
|
<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 || []).map(w => (
|
||||||
|
<span key={w.id} className="text-xs bg-white border border-indigo-100 text-indigo-600 rounded px-1.5 py-0.5">
|
||||||
|
{w.titel_de || w.id}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{(!obj._words || obj._words.length === 0) && (
|
||||||
|
<span className="text-xs text-slate-400">Keine Wörter</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="p-3 border-t border-slate-100">
|
||||||
|
<button
|
||||||
|
onClick={onStartDraw}
|
||||||
|
disabled={drawMode}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-medium py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none">+</span> Neues Objekt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Right panel: Words ───────────────────────────────────────────────────────
|
||||||
|
function WordsPanel({
|
||||||
|
pictureWords, onAddPictureWord, onRemovePictureWord,
|
||||||
|
objectWords, onAddObjectWord, onRemoveObjectWord,
|
||||||
|
drawMode,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<aside className="w-1/5 min-w-[180px] border-l border-slate-200 bg-white flex flex-col overflow-hidden">
|
||||||
|
{/* Picture words */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden border-b border-slate-200">
|
||||||
|
<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">Bild-Wörter</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
<div className="flex flex-wrap gap-1 mb-3">
|
||||||
|
{pictureWords.map(w => (
|
||||||
|
<WordTag key={w.id} word={w} onRemove={onRemovePictureWord} />
|
||||||
|
))}
|
||||||
|
{pictureWords.length === 0 && (
|
||||||
|
<span className="text-xs text-slate-400">Noch keine Wörter</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<WordSearch
|
||||||
|
existingIds={pictureWords.map(w => w.id)}
|
||||||
|
onAdd={onAddPictureWord}
|
||||||
|
placeholder="Wort zum Bild hinzufügen…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Object words (only relevant when in draw mode) */}
|
||||||
|
<div className="flex-1 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">
|
||||||
|
Objekt-Wörter
|
||||||
|
</h2>
|
||||||
|
{!drawMode && <p className="text-xs text-slate-400 mt-0.5">Aktiviere ein Objekt</p>}
|
||||||
|
</div>
|
||||||
|
<div className={`flex-1 overflow-y-auto p-3 transition-opacity ${drawMode ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}>
|
||||||
|
<div className="flex flex-wrap gap-1 mb-3">
|
||||||
|
{objectWords.map(w => (
|
||||||
|
<WordTag key={w.id} word={w} onRemove={onRemoveObjectWord} />
|
||||||
|
))}
|
||||||
|
{objectWords.length === 0 && (
|
||||||
|
<span className="text-xs text-slate-400">Noch keine Wörter</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<WordSearch
|
||||||
|
existingIds={objectWords.map(w => w.id)}
|
||||||
|
onAdd={onAddObjectWord}
|
||||||
|
placeholder="Wort zum Objekt hinzufügen…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Right toolbar: Draw ──────────────────────────────────────────────────────
|
||||||
|
function DrawToolbar({ drawMode, currentPolygon, savedSelections, onAddSelection, onSaveObject, canSave, saving, onCancel }) {
|
||||||
|
const polygonClosed = currentPolygon.length >= 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-1/5 min-w-[180px] 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">Zeichen-Tool</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-3 space-y-4">
|
||||||
|
{!drawMode ? (
|
||||||
|
<p className="text-xs text-slate-400 text-center mt-6">
|
||||||
|
Klicke links auf <strong>+ Neues Objekt</strong> um zu starten.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="bg-indigo-50 rounded-lg p-3 text-xs text-indigo-700 space-y-1">
|
||||||
|
<p className="font-medium">Anleitung:</p>
|
||||||
|
<p>🖱 Klick → Punkt setzen</p>
|
||||||
|
<p>✦ Doppelklick → Polygon schließen</p>
|
||||||
|
<p>Mindestens 3 Punkte nötig</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentPolygon.length > 0 && (
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
<span className="font-medium text-slate-700">{currentPolygon.length}</span> Punkt{currentPolygon.length !== 1 ? 'e' : ''} gesetzt
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onAddSelection}
|
||||||
|
disabled={!polygonClosed}
|
||||||
|
className="w-full py-2 text-xs font-medium rounded-lg transition-colors
|
||||||
|
bg-amber-500 hover:bg-amber-600 disabled:opacity-40 disabled:cursor-not-allowed text-white"
|
||||||
|
>
|
||||||
|
Auswahl hinzufügen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{savedSelections.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
|
||||||
|
Auswahlen ({savedSelections.length})
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{savedSelections.map((sel, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-xs bg-slate-50 rounded px-2 py-1.5">
|
||||||
|
<span className="w-5 h-5 rounded-full bg-indigo-600 text-white text-[10px] flex items-center justify-center font-bold flex-shrink-0">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-600">{sel.points.length} Punkte</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-slate-100 space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={onSaveObject}
|
||||||
|
disabled={!canSave || saving}
|
||||||
|
className="w-full py-2 text-xs font-medium rounded-lg transition-colors
|
||||||
|
bg-green-600 hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed text-white"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichern…' : '✓ Objekt speichern'}
|
||||||
|
</button>
|
||||||
|
{!canSave && drawMode && (
|
||||||
|
<p className="text-xs text-slate-400 text-center">
|
||||||
|
{savedSelections.length === 0 ? 'Zeichne mindestens eine Auswahl' : 'Füge mindestens ein Objekt-Wort hinzu'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="w-full py-1.5 text-xs text-slate-500 hover:text-slate-700 underline"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<div className="flex-1 flex items-center justify-center bg-slate-100">
|
||||||
|
<p className="text-slate-400">Keine Bilder mit Status „uploaded" gefunden.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`flex-1 flex items-center justify-center bg-slate-900 relative overflow-hidden
|
||||||
|
${drawMode ? 'cursor-crosshair' : 'cursor-default'}`}
|
||||||
|
onClick={drawMode ? handleClick : undefined}
|
||||||
|
onDoubleClick={drawMode ? handleDblClick : undefined}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
>
|
||||||
|
{picture.picture_link ? (
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
src={picture.picture_link}
|
||||||
|
alt={picture.design || 'Bild'}
|
||||||
|
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 hochgeladen</span>
|
||||||
|
<span className="text-xs opacity-60">{picture.design}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="absolute pointer-events-none"
|
||||||
|
style={{ position: 'absolute' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<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">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold text-slate-700 text-sm">Object Creation</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Picture navigation */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={goPrev}
|
||||||
|
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 transition-colors"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</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={goNext}
|
||||||
|
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 transition-colors"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Picture info */}
|
||||||
|
<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 className="text-slate-600">{currentPicture.design}</span>}
|
||||||
|
<span className={`px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[currentPicture.status] || 'bg-slate-100 text-slate-600'}`}>
|
||||||
|
{currentPicture.status}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main 4-column layout */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<ObjectsPanel
|
||||||
|
objects={pictureObjects}
|
||||||
|
loadingObjects={loadingObjects}
|
||||||
|
drawMode={drawMode}
|
||||||
|
onStartDraw={handleStartDraw}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ImageCanvas
|
||||||
|
picture={currentPicture}
|
||||||
|
drawMode={drawMode}
|
||||||
|
currentPolygon={currentPolygon}
|
||||||
|
savedSelections={savedSelections}
|
||||||
|
mousePos={mousePos}
|
||||||
|
onImageClick={handleImageClick}
|
||||||
|
onImageDblClick={handleImageDblClick}
|
||||||
|
onMouseMove={setMousePos}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WordsPanel
|
||||||
|
pictureWords={pictureWords}
|
||||||
|
onAddPictureWord={handleAddPictureWord}
|
||||||
|
onRemovePictureWord={handleRemovePictureWord}
|
||||||
|
objectWords={objectWords}
|
||||||
|
onAddObjectWord={handleAddObjectWord}
|
||||||
|
onRemoveObjectWord={handleRemoveObjectWord}
|
||||||
|
drawMode={drawMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DrawToolbar
|
||||||
|
drawMode={drawMode}
|
||||||
|
currentPolygon={currentPolygon}
|
||||||
|
savedSelections={savedSelections}
|
||||||
|
onAddSelection={handleAddSelection}
|
||||||
|
onSaveObject={handleSaveObject}
|
||||||
|
canSave={canSave}
|
||||||
|
saving={saving}
|
||||||
|
onCancel={handleCancelDraw}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user