From c72ddc6b39b107ed1ee680efa2d52a8864dbc69b Mon Sep 17 00:00:00 2001 From: Dicken Date: Wed, 24 Jun 2026 01:10:41 +0200 Subject: [PATCH] =?UTF-8?q?Kanban=20Board:=20neues=20Tool=20in=20Werkzeuge?= =?UTF-8?q?=20(Spalten=20konfigurierbar,=20Drag&Drop=20+=20Pfeile,=20Prior?= =?UTF-8?q?it=C3=A4ten)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/tools/kanban/routes.js | 171 +++++++++++ frontend/src/icons.jsx | 5 + frontend/src/toolRegistry.js | 11 +- frontend/src/tools/kanban.jsx | 451 +++++++++++++++++++++++++++++ 4 files changed, 637 insertions(+), 1 deletion(-) create mode 100644 backend/src/tools/kanban/routes.js create mode 100644 frontend/src/tools/kanban.jsx diff --git a/backend/src/tools/kanban/routes.js b/backend/src/tools/kanban/routes.js new file mode 100644 index 0000000..75b9ae9 --- /dev/null +++ b/backend/src/tools/kanban/routes.js @@ -0,0 +1,171 @@ +const express = require('express'); +const db = require('../../db'); +const { authenticate } = require('../../middleware/auth'); +const router = express.Router(); + +// ── DB-Migration ────────────────────────────────────────────────────────────── +(function migrate() { + db.exec(` + CREATE TABLE IF NOT EXISTS kanban_columns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) + ); + CREATE TABLE IF NOT EXISTS kanban_cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + column_id INTEGER NOT NULL REFERENCES kanban_columns(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + priority TEXT NOT NULL DEFAULT 'none', + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) + ); + `); +})(); + +const uid = req => req.user.id; + +// ── Alle Spalten + Karten laden ─────────────────────────────────────────────── +// Feste Routen vor :param-Routen (Lessons Learned) +router.get('/board', authenticate, (req, res) => { + const me = uid(req); + const columns = db.prepare( + 'SELECT * FROM kanban_columns WHERE user_id=? ORDER BY position ASC, id ASC' + ).all(me); + const cards = db.prepare( + 'SELECT * FROM kanban_cards WHERE user_id=? ORDER BY position ASC, id ASC' + ).all(me); + // Karten in Spalten einsortieren + const colMap = {}; + for (const c of columns) { c.cards = []; colMap[c.id] = c; } + for (const card of cards) { + if (colMap[card.column_id]) colMap[card.column_id].cards.push(card); + } + res.json({ columns }); +}); + +// ── Spalte erstellen ────────────────────────────────────────────────────────── +router.post('/columns', authenticate, (req, res) => { + const { title } = req.body; + if (!title?.trim()) return res.status(400).json({ error: 'Titel fehlt' }); + const me = uid(req); + const maxPos = db.prepare('SELECT MAX(position) as m FROM kanban_columns WHERE user_id=?').get(me); + const pos = (maxPos?.m ?? -1) + 1; + const r = db.prepare( + "INSERT INTO kanban_columns (user_id,title,position) VALUES (?,?,?)" + ).run(me, title.trim(), pos); + res.json(db.prepare('SELECT * FROM kanban_columns WHERE id=?').get(r.lastInsertRowid)); +}); + +// ── Spalte umbenennen ───────────────────────────────────────────────────────── +router.patch('/columns/:id', authenticate, (req, res) => { + const { title } = req.body; + if (!title?.trim()) return res.status(400).json({ error: 'Titel fehlt' }); + const me = uid(req); + const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(req.params.id, me); + if (!col) return res.status(404).json({ error: 'Spalte nicht gefunden' }); + db.prepare('UPDATE kanban_columns SET title=? WHERE id=?').run(title.trim(), col.id); + res.json({ ok: true }); +}); + +// ── Spalte löschen (inkl. aller Karten via CASCADE) ────────────────────────── +router.delete('/columns/:id', authenticate, (req, res) => { + const me = uid(req); + const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(req.params.id, me); + if (!col) return res.status(404).json({ error: 'Spalte nicht gefunden' }); + db.prepare('DELETE FROM kanban_columns WHERE id=?').run(col.id); + res.json({ ok: true }); +}); + +// ── Spalten-Reihenfolge speichern ───────────────────────────────────────────── +router.post('/columns/reorder', authenticate, (req, res) => { + const { order } = req.body; // Array von column-IDs in neuer Reihenfolge + if (!Array.isArray(order)) return res.status(400).json({ error: 'order fehlt' }); + const me = uid(req); + const upd = db.prepare('UPDATE kanban_columns SET position=? WHERE id=? AND user_id=?'); + const tx = db.transaction(() => { + for (let i = 0; i < order.length; i++) upd.run(i, order[i], me); + }); + tx(); + res.json({ ok: true }); +}); + +// ── Karte erstellen ─────────────────────────────────────────────────────────── +router.post('/cards', authenticate, (req, res) => { + const { column_id, title, description = '', priority = 'none' } = req.body; + if (!column_id || !title?.trim()) return res.status(400).json({ error: 'column_id und title erforderlich' }); + const me = uid(req); + const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(column_id, me); + if (!col) return res.status(404).json({ error: 'Spalte nicht gefunden' }); + const maxPos = db.prepare('SELECT MAX(position) as m FROM kanban_cards WHERE column_id=?').get(column_id); + const pos = (maxPos?.m ?? -1) + 1; + const r = db.prepare( + "INSERT INTO kanban_cards (column_id,user_id,title,description,priority,position) VALUES (?,?,?,?,?,?)" + ).run(column_id, me, title.trim(), description.trim(), priority, pos); + res.json(db.prepare('SELECT * FROM kanban_cards WHERE id=?').get(r.lastInsertRowid)); +}); + +// ── Karte bearbeiten ────────────────────────────────────────────────────────── +router.patch('/cards/:id', authenticate, (req, res) => { + const me = uid(req); + const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me); + if (!card) return res.status(404).json({ error: 'Karte nicht gefunden' }); + const title = req.body.title !== undefined ? req.body.title.trim() : card.title; + const description = req.body.description !== undefined ? req.body.description.trim() : card.description; + const priority = req.body.priority !== undefined ? req.body.priority : card.priority; + if (!title) return res.status(400).json({ error: 'Titel darf nicht leer sein' }); + db.prepare( + "UPDATE kanban_cards SET title=?,description=?,priority=?,updated_at=datetime('now','localtime') WHERE id=?" + ).run(title, description, priority, card.id); + res.json(db.prepare('SELECT * FROM kanban_cards WHERE id=?').get(card.id)); +}); + +// ── Karte löschen ───────────────────────────────────────────────────────────── +router.delete('/cards/:id', authenticate, (req, res) => { + const me = uid(req); + const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me); + if (!card) return res.status(404).json({ error: 'Karte nicht gefunden' }); + db.prepare('DELETE FROM kanban_cards WHERE id=?').run(card.id); + res.json({ ok: true }); +}); + +// ── Karte verschieben (Spalte + Position) ───────────────────────────────────── +// Wird genutzt für: Drag&Drop zwischen Spalten, Pfeil-Buttons, Position hoch/runter +router.post('/cards/:id/move', authenticate, (req, res) => { + const { column_id, position } = req.body; + const me = uid(req); + const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me); + if (!card) return res.status(404).json({ error: 'Karte nicht gefunden' }); + + const targetColId = column_id !== undefined ? column_id : card.column_id; + + // Zielspalte gehört dem User? + const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(targetColId, me); + if (!col) return res.status(404).json({ error: 'Zielspalte nicht gefunden' }); + + // Alle Karten der Zielspalte (ohne die verschobene), dann an position einfügen + const siblings = db.prepare( + 'SELECT id FROM kanban_cards WHERE column_id=? AND id!=? ORDER BY position ASC, id ASC' + ).all(targetColId, card.id).map(r => r.id); + + const targetPos = position !== undefined + ? Math.max(0, Math.min(position, siblings.length)) + : siblings.length; // ans Ende wenn keine position angegeben + + siblings.splice(targetPos, 0, card.id); + + const upd = db.prepare('UPDATE kanban_cards SET column_id=?, position=? WHERE id=?'); + const tx = db.transaction(() => { + for (let i = 0; i < siblings.length; i++) { + upd.run(targetColId, i, siblings[i]); + } + }); + tx(); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/frontend/src/icons.jsx b/frontend/src/icons.jsx index 1255538..5f86327 100644 --- a/frontend/src/icons.jsx +++ b/frontend/src/icons.jsx @@ -176,3 +176,8 @@ export const ChartIcon = ({size=20,color='currentColor',sw}) => base(<> , size, color, sw); +export const KanbanIcon = ({size=20,color='currentColor',sw}) => base(<> + + + +, size, color, sw); diff --git a/frontend/src/toolRegistry.js b/frontend/src/toolRegistry.js index 253b23b..5063633 100644 --- a/frontend/src/toolRegistry.js +++ b/frontend/src/toolRegistry.js @@ -9,7 +9,8 @@ import Skizze from './tools/skizze.jsx'; import DevTools from './tools/devtools.jsx'; import Media from './tools/media.jsx'; import PaywallKiller from './tools/paywallkiller.jsx'; -import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon, CodeIcon, SkizzeIcon, WrenchIcon, FilmIcon, PaywallIcon, ChartIcon } from './icons.jsx'; +import Kanban from './tools/kanban.jsx'; +import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon, CodeIcon, SkizzeIcon, WrenchIcon, FilmIcon, PaywallIcon, ChartIcon, KanbanIcon } from './icons.jsx'; export const TOOLS = [ { @@ -84,6 +85,14 @@ export const TOOLS = [ group: 'Werkzeuge', component: Skizze, }, + { + id: 'kanban', + Icon: KanbanIcon, + label: 'Kanban', + navLabel: 'Kanban', + group: 'Werkzeuge', + component: Kanban, + }, { id: 'devtools', Icon: WrenchIcon, diff --git a/frontend/src/tools/kanban.jsx b/frontend/src/tools/kanban.jsx new file mode 100644 index 0000000..bbc8769 --- /dev/null +++ b/frontend/src/tools/kanban.jsx @@ -0,0 +1,451 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { api, S } from '../lib.js'; + +// ── Prioritäten ─────────────────────────────────────────────────────────────── +const PRIORITIES = [ + { id: 'none', label: 'Keine', color: '#888888' }, + { id: 'low', label: 'Niedrig', color: '#4ecdc4' }, + { id: 'medium', label: 'Mittel', color: '#ffe66d' }, + { id: 'high', label: 'Hoch', color: '#ff6b9d' }, +]; + +const prioOf = id => PRIORITIES.find(p => p.id === id) || PRIORITIES[0]; + +// ── Karten-Modal (Erstellen + Bearbeiten) ───────────────────────────────────── +// Absichtlich AUSSERHALB der Hauptkomponente definiert (Lesson Learned: kein Remount) +function CardModal({ card, columnId, onSave, onClose }) { + const [title, setTitle] = useState(card?.title || ''); + const [desc, setDesc] = useState(card?.description || ''); + const [prio, setPrio] = useState(card?.priority || 'none'); + const [saving, setSaving] = useState(false); + const titleRef = useRef(null); + + useEffect(() => { titleRef.current?.focus(); }, []); + + const save = async () => { + if (!title.trim()) return; + setSaving(true); + try { + if (card) { + await api(`/tools/kanban/cards/${card.id}`, { method: 'PATCH', body: { title, description: desc, priority: prio } }); + } else { + await api('/tools/kanban/cards', { body: { column_id: columnId, title, description: desc, priority: prio } }); + } + onSave(); + } catch (e) { alert(e.message); } + finally { setSaving(false); } + }; + + const onKey = e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) save(); if (e.key === 'Escape') onClose(); }; + + return ( +
+
e.stopPropagation()} style={{ background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.1)',borderRadius:14,padding:24,width:'100%',maxWidth:440 }}> +
+ {card ? 'KARTE BEARBEITEN' : 'NEUE KARTE'} +
+ + setTitle(e.target.value)} + onKeyDown={onKey} + placeholder="Titel" + style={{ ...S.inp, marginBottom: 10 }} + /> + +