Dicken Dock v1.0.0

This commit is contained in:
2026-05-25 12:47:51 +02:00
commit f06ad1b307
21 changed files with 1246 additions and 0 deletions

18
Dockerfile Normal file
View File

@@ -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"]

12
backend/package.json Normal file
View File

@@ -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"
}
}

71
backend/src/db.js Normal file
View File

@@ -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;

34
backend/src/index.js Normal file
View File

@@ -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/<name>/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'));

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

16
build.sh Normal file
View File

@@ -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"

15
docker-compose.yml Normal file
View File

@@ -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"

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dicken Dock</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

16
frontend/package.json Normal file
View File

@@ -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"
}
}

478
frontend/src/App.jsx Normal file
View File

@@ -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 (
<div style={{ position:'fixed', bottom:24, right:24, zIndex:9999, background:'#1a1a1e',
border:`1px solid ${c}55`, borderRadius:10, padding:'11px 18px',
color:c, fontFamily:'monospace', fontSize:12, animation:'fadeIn 0.2s ease',
boxShadow:`0 4px 24px ${c}22` }}>
{type==='error'?'✕ ':'✓ '}{msg}
</div>
);
}
// ── Modal ─────────────────────────────────────────────────────────────────────
function Modal({ title, onClose, children }) {
return (
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.75)', zIndex:5000,
display:'flex', alignItems:'center', justifyContent:'center', padding:24 }}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{ background:'#1a1a1e', border:'1px solid rgba(255,255,255,0.1)',
borderRadius:14, padding:28, maxWidth:560, width:'100%', maxHeight:'80vh', overflowY:'auto' }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:20 }}>
<span style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:15, fontWeight:700 }}>{title}</span>
<button onClick={onClose} style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.4)', fontSize:18, cursor:'pointer' }}></button>
</div>
{children}
</div>
</div>
);
}
// ── 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 (
<div style={{ minHeight:'100vh', background:'#0d0d0f', display:'flex', alignItems:'center', justifyContent:'center' }}>
<div style={{ width:360, ...S.card, padding:40 }}>
<div style={{ textAlign:'center', marginBottom:28 }}>
<div style={{ fontSize:32 }}></div>
<div style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:18, fontWeight:700, marginTop:8 }}>
DICKEN<span style={{ color:'#4ecdc4' }}>DOCK</span>
</div>
</div>
{[['BENUTZERNAME',u,setU,'text'],['PASSWORT',p,setP,'password']].map(([l,v,s,t])=>(
<div key={l} style={{ marginBottom:14 }}>
<label style={{ ...S.head, display:'block', marginBottom:3 }}>{l}</label>
<input type={t} value={v} onChange={e=>s(e.target.value)} onKeyDown={e=>e.key==='Enter'&&go()} style={S.inp} />
</div>
))}
{err && <div style={{ color:'#ff6b9d', fontSize:12, fontFamily:'monospace', marginBottom:12, textAlign:'center' }}> {err}</div>}
<button onClick={go} disabled={busy} style={{
width:'100%', background:'linear-gradient(135deg,#4ecdc4,#45b7d1)', border:'none',
borderRadius:8, padding:'11px 0', color:'#0d0d0f', fontFamily:"'Space Mono',monospace",
fontWeight:700, fontSize:13, cursor:busy?'default':'pointer', opacity:busy?0.7:1,
}}>{busy?'…':'Einloggen →'}</button>
</div>
</div>
);
}
// ── 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 }) => (
<button onClick={()=>setActive(item.id)} style={{
width:'100%', display:'flex', alignItems:'center', gap:9, padding:'9px 12px',
borderRadius:7, border:'none',
background: active===item.id ? 'linear-gradient(90deg,rgba(78,205,196,0.14),rgba(78,205,196,0.02))' : 'transparent',
color: active===item.id ? '#4ecdc4' : 'rgba(255,255,255,0.6)',
cursor:'pointer', fontSize:12, fontFamily:"'Space Mono',monospace", textAlign:'left', marginBottom:1,
borderLeft: active===item.id ? '2px solid #4ecdc4' : '2px solid transparent',
}}>
<span style={{ fontSize:14 }}>{item.icon}</span>
<span style={{ flex:1 }}>{item.label}</span>
</button>
);
return (
<aside style={{ width:220, minHeight:'100vh', background:'#0d0d0f',
borderRight:'1px solid rgba(255,255,255,0.06)', display:'flex', flexDirection:'column', flexShrink:0 }}>
<div style={{ padding:'24px 20px 18px', borderBottom:'1px solid rgba(255,255,255,0.06)', marginBottom:10 }}>
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
<div style={{ width:30, height:30, background:'linear-gradient(135deg,#4ecdc4,#ff6b9d)',
borderRadius:7, display:'flex', alignItems:'center', justifyContent:'center', fontSize:15 }}></div>
<div>
<div style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:13, fontWeight:700, letterSpacing:1 }}>
DICKEN<span style={{ color:'#4ecdc4' }}>DOCK</span>
</div>
<div style={{ color:'rgba(255,255,255,0.25)', fontSize:9, fontFamily:'monospace' }}>v1.0.0</div>
</div>
</div>
</div>
<nav style={{ flex:1, padding:'0 10px' }}>
{/* Core */}
{coreNav.map(i => <NavBtn key={i.id} item={i} />)}
{/* Tools aus Registry automatisch generiert */}
<div style={{ ...S.head, padding:'10px 12px 4px', marginBottom:0 }}>TOOLS</div>
{TOOLS.map(t => <NavBtn key={t.id} item={t} />)}
{/* Admin */}
{adminNav.length>0 && <>
<div style={{ ...S.head, padding:'10px 12px 4px', marginBottom:0 }}>SYSTEM</div>
{adminNav.map(i => <NavBtn key={i.id} item={i} />)}
</>}
</nav>
{/* Update Badge */}
{updateInfo?.hasUpdate && (
<button onClick={onUpdateClick} style={{
margin:'0 10px 8px', background:'rgba(255,230,109,0.08)',
border:'1px solid rgba(255,230,109,0.3)', borderRadius:8,
padding:'8px 12px', cursor:'pointer', display:'flex', alignItems:'center', gap:8,
}}>
<span style={{ width:7, height:7, borderRadius:'50%', background:'#ffe66d', flexShrink:0,
boxShadow:'0 0 6px #ffe66d', display:'inline-block' }} />
<span style={{ color:'#ffe66d', fontSize:11, fontFamily:'monospace' }}>Update verfügbar</span>
<span style={{ color:'rgba(255,230,109,0.5)', fontSize:10, fontFamily:'monospace', marginLeft:'auto' }}>{updateInfo.latestVersion}</span>
</button>
)}
{/* User */}
<div style={{ padding:'12px 14px 18px', borderTop:'1px solid rgba(255,255,255,0.06)' }}>
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:8 }}>
<div style={{ width:26, height:26, borderRadius:'50%', background:'linear-gradient(135deg,#ff6b9d,#4ecdc4)',
display:'flex', alignItems:'center', justifyContent:'center', fontSize:11 }}>👤</div>
<div>
<div style={{ color:'#fff', fontSize:11, fontFamily:'monospace' }}>{user?.username}</div>
<div style={{ color:'rgba(255,255,255,0.25)', fontSize:9, fontFamily:'monospace' }}>{user?.role}</div>
</div>
</div>
<button onClick={onLogout} style={{ ...S.btn('#ff6b9d',true), width:'100%', textAlign:'center' }}>Ausloggen</button>
</div>
</aside>
);
}
// ── Update Modal ──────────────────────────────────────────────────────────────
function UpdateModal({ data, onClose }) {
return (
<Modal title="📦 Verfügbare Updates" onClose={onClose}>
<div style={{ marginBottom:14, padding:'8px 12px', background:'rgba(255,230,109,0.06)',
border:'1px solid rgba(255,230,109,0.2)', borderRadius:8 }}>
<span style={{ color:'rgba(255,255,255,0.5)', fontSize:12, fontFamily:'monospace' }}>
Installiert: <span style={{ color:'#4ecdc4' }}>{data.currentVersion}</span>
{' → '}Aktuell: <span style={{ color:'#ffe66d' }}>{data.latestVersion}</span>
</span>
</div>
{(data.releases||[]).map((r,i)=>(
<div key={i} style={{ marginBottom:12, padding:'12px 14px',
background:i===0?'rgba(78,205,196,0.05)':'rgba(255,255,255,0.02)',
border:`1px solid ${i===0?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)'}`, borderRadius:8 }}>
<div style={{ display:'flex', justifyContent:'space-between', marginBottom:6 }}>
<span style={{ color:i===0?'#4ecdc4':'#fff', fontFamily:"'Space Mono',monospace", fontSize:13, fontWeight:700 }}>{r.name||r.version}</span>
<span style={{ color:'rgba(255,255,255,0.3)', fontSize:10, fontFamily:'monospace' }}>{r.publishedAt?new Date(r.publishedAt).toLocaleDateString('de-DE'):''}</span>
</div>
<pre style={{ color:'rgba(255,255,255,0.5)', fontSize:12, fontFamily:'monospace', whiteSpace:'pre-wrap', lineHeight:1.6, margin:0 }}>{r.body}</pre>
</div>
))}
<div style={{ color:'rgba(255,255,255,0.25)', fontSize:11, fontFamily:'monospace', marginTop:8, lineHeight:1.8 }}>
Update: <code style={{ color:'#4ecdc4' }}>sh build.sh && docker compose up -d</code>
</div>
</Modal>
);
}
// ── 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 (
<div style={{ ...S.card, marginBottom:14 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:14 }}>
<div style={S.head}>SCHNELLZUGRIFF</div>
<div style={{ display:'flex', gap:6 }}>
{editMode && <button onClick={()=>{setForm(true);setEditId(null);setF({title:'',url:'',icon:'🔗'});}} style={S.btn('#4ecdc4',true)}>+ Link</button>}
<button onClick={()=>{setEdit(v=>!v);setForm(false);}} style={S.btn(editMode?'#ff6b9d':'rgba(255,255,255,0.4)',true)}>{editMode?'✓ Fertig':'✎ Bearbeiten'}</button>
</div>
</div>
<div style={{ display:'flex', flexWrap:'wrap', gap:10, minHeight:50 }}>
{links.length===0&&!editMode&&<div style={{ color:'rgba(255,255,255,0.2)', fontSize:12, fontFamily:'monospace', padding:'8px 0' }}>Klicke "Bearbeiten" um Links hinzuzufügen.</div>}
{links.map(l=>(
<div key={l.id} style={{ position:'relative' }}>
{editMode&&(
<div style={{ position:'absolute', top:-6, right:-6, display:'flex', gap:2, zIndex:1 }}>
<button onClick={()=>{setF({title:l.title,url:l.url,icon:l.icon});setEditId(l.id);setForm(true);}} style={{ width:18,height:18,borderRadius:'50%',background:'#4ecdc4',border:'none',color:'#0d0d0f',fontSize:9,cursor:'pointer' }}></button>
<button onClick={()=>del(l.id)} style={{ width:18,height:18,borderRadius:'50%',background:'#ff6b9d',border:'none',color:'#0d0d0f',fontSize:10,cursor:'pointer' }}></button>
</div>
)}
<a href={l.url} target="_blank" rel="noopener noreferrer" onClick={editMode?e=>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' }}>
<span style={{ fontSize:22 }}>{l.icon}</span>
<span style={{ color:'rgba(255,255,255,0.7)',fontSize:10,fontFamily:'monospace',maxWidth:70,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap' }}>{l.title}</span>
</a>
</div>
))}
</div>
{showForm&&(
<div style={{ marginTop:14,padding:14,background:'rgba(0,0,0,0.2)',border:'1px solid rgba(255,255,255,0.06)',borderRadius:10 }}>
<div style={{ marginBottom:10 }}>
<div style={S.head}>ICON WÄHLEN</div>
<div style={{ display:'flex',flexWrap:'wrap',gap:4 }}>
{ICONS.map(ic=>(
<button key={ic} onClick={()=>setF(f=>({...f,icon:ic}))} style={{ width:28,height:28,borderRadius:5,border:`1px solid ${form.icon===ic?'#4ecdc4':'rgba(255,255,255,0.1)'}`,background:form.icon===ic?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.04)',cursor:'pointer',fontSize:14 }}>{ic}</button>
))}
</div>
</div>
<div style={{ display:'grid',gridTemplateColumns:'1fr 1fr',gap:10,marginBottom:10 }}>
<div>
<label style={{ ...S.head, display:'block', marginBottom:3 }}>TITEL</label>
<input value={form.title} onChange={e=>setF(f=>({...f,title:e.target.value}))} style={S.inp} placeholder="Mein Link" />
</div>
<div>
<label style={{ ...S.head, display:'block', marginBottom:3 }}>URL</label>
<input value={form.url} onChange={e=>setF(f=>({...f,url:e.target.value}))} onKeyDown={e=>e.key==='Enter'&&save()} style={S.inp} placeholder="https://example.com" />
</div>
</div>
<div style={{ display:'flex',gap:8 }}>
<button onClick={save} style={S.btn('#4ecdc4')}>{editId?'✓ Aktualisieren':'✓ Speichern'}</button>
<button onClick={()=>{setForm(false);setEditId(null);setF({title:'',url:'',icon:'🔗'});}} style={S.btn('#ff6b9d')}>Abbrechen</button>
</div>
</div>
)}
</div>
);
}
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(
<div style={{...S.card,flex:1}}>
<div style={S.head}>TO-DO LISTE</div>
<div style={{display:'flex',gap:6,marginBottom:12}}>
<input value={text} onChange={e=>setText(e.target.value)} onKeyDown={e=>e.key==='Enter'&&add()} style={{...S.inp,flex:1}} placeholder="Neue Aufgabe…"/>
<button onClick={add} style={S.btn('#4ecdc4')}>+</button>
</div>
<div style={{maxHeight:220,overflowY:'auto'}}>
{open.map(item=>(
<div key={item.id} style={{display:'flex',alignItems:'center',gap:8,padding:'6px 0',borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
<button onClick={()=>toggle(item)} style={{width:16,height:16,borderRadius:3,border:'1px solid rgba(78,205,196,0.4)',background:'transparent',cursor:'pointer',flexShrink:0}}/>
<span style={{color:'rgba(255,255,255,0.75)',fontSize:12,fontFamily:'monospace',flex:1,lineHeight:1.4}}>{item.text}</span>
<button onClick={()=>del(item.id)} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',cursor:'pointer',fontSize:13}}></button>
</div>
))}
{done.length>0&&<>
<div style={{...S.head,padding:'8px 0 4px',marginBottom:0}}>ERLEDIGT</div>
{done.map(item=>(
<div key={item.id} style={{display:'flex',alignItems:'center',gap:8,padding:'5px 0',opacity:0.4}}>
<button onClick={()=>toggle(item)} style={{width:16,height:16,borderRadius:3,border:'1px solid rgba(78,205,196,0.3)',background:'rgba(78,205,196,0.15)',cursor:'pointer',flexShrink:0,fontSize:10,color:'#4ecdc4'}}></button>
<span style={{color:'rgba(255,255,255,0.4)',fontSize:12,fontFamily:'monospace',flex:1,textDecoration:'line-through'}}>{item.text}</span>
<button onClick={()=>del(item.id)} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',cursor:'pointer',fontSize:13}}></button>
</div>
))}
</>}
{items.length===0&&<div style={{color:'rgba(255,255,255,0.15)',fontSize:11,fontFamily:'monospace',padding:'8px 0'}}>Noch keine Aufgaben.</div>}
</div>
</div>
);
}
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(
<div style={{...S.card,flex:1,display:'flex',flexDirection:'column'}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:12}}>
<div style={S.head}>NOTIZBLOCK</div>
<div style={{color:saved?'rgba(78,205,196,0.5)':'rgba(255,230,109,0.5)',fontSize:10,fontFamily:'monospace'}}>
{saved?(saveTime?`gespeichert ${new Date(saveTime).toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}`:'gespeichert'):'…'}
</div>
</div>
<textarea value={content} onChange={e=>change(e.target.value)}
placeholder="Notizen, IPs, Zugangsdaten…"
style={{...S.inp,flex:1,minHeight:200,resize:'none',lineHeight:1.7,padding:'10px 12px'}}/>
</div>
);
}
function Dashboard({ toast }) {
return(
<div style={{padding:'36px 44px',maxWidth:1100}}>
<div style={{marginBottom:22}}>
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:22,margin:0}}>Dashboard</h1>
<p style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:11,marginTop:5}}>
{new Date().toLocaleDateString('de-DE',{weekday:'long',year:'numeric',month:'long',day:'numeric'})}
</p>
</div>
<QuickLinks toast={toast}/>
<div style={{display:'flex',gap:14}}><TodoList toast={toast}/><Notepad toast={toast}/></div>
</div>
);
}
// ── Admin Panel ───────────────────────────────────────────────────────────────
function AdminPanel({ toast }) {
const [pw,setPw]=useState({current:'',newPw:'',confirm:''}); const [busy,setBusy]=useState(false);
const [file,setFile]=useState(null); const [restoring,setRestoring]=useState(false);
const changePw=async()=>{
if(pw.newPw!==pw.confirm){toast('Passwörter stimmen nicht überein','error');return;}
if(pw.newPw.length<8){toast('Mindestens 8 Zeichen','error');return;}
setBusy(true);
try{await api('/auth/change-password',{body:{currentPassword:pw.current,newPassword:pw.newPw}});toast('Passwort geändert');setPw({current:'',newPw:'',confirm:''});}
catch(e){toast(e.message,'error');}finally{setBusy(false);}
};
const backup=async()=>{
try{const blob=await api('/admin/backup');const a=Object.assign(document.createElement('a'),{href:URL.createObjectURL(blob),download:`dickendock-backup-${new Date().toISOString().split('T')[0]}.db`});a.click();URL.revokeObjectURL(a.href);toast('Backup heruntergeladen');}
catch(e){toast(e.message,'error');}
};
const restore=async()=>{
if(!file){toast('Datei auswählen','error');return;}
if(!window.confirm('⚠️ Aktuelle Datenbank wird ersetzt. Fortfahren?'))return;
setRestoring(true);
try{const f=new FormData();f.append('database',file);await api('/admin/restore',{method:'POST',body:f,isFile:true});toast('Wiederhergestellt');setFile(null);}
catch(e){toast(e.message,'error');}finally{setRestoring(false);}
};
const Sec=({title,children})=><div style={{...S.card,marginBottom:16}}><div style={S.head}>{title}</div>{children}</div>;
return(
<div style={{padding:'36px 44px',maxWidth:640}}>
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:22,marginBottom:24}}>Admin-Bereich</h1>
<Sec title="PASSWORT ÄNDERN">
{[['Aktuelles Passwort','current'],['Neues Passwort','newPw'],['Bestätigen','confirm']].map(([l,k])=>(
<div key={k} style={{marginBottom:12}}>
<label style={{...S.head,display:'block',marginBottom:3}}>{l.toUpperCase()}</label>
<input type="password" value={pw[k]} onChange={e=>setPw(p=>({...p,[k]:e.target.value}))} style={S.inp}/>
</div>
))}
<button onClick={changePw} disabled={busy} style={S.btn('#4ecdc4')}>{busy?'…':'✓ Passwort ändern'}</button>
</Sec>
<Sec title="DATENBANK-BACKUP">
<p style={{color:'rgba(255,255,255,0.3)',fontSize:12,fontFamily:'monospace',marginBottom:14,lineHeight:1.6}}>Lädt die vollständige SQLite-Datenbank herunter.</p>
<button onClick={backup} style={S.btn('#ffe66d')}> Backup herunterladen</button>
</Sec>
<Sec title="SICHERUNG EINSPIELEN">
<p style={{color:'rgba(255,255,255,0.3)',fontSize:12,fontFamily:'monospace',marginBottom:14,lineHeight:1.6}}> Aktuelle Datenbank wird vollständig ersetzt.</p>
<div style={{display:'flex',gap:10,alignItems:'flex-end'}}>
<div style={{flex:1}}>
<label style={{...S.head,display:'block',marginBottom:3}}>BACKUP-DATEI (.db)</label>
<input type="file" accept=".db" onChange={e=>setFile(e.target.files[0])} style={{...S.inp,padding:'6px 10px',cursor:'pointer'}}/>
</div>
<button onClick={restore} disabled={restoring||!file} style={{...S.btn('#ff6b9d'),opacity:restoring||!file?0.45:1}}>{restoring?'…':'↑ Einspielen'}</button>
</div>
{file&&<div style={{color:'rgba(255,255,255,0.25)',fontSize:10,fontFamily:'monospace',marginTop:6}}>{file.name}</div>}
</Sec>
</div>
);
}
// ── App Shell ─────────────────────────────────────────────────────────────────
export default function App() {
const [user, setUser] = useState(null);
const [active, setActive] = useState('dashboard');
const [toastMsg, setToastMsg] = useState({ msg:'', type:'success' });
const [updateInfo,setUpdateInfo]= useState(null);
const [showUpd, setShowUpd] = useState(false);
useEffect(()=>{
const token=localStorage.getItem('sk_token');
if(!token)return;
try{const p=JSON.parse(atob(token.split('.')[1]));
if(p.exp*1000>Date.now())setUser({id:p.id,username:p.username,role:p.role});
else localStorage.removeItem('sk_token');
}catch{localStorage.removeItem('sk_token');}
},[]);
useEffect(()=>{
if(!user)return;
const check=()=>api('/system/update-check').then(setUpdateInfo).catch(()=>{});
check();
const t=setInterval(check,5*60*1000);
return()=>clearInterval(t);
},[user]);
const toast=useCallback((msg,type='success')=>{
setToastMsg({msg,type});
setTimeout(()=>setToastMsg({msg:'',type:'success'}),3500);
},[]);
if(!user) return <Login onLogin={u=>setUser(u)}/>;
// Aktive Tool-Komponente ermitteln
const activeTool = TOOLS.find(t=>t.id===active);
return(
<>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap');
*{margin:0;padding:0;box-sizing:border-box;}
body{background:#111114;}
input[type=number]::-webkit-inner-spin-button{opacity:0.25;}
@keyframes fadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}
::-webkit-scrollbar{width:3px;}::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:2px;}
select option,textarea{background:#111114;}
`}</style>
<div style={{display:'flex',minHeight:'100vh',background:'#111114'}}>
<Sidebar active={active} setActive={setActive} user={user}
onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);}}
updateInfo={updateInfo} onUpdateClick={()=>setShowUpd(true)}/>
<main style={{flex:1,overflowY:'auto'}}>
{active==='dashboard' && <Dashboard toast={toast}/>}
{active==='admin' && user?.role==='admin' && <AdminPanel toast={toast}/>}
{activeTool && <activeTool.component toast={toast}/>}
</main>
</div>
<Toast msg={toastMsg.msg} type={toastMsg.type}/>
{showUpd&&updateInfo&&<UpdateModal data={updateInfo} onClose={()=>setShowUpd(false)}/>}
</>
);
}

46
frontend/src/lib.js Normal file
View File

@@ -0,0 +1,46 @@
// ── API Client ────────────────────────────────────────────────────────────────
// Einheitlicher Fetch-Wrapper für alle Tools und Core-Komponenten
export const api = async (path, { body, method, isFile } = {}) => {
const token = localStorage.getItem('sk_token');
const headers = token ? { Authorization: `Bearer ${token}` } : {};
if (!isFile) headers['Content-Type'] = 'application/json';
const res = await fetch(`/api${path}`, {
method: method || (body ? 'POST' : 'GET'),
headers,
body: isFile ? body : body ? JSON.stringify(body) : undefined,
});
if (path.includes('/admin/backup') && res.ok) return res.blob();
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Fehler');
return data;
};
// ── Design Tokens ─────────────────────────────────────────────────────────────
// Einheitliche Styles für alle Tools in jedem Tool importierbar
export const S = {
// Eingabefeld
inp: {
width: '100%', background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)', borderRadius: 6,
padding: '8px 10px', color: '#fff', fontSize: 13,
fontFamily: "'Space Mono',monospace", outline: 'none', boxSizing: 'border-box',
},
// Button-Factory: btn('#4ecdc4') oder btn('#ff6b9d', true) für kleine Variante
btn: (c = '#4ecdc4', sm = false) => ({
background: `${c}18`, border: `1px solid ${c}44`,
borderRadius: 6, padding: sm ? '5px 10px' : '8px 14px',
color: c, cursor: 'pointer', fontFamily: 'monospace',
fontSize: sm ? 11 : 12, whiteSpace: 'nowrap', transition: 'all 0.15s',
}),
// Karten-Container
card: {
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.07)',
borderRadius: 12, padding: 20,
},
// Abschnitts-Überschrift
head: {
color: 'rgba(255,255,255,0.3)', fontSize: 10,
fontFamily: 'monospace', letterSpacing: 2, marginBottom: 14,
},
};

7
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,7 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode><App /></React.StrictMode>
)

View File

@@ -0,0 +1,26 @@
import Kalkulator3D, { SavedList } from './tools/Kalkulator3D.jsx';
// ══════════════════════════════════════════════════════════════════════════════
// Tool-Registry hier neue Tools eintragen
//
// Neues Tool hinzufügen:
// 1. Backend: src/tools/<name>/routes.js anlegen → wird automatisch geladen
// 2. Frontend: src/tools/<Name>.jsx anlegen
// 3. Hier einen Eintrag ergänzen
// ══════════════════════════════════════════════════════════════════════════════
export const TOOLS = [
{
id: 'kalkulator3d',
icon: '◈',
label: '3D-Kalkulator',
component: Kalkulator3D,
},
{
id: 'kalkulator3d-saved',
icon: '◉',
label: 'Gespeichert',
component: SavedList,
},
// Nächstes Tool:
// { id: 'newtool', icon: '◇', label: 'Mein Tool', component: MeinTool },
];

View File

@@ -0,0 +1,237 @@
import { useState, useMemo, useEffect, useRef } from 'react';
import { api, S } from '../lib.js';
// ── Konstanten ────────────────────────────────────────────────────────────────
const DEFAULTS = {
materialpreis_pro_gramm: 0.01, stromverbrauch_kw: 0.15,
strompreis_pro_kwh: 0.38, druckerpreis: 550,
gesamtdruckstunden: 5000, verschleiss_pro_stunde: 0.06,
};
const FARB = { 1: 1.0, 2: 1.1, 3: 1.2, 4: 1.3 };
const STUFEN = [
{ key: 'f', emoji: '💖', label: 'Freundschaft', mult: 1.0, c: '#ff6b9d' },
{ key: 'n', emoji: '🤝', label: 'Normal', mult: 1.5, c: '#4ecdc4' },
{ key: 'a', emoji: '💼', label: 'Auftrag', mult: 2.5, c: '#ffe66d' },
];
// ── Hilfselemente ─────────────────────────────────────────────────────────────
function NInput({ label, value, onChange, step = 0.01, unit }) {
return (
<div style={{ marginBottom: 12 }}>
<label style={{ ...S.head, display: 'block', marginBottom: 3 }}>
{label.toUpperCase()} {unit && <span style={{ color: 'rgba(255,255,255,0.2)' }}>({unit})</span>}
</label>
<input type="number" value={value} step={step}
onChange={e => onChange(parseFloat(e.target.value) || 0)} style={S.inp}
onFocus={e => e.target.style.borderColor = 'rgba(78,205,196,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(255,255,255,0.1)'}
/>
</div>
);
}
// ── Kalkulator ────────────────────────────────────────────────────────────────
export default function Kalkulator3D({ toast, editData, onEditDone, onNavigate }) {
const [gramm, setGramm] = useState(editData?.gramm ?? 50);
const [stunden, setStunden] = useState(editData?.stunden ?? 3);
const [farben, setFarben] = useState(editData?.farben ?? 1);
const [vars, setVars] = useState(editData
? { materialpreis_pro_gramm: editData.materialpreis_pro_gramm, stromverbrauch_kw: editData.stromverbrauch_kw,
strompreis_pro_kwh: editData.strompreis_pro_kwh, druckerpreis: editData.druckerpreis,
gesamtdruckstunden: editData.gesamtdruckstunden, verschleiss_pro_stunde: editData.verschleiss_pro_stunde }
: { ...DEFAULTS });
const [showAdv, setShowAdv] = useState(false);
const [name, setName] = useState(editData?.name ?? '');
const [saving, setSaving] = useState(false);
const sv = (k, v) => setVars(p => ({ ...p, [k]: v }));
const calc = useMemo(() => {
const f = FARB[farben] || 1;
const mat = gramm * vars.materialpreis_pro_gramm * f;
const str = stunden * vars.stromverbrauch_kw * vars.strompreis_pro_kwh;
const ver = stunden * vars.verschleiss_pro_stunde;
return { mat, str, ver, grund: mat + str + ver };
}, [gramm, stunden, farben, vars]);
const save = async () => {
if (!name.trim()) { toast('Bitte einen Namen eingeben', 'error'); return; }
setSaving(true);
try {
const payload = { name, gramm, stunden, farben, ...vars,
preis_freundschaft: calc.grund * 1.0,
preis_normal: calc.grund * 1.5,
preis_auftrag: calc.grund * 2.5 };
if (editData?.id) {
await api(`/tools/kalkulator3d/${editData.id}`, { method: 'PUT', body: payload });
toast('Aktualisiert ✓'); onEditDone?.();
} else {
await api('/tools/kalkulator3d', { body: payload });
toast('Gespeichert ✓'); setName('');
}
} catch (e) { toast(e.message, 'error'); } finally { setSaving(false); }
};
return (
<div style={{ padding: '36px 44px', maxWidth: 860 }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<div>
{editData && <div style={{ color: '#ffe66d', fontSize: 11, fontFamily: 'monospace', marginBottom: 5 }}> Bearbeite: {editData.name}</div>}
<h1 style={{ color: '#fff', fontFamily: "'Space Mono',monospace", fontSize: 22, margin: 0 }}>3D-Druck Kalkulator</h1>
<p style={{ color: 'rgba(255,255,255,0.25)', fontSize: 11, fontFamily: 'monospace', marginTop: 5 }}>Echtzeit-Kalkulation · 3 Preisstufen</p>
</div>
{editData && <button onClick={onEditDone} style={S.btn('#ff6b9d')}> Abbrechen</button>}
</div>
{/* Eingaben */}
<div style={{ ...S.card, marginBottom: 12 }}>
<div style={S.head}>EINGABEN</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16 }}>
<NInput label="Gewicht" value={gramm} onChange={setGramm} step={1} unit="g" />
<NInput label="Druckdauer" value={stunden} onChange={setStunden} step={0.5} unit="h" />
<div>
<label style={{ ...S.head, display: 'block', marginBottom: 3 }}>ANZAHL FARBEN</label>
<select value={farben} onChange={e => setFarben(Number(e.target.value))} style={S.inp}>
{[1,2,3,4].map(n => <option key={n} value={n} style={{ background: '#1a1a1e' }}>{n} Farbe{n>1?'n':''} (×{FARB[n].toFixed(1)})</option>)}
</select>
</div>
</div>
{/* Erweiterte Einstellungen */}
<button onClick={() => setShowAdv(v => !v)} style={{
width: '100%', marginTop: 12, background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.06)', borderRadius: 7,
padding: '8px 12px', color: 'rgba(255,255,255,0.35)', cursor: 'pointer',
fontFamily: 'monospace', fontSize: 11, textAlign: 'left', display: 'flex', alignItems: 'center', gap: 7,
}}>
<span style={{ display: 'inline-block', transform: showAdv ? 'rotate(90deg)' : 'rotate(0)', transition: 'transform 0.18s' }}></span>
Erweiterte Einstellungen
<span style={{ marginLeft: 'auto', fontSize: 9, background: 'rgba(78,205,196,0.1)', borderRadius: 3, padding: '2px 6px', color: '#4ecdc4' }}>
{Object.entries(vars).some(([k,v]) => v !== DEFAULTS[k]) ? 'GEÄNDERT' : 'STANDARD'}
</span>
</button>
{showAdv && (
<div style={{ marginTop: 10, background: 'rgba(0,0,0,0.2)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: 9, padding: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<NInput label="Material €/g" value={vars.materialpreis_pro_gramm} onChange={v=>sv('materialpreis_pro_gramm',v)} step={0.001} />
<NInput label="Strom kW" value={vars.stromverbrauch_kw} onChange={v=>sv('stromverbrauch_kw',v)} step={0.01} />
<NInput label="Strom €/kWh" value={vars.strompreis_pro_kwh} onChange={v=>sv('strompreis_pro_kwh',v)} step={0.01} />
<NInput label="Druckerpreis €" value={vars.druckerpreis} onChange={v=>sv('druckerpreis',v)} step={10} />
<NInput label="Lebensdauer h" value={vars.gesamtdruckstunden} onChange={v=>sv('gesamtdruckstunden',v)} step={100} />
<NInput label="Verschleiß €/h" value={vars.verschleiss_pro_stunde} onChange={v=>sv('verschleiss_pro_stunde',v)} step={0.01} />
</div>
<button onClick={() => setVars({...DEFAULTS})} style={{ ...S.btn('#ff6b9d', true), marginTop: 10 }}> Standardwerte</button>
</div>
)}
</div>
{/* Kostenaufschlüsselung */}
<div style={{ ...S.card, display: 'flex', padding: '12px 0', marginBottom: 12 }}>
{[['Material',calc.mat,'#a78bfa'],['Strom',calc.str,'#60a5fa'],['Verschleiß',calc.ver,'#f59e0b'],['Grundkosten',calc.grund,'#4ecdc4',true]].map(([l,v,c,bold],i)=>(
<div key={l} style={{ flex:1, padding:'5px 18px', borderLeft:i>0?'1px solid rgba(255,255,255,0.05)':'none' }}>
<div style={{ color:'rgba(255,255,255,0.28)', fontSize:10, fontFamily:'monospace', marginBottom:3 }}>{l}</div>
<div style={{ color:c, fontSize:bold?16:13, fontFamily:"'Space Mono',monospace", fontWeight:bold?700:400 }}>{v.toFixed(4)} </div>
</div>
))}
</div>
{/* Preis-Cards */}
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap' }}>
{STUFEN.map((s,i) => (
<div key={s.key} style={{ background:`${s.c}10`, border:`1px solid ${s.c}30`, borderRadius:11,
padding:'16px 20px', flex:1, minWidth:130, transform:i===1?'scale(1.04)':'scale(1)' }}>
<div style={{ fontSize:18, marginBottom:4 }}>{s.emoji}</div>
<div style={{ color:'rgba(255,255,255,0.4)', fontSize:9, fontFamily:'monospace', marginBottom:2 }}>{s.label}</div>
<div style={{ color:s.c, fontSize:24, fontFamily:"'Space Mono',monospace", fontWeight:700 }}>{(calc.grund*s.mult).toFixed(2)} </div>
<div style={{ color:'rgba(255,255,255,0.2)', fontSize:9, fontFamily:'monospace', marginTop:2 }}>×{s.mult}</div>
</div>
))}
</div>
{/* Speichern */}
<div style={S.card}>
<div style={S.head}>BERECHNUNG SPEICHERN</div>
<div style={{ display: 'flex', gap: 10 }}>
<div style={{ flex: 1 }}>
<input value={name} onChange={e=>setName(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&save()}
placeholder='z.B. "Halterung Kunde Müller"' style={S.inp} />
</div>
<button onClick={save} disabled={saving} style={{
background:'linear-gradient(135deg,#4ecdc4,#45b7d1)', border:'none', borderRadius:7,
padding:'8px 18px', color:'#0d0d0f', fontFamily:"'Space Mono',monospace",
fontWeight:700, fontSize:12, cursor:saving?'default':'pointer', opacity:saving?0.7:1,
}}>{saving?'…':editData?.id?'✓ Aktualisieren':'↓ Speichern'}</button>
</div>
</div>
</div>
);
}
// ── Gespeicherte Berechnungen ─────────────────────────────────────────────────
export function SavedList({ toast, onNavigate }) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [delId, setDelId] = useState(null);
const [editData, setEditData] = useState(null);
useEffect(() => {
api('/tools/kalkulator3d').then(setItems).catch(e=>toast(e.message,'error')).finally(()=>setLoading(false));
}, []);
const del = async id => {
try { await api(`/tools/kalkulator3d/${id}`,{method:'DELETE'}); setItems(p=>p.filter(i=>i.id!==id)); toast('Gelöscht'); }
catch(e) { toast(e.message,'error'); } finally { setDelId(null); }
};
if (editData) return (
<Kalkulator3D toast={toast} editData={editData} onEditDone={() => {
setEditData(null);
api('/tools/kalkulator3d').then(setItems).catch(()=>{});
}} />
);
return (
<div style={{ padding:'36px 44px', maxWidth:1000 }}>
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:22, marginBottom:6 }}>Gespeicherte Berechnungen</h1>
<p style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:11, marginBottom:24 }}>{items.length} Einträge</p>
{loading && <div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace' }}>Lädt</div>}
{!loading && items.length===0 && (
<div style={{ ...S.card, textAlign:'center', padding:48 }}>
<div style={{ fontSize:28, marginBottom:10 }}></div>
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:12 }}>Noch keine Berechnungen gespeichert.</div>
</div>
)}
{items.map(item => (
<div key={item.id} style={{ ...S.card, display:'flex', alignItems:'center', gap:14, marginBottom:8, padding:'14px 18px' }}>
<div style={{ flex:1 }}>
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13, fontWeight:700, marginBottom:3 }}>{item.name}</div>
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10 }}>
{item.gramm}g · {item.stunden}h · {item.farben} Farbe{item.farben>1?'n':''} · {new Date(item.created_at).toLocaleDateString('de-DE')}
</div>
</div>
<div style={{ display:'flex', gap:12 }}>
{[['💖',item.preis_freundschaft,'#ff6b9d'],['🤝',item.preis_normal,'#4ecdc4'],['💼',item.preis_auftrag,'#ffe66d']].map(([e,v,c])=>(
<div key={e} style={{ textAlign:'center' }}>
<div style={{ fontSize:11 }}>{e}</div>
<div style={{ color:c, fontFamily:"'Space Mono',monospace", fontSize:12, fontWeight:700 }}>{v.toFixed(2)}</div>
</div>
))}
</div>
<div style={{ display:'flex', gap:6 }}>
<button onClick={()=>setEditData(item)} style={S.btn('#4ecdc4',true)}> Bearbeiten</button>
{delId===item.id ? (
<div style={{ display:'flex', gap:5, alignItems:'center' }}>
<span style={{ color:'rgba(255,255,255,0.35)', fontSize:10, fontFamily:'monospace' }}>Sicher?</span>
<button onClick={()=>del(item.id)} style={S.btn('#ff6b9d',true)}> Ja</button>
<button onClick={()=>setDelId(null)} style={S.btn('rgba(255,255,255,0.4)',true)}>Nein</button>
</div>
) : (
<button onClick={()=>setDelId(item.id)} style={S.btn('#ff6b9d',true)}> Löschen</button>
)}
</div>
</div>
))}
</div>
);
}

7
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: { proxy: { '/api': 'http://localhost:4000' } },
})

1
version.txt Normal file
View File

@@ -0,0 +1 @@
v1.0.0