From f06ad1b307485b8223e7b961ccf4745da7eea2f0 Mon Sep 17 00:00:00 2001 From: Dicken Date: Mon, 25 May 2026 12:47:51 +0200 Subject: [PATCH] Dicken Dock v1.0.0 --- Dockerfile | 18 + backend/package.json | 12 + backend/src/db.js | 71 ++++ backend/src/index.js | 34 ++ backend/src/middleware/auth.js | 16 + backend/src/routes/admin.js | 30 ++ backend/src/routes/auth.js | 37 ++ backend/src/routes/dashboard.js | 70 ++++ backend/src/routes/system.js | 40 ++ backend/src/tools/kalkulator3d/routes.js | 57 +++ build.sh | 16 + docker-compose.yml | 15 + frontend/index.html | 12 + frontend/package.json | 16 + frontend/src/App.jsx | 478 +++++++++++++++++++++++ frontend/src/lib.js | 46 +++ frontend/src/main.jsx | 7 + frontend/src/toolRegistry.js | 26 ++ frontend/src/tools/Kalkulator3D.jsx | 237 +++++++++++ frontend/vite.config.js | 7 + version.txt | 1 + 21 files changed, 1246 insertions(+) create mode 100644 Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/db.js create mode 100644 backend/src/index.js create mode 100644 backend/src/middleware/auth.js create mode 100644 backend/src/routes/admin.js create mode 100644 backend/src/routes/auth.js create mode 100644 backend/src/routes/dashboard.js create mode 100644 backend/src/routes/system.js create mode 100644 backend/src/tools/kalkulator3d/routes.js create mode 100644 build.sh create mode 100644 docker-compose.yml create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/lib.js create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/toolRegistry.js create mode 100644 frontend/src/tools/Kalkulator3D.jsx create mode 100644 frontend/vite.config.js create mode 100644 version.txt diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3879c72 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# ── Stage 1: React bauen ───────────────────────────────────────────────────── +FROM node:20-alpine AS builder +WORKDIR /build +COPY frontend/package.json ./ +RUN npm install +COPY frontend/ ./ +RUN npm run build + +# ── Stage 2: Express liefert API + React in einem Container ────────────────── +FROM node:20-alpine +WORKDIR /app +COPY backend/package.json ./ +RUN npm install --production +COPY backend/ ./ +COPY version.txt ./version.txt +COPY --from=builder /build/dist ./public +EXPOSE 4000 +CMD ["node", "src/index.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..40bd080 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,12 @@ +{ + "name": "dickendock-backend", + "version": "1.0.0", + "main": "src/index.js", + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^9.4.3", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1" + } +} diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000..d117e04 --- /dev/null +++ b/backend/src/db.js @@ -0,0 +1,71 @@ +const Database = require('better-sqlite3'); +const bcrypt = require('bcryptjs'); +const path = require('path'); + +const db = new Database(process.env.DB_PATH || '/data/dickendock.db'); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + -- Dashboard-Widgets + CREATE TABLE IF NOT EXISTS quick_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + url TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '🔗', + sort_order INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + text TEXT NOT NULL, + done INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL DEFAULT '', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + -- Tool: 3D-Kalkulator + CREATE TABLE IF NOT EXISTS calculations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + gramm REAL NOT NULL, + stunden REAL NOT NULL, + farben INTEGER NOT NULL DEFAULT 1, + materialpreis_pro_gramm REAL NOT NULL DEFAULT 0.01, + stromverbrauch_kw REAL NOT NULL DEFAULT 0.15, + strompreis_pro_kwh REAL NOT NULL DEFAULT 0.38, + druckerpreis REAL NOT NULL DEFAULT 550, + gesamtdruckstunden REAL NOT NULL DEFAULT 5000, + verschleiss_pro_stunde REAL NOT NULL DEFAULT 0.06, + preis_freundschaft REAL NOT NULL, + preis_normal REAL NOT NULL, + preis_auftrag REAL NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); +`); + +// Standard-Admin beim ersten Start anlegen +if (!db.prepare('SELECT id FROM users WHERE username = ?').get('admin')) { + db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)') + .run('admin', bcrypt.hashSync('admin123', 12), 'admin'); + console.log('✅ Admin angelegt: admin / admin123 → Bitte Passwort sofort ändern!'); +} + +module.exports = db; diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..616366f --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,34 @@ +const express = require('express'); +const path = require('path'); +const fs = require('fs'); + +require('./db'); // Datenbank + Tabellen initialisieren + +const app = express(); +app.use(express.json()); + +// ── Core-Routen ─────────────────────────────────────────────────────────────── +app.use('/api/auth', require('./routes/auth')); +app.use('/api/admin', require('./routes/admin')); +app.use('/api/dashboard', require('./routes/dashboard')); +app.use('/api/system', require('./routes/system')); + +// ── Tool-Routen automatisch laden ───────────────────────────────────────────── +// Neues Tool: Datei unter src/tools//routes.js ablegen → wird automatisch eingebunden +const toolsDir = path.join(__dirname, 'tools'); +fs.readdirSync(toolsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .forEach(d => { + const routeFile = path.join(toolsDir, d.name, 'routes.js'); + if (fs.existsSync(routeFile)) { + app.use(`/api/tools/${d.name}`, require(routeFile)); + console.log(` 🔧 Tool geladen: ${d.name}`); + } + }); + +// ── React-Frontend ausliefern ───────────────────────────────────────────────── +const PUBLIC = path.join(__dirname, '../public'); +app.use(express.static(PUBLIC)); +app.get('*', (_req, res) => res.sendFile(path.join(PUBLIC, 'index.html'))); + +app.listen(4000, () => console.log('🚀 Dicken Dock läuft auf Port 4000')); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..ae29f2e --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,16 @@ +const jwt = require('jsonwebtoken'); +const SECRET = process.env.JWT_SECRET || 'dev-secret'; + +function authenticate(req, res, next) { + const h = req.headers.authorization; + if (!h?.startsWith('Bearer ')) return res.status(401).json({ error: 'Nicht eingeloggt' }); + try { req.user = jwt.verify(h.slice(7), SECRET); next(); } + catch { res.status(401).json({ error: 'Token ungültig' }); } +} + +function requireAdmin(req, res, next) { + if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + next(); +} + +module.exports = { authenticate, requireAdmin }; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js new file mode 100644 index 0000000..a70f757 --- /dev/null +++ b/backend/src/routes/admin.js @@ -0,0 +1,30 @@ +const express = require('express'); +const fs = require('fs'); +const multer = require('multer'); +const { authenticate, requireAdmin } = require('../middleware/auth'); + +const router = express.Router(); +const DB_PATH = process.env.DB_PATH || '/data/dickendock.db'; +const upload = multer({ dest: '/tmp/' }); + +// GET /api/admin/backup +router.get('/backup', authenticate, requireAdmin, (req, res) => { + if (!fs.existsSync(DB_PATH)) return res.status(404).json({ error: 'DB nicht gefunden' }); + const date = new Date().toISOString().split('T')[0]; + res.download(DB_PATH, `dickendock-backup-${date}.db`); +}); + +// POST /api/admin/restore +router.post('/restore', authenticate, requireAdmin, upload.single('database'), (req, res) => { + if (!req.file) return res.status(400).json({ error: 'Keine Datei erhalten' }); + try { + if (fs.existsSync(DB_PATH)) fs.copyFileSync(DB_PATH, DB_PATH + '.bak'); + fs.copyFileSync(req.file.path, DB_PATH); + fs.unlinkSync(req.file.path); + res.json({ success: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..3f7a9db --- /dev/null +++ b/backend/src/routes/auth.js @@ -0,0 +1,37 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const db = require('../db'); +const { authenticate } = require('../middleware/auth'); + +const router = express.Router(); +const SECRET = process.env.JWT_SECRET || 'dev-secret'; + +// POST /api/auth/login +router.post('/login', (req, res) => { + const { username, password } = req.body; + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); + if (!user || !bcrypt.compareSync(password, user.password_hash)) + return res.status(401).json({ error: 'Benutzername oder Passwort falsch' }); + + const token = jwt.sign( + { id: user.id, username: user.username, role: user.role }, + SECRET, { expiresIn: '7d' } + ); + res.json({ token, user: { id: user.id, username: user.username, role: user.role } }); +}); + +// POST /api/auth/change-password +router.post('/change-password', authenticate, (req, res) => { + const { currentPassword, newPassword } = req.body; + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); + if (!bcrypt.compareSync(currentPassword, user.password_hash)) + return res.status(400).json({ error: 'Aktuelles Passwort falsch' }); + if (!newPassword || newPassword.length < 8) + return res.status(400).json({ error: 'Mindestens 8 Zeichen erforderlich' }); + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?') + .run(bcrypt.hashSync(newPassword, 12), req.user.id); + res.json({ success: true }); +}); + +module.exports = router; diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js new file mode 100644 index 0000000..dd0d190 --- /dev/null +++ b/backend/src/routes/dashboard.js @@ -0,0 +1,70 @@ +const express = require('express'); +const db = require('../db'); +const { authenticate } = require('../middleware/auth'); +const router = express.Router(); +const uid = req => req.user.id; + +// ── Quick Links ─────────────────────────────────────────────────────────────── +router.get('/links', authenticate, (req, res) => { + res.json(db.prepare('SELECT * FROM quick_links WHERE user_id=? ORDER BY sort_order,id').all(uid(req))); +}); +router.post('/links', authenticate, (req, res) => { + const { title, url, icon = '🔗' } = req.body; + if (!title || !url) return res.status(400).json({ error: 'Titel und URL erforderlich' }); + const max = db.prepare('SELECT MAX(sort_order) m FROM quick_links WHERE user_id=?').get(uid(req)); + const r = db.prepare('INSERT INTO quick_links (user_id,title,url,icon,sort_order) VALUES (?,?,?,?,?)') + .run(uid(req), title, url, icon, (max?.m ?? -1) + 1); + res.json(db.prepare('SELECT * FROM quick_links WHERE id=?').get(r.lastInsertRowid)); +}); +router.put('/links/:id', authenticate, (req, res) => { + const ex = db.prepare('SELECT * FROM quick_links WHERE id=? AND user_id=?').get(req.params.id, uid(req)); + if (!ex) return res.status(404).json({ error: 'Nicht gefunden' }); + const { title=ex.title, url=ex.url, icon=ex.icon } = req.body; + db.prepare('UPDATE quick_links SET title=?,url=?,icon=? WHERE id=? AND user_id=?') + .run(title, url, icon, req.params.id, uid(req)); + res.json(db.prepare('SELECT * FROM quick_links WHERE id=?').get(req.params.id)); +}); +router.delete('/links/:id', authenticate, (req, res) => { + const r = db.prepare('DELETE FROM quick_links WHERE id=? AND user_id=?').run(req.params.id, uid(req)); + if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json({ success: true }); +}); + +// ── Todos ───────────────────────────────────────────────────────────────────── +router.get('/todos', authenticate, (req, res) => { + res.json(db.prepare('SELECT * FROM todos WHERE user_id=? ORDER BY done,sort_order,id').all(uid(req))); +}); +router.post('/todos', authenticate, (req, res) => { + const { text } = req.body; + if (!text?.trim()) return res.status(400).json({ error: 'Text erforderlich' }); + const max = db.prepare('SELECT MAX(sort_order) m FROM todos WHERE user_id=?').get(uid(req)); + const r = db.prepare('INSERT INTO todos (user_id,text,sort_order) VALUES (?,?,?)') + .run(uid(req), text.trim(), (max?.m ?? -1) + 1); + res.json(db.prepare('SELECT * FROM todos WHERE id=?').get(r.lastInsertRowid)); +}); +router.put('/todos/:id', authenticate, (req, res) => { + const ex = db.prepare('SELECT * FROM todos WHERE id=? AND user_id=?').get(req.params.id, uid(req)); + if (!ex) return res.status(404).json({ error: 'Nicht gefunden' }); + const text = req.body.text ?? ex.text; + const done = req.body.done !== undefined ? (req.body.done ? 1 : 0) : ex.done; + db.prepare('UPDATE todos SET text=?,done=? WHERE id=? AND user_id=?').run(text, done, req.params.id, uid(req)); + res.json(db.prepare('SELECT * FROM todos WHERE id=?').get(req.params.id)); +}); +router.delete('/todos/:id', authenticate, (req, res) => { + const r = db.prepare('DELETE FROM todos WHERE id=? AND user_id=?').run(req.params.id, uid(req)); + if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json({ success: true }); +}); + +// ── Notizen ─────────────────────────────────────────────────────────────────── +router.get('/note', authenticate, (req, res) => { + res.json(db.prepare('SELECT * FROM notes WHERE user_id=?').get(uid(req)) || { content: '', updated_at: null }); +}); +router.put('/note', authenticate, (req, res) => { + db.prepare(`INSERT INTO notes (user_id,content,updated_at) VALUES (?,?,CURRENT_TIMESTAMP) + ON CONFLICT(user_id) DO UPDATE SET content=excluded.content,updated_at=CURRENT_TIMESTAMP`) + .run(uid(req), req.body.content ?? ''); + res.json({ success: true, updated_at: new Date().toISOString() }); +}); + +module.exports = router; diff --git a/backend/src/routes/system.js b/backend/src/routes/system.js new file mode 100644 index 0000000..561875e --- /dev/null +++ b/backend/src/routes/system.js @@ -0,0 +1,40 @@ +const express = require('express'); +const fs = require('fs'); +const path = require('path'); +const { authenticate } = require('../middleware/auth'); + +const router = express.Router(); + +let VERSION = 'v1.0.0'; +try { VERSION = fs.readFileSync(path.join(__dirname, '../../version.txt'), 'utf8').trim(); } catch {} + +const GITEA_URL = process.env.GITEA_URL; +const GITEA_REPO = process.env.GITEA_REPO; + +router.get('/version', authenticate, (_req, res) => res.json({ version: VERSION })); + +router.get('/update-check', authenticate, async (_req, res) => { + if (!GITEA_URL || !GITEA_REPO) + return res.json({ hasUpdate: false, currentVersion: VERSION, configured: false }); + try { + const r = await fetch(`${GITEA_URL}/api/v1/repos/${GITEA_REPO}/releases?limit=5`, + { signal: AbortSignal.timeout(5000) }); + const releases = await r.json(); + if (!Array.isArray(releases) || !releases.length) + return res.json({ hasUpdate: false, currentVersion: VERSION, configured: true }); + res.json({ + hasUpdate: releases[0].tag_name !== VERSION, + currentVersion: VERSION, + latestVersion: releases[0].tag_name, + configured: true, + releases: releases.map(r => ({ + version: r.tag_name, name: r.name || r.tag_name, + body: r.body || '(Keine Beschreibung)', publishedAt: r.published_at, + })), + }); + } catch (e) { + res.json({ hasUpdate: false, currentVersion: VERSION, configured: true }); + } +}); + +module.exports = router; diff --git a/backend/src/tools/kalkulator3d/routes.js b/backend/src/tools/kalkulator3d/routes.js new file mode 100644 index 0000000..d4ee06a --- /dev/null +++ b/backend/src/tools/kalkulator3d/routes.js @@ -0,0 +1,57 @@ +// Tool: 3D-Druck-Kalkulator +// Routen werden unter /api/tools/kalkulator3d/... eingebunden +const express = require('express'); +const db = require('../../db'); +const { authenticate } = require('../../middleware/auth'); +const router = express.Router(); + +router.get('/', authenticate, (req, res) => { + res.json(db.prepare('SELECT * FROM calculations WHERE user_id=? ORDER BY created_at DESC').all(req.user.id)); +}); + +router.post('/', authenticate, (req, res) => { + const { name, gramm, stunden, farben, + materialpreis_pro_gramm, stromverbrauch_kw, strompreis_pro_kwh, + druckerpreis, gesamtdruckstunden, verschleiss_pro_stunde, + preis_freundschaft, preis_normal, preis_auftrag } = req.body; + if (!name || gramm == null || stunden == null) + return res.status(400).json({ error: 'Name, Gramm und Stunden erforderlich' }); + const r = db.prepare(`INSERT INTO calculations + (user_id,name,gramm,stunden,farben,materialpreis_pro_gramm,stromverbrauch_kw, + strompreis_pro_kwh,druckerpreis,gesamtdruckstunden,verschleiss_pro_stunde, + preis_freundschaft,preis_normal,preis_auftrag) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) + .run(req.user.id, name, gramm, stunden, farben, + materialpreis_pro_gramm, stromverbrauch_kw, strompreis_pro_kwh, + druckerpreis, gesamtdruckstunden, verschleiss_pro_stunde, + preis_freundschaft, preis_normal, preis_auftrag); + res.json(db.prepare('SELECT * FROM calculations WHERE id=?').get(r.lastInsertRowid)); +}); + +router.put('/:id', authenticate, (req, res) => { + const ex = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!ex) return res.status(404).json({ error: 'Nicht gefunden' }); + const { name, gramm, stunden, farben, + materialpreis_pro_gramm, stromverbrauch_kw, strompreis_pro_kwh, + druckerpreis, gesamtdruckstunden, verschleiss_pro_stunde, + preis_freundschaft, preis_normal, preis_auftrag } = req.body; + db.prepare(`UPDATE calculations SET + name=?,gramm=?,stunden=?,farben=?,materialpreis_pro_gramm=?,stromverbrauch_kw=?, + strompreis_pro_kwh=?,druckerpreis=?,gesamtdruckstunden=?,verschleiss_pro_stunde=?, + preis_freundschaft=?,preis_normal=?,preis_auftrag=?,updated_at=CURRENT_TIMESTAMP + WHERE id=? AND user_id=?`) + .run(name, gramm, stunden, farben, + materialpreis_pro_gramm, stromverbrauch_kw, strompreis_pro_kwh, + druckerpreis, gesamtdruckstunden, verschleiss_pro_stunde, + preis_freundschaft, preis_normal, preis_auftrag, + req.params.id, req.user.id); + res.json(db.prepare('SELECT * FROM calculations WHERE id=?').get(req.params.id)); +}); + +router.delete('/:id', authenticate, (req, res) => { + const r = db.prepare('DELETE FROM calculations WHERE id=? AND user_id=?').run(req.params.id, req.user.id); + if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json({ success: true }); +}); + +module.exports = router; diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..4c198f7 --- /dev/null +++ b/build.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Dicken Dock – Image bauen und in Gitea-Registry pushen +# Ausführen auf dem Server: sh build.sh + +REGISTRY="192.168.1.26:3000" +USER="admin" +IMAGE="$REGISTRY/$USER/dickendock:latest" +REPO="http://$REGISTRY/$USER/dickendock.git" + +echo "🔨 Baue Image direkt aus Gitea..." +docker build "$REPO" -t "$IMAGE" + +echo "📤 Pushe in Registry..." +docker push "$IMAGE" + +echo "✅ Fertig – starte mit: docker compose up -d" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dd17e7b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +version: '3.8' + +services: + dickendock: + image: 192.168.1.26:3000/admin/dickendock:latest + container_name: dickendock + restart: unless-stopped + environment: + - JWT_SECRET=BITTE-AENDERN-langerZufallsstring + - GITEA_URL=http://192.168.1.26:3000 + - GITEA_REPO=admin/dickendock + volumes: + - ./data:/data + ports: + - "8080:4000" diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ac60c0c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Dicken Dock + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..87d04e2 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,16 @@ +{ + "name": "dickendock-frontend", + "version": "1.0.0", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.0.0", + "vite": "^5.0.0" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..b8f1ff0 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,478 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { api, S } from './lib.js'; +import { TOOLS } from './toolRegistry.js'; + +const ICONS = ['🔗','🌐','📊','📁','🛠','📧','🗓','💾','🖥','📡','🔒','📖','🎯','⚡','🏠','🔧','📱','🎮','🚀','💡']; + +// ── Toast ───────────────────────────────────────────────────────────────────── +function Toast({ msg, type }) { + if (!msg) return null; + const c = type === 'error' ? '#ff6b9d' : '#4ecdc4'; + return ( +
+ {type==='error'?'✕ ':'✓ '}{msg} +
+ ); +} + +// ── Modal ───────────────────────────────────────────────────────────────────── +function Modal({ title, onClose, children }) { + return ( +
e.target===e.currentTarget&&onClose()}> +
+
+ {title} + +
+ {children} +
+
+ ); +} + +// ── Login ───────────────────────────────────────────────────────────────────── +function Login({ onLogin }) { + const [u,setU]=useState(''); const [p,setP]=useState(''); + const [err,setErr]=useState(''); const [busy,setBusy]=useState(false); + const go = async () => { + setErr(''); setBusy(true); + try { const d=await api('/auth/login',{body:{username:u,password:p}}); localStorage.setItem('sk_token',d.token); onLogin(d.user); } + catch(e) { setErr(e.message); } finally { setBusy(false); } + }; + return ( +
+
+
+
+
+ DICKENDOCK +
+
+ {[['BENUTZERNAME',u,setU,'text'],['PASSWORT',p,setP,'password']].map(([l,v,s,t])=>( +
+ + s(e.target.value)} onKeyDown={e=>e.key==='Enter'&&go()} style={S.inp} /> +
+ ))} + {err &&
✕ {err}
} + +
+
+ ); +} + +// ── Sidebar ─────────────────────────────────────────────────────────────────── +function Sidebar({ active, setActive, user, onLogout, updateInfo, onUpdateClick }) { + const coreNav = [{ id:'dashboard', icon:'⬡', label:'Dashboard' }]; + const adminNav = user?.role==='admin' ? [{ id:'admin', icon:'⚙', label:'Admin' }] : []; + + const NavBtn = ({ item }) => ( + + ); + + return ( + + ); +} + +// ── Update Modal ────────────────────────────────────────────────────────────── +function UpdateModal({ data, onClose }) { + return ( + +
+ + Installiert: {data.currentVersion} + {' → '}Aktuell: {data.latestVersion} + +
+ {(data.releases||[]).map((r,i)=>( +
+
+ {r.name||r.version} + {r.publishedAt?new Date(r.publishedAt).toLocaleDateString('de-DE'):''} +
+
{r.body}
+
+ ))} +
+ Update: sh build.sh && docker compose up -d +
+
+ ); +} + +// ── Dashboard Widgets ───────────────────────────────────────────────────────── +function QuickLinks({ toast }) { + const [links, setLinks] = useState([]); + const [editMode, setEdit] = useState(false); + const [showForm, setForm] = useState(false); + const [form, setF] = useState({ title:'', url:'', icon:'🔗' }); + const [editId, setEditId] = useState(null); + + useEffect(() => { api('/dashboard/links').then(setLinks).catch(()=>{}); }, []); + + const save = async () => { + if (!form.title.trim()||!form.url.trim()) { toast('Titel und URL erforderlich','error'); return; } + let url = form.url.trim(); + if (!/^https?:\/\//i.test(url)) url = 'https://'+url; + try { + if (editId) { + const u = await api(`/dashboard/links/${editId}`,{method:'PUT',body:{...form,url}}); + setLinks(p=>p.map(l=>l.id===editId?u:l)); + } else { + const n = await api('/dashboard/links',{body:{...form,url}}); + setLinks(p=>[...p,n]); + } + toast(editId?'Link aktualisiert':'Link gespeichert'); + setF({title:'',url:'',icon:'🔗'}); setForm(false); setEditId(null); + } catch(e) { toast(e.message,'error'); } + }; + + const del = async id => { + try { await api(`/dashboard/links/${id}`,{method:'DELETE'}); setLinks(p=>p.filter(l=>l.id!==id)); toast('Link gelöscht'); } + catch(e) { toast(e.message,'error'); } + }; + + return ( +
+
+
SCHNELLZUGRIFF
+
+ {editMode && } + +
+
+
+ {links.length===0&&!editMode&&
Klicke "Bearbeiten" um Links hinzuzufügen.
} + {links.map(l=>( +
+ {editMode&&( +
+ + +
+ )} + e.preventDefault():undefined} + style={{ display:'flex',flexDirection:'column',alignItems:'center',gap:5,padding:'10px 14px', + background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.08)', + borderRadius:10,textDecoration:'none',minWidth:70,cursor:editMode?'default':'pointer' }}> + {l.icon} + {l.title} + +
+ ))} +
+ {showForm&&( +
+
+
ICON WÄHLEN
+
+ {ICONS.map(ic=>( + + ))} +
+
+
+
+ + setF(f=>({...f,title:e.target.value}))} style={S.inp} placeholder="Mein Link" /> +
+
+ + setF(f=>({...f,url:e.target.value}))} onKeyDown={e=>e.key==='Enter'&&save()} style={S.inp} placeholder="https://example.com" /> +
+
+
+ + +
+
+ )} +
+ ); +} + +function TodoList({ toast }) { + const [items,setItems]=useState([]); const [text,setText]=useState(''); + useEffect(()=>{api('/dashboard/todos').then(setItems).catch(()=>{});},[]); + const add = async()=>{ + if(!text.trim())return; + try{const n=await api('/dashboard/todos',{body:{text:text.trim()}});setItems(p=>[...p,n]);setText('');} + catch(e){toast(e.message,'error');} + }; + const toggle=async item=>{ + try{const u=await api(`/dashboard/todos/${item.id}`,{method:'PUT',body:{done:!item.done}});setItems(p=>p.map(i=>i.id===item.id?u:i));} + catch(e){toast(e.message,'error');} + }; + const del=async id=>{ + try{await api(`/dashboard/todos/${id}`,{method:'DELETE'});setItems(p=>p.filter(i=>i.id!==id));} + catch(e){toast(e.message,'error');} + }; + const open=items.filter(i=>!i.done), done=items.filter(i=>i.done); + return( +
+
TO-DO LISTE
+
+ setText(e.target.value)} onKeyDown={e=>e.key==='Enter'&&add()} style={{...S.inp,flex:1}} placeholder="Neue Aufgabe…"/> + +
+
+ {open.map(item=>( +
+ +
+ ))} + {done.length>0&&<> +
ERLEDIGT
+ {done.map(item=>( +
+ + {item.text} + +
+ ))} + } + {items.length===0&&
Noch keine Aufgaben.
} +
+
+ ); +} + +function Notepad({ toast }) { + const [content,setContent]=useState(''); const [saved,setSaved]=useState(true); + const [saveTime,setSaveTime]=useState(null); const tmr=useRef(null); + useEffect(()=>{api('/dashboard/note').then(d=>{setContent(d.content||'');setSaveTime(d.updated_at);}).catch(()=>{});},[]); + const change=val=>{ + setContent(val);setSaved(false);clearTimeout(tmr.current); + tmr.current=setTimeout(async()=>{ + try{const r=await api('/dashboard/note',{method:'PUT',body:{content:val}});setSaved(true);setSaveTime(r.updated_at);} + catch(e){toast(e.message,'error');} + },900); + }; + return( +
+
+
NOTIZBLOCK
+
+ {saved?(saveTime?`gespeichert ${new Date(saveTime).toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}`:'gespeichert'):'…'} +
+
+