Dicken Dock v1.0.0
This commit is contained in:
18
Dockerfile
Normal file
18
Dockerfile
Normal 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
12
backend/package.json
Normal 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
71
backend/src/db.js
Normal 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
34
backend/src/index.js
Normal 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'));
|
||||
16
backend/src/middleware/auth.js
Normal file
16
backend/src/middleware/auth.js
Normal 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 };
|
||||
30
backend/src/routes/admin.js
Normal file
30
backend/src/routes/admin.js
Normal 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;
|
||||
37
backend/src/routes/auth.js
Normal file
37
backend/src/routes/auth.js
Normal 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;
|
||||
70
backend/src/routes/dashboard.js
Normal file
70
backend/src/routes/dashboard.js
Normal 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;
|
||||
40
backend/src/routes/system.js
Normal file
40
backend/src/routes/system.js
Normal 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;
|
||||
57
backend/src/tools/kalkulator3d/routes.js
Normal file
57
backend/src/tools/kalkulator3d/routes.js
Normal 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
16
build.sh
Normal 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
15
docker-compose.yml
Normal 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
12
frontend/index.html
Normal 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
16
frontend/package.json
Normal 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
478
frontend/src/App.jsx
Normal 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
46
frontend/src/lib.js
Normal 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
7
frontend/src/main.jsx
Normal 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>
|
||||
)
|
||||
26
frontend/src/toolRegistry.js
Normal file
26
frontend/src/toolRegistry.js
Normal 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 },
|
||||
];
|
||||
237
frontend/src/tools/Kalkulator3D.jsx
Normal file
237
frontend/src/tools/Kalkulator3D.jsx
Normal 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
7
frontend/vite.config.js
Normal 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
1
version.txt
Normal file
@@ -0,0 +1 @@
|
||||
v1.0.0
|
||||
Reference in New Issue
Block a user