Whiteboard: neues Tool mit Canvas, Undo, Kollaboration via WebSocket, Berechtigungssystem
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
"better-sqlite3": "^9.4.3",
|
"better-sqlite3": "^9.4.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^1.4.5-lts.1"
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"ws": "^8.17.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ app.use((_req, res, next) => {
|
|||||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||||
"font-src 'self' https://fonts.gstatic.com",
|
"font-src 'self' https://fonts.gstatic.com",
|
||||||
// iCal-Feeds werden serverseitig geprosxt → nur 'self' nötig
|
// iCal-Feeds werden serverseitig geprosxt → nur 'self' nötig
|
||||||
"connect-src 'self' https://web.archive.org",
|
"connect-src 'self' https://web.archive.org wss://dickendock.sermer.org ws://localhost:4000",
|
||||||
// data: für Avatare (base64), google.com + gstatic.com für Favicons im QuickLinks-Widget
|
// data: für Avatare (base64), google.com + gstatic.com für Favicons im QuickLinks-Widget
|
||||||
"img-src 'self' data: https://www.google.com https://*.gstatic.com https://image.tmdb.org",
|
"img-src 'self' data: https://www.google.com https://*.gstatic.com https://image.tmdb.org",
|
||||||
"worker-src 'self'",
|
"worker-src 'self'",
|
||||||
@@ -127,7 +127,104 @@ app.get('/api/build-time', (_req, res) => {
|
|||||||
} catch { res.json({ buildTime: 'unknown' }); }
|
} catch { res.json({ buildTime: 'unknown' }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(4000, () => console.log('🚀 Dicken Dock läuft auf Port 4000'));
|
// ── HTTP + WebSocket Server ───────────────────────────────────────────────────
|
||||||
|
const http = require('http');
|
||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const wss = new WebSocketServer({ server, path: '/ws/whiteboard' });
|
||||||
|
|
||||||
|
// Raum-Map: whiteboard_id → Set<ws>
|
||||||
|
const rooms = new Map();
|
||||||
|
|
||||||
|
wss.on('connection', (ws, req) => {
|
||||||
|
// Token aus Query-String auslesen: /ws/whiteboard?token=...&id=...
|
||||||
|
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||||
|
const token = params.get('token');
|
||||||
|
const wbId = parseInt(params.get('id'));
|
||||||
|
|
||||||
|
let userId = null;
|
||||||
|
try {
|
||||||
|
const p = jwt.verify(token, process.env.JWT_SECRET || 'dev-secret');
|
||||||
|
userId = p.id;
|
||||||
|
} catch {
|
||||||
|
ws.close(1008, 'Unauthorized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zugriff prüfen
|
||||||
|
const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(wbId);
|
||||||
|
if (!wb) { ws.close(1008, 'Not found'); return; }
|
||||||
|
const isOwner = wb.owner_id === userId;
|
||||||
|
const perm = db.prepare('SELECT role FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?').get(wbId, userId);
|
||||||
|
if (!isOwner && !perm) { ws.close(1008, 'Forbidden'); return; }
|
||||||
|
const role = isOwner ? 'owner' : perm.role;
|
||||||
|
|
||||||
|
ws.userId = userId;
|
||||||
|
ws.wbId = wbId;
|
||||||
|
ws.role = role;
|
||||||
|
ws.username = db.prepare('SELECT username FROM users WHERE id=?').get(userId)?.username || 'Unbekannt';
|
||||||
|
|
||||||
|
// Raum beitreten
|
||||||
|
if (!rooms.has(wbId)) rooms.set(wbId, new Set());
|
||||||
|
rooms.get(wbId).add(ws);
|
||||||
|
|
||||||
|
// Anderen im Raum mitteilen wer jointe
|
||||||
|
broadcast(wbId, { type: 'user_join', userId, username: ws.username, role }, ws);
|
||||||
|
|
||||||
|
// Aktive User-Liste an neuen Client schicken
|
||||||
|
const activeUsers = [...rooms.get(wbId)]
|
||||||
|
.filter(c => c !== ws && c.readyState === 1)
|
||||||
|
.map(c => ({ userId: c.userId, username: c.username, role: c.role }));
|
||||||
|
ws.send(JSON.stringify({ type: 'active_users', users: activeUsers }));
|
||||||
|
|
||||||
|
ws.on('message', raw => {
|
||||||
|
let msg;
|
||||||
|
try { msg = JSON.parse(raw); } catch { return; }
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'cursor':
|
||||||
|
// Cursor-Position live broadcasten (nur wenn nicht view-only)
|
||||||
|
broadcast(wbId, { type:'cursor', userId, username:ws.username, x:msg.x, y:msg.y }, ws);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'elements':
|
||||||
|
// Canvas-Änderungen von Edit-Berechtigten an alle broadcasten
|
||||||
|
if (role === 'view') break;
|
||||||
|
broadcast(wbId, { type:'elements', userId, elements: msg.elements }, ws);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ping':
|
||||||
|
ws.send(JSON.stringify({ type: 'pong' }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
const room = rooms.get(wbId);
|
||||||
|
if (room) {
|
||||||
|
room.delete(ws);
|
||||||
|
if (room.size === 0) rooms.delete(wbId);
|
||||||
|
else broadcast(wbId, { type: 'user_leave', userId, username: ws.username });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', () => ws.terminate());
|
||||||
|
});
|
||||||
|
|
||||||
|
function broadcast(wbId, msg, exclude = null) {
|
||||||
|
const room = rooms.get(wbId);
|
||||||
|
if (!room) return;
|
||||||
|
const data = JSON.stringify(msg);
|
||||||
|
for (const client of room) {
|
||||||
|
if (client !== exclude && client.readyState === 1) {
|
||||||
|
client.send(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.listen(4000, () => console.log('🚀 Dicken Dock läuft auf Port 4000'));
|
||||||
|
|
||||||
// ── Push-Zeitplaner Hintergrund-Job ──────────────────────────────────────────
|
// ── Push-Zeitplaner Hintergrund-Job ──────────────────────────────────────────
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
|
|||||||
153
backend/src/tools/whiteboard/routes.js
Normal file
153
backend/src/tools/whiteboard/routes.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const db = require('../../db');
|
||||||
|
const { authenticate, requireAdmin } = require('../../middleware/auth');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ── DB-Migration ──────────────────────────────────────────────────────────────
|
||||||
|
(function migrate() {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS whiteboards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT 'Neues Whiteboard',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS whiteboard_data (
|
||||||
|
whiteboard_id INTEGER PRIMARY KEY REFERENCES whiteboards(id) ON DELETE CASCADE,
|
||||||
|
elements TEXT NOT NULL DEFAULT '[]',
|
||||||
|
viewport TEXT NOT NULL DEFAULT '{"x":0,"y":0,"zoom":1}'
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS whiteboard_permissions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
whiteboard_id INTEGER NOT NULL REFERENCES whiteboards(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'edit',
|
||||||
|
UNIQUE(whiteboard_id, user_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const uid = req => req.user.id;
|
||||||
|
|
||||||
|
// Hilfsfunktion: Hat User Zugriff? Gibt 'owner'|'edit'|'view'|null zurück
|
||||||
|
function access(whiteboardId, userId) {
|
||||||
|
const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(whiteboardId);
|
||||||
|
if (!wb) return null;
|
||||||
|
if (wb.owner_id === userId) return 'owner';
|
||||||
|
const perm = db.prepare('SELECT role FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?').get(whiteboardId, userId);
|
||||||
|
return perm?.role || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Liste ─────────────────────────────────────────────────────────────────────
|
||||||
|
router.get('/', authenticate, (req, res) => {
|
||||||
|
const me = uid(req);
|
||||||
|
// Eigene + geteilte Whiteboards
|
||||||
|
const own = db.prepare(`
|
||||||
|
SELECT w.*, 'owner' as role, u.username as owner_name
|
||||||
|
FROM whiteboards w JOIN users u ON u.id=w.owner_id
|
||||||
|
WHERE w.owner_id=? ORDER BY w.updated_at DESC
|
||||||
|
`).all(me);
|
||||||
|
const shared = db.prepare(`
|
||||||
|
SELECT w.*, p.role, u.username as owner_name
|
||||||
|
FROM whiteboards w
|
||||||
|
JOIN whiteboard_permissions p ON p.whiteboard_id=w.id AND p.user_id=?
|
||||||
|
JOIN users u ON u.id=w.owner_id
|
||||||
|
ORDER BY w.updated_at DESC
|
||||||
|
`).all(me);
|
||||||
|
res.json({ whiteboards: [...own, ...shared] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Erstellen ─────────────────────────────────────────────────────────────────
|
||||||
|
router.post('/', authenticate, (req, res) => {
|
||||||
|
const { title = 'Neues Whiteboard' } = req.body;
|
||||||
|
const me = uid(req);
|
||||||
|
const r = db.prepare("INSERT INTO whiteboards (owner_id, title) VALUES (?,?)").run(me, title.trim() || 'Neues Whiteboard');
|
||||||
|
db.prepare("INSERT INTO whiteboard_data (whiteboard_id) VALUES (?)").run(r.lastInsertRowid);
|
||||||
|
res.json(db.prepare('SELECT * FROM whiteboards WHERE id=?').get(r.lastInsertRowid));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Umbenennen ────────────────────────────────────────────────────────────────
|
||||||
|
router.patch('/:id/title', authenticate, (req, res) => {
|
||||||
|
const { title } = req.body;
|
||||||
|
if (!title?.trim()) return res.status(400).json({ error: 'Titel fehlt' });
|
||||||
|
const me = uid(req);
|
||||||
|
const role = access(req.params.id, me);
|
||||||
|
if (!role || role === 'view') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||||
|
db.prepare("UPDATE whiteboards SET title=?, updated_at=datetime('now','localtime') WHERE id=?").run(title.trim(), req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Löschen ───────────────────────────────────────────────────────────────────
|
||||||
|
router.delete('/:id', authenticate, (req, res) => {
|
||||||
|
const me = uid(req);
|
||||||
|
const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id);
|
||||||
|
if (!wb) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
if (wb.owner_id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann löschen' });
|
||||||
|
db.prepare('DELETE FROM whiteboards WHERE id=?').run(wb.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Canvas laden ──────────────────────────────────────────────────────────────
|
||||||
|
router.get('/:id/data', authenticate, (req, res) => {
|
||||||
|
const me = uid(req);
|
||||||
|
const role = access(req.params.id, me);
|
||||||
|
if (!role) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||||
|
const data = db.prepare('SELECT * FROM whiteboard_data WHERE whiteboard_id=?').get(req.params.id);
|
||||||
|
const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id);
|
||||||
|
const perms = db.prepare(`
|
||||||
|
SELECT p.*, u.username FROM whiteboard_permissions p
|
||||||
|
JOIN users u ON u.id=p.user_id WHERE p.whiteboard_id=?
|
||||||
|
`).all(req.params.id);
|
||||||
|
const owner = db.prepare('SELECT username FROM users WHERE id=?').get(wb.owner_id);
|
||||||
|
res.json({ ...data, role, title: wb.title, owner: owner.username, permissions: perms });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Canvas speichern ──────────────────────────────────────────────────────────
|
||||||
|
// Feste Route vor :id-Routen
|
||||||
|
router.post('/:id/save', authenticate, (req, res) => {
|
||||||
|
const me = uid(req);
|
||||||
|
const role = access(req.params.id, me);
|
||||||
|
if (!role || role === 'view') return res.status(403).json({ error: 'Kein Schreibzugriff' });
|
||||||
|
const { elements, viewport } = req.body;
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE whiteboard_data SET elements=?, viewport=? WHERE whiteboard_id=?
|
||||||
|
`).run(JSON.stringify(elements || []), JSON.stringify(viewport || {x:0,y:0,zoom:1}), req.params.id);
|
||||||
|
db.prepare("UPDATE whiteboards SET updated_at=datetime('now','localtime') WHERE id=?").run(req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Berechtigungen: alle User für Share-Modal ─────────────────────────────────
|
||||||
|
router.get('/users-list', authenticate, (req, res) => {
|
||||||
|
const me = uid(req);
|
||||||
|
const users = db.prepare('SELECT id, username FROM users WHERE id!=? ORDER BY username').all(me);
|
||||||
|
res.json({ users });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Berechtigungen setzen ─────────────────────────────────────────────────────
|
||||||
|
router.put('/:id/permissions', authenticate, (req, res) => {
|
||||||
|
const me = uid(req);
|
||||||
|
const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id);
|
||||||
|
if (!wb) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
if (wb.owner_id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann Berechtigungen vergeben' });
|
||||||
|
const { permissions } = req.body; // [{user_id, role: 'edit'|'view'|null}]
|
||||||
|
if (!Array.isArray(permissions)) return res.status(400).json({ error: 'permissions fehlt' });
|
||||||
|
|
||||||
|
const upsert = db.prepare("INSERT INTO whiteboard_permissions (whiteboard_id,user_id,role) VALUES (?,?,?) ON CONFLICT(whiteboard_id,user_id) DO UPDATE SET role=excluded.role");
|
||||||
|
const remove = db.prepare("DELETE FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?");
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
for (const p of permissions) {
|
||||||
|
if (!p.user_id) continue;
|
||||||
|
if (p.role === null || p.role === 'none') remove.run(req.params.id, p.user_id);
|
||||||
|
else upsert.run(req.params.id, p.user_id, p.role);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
const updated = db.prepare(`
|
||||||
|
SELECT p.*, u.username FROM whiteboard_permissions p
|
||||||
|
JOIN users u ON u.id=p.user_id WHERE p.whiteboard_id=?
|
||||||
|
`).all(req.params.id);
|
||||||
|
res.json({ permissions: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -181,3 +181,8 @@ export const KanbanIcon = ({size=20,color='currentColor',sw}) => base(<>
|
|||||||
<rect x="10" y="3" width="5" height="8" rx="1.5" stroke={color} strokeWidth={sw||1.5} fill="none"/>
|
<rect x="10" y="3" width="5" height="8" rx="1.5" stroke={color} strokeWidth={sw||1.5} fill="none"/>
|
||||||
<rect x="17" y="3" width="4" height="5" rx="1.5" stroke={color} strokeWidth={sw||1.5} fill="none"/>
|
<rect x="17" y="3" width="4" height="5" rx="1.5" stroke={color} strokeWidth={sw||1.5} fill="none"/>
|
||||||
</>, size, color, sw);
|
</>, size, color, sw);
|
||||||
|
export const WhiteboardIcon = ({size=20,color='currentColor',sw}) => base(<>
|
||||||
|
<rect x="3" y="3" width="18" height="14" rx="2" stroke={color} strokeWidth={sw||1.5} fill="none"/>
|
||||||
|
<path d="M7 13 L10 9 L13 11 L16 7" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<line x1="3" y1="19" x2="21" y2="19" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
|
||||||
|
</>, size, color, sw);
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import DevTools from './tools/devtools.jsx';
|
|||||||
import Media from './tools/media.jsx';
|
import Media from './tools/media.jsx';
|
||||||
import PaywallKiller from './tools/paywallkiller.jsx';
|
import PaywallKiller from './tools/paywallkiller.jsx';
|
||||||
import Kanban from './tools/kanban.jsx';
|
import Kanban from './tools/kanban.jsx';
|
||||||
import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon, CodeIcon, SkizzeIcon, WrenchIcon, FilmIcon, PaywallIcon, ChartIcon, KanbanIcon } from './icons.jsx';
|
import Whiteboard from './tools/whiteboard.jsx';
|
||||||
|
import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon, CodeIcon, SkizzeIcon, WrenchIcon, FilmIcon, PaywallIcon, ChartIcon, KanbanIcon, WhiteboardIcon } from './icons.jsx';
|
||||||
|
|
||||||
export const TOOLS = [
|
export const TOOLS = [
|
||||||
{
|
{
|
||||||
@@ -53,6 +54,14 @@ export const TOOLS = [
|
|||||||
group: 'Werkzeuge',
|
group: 'Werkzeuge',
|
||||||
component: Kanban,
|
component: Kanban,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'whiteboard',
|
||||||
|
Icon: WhiteboardIcon,
|
||||||
|
label: 'Whiteboard',
|
||||||
|
navLabel: 'Whiteboard',
|
||||||
|
group: 'Werkzeuge',
|
||||||
|
component: Whiteboard,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'dateien',
|
id: 'dateien',
|
||||||
Icon: DatabaseIcon,
|
Icon: DatabaseIcon,
|
||||||
|
|||||||
695
frontend/src/tools/whiteboard.jsx
Normal file
695
frontend/src/tools/whiteboard.jsx
Normal file
@@ -0,0 +1,695 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { api, S } from '../lib.js';
|
||||||
|
|
||||||
|
// ── Konstanten ────────────────────────────────────────────────────────────────
|
||||||
|
const COLORS = ['#FFFFFF','#FF6B9D','#4ECDC4','#FFE66D','#60A5FA','#A78BFA','#FB923C','#4ADE80','#F87171','#000000'];
|
||||||
|
const SIZES = [2, 4, 8, 14, 22];
|
||||||
|
const TOOLS = [
|
||||||
|
{ id:'select', label:'↖', tip:'Auswählen' },
|
||||||
|
{ id:'pen', label:'✏', tip:'Stift' },
|
||||||
|
{ id:'line', label:'╱', tip:'Linie' },
|
||||||
|
{ id:'rect', label:'▭', tip:'Rechteck' },
|
||||||
|
{ id:'ellipse', label:'◯', tip:'Ellipse' },
|
||||||
|
{ id:'text', label:'T', tip:'Text' },
|
||||||
|
{ id:'eraser', label:'⌫', tip:'Radierer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CURSOR_COLORS = ['#FF6B9D','#4ECDC4','#FFE66D','#60A5FA','#A78BFA','#FB923C'];
|
||||||
|
|
||||||
|
// ── Utility ───────────────────────────────────────────────────────────────────
|
||||||
|
const getId = () => Math.random().toString(36).slice(2);
|
||||||
|
|
||||||
|
// ── Share-Modal (außerhalb Hauptkomponente — Lesson Learned) ──────────────────
|
||||||
|
function ShareModal({ wbId, onClose, toast }) {
|
||||||
|
const [allUsers, setAllUsers] = useState([]);
|
||||||
|
const [perms, setPerms] = useState([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
api('/tools/whiteboard/users-list'),
|
||||||
|
api(`/tools/whiteboard/${wbId}/data`),
|
||||||
|
]).then(([u, d]) => {
|
||||||
|
setAllUsers(u.users || []);
|
||||||
|
setPerms(d.permissions || []);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [wbId]);
|
||||||
|
|
||||||
|
const roleOf = (userId) => perms.find(p => p.user_id === userId)?.role || 'none';
|
||||||
|
|
||||||
|
const setRole = (userId, role) => {
|
||||||
|
setPerms(prev => {
|
||||||
|
const filtered = prev.filter(p => p.user_id !== userId);
|
||||||
|
if (role === 'none') return filtered;
|
||||||
|
return [...filtered, { user_id: userId, role }];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api(`/tools/whiteboard/${wbId}/permissions`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { permissions: allUsers.map(u => ({ user_id: u.id, role: roleOf(u.id) === 'none' ? null : roleOf(u.id) })) },
|
||||||
|
});
|
||||||
|
toast('Berechtigungen gespeichert ✓');
|
||||||
|
onClose();
|
||||||
|
} catch(e) { toast(e.message, 'error'); }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const mob = window.innerWidth < 768;
|
||||||
|
return (
|
||||||
|
<div onClick={e => e.target === e.currentTarget && onClose()}
|
||||||
|
style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.75)', zIndex:2000,
|
||||||
|
display:'flex', alignItems: mob ? 'flex-end' : 'center', justifyContent:'center', padding: mob ? 0 : 20 }}>
|
||||||
|
<div style={{ background:'#1a1a1e', border:'1px solid rgba(255,255,255,0.12)',
|
||||||
|
borderRadius: mob ? '16px 16px 0 0' : 14, width:'100%', maxWidth:440,
|
||||||
|
display:'flex', flexDirection:'column', maxHeight: mob ? '80vh' : '75vh',
|
||||||
|
paddingBottom: mob ? 'calc(56px + env(safe-area-inset-bottom,0px))' : 0 }}>
|
||||||
|
<div style={{ padding:'18px 20px 0', flexShrink:0 }}>
|
||||||
|
{mob && <div style={{ width:36, height:4, background:'rgba(255,255,255,0.15)', borderRadius:2, margin:'0 auto 14px' }}/>}
|
||||||
|
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:14 }}>
|
||||||
|
<div style={{ ...S.head, marginBottom:0 }}>TEILEN</div>
|
||||||
|
<button onClick={onClose} style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.4)', cursor:'pointer', fontSize:18 }}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowY:'auto', padding:'0 20px', flex:1 }}>
|
||||||
|
{allUsers.length === 0 && <div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:12 }}>Keine anderen Benutzer vorhanden.</div>}
|
||||||
|
{allUsers.map(u => (
|
||||||
|
<div key={u.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 0', borderBottom:'1px solid rgba(255,255,255,0.05)' }}>
|
||||||
|
<span style={{ flex:1, fontFamily:'monospace', fontSize:13, color:'#fff' }}>{u.username}</span>
|
||||||
|
{['none','view','edit'].map(r => (
|
||||||
|
<button key={r} onClick={() => setRole(u.id, r)} style={{
|
||||||
|
...S.btn(r==='edit'?'#4ECDC4':r==='view'?'#60A5FA':'#888888', true),
|
||||||
|
fontSize:10, padding:'3px 8px',
|
||||||
|
background: roleOf(u.id) === r ? (r==='edit'?'rgba(78,205,196,0.2)':r==='view'?'rgba(96,165,250,0.2)':'rgba(136,136,136,0.2)') : 'transparent',
|
||||||
|
fontWeight: roleOf(u.id) === r ? 700 : 400,
|
||||||
|
}}>
|
||||||
|
{r === 'none' ? '✕ kein' : r === 'view' ? '👁 lesen' : '✏ bearbeiten'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding:'14px 20px 20px', flexShrink:0, borderTop:'1px solid rgba(255,255,255,0.07)', display:'flex', gap:8, justifyContent:'flex-end' }}>
|
||||||
|
<button onClick={onClose} style={S.btn('#888888', true)}>Abbrechen</button>
|
||||||
|
<button onClick={save} disabled={saving} style={S.btn('#4ECDC4', true)}>{saving ? '…' : 'Speichern'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Text-Eingabe-Overlay ───────────────────────────────────────────────────────
|
||||||
|
function TextInput({ x, y, color, size, onDone }) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
useEffect(() => { ref.current?.focus(); }, []);
|
||||||
|
return (
|
||||||
|
<input ref={ref}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') onDone(ref.current.value); if (e.key === 'Escape') onDone(''); }}
|
||||||
|
onBlur={() => onDone(ref.current?.value || '')}
|
||||||
|
style={{ position:'absolute', left:x, top:y, background:'transparent', border:'none',
|
||||||
|
outline:'1px dashed rgba(255,255,255,0.4)', color, fontSize: size * 4,
|
||||||
|
fontFamily:'monospace', minWidth:100, zIndex:100 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Canvas-Renderer ────────────────────────────────────────────────────────────
|
||||||
|
function renderElements(ctx, elements, vp) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(vp.x, vp.y);
|
||||||
|
ctx.scale(vp.zoom, vp.zoom);
|
||||||
|
ctx.clearRect(-vp.x/vp.zoom, -vp.y/vp.zoom, ctx.canvas.width/vp.zoom, ctx.canvas.height/vp.zoom);
|
||||||
|
|
||||||
|
for (const el of elements) {
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.strokeStyle = el.color || '#fff';
|
||||||
|
ctx.fillStyle = el.color || '#fff';
|
||||||
|
ctx.lineWidth = (el.size || 4) / vp.zoom;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
|
||||||
|
switch (el.type) {
|
||||||
|
case 'pen': {
|
||||||
|
if (!el.points?.length) break;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(el.points[0].x, el.points[0].y);
|
||||||
|
for (const p of el.points.slice(1)) ctx.lineTo(p.x, p.y);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'eraser': {
|
||||||
|
if (!el.points?.length) break;
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = 'destination-out';
|
||||||
|
ctx.lineWidth = (el.size || 20) / vp.zoom;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(el.points[0].x, el.points[0].y);
|
||||||
|
for (const p of el.points.slice(1)) ctx.lineTo(p.x, p.y);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'line': {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(el.x1, el.y1);
|
||||||
|
ctx.lineTo(el.x2, el.y2);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'rect': {
|
||||||
|
ctx.strokeRect(el.x, el.y, el.w, el.h);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ellipse': {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(el.cx, el.cy, Math.abs(el.rx), Math.abs(el.ry), 0, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'text': {
|
||||||
|
ctx.font = `${(el.size || 4) * 4}px monospace`;
|
||||||
|
ctx.fillText(el.text, el.x, el.y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Haupt-Whiteboard-Komponente ────────────────────────────────────────────────
|
||||||
|
export default function Whiteboard({ toast, mobile }) {
|
||||||
|
// ── View-State ────────────────────────────────────────────────────────────
|
||||||
|
const [view, setView] = useState('list'); // 'list' | 'canvas'
|
||||||
|
const [boards, setBoards] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [current, setCurrent] = useState(null); // { id, title, role }
|
||||||
|
|
||||||
|
// ── Canvas-State ──────────────────────────────────────────────────────────
|
||||||
|
const [elements, setElements] = useState([]);
|
||||||
|
const [undoStack, setUndoStack] = useState([]);
|
||||||
|
const [tool, setTool] = useState('pen');
|
||||||
|
const [color, setColor] = useState('#FFFFFF');
|
||||||
|
const [size, setSize] = useState(1); // Index in SIZES
|
||||||
|
const [viewport, setViewport] = useState({ x:0, y:0, zoom:1 });
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [shareModal, setShareModal] = useState(false);
|
||||||
|
const [textInput, setTextInput] = useState(null); // {x,y} | null
|
||||||
|
|
||||||
|
// ── Kollaboration ─────────────────────────────────────────────────────────
|
||||||
|
const [collab, setCollab] = useState([]); // [{userId, username, x, y, color}]
|
||||||
|
const wsRef = useRef(null);
|
||||||
|
const cursorTimers = useRef({});
|
||||||
|
|
||||||
|
// ── Canvas-Refs ───────────────────────────────────────────────────────────
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const drawing = useRef(false);
|
||||||
|
const currentEl = useRef(null);
|
||||||
|
const startPos = useRef({ x:0, y:0 });
|
||||||
|
const isPanning = useRef(false);
|
||||||
|
const panStart = useRef({ x:0, y:0 });
|
||||||
|
const vpRef = useRef({ x:0, y:0, zoom:1 });
|
||||||
|
const elementsRef = useRef([]);
|
||||||
|
|
||||||
|
// Hooks immer vor Early Returns (Lesson Learned)
|
||||||
|
const loadBoards = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
api('/tools/whiteboard')
|
||||||
|
.then(d => setBoards(d.whiteboards || []))
|
||||||
|
.catch(() => toast('Fehler beim Laden', 'error'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadBoards(); }, [loadBoards]);
|
||||||
|
|
||||||
|
// Canvas rendern wenn sich Elemente oder Viewport ändern
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
renderElements(ctx, elementsRef.current, vpRef.current);
|
||||||
|
// Cursor anderer User zeichnen
|
||||||
|
for (const c of collab) {
|
||||||
|
const sx = c.x * vpRef.current.zoom + vpRef.current.x;
|
||||||
|
const sy = c.y * vpRef.current.zoom + vpRef.current.y;
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = c.color;
|
||||||
|
ctx.font = '10px monospace';
|
||||||
|
ctx.fillText('▶ ' + c.username, sx + 4, sy - 4);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}, [elements, viewport, collab]);
|
||||||
|
|
||||||
|
// ── WebSocket aufbauen ────────────────────────────────────────────────────
|
||||||
|
const connectWs = useCallback((wbId) => {
|
||||||
|
const token = localStorage.getItem('sk_token');
|
||||||
|
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/whiteboard?token=${token}&id=${wbId}`;
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
const collabColors = {};
|
||||||
|
let colorIdx = 0;
|
||||||
|
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'user_join':
|
||||||
|
if (!collabColors[msg.userId]) collabColors[msg.userId] = CURSOR_COLORS[colorIdx++ % CURSOR_COLORS.length];
|
||||||
|
setCollab(prev => [...prev.filter(c => c.userId !== msg.userId), { userId: msg.userId, username: msg.username, color: collabColors[msg.userId], x:0, y:0 }]);
|
||||||
|
break;
|
||||||
|
case 'user_leave':
|
||||||
|
setCollab(prev => prev.filter(c => c.userId !== msg.userId));
|
||||||
|
break;
|
||||||
|
case 'active_users':
|
||||||
|
for (const u of msg.users) {
|
||||||
|
if (!collabColors[u.userId]) collabColors[u.userId] = CURSOR_COLORS[colorIdx++ % CURSOR_COLORS.length];
|
||||||
|
}
|
||||||
|
setCollab(msg.users.map(u => ({ ...u, color: collabColors[u.userId] || CURSOR_COLORS[0], x:0, y:0 })));
|
||||||
|
break;
|
||||||
|
case 'cursor':
|
||||||
|
if (!collabColors[msg.userId]) collabColors[msg.userId] = CURSOR_COLORS[colorIdx++ % CURSOR_COLORS.length];
|
||||||
|
setCollab(prev => prev.map(c => c.userId === msg.userId ? { ...c, x: msg.x, y: msg.y } : c));
|
||||||
|
// Cursor nach 3s ausblenden
|
||||||
|
clearTimeout(cursorTimers.current[msg.userId]);
|
||||||
|
cursorTimers.current[msg.userId] = setTimeout(() => {
|
||||||
|
setCollab(prev => prev.map(c => c.userId === msg.userId ? { ...c, x:-999, y:-999 } : c));
|
||||||
|
}, 3000);
|
||||||
|
break;
|
||||||
|
case 'elements':
|
||||||
|
// Canvas von Mitarbeiter aktualisieren
|
||||||
|
elementsRef.current = msg.elements;
|
||||||
|
setElements([...msg.elements]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onclose = () => setCollab([]);
|
||||||
|
ws.onerror = () => {};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const disconnectWs = useCallback(() => {
|
||||||
|
wsRef.current?.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
setCollab([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Whiteboard öffnen ─────────────────────────────────────────────────────
|
||||||
|
const openBoard = async (board) => {
|
||||||
|
try {
|
||||||
|
const d = await api(`/tools/whiteboard/${board.id}/data`);
|
||||||
|
const els = JSON.parse(typeof d.elements === 'string' ? d.elements : JSON.stringify(d.elements || []));
|
||||||
|
const vp = JSON.parse(typeof d.viewport === 'string' ? d.viewport : JSON.stringify(d.viewport || {x:0,y:0,zoom:1}));
|
||||||
|
elementsRef.current = els;
|
||||||
|
vpRef.current = vp;
|
||||||
|
setElements(els);
|
||||||
|
setViewport(vp);
|
||||||
|
setUndoStack([]);
|
||||||
|
setCurrent({ id: board.id, title: d.title, role: d.role });
|
||||||
|
setView('canvas');
|
||||||
|
connectWs(board.id);
|
||||||
|
} catch(e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Whiteboard schließen ──────────────────────────────────────────────────
|
||||||
|
const closeBoard = () => {
|
||||||
|
disconnectWs();
|
||||||
|
setView('list');
|
||||||
|
setCurrent(null);
|
||||||
|
setElements([]);
|
||||||
|
setUndoStack([]);
|
||||||
|
setCollab([]);
|
||||||
|
loadBoards();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Canvas-Koordinaten ────────────────────────────────────────────────────
|
||||||
|
const toCanvas = (e) => {
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const cx = (e.clientX ?? e.touches?.[0]?.clientX ?? 0) - rect.left;
|
||||||
|
const cy = (e.clientY ?? e.touches?.[0]?.clientY ?? 0) - rect.top;
|
||||||
|
return {
|
||||||
|
x: (cx - vpRef.current.x) / vpRef.current.zoom,
|
||||||
|
y: (cy - vpRef.current.y) / vpRef.current.zoom,
|
||||||
|
sx: cx, sy: cy,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Zeichnen ──────────────────────────────────────────────────────────────
|
||||||
|
const pushElement = (el) => {
|
||||||
|
const next = [...elementsRef.current, el];
|
||||||
|
elementsRef.current = next;
|
||||||
|
setUndoStack(prev => [...prev, elementsRef.current.slice(0, -1)]);
|
||||||
|
setElements([...next]);
|
||||||
|
wsRef.current?.readyState === 1 && wsRef.current.send(JSON.stringify({ type:'elements', elements: next }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLastElement = (el) => {
|
||||||
|
const next = [...elementsRef.current.slice(0, -1), el];
|
||||||
|
elementsRef.current = next;
|
||||||
|
setElements([...next]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalizeLastElement = (el) => {
|
||||||
|
const next = [...elementsRef.current.slice(0, -1), el];
|
||||||
|
elementsRef.current = next;
|
||||||
|
setElements([...next]);
|
||||||
|
wsRef.current?.readyState === 1 && wsRef.current.send(JSON.stringify({ type:'elements', elements: next }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const undo = () => {
|
||||||
|
if (!undoStack.length) return;
|
||||||
|
const prev = undoStack[undoStack.length - 1];
|
||||||
|
setUndoStack(s => s.slice(0, -1));
|
||||||
|
elementsRef.current = prev;
|
||||||
|
setElements([...prev]);
|
||||||
|
wsRef.current?.readyState === 1 && wsRef.current.send(JSON.stringify({ type:'elements', elements: prev }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Pointer Events ────────────────────────────────────────────────────────
|
||||||
|
const onPointerDown = (e) => {
|
||||||
|
if (current?.role === 'view') return;
|
||||||
|
e.preventDefault();
|
||||||
|
const { x, y, sx, sy } = toCanvas(e);
|
||||||
|
|
||||||
|
// Panning mit Mittelklick oder Space+Drag
|
||||||
|
if (e.button === 1 || tool === 'select') {
|
||||||
|
isPanning.current = true;
|
||||||
|
panStart.current = { x: sx - vpRef.current.x, y: sy - vpRef.current.y };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === 'text') {
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
setTextInput({ x: sx + rect.left, y: sy + rect.top - SIZES[size] * 2 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawing.current = true;
|
||||||
|
startPos.current = { x, y };
|
||||||
|
|
||||||
|
if (tool === 'pen' || tool === 'eraser') {
|
||||||
|
const el = { id: getId(), type: tool, color, size: SIZES[size], points: [{ x, y }] };
|
||||||
|
currentEl.current = el;
|
||||||
|
pushElement(el);
|
||||||
|
} else if (tool === 'line') {
|
||||||
|
const el = { id: getId(), type: 'line', color, size: SIZES[size], x1:x, y1:y, x2:x, y2:y };
|
||||||
|
currentEl.current = el;
|
||||||
|
pushElement(el);
|
||||||
|
} else if (tool === 'rect') {
|
||||||
|
const el = { id: getId(), type: 'rect', color, size: SIZES[size], x, y, w:0, h:0 };
|
||||||
|
currentEl.current = el;
|
||||||
|
pushElement(el);
|
||||||
|
} else if (tool === 'ellipse') {
|
||||||
|
const el = { id: getId(), type: 'ellipse', color, size: SIZES[size], cx:x, cy:y, rx:0, ry:0 };
|
||||||
|
currentEl.current = el;
|
||||||
|
pushElement(el);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e) => {
|
||||||
|
const { x, y, sx, sy } = toCanvas(e);
|
||||||
|
|
||||||
|
// Cursor per WS senden (gedrosselt)
|
||||||
|
if (wsRef.current?.readyState === 1) {
|
||||||
|
wsRef.current.send(JSON.stringify({ type:'cursor', x, y }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPanning.current) {
|
||||||
|
const nx = sx - panStart.current.x;
|
||||||
|
const ny = sy - panStart.current.y;
|
||||||
|
vpRef.current = { ...vpRef.current, x: nx, y: ny };
|
||||||
|
setViewport({ ...vpRef.current });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!drawing.current || !currentEl.current) return;
|
||||||
|
const el = currentEl.current;
|
||||||
|
|
||||||
|
if (tool === 'pen' || tool === 'eraser') {
|
||||||
|
const updated = { ...el, points: [...el.points, { x, y }] };
|
||||||
|
currentEl.current = updated;
|
||||||
|
updateLastElement(updated);
|
||||||
|
} else if (tool === 'line') {
|
||||||
|
const updated = { ...el, x2: x, y2: y };
|
||||||
|
currentEl.current = updated;
|
||||||
|
updateLastElement(updated);
|
||||||
|
} else if (tool === 'rect') {
|
||||||
|
const updated = { ...el, w: x - el.x, h: y - el.y };
|
||||||
|
currentEl.current = updated;
|
||||||
|
updateLastElement(updated);
|
||||||
|
} else if (tool === 'ellipse') {
|
||||||
|
const updated = { ...el, rx: (x - el.cx), ry: (y - el.cy) };
|
||||||
|
currentEl.current = updated;
|
||||||
|
updateLastElement(updated);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = () => {
|
||||||
|
if (isPanning.current) { isPanning.current = false; return; }
|
||||||
|
if (!drawing.current) return;
|
||||||
|
drawing.current = false;
|
||||||
|
if (currentEl.current) {
|
||||||
|
finalizeLastElement(currentEl.current);
|
||||||
|
currentEl.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Zoom mit Mausrad
|
||||||
|
const onWheel = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const mx = e.clientX - rect.left;
|
||||||
|
const my = e.clientY - rect.top;
|
||||||
|
const newZoom = Math.max(0.1, Math.min(5, vpRef.current.zoom * delta));
|
||||||
|
const nx = mx - (mx - vpRef.current.x) * (newZoom / vpRef.current.zoom);
|
||||||
|
const ny = my - (my - vpRef.current.y) * (newZoom / vpRef.current.zoom);
|
||||||
|
vpRef.current = { x: nx, y: ny, zoom: newZoom };
|
||||||
|
setViewport({ ...vpRef.current });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Canvas-Größe anpassen
|
||||||
|
useEffect(() => {
|
||||||
|
if (view !== 'canvas') return;
|
||||||
|
const resize = () => {
|
||||||
|
const c = canvasRef.current;
|
||||||
|
if (!c) return;
|
||||||
|
c.width = c.offsetWidth;
|
||||||
|
c.height = c.offsetHeight;
|
||||||
|
renderElements(c.getContext('2d'), elementsRef.current, vpRef.current);
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
return () => window.removeEventListener('resize', resize);
|
||||||
|
}, [view]);
|
||||||
|
|
||||||
|
// Speichern
|
||||||
|
const save = async () => {
|
||||||
|
if (saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api(`/tools/whiteboard/${current.id}/save`, {
|
||||||
|
body: { elements: elementsRef.current, viewport: vpRef.current },
|
||||||
|
});
|
||||||
|
toast('Gespeichert ✓');
|
||||||
|
} catch(e) { toast(e.message, 'error'); }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tastenkürzel
|
||||||
|
useEffect(() => {
|
||||||
|
if (view !== 'canvas') return;
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.target.tagName === 'INPUT') return;
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); undo(); }
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); save(); }
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [view, undoStack]);
|
||||||
|
|
||||||
|
// ── Render: Liste ──────────────────────────────────────────────────────────
|
||||||
|
if (view === 'list') return (
|
||||||
|
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px' }}>
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:24, flexWrap:'wrap' }}>
|
||||||
|
<h2 style={{ margin:0, fontSize:15, fontFamily:'monospace', color:'rgba(255,255,255,0.55)', letterSpacing:2, fontWeight:400 }}>
|
||||||
|
WHITEBOARD
|
||||||
|
</h2>
|
||||||
|
<div style={{ flex:1 }}/>
|
||||||
|
<button onClick={async () => {
|
||||||
|
try {
|
||||||
|
const wb = await api('/tools/whiteboard', { body: { title: 'Neues Whiteboard' } });
|
||||||
|
await openBoard(wb);
|
||||||
|
} catch(e) { toast(e.message, 'error'); }
|
||||||
|
}} style={S.btn('#4ECDC4')}>+ Neu</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:12 }}>Lädt…</div>}
|
||||||
|
|
||||||
|
{!loading && boards.length === 0 && (
|
||||||
|
<div style={{ ...S.card, textAlign:'center', padding:40, color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:12 }}>
|
||||||
|
Noch kein Whiteboard. Erstelle eines mit "+ Neu".
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{boards.map(b => (
|
||||||
|
<div key={b.id} style={{ ...S.card, display:'flex', alignItems:'center', gap:12, marginBottom:8, cursor:'pointer' }}
|
||||||
|
onClick={() => openBoard(b)}>
|
||||||
|
<div style={{ fontSize:24 }}>🖊</div>
|
||||||
|
<div style={{ flex:1, minWidth:0 }}>
|
||||||
|
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13, fontWeight:700 }}>{b.title}</div>
|
||||||
|
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10, marginTop:2 }}>
|
||||||
|
{b.role === 'owner' ? '👑 Eigenes' : b.role === 'edit' ? '✏ Bearbeiten' : '👁 Lesen'}
|
||||||
|
{b.owner_name && b.role !== 'owner' && ` · von ${b.owner_name}`}
|
||||||
|
{' · ' + new Date(b.updated_at).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{b.role === 'owner' && (
|
||||||
|
<button onClick={e => { e.stopPropagation(); if (confirm(`"${b.title}" löschen?`))
|
||||||
|
api(`/tools/whiteboard/${b.id}`, { method:'DELETE' }).then(loadBoards).catch(err => toast(err.message, 'error'));
|
||||||
|
}} style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.2)', cursor:'pointer', fontSize:16, padding:'4px 6px' }}>✕</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Render: Canvas ─────────────────────────────────────────────────────────
|
||||||
|
const canEdit = current?.role !== 'view';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display:'flex', flexDirection:'column', height:'100%', background:'#0D0D0F', position:'relative' }}>
|
||||||
|
|
||||||
|
{/* Share-Modal */}
|
||||||
|
{shareModal && current?.role === 'owner' && (
|
||||||
|
<ShareModal wbId={current.id} onClose={() => setShareModal(false)} toast={toast}/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Text-Eingabe */}
|
||||||
|
{textInput && (
|
||||||
|
<TextInput x={textInput.x} y={textInput.y} color={color} size={SIZES[size]}
|
||||||
|
onDone={text => {
|
||||||
|
setTextInput(null);
|
||||||
|
if (text?.trim()) {
|
||||||
|
const { x, y } = toCanvas({ clientX: textInput.x, clientY: textInput.y + SIZES[size] * 2 });
|
||||||
|
pushElement({ id: getId(), type:'text', color, size, text, x, y });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Top-Bar ──────────────────────────────────────────────────────── */}
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:8, padding:'8px 12px',
|
||||||
|
background:'rgba(0,0,0,0.6)', borderBottom:'1px solid rgba(255,255,255,0.08)', flexShrink:0, flexWrap:'wrap' }}>
|
||||||
|
<button onClick={closeBoard} style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.5)',
|
||||||
|
cursor:'pointer', fontSize:18, padding:'2px 6px', lineHeight:1 }}>←</button>
|
||||||
|
<span style={{ color:'#fff', fontFamily:'monospace', fontSize:13, fontWeight:700, flex:1, minWidth:0,
|
||||||
|
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
||||||
|
{current?.title}
|
||||||
|
{current?.role === 'view' && <span style={{ color:'rgba(255,255,255,0.3)', fontSize:10, marginLeft:8 }}>👁 nur lesen</span>}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Aktive User */}
|
||||||
|
{collab.length > 0 && (
|
||||||
|
<div style={{ display:'flex', gap:4, alignItems:'center' }}>
|
||||||
|
{collab.map(c => (
|
||||||
|
<span key={c.userId} style={{ background: c.color, color:'#000', fontFamily:'monospace', fontSize:9,
|
||||||
|
borderRadius:10, padding:'2px 7px', fontWeight:700 }}>
|
||||||
|
{c.username[0].toUpperCase()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEdit && <button onClick={save} disabled={saving}
|
||||||
|
style={{ ...S.btn('#4ECDC4', true), fontSize:11 }}>
|
||||||
|
{saving ? '…' : '💾 Speichern'}
|
||||||
|
</button>}
|
||||||
|
{current?.role === 'owner' && (
|
||||||
|
<button onClick={() => setShareModal(true)} style={{ ...S.btn('#A78BFA', true), fontSize:11 }}>👥 Teilen</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
|
||||||
|
{canEdit && (
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:6, padding:'6px 12px',
|
||||||
|
background:'rgba(0,0,0,0.45)', borderBottom:'1px solid rgba(255,255,255,0.06)',
|
||||||
|
flexShrink:0, flexWrap:'wrap' }}>
|
||||||
|
|
||||||
|
{/* Werkzeuge */}
|
||||||
|
<div style={{ display:'flex', gap:3 }}>
|
||||||
|
{TOOLS.map(t => (
|
||||||
|
<button key={t.id} onClick={() => setTool(t.id)} title={t.tip}
|
||||||
|
style={{ background: tool===t.id ? 'rgba(78,205,196,0.2)' : 'rgba(255,255,255,0.05)',
|
||||||
|
border: `1px solid ${tool===t.id ? '#4ECDC4' : 'rgba(255,255,255,0.1)'}`,
|
||||||
|
borderRadius:7, padding:'5px 9px', color: tool===t.id ? '#4ECDC4' : 'rgba(255,255,255,0.7)',
|
||||||
|
cursor:'pointer', fontSize:14, lineHeight:1, fontFamily:'monospace' }}>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ width:1, height:22, background:'rgba(255,255,255,0.1)' }}/>
|
||||||
|
|
||||||
|
{/* Farben */}
|
||||||
|
<div style={{ display:'flex', gap:3 }}>
|
||||||
|
{COLORS.map(c => (
|
||||||
|
<button key={c} onClick={() => setColor(c)}
|
||||||
|
style={{ width:18, height:18, borderRadius:'50%', background:c, border: color===c ? '2px solid #fff' : '1px solid rgba(255,255,255,0.2)', cursor:'pointer', padding:0, flexShrink:0 }}/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ width:1, height:22, background:'rgba(255,255,255,0.1)' }}/>
|
||||||
|
|
||||||
|
{/* Strichstärke */}
|
||||||
|
<div style={{ display:'flex', gap:3, alignItems:'center' }}>
|
||||||
|
{SIZES.map((s, i) => (
|
||||||
|
<button key={i} onClick={() => setSize(i)}
|
||||||
|
style={{ width:18, height:18, borderRadius:'50%',
|
||||||
|
background: size===i ? 'rgba(78,205,196,0.2)' : 'rgba(255,255,255,0.05)',
|
||||||
|
border: `1px solid ${size===i ? '#4ECDC4' : 'rgba(255,255,255,0.15)'}`,
|
||||||
|
cursor:'pointer', padding:0, display:'flex', alignItems:'center', justifyContent:'center' }}>
|
||||||
|
<div style={{ width:s+2, height:s+2, borderRadius:'50%', background: size===i ? '#4ECDC4' : '#888' }}/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ width:1, height:22, background:'rgba(255,255,255,0.1)' }}/>
|
||||||
|
|
||||||
|
{/* Undo + Löschen */}
|
||||||
|
<button onClick={undo} disabled={!undoStack.length} title="Rückgängig (Ctrl+Z)"
|
||||||
|
style={{ background:'rgba(255,255,255,0.05)', border:'1px solid rgba(255,255,255,0.1)',
|
||||||
|
borderRadius:7, padding:'5px 9px', color: undoStack.length ? 'rgba(255,255,255,0.7)' : 'rgba(255,255,255,0.2)',
|
||||||
|
cursor: undoStack.length ? 'pointer' : 'default', fontSize:12, fontFamily:'monospace' }}>
|
||||||
|
↩ Undo
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { if (confirm('Alles löschen?')) { elementsRef.current=[]; setElements([]); setUndoStack([]); wsRef.current?.readyState===1&&wsRef.current.send(JSON.stringify({type:'elements',elements:[]})); } }}
|
||||||
|
style={{ background:'rgba(255,107,157,0.08)', border:'1px solid rgba(255,107,157,0.2)',
|
||||||
|
borderRadius:7, padding:'5px 9px', color:'rgba(255,107,157,0.7)', cursor:'pointer', fontSize:12, fontFamily:'monospace' }}>
|
||||||
|
🗑 Leeren
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Zoom-Info */}
|
||||||
|
<span style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:10, marginLeft:4 }}>
|
||||||
|
{Math.round(viewport.zoom * 100)}%
|
||||||
|
</span>
|
||||||
|
<button onClick={() => { vpRef.current={x:0,y:0,zoom:1}; setViewport({x:0,y:0,zoom:1}); }}
|
||||||
|
style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.25)', cursor:'pointer', fontFamily:'monospace', fontSize:10 }}>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Canvas ───────────────────────────────────────────────────────── */}
|
||||||
|
<canvas ref={canvasRef}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerLeave={onPointerUp}
|
||||||
|
onWheel={onWheel}
|
||||||
|
style={{ flex:1, width:'100%', touchAction:'none',
|
||||||
|
cursor: tool==='select' ? 'grab' : tool==='eraser' ? 'cell' : tool==='text' ? 'text' : 'crosshair' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user