revert Initial commit
This commit is contained in:
2026-07-18 23:52:47 +02:00
parent 8baba7b51f
commit 33cd014fc4
98 changed files with 0 additions and 30017 deletions

View File

@@ -1,4 +0,0 @@
# Kopiere diese Datei zu .env und passe die Werte an
# .env wird NICHT ins Git eingecheckt
JWT_SECRET=ersetze-mich-mit-einem-langen-zufaelligen-string

5
.gitignore vendored
View File

@@ -1,5 +0,0 @@
node_modules/
.env
*.db
*.db-bak
dist/

View File

@@ -1,33 +0,0 @@
# ── Stage 1: React bauen ─────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /build
COPY frontend/package.json ./
RUN npm install
COPY frontend/ ./
# version.txt VOR dem Build generieren Unix-Timestamp (Sekunden)
# Dieser Wert wird von vite.config.js als __BUILD_TIME__ ins Bundle gebacken
# UND vom Backend als /api/build-time ausgeliefert → exakt derselbe Wert
RUN date -u +"%s" > ./version.txt
# Build-Version in sw.js einsetzen damit der Browser bei jedem Deploy einen neuen SW erkennt
RUN BUILD_V=$(cat ./version.txt) && sed -i "s/__BUILD_VERSION__/${BUILD_V}/g" ./public/sw.js
RUN npm run build
# ── Stage 2: Express liefert API + React in einem Container ──────────────────
FROM node:20-alpine
# Puppeteer/Chromium für KöPi Web-Scraping
RUN apk add --no-cache tzdata python3 make g++ chromium nss freetype harfbuzz ca-certificates ttf-freefont
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
# Zeitzone fest auf Europe/Berlin setzen, damit Server und Browser (DE) immer
# dieselbe lokale Zeit berechnen inkl. automatischer Sommer-/Winterzeit-Umstellung
ENV TZ=Europe/Berlin
RUN cp /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
COPY backend/package.json ./
RUN npm install --production
COPY backend/ ./
# version.txt aus dem Build-Stage kopieren (selber Wert wie im JS-Bundle)
COPY --from=builder /build/version.txt ./version.txt
COPY --from=builder /build/dist ./public
EXPOSE 4000
CMD ["node", "src/index.js"]

View File

@@ -1,15 +0,0 @@
{
"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",
"ws": "^8.17.1",
"puppeteer-core": "^22.0.0",
"mqtt": "^5.10.1"
}
}

View File

@@ -1,63 +0,0 @@
const fs = require('fs');
// Persistenter Pfad (selbes Volume wie die DB), übersteht Container-Neustarts
const LOG_FILE = process.env.CONSOLE_LOG_FILE || '/data/app-console.log';
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
const origLog = console.log.bind(console);
const origError = console.error.bind(console);
const origWarn = console.warn.bind(console);
function stringifyArg(a) {
if (typeof a === 'string') return a;
if (a instanceof Error) return a.stack || a.message;
try { return JSON.stringify(a); } catch { return String(a); }
}
function localTimestamp() {
const d = new Date();
const pad = (n, len=2) => String(n).padStart(len, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(),3)}`;
}
function appendLine(level, args) {
try {
const msg = args.map(stringifyArg).join(' ');
fs.appendFileSync(LOG_FILE, `[${localTimestamp()}] [${level}] ${msg}\n`);
} catch {
// Darf niemals die eigentliche Konsolen-Ausgabe verhindern
}
}
// Entfernt Zeilen, die älter als MAX_AGE_MS sind. Zeilen ohne erkennbaren
// Zeitstempel (z.B. Fortsetzungszeilen eines mehrzeiligen Stacktraces) werden
// bewusst behalten, um nichts mittendrin abzuschneiden.
function pruneOldLines() {
try {
if (!fs.existsSync(LOG_FILE)) return;
const content = fs.readFileSync(LOG_FILE, 'utf8');
const cutoff = Date.now() - MAX_AGE_MS;
const lines = content.split('\n').filter(line => {
const m = line.match(/^\[([^\]]+)\]/);
if (!m) return true;
const t = new Date(m[1].replace(' ', 'T')).getTime();
return Number.isNaN(t) || t >= cutoff;
});
fs.writeFileSync(LOG_FILE, lines.join('\n'));
} catch (e) {
origError('Konsolen-Log-Aufräumen fehlgeschlagen:', e.message);
}
}
function installConsoleCapture() {
console.log = (...args) => { origLog(...args); appendLine('LOG', args); };
console.error = (...args) => { origError(...args); appendLine('ERROR', args); };
console.warn = (...args) => { origWarn(...args); appendLine('WARN', args); };
pruneOldLines(); // einmal direkt beim Start
setInterval(pruneOldLines, 60 * 60 * 1000); // danach stündlich
}
function getLogFilePath() { return LOG_FILE; }
module.exports = { installConsoleCapture, getLogFilePath, pruneOldLines };

View File

@@ -1,36 +0,0 @@
// ── Verschlüsselungs-Helper für sensible gespeicherte Zugangsdaten ──────────
// Nutzt AES-256-GCM. Der Schlüssel wird aus JWT_SECRET abgeleitet (kein
// zusätzlicher Env-Var nötig) — ausreichend für den Zweck hier (Zugangsdaten
// liegen nicht mehr im Klartext in der SQLite-DB), ersetzt aber kein
// dediziertes Secret-Management für hochsensible Fälle.
const crypto = require('crypto');
const KEY = crypto.createHash('sha256')
.update(process.env.JWT_SECRET || 'dickendock-fallback-key-bitte-JWT_SECRET-setzen')
.digest();
function encrypt(text) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
const enc = Buffer.concat([cipher.update(String(text), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, enc]).toString('base64');
}
function decrypt(b64) {
if (!b64) return null;
try {
const buf = Buffer.from(b64, 'base64');
const iv = buf.subarray(0, 12);
const tag = buf.subarray(12, 28);
const enc = buf.subarray(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8');
} catch {
return null; // falsches/verändertes Secret oder korrupte Daten
}
}
module.exports = { encrypt, decrypt };

View File

@@ -1,710 +0,0 @@
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 calendar_feeds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
url TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#4ecdc4',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS folder_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(folder_id, shared_with)
);
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
originalname TEXT NOT NULL,
mimetype TEXT NOT NULL DEFAULT 'application/octet-stream',
size INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS file_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(file_id, shared_with)
);
CREATE TABLE IF NOT EXISTS admin_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
bemerkung TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'warteliste',
custom_price REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS order_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
calculation_id INTEGER REFERENCES calculations(id) ON DELETE SET NULL,
calc_name TEXT NOT NULL,
preis_freundschaft REAL NOT NULL,
preis_normal REAL NOT NULL,
preis_auftrag REAL NOT NULL,
stueckzahl INTEGER NOT NULL DEFAULT 1
);
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
// Default admin settings
const settingDefaults = [
['file_max_size_mb', '50'],
['file_max_count', '20'],
['file_allowed_ext', '.pdf,.jpg,.jpeg,.png,.gif,.zip,.txt,.docx,.xlsx,.mp4,.stl,.3mf'],
];
for (const [key, value] of settingDefaults) {
if (!db.prepare('SELECT key FROM admin_settings WHERE key=?').get(key))
db.prepare('INSERT INTO admin_settings (key,value) VALUES (?,?)').run(key, value);
}
if (db.prepare('SELECT COUNT(*) c FROM users').get().c === 0) {
db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
.run('admin', bcrypt.hashSync('admin123', 12), 'admin');
console.log('✅ Erster Start: Admin angelegt: admin / admin123 → Bitte Passwort sofort ändern!');
}
// ── Migrationen (werden bei jedem Start geprüft) ─────────────────────────────
const cols = db.prepare("PRAGMA table_info(calculations)").all().map(r => r.name);
if (!cols.includes('image')) db.exec("ALTER TABLE calculations ADD COLUMN image TEXT");
if (!cols.includes('bemerkung')) db.exec("ALTER TABLE calculations ADD COLUMN bemerkung TEXT NOT NULL DEFAULT ''");
// Migrationen orders
const ordCols = db.prepare("PRAGMA table_info(orders)").all().map(r => r.name);
if (!ordCols.includes('bezahlt')) db.exec("ALTER TABLE orders ADD COLUMN bezahlt INTEGER NOT NULL DEFAULT 0");
if (!ordCols.includes('bezahlt_am')) db.exec("ALTER TABLE orders ADD COLUMN bezahlt_am DATETIME");
if (!ordCols.includes('abgeholt')) db.exec("ALTER TABLE orders ADD COLUMN abgeholt INTEGER NOT NULL DEFAULT 0");
if (!ordCols.includes('abgeholt_am'))db.exec("ALTER TABLE orders ADD COLUMN abgeholt_am DATETIME");
// Migrationen order_items
// files migrations
const fileCols = db.prepare("PRAGMA table_info(files)").all().map(r => r.name);
if (!fileCols.includes('folder_id')) db.exec("ALTER TABLE files ADD COLUMN folder_id INTEGER REFERENCES folders(id) ON DELETE SET NULL");
// folder_shares migrations (only if table exists)
const fsTableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='folder_shares'").get();
if (fsTableExists) {
const fsCols = db.prepare("PRAGMA table_info(folder_shares)").all().map(r => r.name);
if (!fsCols.includes('created_at')) db.exec("ALTER TABLE folder_shares ADD COLUMN created_at DATETIME DEFAULT NULL");
}
const oiCols = db.prepare("PRAGMA table_info(order_items)").all().map(r => r.name);
if (!oiCols.includes('stunden')) db.exec("ALTER TABLE order_items ADD COLUMN stunden REAL NOT NULL DEFAULT 0");
if (!oiCols.includes('custom_price')) db.exec("ALTER TABLE order_items ADD COLUMN custom_price REAL");
if (!oiCols.includes('status')) db.exec("ALTER TABLE order_items ADD COLUMN status TEXT NOT NULL DEFAULT 'warteliste'");
if (!oiCols.includes('qty_warteliste')) db.exec("ALTER TABLE order_items ADD COLUMN qty_warteliste INTEGER NOT NULL DEFAULT 0");
if (!oiCols.includes('qty_in_arbeit')) db.exec("ALTER TABLE order_items ADD COLUMN qty_in_arbeit INTEGER NOT NULL DEFAULT 0");
if (!oiCols.includes('qty_fertig')) db.exec("ALTER TABLE order_items ADD COLUMN qty_fertig INTEGER NOT NULL DEFAULT 0");
// Bestehende Einträge: alle Stücke auf Warteliste setzen falls noch keine Qtys gesetzt
db.exec("UPDATE order_items SET qty_warteliste=stueckzahl WHERE qty_warteliste=0 AND qty_in_arbeit=0 AND qty_fertig=0");
// Migration: file_shares Passwort und Zugriffszeit
const fShareCols = db.prepare("PRAGMA table_info(file_shares)").all().map(r => r.name);
if (fShareCols.length && !fShareCols.includes('password_hash'))
db.exec("ALTER TABLE file_shares ADD COLUMN password_hash TEXT DEFAULT NULL");
if (fShareCols.length && !fShareCols.includes('accessed_at'))
db.exec("ALTER TABLE file_shares ADD COLUMN accessed_at DATETIME DEFAULT NULL");
// ── Nachrichten-Migrationen ───────────────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='messages'").get()) {
db.exec(`
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
encrypted_content TEXT NOT NULL,
iv TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
read_by_recipient INTEGER NOT NULL DEFAULT 0
)
`);
}
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_keys'").get()) {
db.exec(`
CREATE TABLE user_keys (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
public_key TEXT NOT NULL,
updated_at DATETIME DEFAULT NULL
)
`);
}
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='pushover_settings'").get()) {
db.exec(`
CREATE TABLE pushover_settings (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
user_key TEXT NOT NULL,
app_token TEXT NOT NULL
)
`);
}
// Migration: Soft-Delete-Spalten entfernen (nur Nachrichten übernehmen die noch sichtbar waren)
const msgCols = db.prepare("PRAGMA table_info(messages)").all().map(r => r.name);
if (msgCols.includes('deleted_by_sender')) {
db.exec(`
CREATE TABLE messages_v2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
encrypted_content TEXT NOT NULL,
iv TEXT NOT NULL,
created_at DATETIME DEFAULT NULL,
read_by_recipient INTEGER NOT NULL DEFAULT 0
);
INSERT INTO messages_v2 (id, sender_id, recipient_id, encrypted_content, iv, created_at, read_by_recipient)
SELECT id, sender_id, recipient_id, encrypted_content, iv, created_at,
COALESCE(read_by_recipient, 0)
FROM messages
WHERE deleted_by_sender = 0 AND deleted_by_recipient = 0;
DROP TABLE messages;
ALTER TABLE messages_v2 RENAME TO messages;
`);
console.log('✅ Migration: messages → Hard-Delete (Soft-Delete-Spalten entfernt)');
}
// Falls read_by_recipient noch fehlt (sehr alte Installation)
const msgCols2 = db.prepare("PRAGMA table_info(messages)").all().map(r => r.name);
if (msgCols2.length && !msgCols2.includes('read_by_recipient')) {
db.exec("ALTER TABLE messages ADD COLUMN read_by_recipient INTEGER NOT NULL DEFAULT 0");
}
// User-Preferences (JSON-Blob)
const userCols2 = db.prepare("PRAGMA table_info(users)").all().map(r => r.name);
if (!userCols2.includes('preferences'))
db.exec("ALTER TABLE users ADD COLUMN preferences TEXT DEFAULT NULL");
if (!userCols2.includes('last_active_at'))
db.exec("ALTER TABLE users ADD COLUMN last_active_at DATETIME DEFAULT NULL");
if (!userCols2.includes('hidden'))
db.exec("ALTER TABLE users ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0");
if (!userCols2.includes('chat_active_at'))
db.exec("ALTER TABLE users ADD COLUMN chat_active_at DATETIME DEFAULT NULL");
if (!userCols2.includes('hidden_tools'))
db.exec("ALTER TABLE users ADD COLUMN hidden_tools TEXT NOT NULL DEFAULT '[]'");
// ── Login-Sicherheit ──────────────────────────────────────────────────────────
// Spalten für Account-Lockout in users-Tabelle
const userCols = db.prepare("PRAGMA table_info(users)").all().map(r => r.name);
if (!userCols.includes('failed_attempts'))
db.exec("ALTER TABLE users ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0");
if (!userCols.includes('locked_until'))
db.exec("ALTER TABLE users ADD COLUMN locked_until DATETIME DEFAULT NULL");
if (!userCols.includes('last_failed_at'))
db.exec("ALTER TABLE users ADD COLUMN last_failed_at DATETIME DEFAULT NULL");
// Login-Fehlversuche Log
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='login_attempts'").get()) {
db.exec(`
CREATE TABLE login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
ip TEXT,
success INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT NULL
)
`);
}
// Default-Einstellungen für Login-Schutz
const loginDefaults = [
['login_max_attempts', '5'],
['login_lockout_minutes', '30'],
];
for (const [k, v] of loginDefaults) {
if (!db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k))
db.prepare('INSERT INTO admin_settings (key, value) VALUES (?, ?)').run(k, v);
}
// ── Ideen-Board ───────────────────────────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='board_items'").get()) {
db.exec(`
CREATE TABLE board_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL CHECK(type IN ('roadmap','wish')),
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
promoted_from_wish INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT NULL
)
`);
}
// Migration: promoted_from_wish Spalte
const boardCols = db.prepare("PRAGMA table_info(board_items)").all().map(r => r.name);
if (boardCols.length && !boardCols.includes('promoted_from_wish'))
db.exec("ALTER TABLE board_items ADD COLUMN promoted_from_wish INTEGER NOT NULL DEFAULT 0");
if (boardCols.length && !boardCols.includes('promoted_at'))
db.exec("ALTER TABLE board_items ADD COLUMN promoted_at DATETIME DEFAULT NULL");
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='board_reads'").get()) {
db.exec(`
CREATE TABLE board_reads (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
last_read DATETIME DEFAULT NULL
)
`);
}
// Migration: Pushover retry/expire
const poCols = db.prepare("PRAGMA table_info(pushover_settings)").all().map(r => r.name);
if (poCols.length && !poCols.includes('retry'))
db.exec("ALTER TABLE pushover_settings ADD COLUMN retry INTEGER DEFAULT NULL");
if (poCols.length && !poCols.includes('expire'))
db.exec("ALTER TABLE pushover_settings ADD COLUMN expire INTEGER DEFAULT NULL");
// ── Changelog ─────────────────────────────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='changelog'").get()) {
db.exec(`
CREATE TABLE changelog (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
build_time TEXT DEFAULT NULL,
created_at DATETIME DEFAULT NULL
)
`);
}
// Migration: build_time Spalte
const clCols = db.prepare("PRAGMA table_info(changelog)").all().map(r => r.name);
if (clCols.length && !clCols.includes('build_time'))
db.exec("ALTER TABLE changelog ADD COLUMN build_time TEXT DEFAULT NULL");
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='changelog_reads'").get()) {
db.exec(`
CREATE TABLE changelog_reads (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
last_read DATETIME DEFAULT NULL
)
`);
}
// ── Push-Zeitplaner ───────────────────────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='push_schedules'").get()) {
db.exec(`
CREATE TABLE push_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
message TEXT NOT NULL,
scheduled_at DATETIME NOT NULL,
sent INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT NULL
)
`);
}
// ── Link-Liste ────────────────────────────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list'").get()) {
db.exec(`
CREATE TABLE link_list (
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 '🔗',
description TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT NULL
)
`);
}
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list_shares'").get()) {
db.exec(`
CREATE TABLE link_list_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
link_id INTEGER NOT NULL REFERENCES link_list(id) ON DELETE CASCADE,
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT NULL,
UNIQUE(link_id, shared_with)
)
`);
}
// ── Kalkulator-Shares ─────────────────────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='calculation_shares'").get()) {
db.exec(`
CREATE TABLE calculation_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
calc_id INTEGER NOT NULL REFERENCES calculations(id) ON DELETE CASCADE,
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT NULL,
UNIQUE(calc_id, shared_with)
)
`);
}
// ── Code-Schnipsel ────────────────────────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='snippets'").get()) {
db.exec(`
CREATE TABLE snippets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
code TEXT NOT NULL DEFAULT '',
language TEXT NOT NULL DEFAULT 'text',
description TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT NULL,
updated_at DATETIME DEFAULT NULL
);
CREATE TABLE snippet_tags (
snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (snippet_id, tag)
);
CREATE TABLE snippet_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
code TEXT NOT NULL,
language TEXT NOT NULL DEFAULT 'text',
saved_at DATETIME DEFAULT NULL
);
CREATE TABLE snippet_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT NULL,
UNIQUE(snippet_id, shared_with)
)
`);
}
// ── Link-Liste Ordner + Erweiterungen ─────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list_folders'").get()) {
db.exec(`
CREATE TABLE link_list_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '📁',
sort_order INTEGER DEFAULT 0,
in_quickaccess INTEGER DEFAULT 0,
created_at DATETIME DEFAULT NULL
)
`);
}
// link_list Spalten nachrüsten
{
const cols = db.pragma('table_info(link_list)').map(c => c.name);
if (!cols.includes('folder_id')) db.exec('ALTER TABLE link_list ADD COLUMN folder_id INTEGER DEFAULT NULL');
if (!cols.includes('sort_order')) db.exec('ALTER TABLE link_list ADD COLUMN sort_order INTEGER DEFAULT 0');
if (!cols.includes('in_quickaccess')) db.exec('ALTER TABLE link_list ADD COLUMN in_quickaccess INTEGER DEFAULT 0');
}
// ── QR-Codes ──────────────────────────────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='qr_codes'").get()) {
db.exec(`
CREATE TABLE qr_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
label TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 256,
fg_color TEXT NOT NULL DEFAULT '#000000',
bg_color TEXT NOT NULL DEFAULT '#ffffff',
margin INTEGER NOT NULL DEFAULT 4,
dot_style TEXT NOT NULL DEFAULT 'square',
corner_style TEXT NOT NULL DEFAULT 'square',
caption TEXT NOT NULL DEFAULT '',
caption_pos TEXT NOT NULL DEFAULT 'bottom',
caption_color TEXT NOT NULL DEFAULT '#000000',
caption_size INTEGER NOT NULL DEFAULT 14,
created_at DATETIME DEFAULT NULL
)
`);
}
// ── Link-Folder Shares ────────────────────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_folder_shares'").get()) {
db.exec(`
CREATE TABLE link_folder_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
folder_id INTEGER NOT NULL REFERENCES link_list_folders(id) ON DELETE CASCADE,
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT NULL,
UNIQUE(folder_id, shared_with)
)
`);
}
// ── Kalender-Event-Cache für Suche ────────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='calendar_event_cache'").get()) {
db.exec(`
CREATE TABLE calendar_event_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feed_id INTEGER NOT NULL REFERENCES calendar_feeds(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
summary TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
location TEXT NOT NULL DEFAULT '',
start_dt TEXT,
end_dt TEXT,
synced_at DATETIME DEFAULT NULL
)
`);
}
// short_url auf upload_shares
{
const cols = db.pragma('table_info(upload_shares)').map(c => c.name);
if (!cols.includes('short_url'))
db.exec('ALTER TABLE upload_shares ADD COLUMN short_url TEXT DEFAULT NULL');
}
// ── Movie-Favoriten ───────────────────────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='movie_favorites'").get()) {
db.exec(`
CREATE TABLE movie_favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tmdb_id INTEGER NOT NULL,
title TEXT NOT NULL DEFAULT '',
poster_path TEXT NOT NULL DEFAULT '',
release_date_de TEXT NOT NULL DEFAULT '',
added_at DATETIME DEFAULT NULL,
UNIQUE(user_id, tmdb_id)
)
`);
}
// movie_favorites neue Spalten (Migration)
{
const cols = db.pragma('table_info(movie_favorites)').map(c => c.name);
if (!cols.includes('release_date'))
db.exec("ALTER TABLE movie_favorites ADD COLUMN release_date TEXT NOT NULL DEFAULT ''");
if (!cols.includes('genres'))
db.exec("ALTER TABLE movie_favorites ADD COLUMN genres TEXT NOT NULL DEFAULT '[]'");
if (!cols.includes('fsk'))
db.exec("ALTER TABLE movie_favorites ADD COLUMN fsk TEXT NOT NULL DEFAULT ''");
if (!cols.includes('acknowledged'))
db.exec("ALTER TABLE movie_favorites ADD COLUMN acknowledged INTEGER NOT NULL DEFAULT 0");
if (!cols.includes('acknowledged_at'))
db.exec("ALTER TABLE movie_favorites ADD COLUMN acknowledged_at DATETIME DEFAULT NULL");
if (!cols.includes('user_notified'))
db.exec("ALTER TABLE movie_favorites ADD COLUMN user_notified INTEGER NOT NULL DEFAULT 0");
if (!cols.includes('media_type'))
db.exec("ALTER TABLE movie_favorites ADD COLUMN media_type TEXT NOT NULL DEFAULT 'movie'");
if (!cols.includes('admin_seen'))
db.exec("ALTER TABLE movie_favorites ADD COLUMN admin_seen INTEGER NOT NULL DEFAULT 0");
}
// xREL Badge Cache (persistent, überlebt Container-Restarts)
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='xrel_cache'").get()) {
db.exec(`
CREATE TABLE xrel_cache (
cache_key TEXT PRIMARY KEY,
value TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
`);
}
// TMDb-Token Default
if (!db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get())
db.prepare("INSERT INTO admin_settings (key,value) VALUES ('tmdb_token','')").run();
// Öffentliche Datei/Ordner-Freigabe Links
// CREATE TABLE IF NOT EXISTS sicher bei jedem Start
db.exec(`
CREATE TABLE IF NOT EXISTS public_file_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
label TEXT,
password_hash TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
download_count INTEGER NOT NULL DEFAULT 0
)
`);
// 3D-Druck Ausgaben (Haushaltsbuch)
db.exec(`
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'Sonstiges',
description TEXT NOT NULL,
amount REAL NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// MakerWorld: Empfangs-Token pro Nutzer für den Bookmarklet-Ansatz (kein
// Login/Cookie-Handling mehr nötig — der Nutzer schickt seine eigenen, schon
// im Browser geladenen Daten per Klick auf ein Lesezeichen an DickenDock)
db.exec(`
CREATE TABLE IF NOT EXISTS makerworld_ingest_tokens (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Zuletzt per Bookmarklet empfangene Statistik — persistiert, damit ein
// Container-Neustart die zuletzt bekannten Zahlen nicht verwirft
db.exec(`
CREATE TABLE IF NOT EXISTS makerworld_stats (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data_json TEXT NOT NULL,
fetched_at INTEGER NOT NULL
)
`);
// Log aller verschickten Pushover-Nachrichten (für Admin-Übersicht "Logs")
db.exec(`
CREATE TABLE IF NOT EXISTS push_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
title TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT '',
success INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// KöPi: Whitelist der exakt gewünschten Filialen (echte marktguru Store-IDs,
// aufgelöst aus bestätigten Prospekt-Links — siehe koepi/routes.js)
db.exec(`
CREATE TABLE IF NOT EXISTS koepi_local_stores (
store_id INTEGER PRIMARY KEY,
retailer TEXT NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
resolved_at DATETIME
)
`);
// Besuche öffentlicher, loginfreier Links (KöPi-Teilen-Link, ggf. künftig
// weitere) — für die Admin-Log-Übersicht
db.exec(`
CREATE TABLE IF NOT EXISTS public_access_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
link_type TEXT NOT NULL DEFAULT '',
path TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
location TEXT NOT NULL DEFAULT '',
created_at DATETIME
)
`);
{
const cols = db.pragma('table_info(public_access_log)').map(c => c.name);
if (!cols.includes('device'))
db.exec("ALTER TABLE public_access_log ADD COLUMN device TEXT NOT NULL DEFAULT ''");
if (!cols.includes('link_name'))
db.exec("ALTER TABLE public_access_log ADD COLUMN link_name TEXT NOT NULL DEFAULT ''");
}
// KöPi: benannte, öffentliche Teilen-Links (mehrere gleichzeitig möglich,
// jeweils mit eigenem Namen wie "Sina") statt nur einem einzigen globalen Link
db.exec(`
CREATE TABLE IF NOT EXISTS koepi_share_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
name TEXT NOT NULL DEFAULT '',
created_at DATETIME
)
`);
// Migration: den bisherigen einzelnen Link (war für Sina gedacht) einmalig in
// die neue Tabelle übernehmen, statt ihn ungültig zu machen
{
const oldToken = db.prepare("SELECT value FROM admin_settings WHERE key='koepi_share_token'").get()?.value;
if (oldToken) {
const already = db.prepare('SELECT 1 FROM koepi_share_links WHERE token=?').get(oldToken);
if (!already) {
db.prepare(`
INSERT INTO koepi_share_links (token, name, created_at)
VALUES (?, 'Sina', datetime('now','localtime'))
`).run(oldToken);
}
db.prepare("DELETE FROM admin_settings WHERE key='koepi_share_token'").run();
}
}
module.exports = db;

View File

@@ -1,362 +0,0 @@
require('./consoleLog').installConsoleCapture();
const express = require('express');
const path = require('path');
const fs = require('fs');
require('./db');
const { logPush } = require('./pushLog');
const app = express();
// Hinter Nginx Proxy Manager (SSL-Terminierung) — sonst meldet req.protocol
// immer "http", auch wenn der Nutzer über https zugreift (z.B. wichtig für
// das MakerWorld-Bookmarklet: sonst mixed-content-blockiert der Browser den
// fetch()-Call stillschweigend, weil die generierte Origin auf http:// zeigt)
app.set('trust proxy', true);
app.use(express.json({ limit: '10mb' }));
// ── Aktivitäts-Tracking: nur bei echten Aktionen (POST/PUT/DELETE) ─────────────
app.use((req, res, next) => {
if (['POST','PUT','DELETE'].includes(req.method)) {
const auth = req.headers.authorization;
if (auth?.startsWith('Bearer ')) {
try {
const jwt = require('jsonwebtoken');
const p = jwt.verify(auth.slice(7), process.env.JWT_SECRET || 'dev-secret');
if (p?.id) {
// Prüfen ob User vorher >15 Min inaktiv war → "gerade online gegangen"
const user = db.prepare('SELECT last_active_at, role FROM users WHERE id=?').get(p.id);
const wasInactive = !user?.last_active_at ||
(Date.now() - new Date(user.last_active_at).getTime()) > 15 * 60 * 1000;
db.prepare("UPDATE users SET last_active_at=datetime('now','localtime') WHERE id=?").run(p.id);
// Pushover an alle Admins (außer wenn User selbst Admin ist)
if (wasInactive && user?.role !== 'admin') {
const username = db.prepare('SELECT username FROM users WHERE id=?').get(p.id)?.username || 'Jemand';
const admins = db.prepare(`
SELECT p.user_id, p.user_key, p.app_token FROM pushover_settings p
JOIN users u ON u.id = p.user_id
WHERE u.role = 'admin' AND p.user_key IS NOT NULL AND p.app_token IS NOT NULL
`).all();
for (const admin of admins) {
const title = '👤 DickenDock';
const message = `${username} ist gerade online gegangen.`;
fetch('https://api.pushover.net/1/messages.json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: admin.app_token,
user: admin.user_key,
title,
message,
priority: -1,
}),
}).catch(() => {});
logPush({ userId: admin.user_id, title, message, priority: -1, source: 'presence' });
}
}
}
} catch {}
}
}
next();
});
// ── Security Headers ──────────────────────────────────────────────────────────
app.use((_req, res, next) => {
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"script-src 'self'",
// unsafe-inline nötig für React inline-styles + @import Google Fonts
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
// iCal-Feeds werden serverseitig geprosxt → nur 'self' nötig
"connect-src 'self' https://web.archive.org wss://dickendock.sermer.org ws://localhost:4000",
// data: für Avatare (base64), google.com + gstatic.com für Favicons im QuickLinks-Widget
"img-src 'self' data: https://www.google.com https://*.gstatic.com https://image.tmdb.org",
"worker-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-src https://archive.ph https://archive.is https://archive.today",
].join('; '));
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
next();
});
// ── API ───────────────────────────────────────────────────────────────────────
app.use('/api/auth', require('./routes/auth'));
app.use('/api/admin', require('./routes/admin'));
app.use('/api/calendar', require('./routes/calendar'));
app.use('/api/dashboard', require('./routes/dashboard'));
app.use('/api/system', require('./routes/system'));
// Öffentliche Datei-Share Verwaltung (vor Auto-Loader damit /:id nicht matcht)
app.use('/api/tools/dateien/public-shares', require('./tools/dateien/public-share'));
// Tool-Routen automatisch laden
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}`);
}
});
app.use('/api/tools/snippets', require('./tools/snippets/routes'));
app.use('/api/tools/qrcodes', require('./tools/qrcodes/routes'));
app.use('/api/upload-shares', require('./tools/dateien/upload-share'));
app.use('/api/public/file-share', require('./tools/dateien/public-share-public'));
app.use('/api/search', require('./routes/search'));
// Öffentliche Upload-Seite: React-App ausliefern App erkennt /u/ Pfad selbst
app.get('/u/:token', (_req, res) => {
res.setHeader('Cache-Control', 'no-store, no-cache');
res.sendFile(require('path').join(PUBLIC, 'index.html'));
});
// Öffentliche Datei-Share-Seite: React-App ausliefern App erkennt /s/ Pfad selbst
app.get('/s/:token', (_req, res) => {
res.setHeader('Cache-Control', 'no-store, no-cache');
res.sendFile(require('path').join(PUBLIC, 'index.html'));
});
const PUBLIC = path.join(__dirname, '../public');
app.get('/manifest.json', (_req, res) => {
res.setHeader('Content-Type', 'application/manifest+json');
res.setHeader('Cache-Control', 'no-cache');
res.sendFile(path.join(PUBLIC, 'manifest.json'));
});
app.get('/sw.js', (_req, res) => {
res.setHeader('Content-Type', 'application/javascript');
res.setHeader('Service-Worker-Allowed', '/');
res.setHeader('Cache-Control', 'no-cache');
res.sendFile(path.join(PUBLIC, 'sw.js'));
});
app.use(express.static(PUBLIC, {
setHeaders: (res, filePath) => {
// JS/CSS Assets haben Hash im Namen → lang cachen
if (filePath.match(/\.(js|css)$/) && !filePath.includes('sw.js')) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
}
}));
// index.html nie cachen
app.get('*', (_req, res) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.sendFile(path.join(PUBLIC, 'index.html'));
});
// Build-Zeit Endpoint Frontend prüft ob es eine neue Version gibt
app.get('/api/build-time', (_req, res) => {
res.setHeader('Cache-Control', 'no-store');
try {
const ver = require('fs').readFileSync(path.join(__dirname, '../version.txt'), 'utf8').trim();
res.json({ buildTime: ver });
} catch { res.json({ buildTime: 'unknown' }); }
});
// ── HTTP + WebSocket Server ───────────────────────────────────────────────────
const http = require('http');
const { WebSocketServer } = require('ws');
const jwt = require('jsonwebtoken');
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws/whiteboard' });
// Raum-Map: whiteboard_id → Set<ws>
const rooms = new Map();
wss.on('connection', (ws, req) => {
// Token aus Query-String auslesen: /ws/whiteboard?token=...&id=...
const params = new URL(req.url, 'http://localhost').searchParams;
const token = params.get('token');
const wbId = parseInt(params.get('id'));
let userId = null;
try {
const p = jwt.verify(token, process.env.JWT_SECRET || 'dev-secret');
userId = p.id;
} catch {
ws.close(1008, 'Unauthorized');
return;
}
// Zugriff prüfen
const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(wbId);
if (!wb) { ws.close(1008, 'Not found'); return; }
const isOwner = wb.owner_id === userId;
const perm = db.prepare('SELECT role FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?').get(wbId, userId);
if (!isOwner && !perm) { ws.close(1008, 'Forbidden'); return; }
const role = isOwner ? 'owner' : perm.role;
ws.userId = userId;
ws.wbId = wbId;
ws.role = role;
ws.username = db.prepare('SELECT username FROM users WHERE id=?').get(userId)?.username || 'Unbekannt';
// Raum beitreten
if (!rooms.has(wbId)) rooms.set(wbId, new Set());
rooms.get(wbId).add(ws);
// Anderen im Raum mitteilen wer jointe
broadcast(wbId, { type: 'user_join', userId, username: ws.username, role }, ws);
// Aktive User-Liste an neuen Client schicken
const activeUsers = [...rooms.get(wbId)]
.filter(c => c !== ws && c.readyState === 1)
.map(c => ({ userId: c.userId, username: c.username, role: c.role }));
ws.send(JSON.stringify({ type: 'active_users', users: activeUsers }));
ws.on('message', raw => {
let msg;
try { msg = JSON.parse(raw); } catch { return; }
switch (msg.type) {
case 'cursor':
// Cursor-Position live broadcasten (nur wenn nicht view-only)
broadcast(wbId, { type:'cursor', userId, username:ws.username, x:msg.x, y:msg.y }, ws);
break;
case 'elements':
// Canvas-Änderungen von Edit-Berechtigten an alle broadcasten
if (role === 'view') break;
broadcast(wbId, { type:'elements', userId, elements: msg.elements }, ws);
break;
case 'ping':
ws.send(JSON.stringify({ type: 'pong' }));
break;
}
});
ws.on('close', () => {
const room = rooms.get(wbId);
if (room) {
room.delete(ws);
if (room.size === 0) rooms.delete(wbId);
else broadcast(wbId, { type: 'user_leave', userId, username: ws.username });
}
});
ws.on('error', () => ws.terminate());
});
function broadcast(wbId, msg, exclude = null) {
const room = rooms.get(wbId);
if (!room) return;
const data = JSON.stringify(msg);
for (const client of room) {
if (client !== exclude && client.readyState === 1) {
client.send(data);
}
}
}
server.listen(4000, () => console.log('🚀 Dicken Dock läuft auf Port 4000'));
// ── Push-Zeitplaner Hintergrund-Job ──────────────────────────────────────────
const db = require('./db');
const koepiRoutes = require('./tools/koepi/routes');
const mediaRoutes = require('./tools/media/routes');
const mqttClient = require('./mqtt');
mqttClient.connectMqtt();
// KöPi: Filial-Whitelist bei jedem Serverstart automatisch neu auflösen
// (nicht erst nach der 6-Tage-Frist) — läuft asynchron im Hintergrund, damit
// ein Deploy nicht auf den marktguru-Abruf warten muss
koepiRoutes.resolveLocalStores()
.then(n => console.log(`🍺 KöPi: Filial-Whitelist beim Start aufgelöst (${n} Filiale(n))`))
.catch(e => console.error('🍺 KöPi: Filial-Whitelist-Auflösung beim Start fehlgeschlagen:', e.message));
// HA-Button "KöPi Check jetzt" ist ein reiner Anzeige-Refresh — sendet nie
// Pushover. Benachrichtigungen bleiben exklusiv dem 06:00-Cron vorbehalten.
mqttClient.setKoepiCheckHandler(() => koepiRoutes.refreshOffersOnly());
// HA-Buttons "Media Quittieren" / "Media Alle quittieren" — danach sofort den
// Media-Anfragen-Sensor + die dynamischen Buttons in HA aktualisieren
mqttClient.setMediaAckHandler((id) => {
mediaRoutes.ackFavorite(id);
mqttClient.publishMediaAnfragen();
});
mqttClient.setMediaAckAllHandler(() => {
mediaRoutes.ackAllFavorites();
mqttClient.publishMediaAnfragen();
});
async function sendPushoverMsg(userKey, appToken, message, opts = {}, userId = null) {
const title = 'DickenDock Erinnerung';
let priority = 0;
try {
const params = { token: appToken, user: userKey, title, message };
if (opts.retry && opts.expire) {
params.priority = 2;
priority = 2;
params.retry = opts.retry;
params.expire = opts.expire;
}
await fetch('https://api.pushover.net/1/messages.json', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params),
});
logPush({ userId, title, message, priority, source: 'dashboard-erinnerung' });
} catch(e) {
console.error('Pushover Fehler:', e.message);
logPush({ userId, title, message, priority, source: 'dashboard-erinnerung', success: false });
}
}
// Beim Start auf die nächste volle Minute synchronisieren
const msToNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds();
setTimeout(() => {
runScheduler();
setInterval(runScheduler, 60 * 1000);
}, msToNextMinute);
async function runScheduler() {
mqttClient.publishPresence();
mqttClient.publishMediaAnfragen();
mqttClient.publishDruckStatistik();
mqttClient.publishPushErinnerungen();
mqttClient.publishKanban();
try {
const now = db.prepare("SELECT datetime('now','localtime') as t").get().t;
const due = db.prepare(`
SELECT s.*, p.user_key, p.app_token, p.retry, p.expire
FROM push_schedules s
JOIN pushover_settings p ON p.user_id = s.user_id
WHERE s.sent = 0 AND s.scheduled_at <= ?
`).all(now);
for (const row of due) {
await sendPushoverMsg(row.user_key, row.app_token, row.message,
{ retry: row.retry, expire: row.expire }, row.user_id);
db.prepare('UPDATE push_schedules SET sent=1 WHERE id=?').run(row.id);
console.log(`✓ Push gesendet an user ${row.user_id}: "${row.message}" (fällig: ${row.scheduled_at})`);
}
} catch(e) { console.error('Push-Job Fehler:', e.message); }
// KöPi Tages-Check: täglich um 06:00 (localtime), Duplikatschutz über admin_settings-Datum
try {
const now = db.prepare("SELECT datetime('now','localtime') as t").get().t;
const date = now.slice(0, 10);
const time = now.slice(11, 16);
if (time === '06:00') {
const lastRunDate = db.prepare("SELECT value FROM admin_settings WHERE key='koepi_cron_last_date'").get()?.value;
if (lastRunDate !== date) {
db.prepare("INSERT OR REPLACE INTO admin_settings (key, value) VALUES ('koepi_cron_last_date', ?)").run(date);
await koepiRoutes.runDailyCheck();
}
}
} catch(e) { console.error('KöPi-Cron Fehler:', e.message); }
}
console.log(`⏰ Push-Scheduler aktiv nächste Prüfung in ${Math.round(msToNextMinute/1000)}s`);

View File

@@ -1,16 +0,0 @@
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

@@ -1,620 +0,0 @@
// ── Home Assistant MQTT Integration ──────────────────────────────────────────
// Verbindet sich zum Mosquitto-Broker (HA Add-on) und meldet DickenDock-Entitäten
// per HA MQTT Discovery an. Kein Custom Component in HA nötig — Entitäten
// erscheinen automatisch unter "Einstellungen → Geräte & Dienste → MQTT".
//
// Namenskonvention: alle Entity-Namen beginnen mit ihrem Bereich ("KöPi", "Media",
// "3D", "System"), damit sie in Home Assistant alphabetisch sortiert gruppiert
// untereinander stehen.
//
// Aktiv nur, wenn MQTT_HOST in der .env gesetzt ist. Ohne Konfiguration bleibt
// der Rest der App unverändert lauffähig (alle publish-Funktionen sind No-Ops).
const mqtt = require('mqtt');
const db = require('./db');
const BASE_TOPIC = process.env.MQTT_BASE_TOPIC || 'dickendock';
const AVAILABILITY_TOPIC = `${BASE_TOPIC}/status`;
const KOEPI_CHECK_CMD_TOPIC = `${BASE_TOPIC}/koepi/check/set`;
const MEDIA_ACK_ALL_CMD_TOPIC = `${BASE_TOPIC}/media/ack_all/set`;
const MEDIA_ACK_TOPIC_PREFIX = `${BASE_TOPIC}/media/ack/`; // + {id}/set
const MEDIA_ACK_SUBSCRIBE = `${BASE_TOPIC}/media/ack/+/set`;
const DEVICE = {
identifiers: ['dickendock'],
name: 'DickenDock',
manufacturer: 'Flo',
model: 'DockStation',
};
let client = null;
let koepiCheckHandler = null; // wird von index.js gesetzt, um Zirkel-Requires zu vermeiden
let mediaAckHandler = null; // (id) => void — einzelnen Favoriten quittieren
let mediaAckAllHandler = null; // () => void — alle Favoriten quittieren
// Zuletzt published dynamische Media-Quittier-Buttons (zum sauberen Retracten
// wenn ein Favorit quittiert/gelöscht wurde und der Button verschwinden soll)
let publishedMediaAckIds = new Set();
function connectMqtt() {
if (!process.env.MQTT_HOST) {
console.log('MQTT: MQTT_HOST nicht gesetzt Home-Assistant-Integration deaktiviert.');
return;
}
client = mqtt.connect({
host: process.env.MQTT_HOST,
port: parseInt(process.env.MQTT_PORT || '1883', 10),
username: process.env.MQTT_USERNAME,
password: process.env.MQTT_PASSWORD,
will: { topic: AVAILABILITY_TOPIC, payload: 'offline', retain: true },
reconnectPeriod: 5000,
});
client.on('connect', () => {
console.log(`MQTT verbunden: ${process.env.MQTT_HOST}:${process.env.MQTT_PORT || 1883}`);
client.publish(AVAILABILITY_TOPIC, 'online', { retain: true });
publishDiscovery();
client.subscribe(KOEPI_CHECK_CMD_TOPIC);
client.subscribe(MEDIA_ACK_ALL_CMD_TOPIC);
client.subscribe(MEDIA_ACK_SUBSCRIBE);
});
client.on('message', async (topic) => {
try {
if (topic === KOEPI_CHECK_CMD_TOPIC && typeof koepiCheckHandler === 'function') {
await koepiCheckHandler();
} else if (topic === MEDIA_ACK_ALL_CMD_TOPIC && typeof mediaAckAllHandler === 'function') {
await mediaAckAllHandler();
} else if (topic.startsWith(MEDIA_ACK_TOPIC_PREFIX) && typeof mediaAckHandler === 'function') {
const id = topic.slice(MEDIA_ACK_TOPIC_PREFIX.length).replace(/\/set$/, '');
if (id) await mediaAckHandler(id);
}
} catch (e) {
console.error('MQTT Command-Fehler:', e.message);
}
});
client.on('error', (e) => console.error('MQTT Fehler:', e.message));
}
function publishDiscovery() {
if (!client) return;
const entities = [
// ── KöPi ──────────────────────────────────────────────────────────────
['sensor', 'dickendock_koepi_angebot', {
name: 'KöPi Angebote',
unique_id: 'dickendock_koepi_bestes_angebot',
state_topic: `${BASE_TOPIC}/koepi/state`,
json_attributes_topic: `${BASE_TOPIC}/koepi/attributes`,
icon: 'mdi:beer',
availability_topic: AVAILABILITY_TOPIC,
device: DEVICE,
}],
['binary_sensor', 'dickendock_koepi_changed', {
name: 'KöPi Angebot geändert',
unique_id: 'dickendock_koepi_changed',
state_topic: `${BASE_TOPIC}/koepi/changed`,
payload_on: 'ON',
payload_off: 'OFF',
icon: 'mdi:bell-alert',
availability_topic: AVAILABILITY_TOPIC,
device: DEVICE,
}],
['button', 'dickendock_koepi_check', {
name: 'KöPi Check jetzt',
unique_id: 'dickendock_koepi_check',
command_topic: KOEPI_CHECK_CMD_TOPIC,
icon: 'mdi:refresh',
availability_topic: AVAILABILITY_TOPIC,
device: DEVICE,
}],
['sensor', 'dickendock_koepi_start', {
name: 'KöPi Angebot Start',
unique_id: 'dickendock_koepi_start',
state_topic: `${BASE_TOPIC}/koepi/start`,
device_class: 'date',
icon: 'mdi:calendar-start',
availability_topic: AVAILABILITY_TOPIC,
device: DEVICE,
}],
['sensor', 'dickendock_koepi_ende', {
name: 'KöPi Angebot Ende',
unique_id: 'dickendock_koepi_ende',
state_topic: `${BASE_TOPIC}/koepi/ende`,
device_class: 'date',
icon: 'mdi:calendar-end',
availability_topic: AVAILABILITY_TOPIC,
device: DEVICE,
}],
// ── Media ─────────────────────────────────────────────────────────────
['sensor', 'dickendock_media_anfragen', {
name: 'Media Anfragen offen',
unique_id: 'dickendock_media_anfragen',
state_topic: `${BASE_TOPIC}/media/anfragen`,
json_attributes_topic: `${BASE_TOPIC}/media/anfragen_attributes`,
icon: 'mdi:movie-open-star',
unit_of_measurement: 'Anfragen',
state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC,
device: DEVICE,
}],
['button', 'dickendock_media_ack_all', {
name: 'Media Alle quittieren',
unique_id: 'dickendock_media_ack_all',
command_topic: MEDIA_ACK_ALL_CMD_TOPIC,
icon: 'mdi:check-all',
availability_topic: AVAILABILITY_TOPIC,
device: DEVICE,
}],
// ── 3D ────────────────────────────────────────────────────────────────
['sensor', 'dickendock_3d_umsatz_monat', {
name: '3D Umsatz Monat',
unique_id: 'dickendock_3ddruck_umsatz_monat',
state_topic: `${BASE_TOPIC}/druck/umsatz_monat`,
icon: 'mdi:cash-multiple',
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
['sensor', 'dickendock_3d_umsatz_gesamt', {
name: '3D Umsatz Gesamt',
unique_id: 'dickendock_3d_umsatz_gesamt',
state_topic: `${BASE_TOPIC}/druck/umsatz_gesamt`,
icon: 'mdi:cash-multiple',
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
['sensor', 'dickendock_3d_grundkosten_monat', {
name: '3D Grundkosten Monat',
unique_id: 'dickendock_3d_grundkosten_monat',
state_topic: `${BASE_TOPIC}/druck/grundkosten_monat`,
icon: 'mdi:currency-eur-off',
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
['sensor', 'dickendock_3d_grundkosten_gesamt', {
name: '3D Grundkosten Gesamt',
unique_id: 'dickendock_3d_grundkosten_gesamt',
state_topic: `${BASE_TOPIC}/druck/grundkosten_gesamt`,
icon: 'mdi:currency-eur-off',
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
['sensor', 'dickendock_3d_rohgewinn_monat', {
name: '3D Rohgewinn Monat',
unique_id: 'dickendock_3d_rohgewinn_monat',
state_topic: `${BASE_TOPIC}/druck/rohgewinn_monat`,
icon: 'mdi:chart-line',
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
['sensor', 'dickendock_3d_rohgewinn_gesamt', {
name: '3D Rohgewinn Gesamt',
unique_id: 'dickendock_3d_rohgewinn_gesamt',
state_topic: `${BASE_TOPIC}/druck/rohgewinn_gesamt`,
icon: 'mdi:chart-line',
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
['sensor', 'dickendock_3d_ausgaben_monat', {
name: '3D Ausgaben Monat',
unique_id: 'dickendock_3d_ausgaben_monat',
state_topic: `${BASE_TOPIC}/druck/ausgaben_monat`,
icon: 'mdi:receipt-text-minus',
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
['sensor', 'dickendock_3d_ausgaben_gesamt', {
name: '3D Ausgaben Gesamt',
unique_id: 'dickendock_3d_ausgaben_gesamt',
state_topic: `${BASE_TOPIC}/druck/ausgaben_gesamt`,
icon: 'mdi:receipt-text-minus',
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
['sensor', 'dickendock_3d_nettogewinn_monat', {
name: '3D Nettogewinn Monat',
unique_id: 'dickendock_3d_nettogewinn_monat',
state_topic: `${BASE_TOPIC}/druck/nettogewinn_monat`,
icon: 'mdi:cash-check',
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
['sensor', 'dickendock_3d_nettogewinn_gesamt', {
name: '3D Nettogewinn Gesamt',
unique_id: 'dickendock_3d_nettogewinn_gesamt',
state_topic: `${BASE_TOPIC}/druck/nettogewinn_gesamt`,
icon: 'mdi:cash-check',
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
['sensor', 'dickendock_3d_bestellungen_offen', {
name: '3D Bestellungen offen',
unique_id: 'dickendock_bestellungen_offen',
state_topic: `${BASE_TOPIC}/druck/bestellungen_offen`,
json_attributes_topic: `${BASE_TOPIC}/druck/bestellungen_offen_attributes`,
icon: 'mdi:cube-send',
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
['sensor', 'dickendock_3d_bestellungen_warteliste', {
name: '3D Bestellungen Warteliste',
unique_id: 'dickendock_3d_bestellungen_warteliste',
state_topic: `${BASE_TOPIC}/druck/bestellungen_warteliste`,
icon: 'mdi:timer-sand',
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
['sensor', 'dickendock_3d_bestellungen_in_arbeit', {
name: '3D Bestellungen In Arbeit',
unique_id: 'dickendock_3d_bestellungen_in_arbeit',
state_topic: `${BASE_TOPIC}/druck/bestellungen_in_arbeit`,
icon: 'mdi:printer-3d-nozzle',
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
['sensor', 'dickendock_3d_bestellungen_fertig', {
name: '3D Bestellungen Fertig',
unique_id: 'dickendock_3d_bestellungen_fertig',
state_topic: `${BASE_TOPIC}/druck/bestellungen_fertig`,
icon: 'mdi:check-circle-outline',
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
['sensor', 'dickendock_3d_bestellungen_bezahlt', {
name: '3D Bestellungen Bezahlt',
unique_id: 'dickendock_3d_bestellungen_bezahlt',
state_topic: `${BASE_TOPIC}/druck/bestellungen_bezahlt`,
icon: 'mdi:cash-check',
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
['sensor', 'dickendock_3d_bestellungen_abgeschlossen', {
name: '3D Bestellungen Abgeschlossen',
unique_id: 'dickendock_3d_bestellungen_abgeschlossen',
state_topic: `${BASE_TOPIC}/druck/bestellungen_abgeschlossen`,
icon: 'mdi:archive-check-outline',
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
['sensor', 'dickendock_3d_bestellungen_gesamt', {
name: '3D Bestellungen Gesamt',
unique_id: 'dickendock_3d_bestellungen_gesamt',
state_topic: `${BASE_TOPIC}/druck/bestellungen_gesamt`,
icon: 'mdi:cube-outline',
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
// ── Dashboard ─────────────────────────────────────────────────────────
['sensor', 'dickendock_dashboard_push_erinnerungen', {
name: 'Dashboard Push-Erinnerungen offen',
unique_id: 'dickendock_dashboard_push_erinnerungen',
state_topic: `${BASE_TOPIC}/dashboard/push_erinnerungen`,
json_attributes_topic: `${BASE_TOPIC}/dashboard/push_erinnerungen_attributes`,
icon: 'mdi:bell-ring-outline',
unit_of_measurement: 'Erinnerungen', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
// ── Kanban ────────────────────────────────────────────────────────────
['sensor', 'dickendock_kanban_karten', {
name: 'Kanban Karten offen',
unique_id: 'dickendock_kanban_karten',
state_topic: `${BASE_TOPIC}/kanban/karten`,
json_attributes_topic: `${BASE_TOPIC}/kanban/karten_attributes`,
icon: 'mdi:view-column-outline',
unit_of_measurement: 'Karten', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
// ── System ────────────────────────────────────────────────────────────
['sensor', 'dickendock_system_online', {
name: 'System Nutzer online',
unique_id: 'dickendock_nutzer_online',
state_topic: `${BASE_TOPIC}/presence/state`,
json_attributes_topic: `${BASE_TOPIC}/presence/attributes`,
icon: 'mdi:account-multiple',
unit_of_measurement: 'Nutzer',
state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC,
device: DEVICE,
}],
];
for (const [component, objectId, payload] of entities) {
client.publish(`homeassistant/${component}/${objectId}/config`, JSON.stringify(payload), { retain: true });
}
}
function round2(n) { return Math.round((n || 0) * 100) / 100; }
function parsePrice(str) {
if (!str) return Infinity;
const n = parseFloat(String(str).replace(/[^0-9,.-]/g, '').replace(',', '.'));
return Number.isNaN(n) ? Infinity : n;
}
// Parst ein deutsches Datum "10.07." oder "10.07.2026" aus einem Textschnipsel.
// Fehlt das Jahr, wird das aktuelle Jahr angenommen (mit Jahreswechsel-Korrektur
// in beide Richtungen, z.B. bei Angeboten die über Silvester laufen).
function parseGermanDate(str, ref = new Date()) {
const m = String(str).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})?/);
if (!m) return null;
const day = parseInt(m[1], 10);
const month = parseInt(m[2], 10) - 1;
let year = m[3] ? parseInt(m[3], 10) : ref.getFullYear();
const d = new Date(year, month, day);
if (!m[3]) {
const diffMonths = (d.getFullYear() - ref.getFullYear()) * 12 + (d.getMonth() - ref.getMonth());
if (diffMonths < -3) d.setFullYear(year + 1); // z.B. Ref=Dez, Datum=Jan → nächstes Jahr gemeint
else if (diffMonths > 3) d.setFullYear(year - 1); // z.B. Ref=Jan, Datum=Dez → vergangenes Jahr gemeint
}
return d;
}
// Ermittelt aus allen Angeboten den frühesten Start- und spätesten End-Termin
// (marktguru-Angebote für ein Produkt laufen i.d.R. im selben Wochenzeitraum)
function extractDateRange(offers) {
let start = null, end = null;
for (const o of offers) {
const text = o.dateRange || '';
const found = [...text.matchAll(/\d{1,2}\.\d{1,2}\.(?:\d{4})?/g)].map(m => m[0]);
if (found.length >= 2) {
const d1 = parseGermanDate(found[0]);
const d2 = parseGermanDate(found[1]);
if (d1 && (!start || d1 < start)) start = d1;
if (d2 && (!end || d2 > end)) end = d2;
} else if (found.length === 1) {
const d1 = parseGermanDate(found[0]);
if (d1 && (!end || d1 > end)) end = d1;
}
}
return { start, end };
}
function toIsoDate(d) {
if (!d) return null;
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
// Wird von koepi/routes.js nach jedem Check (Cron oder manueller Test) aufgerufen
function publishKoepiState({ offers, changed }) {
if (!client?.connected) return;
const list = [...(offers || [])].sort((a, b) => parsePrice(a.price) - parsePrice(b.price));
// State: nur die Anzahl (bleibt immer kurz und stabil) — die eigentlichen
// Angebote stehen vollständig in den Attributen
const state = list.length ? `${list.length} Angebote` : 'keine Angebote';
// Lesbarer Mehrzeiler fürs Dashboard, zusätzlich zur strukturierten Liste
const listeText = list.length
? list.map(o => `${o.retailer}: ${o.price}${o.dateRange ? ' (' + o.dateRange + ')' : ''}`).join('\n')
: 'keine Angebote';
client.publish(`${BASE_TOPIC}/koepi/state`, state, { retain: true });
client.publish(`${BASE_TOPIC}/koepi/attributes`, JSON.stringify({
angebote: list,
liste_text: listeText,
anzahl: list.length,
letzter_check: new Date().toISOString(),
}), { retain: true });
client.publish(`${BASE_TOPIC}/koepi/changed`, changed ? 'ON' : 'OFF', { retain: true });
const { start, end } = extractDateRange(list);
client.publish(`${BASE_TOPIC}/koepi/start`, toIsoDate(start) || 'unknown', { retain: true });
client.publish(`${BASE_TOPIC}/koepi/ende`, toIsoDate(end) || 'unknown', { retain: true });
}
// Wird jede Minute vom Scheduler in index.js aufgerufen
function publishPresence() {
if (!client?.connected) return;
const alleNutzer = db.prepare(`
SELECT username, last_active_at,
CASE WHEN last_active_at > datetime('now','localtime','-5 minutes') THEN 1 ELSE 0 END AS online
FROM users
ORDER BY last_active_at DESC
`).all();
const online = alleNutzer.filter(u => !!u.online);
client.publish(`${BASE_TOPIC}/presence/state`, String(online.length), { retain: true });
client.publish(`${BASE_TOPIC}/presence/attributes`, JSON.stringify({
online: online.map(u => ({ benutzer: u.username, zuletzt_aktiv: u.last_active_at })),
alle_nutzer: alleNutzer.map(u => ({
benutzer: u.username,
zuletzt_aktiv: u.last_active_at,
online: !!u.online,
})),
}), { retain: true });
}
// Wird jede Minute vom Scheduler UND direkt nach jeder Quittierung aufgerufen.
// Offene (unquittierte) Media-Favoriten, systemweit über alle Nutzer.
// Legt zusätzlich pro offener Anfrage einen eigenen "Quittieren"-Button in HA an
// und entfernt Buttons für Anfragen, die inzwischen quittiert/gelöscht wurden.
function publishMediaAnfragen() {
if (!client?.connected) return;
const open = db.prepare(`
SELECT f.id, f.title, f.added_at, u.username
FROM movie_favorites f JOIN users u ON u.id = f.user_id
WHERE f.acknowledged = 0
ORDER BY f.added_at DESC
`).all();
client.publish(`${BASE_TOPIC}/media/anfragen`, String(open.length), { retain: true });
client.publish(`${BASE_TOPIC}/media/anfragen_attributes`, JSON.stringify({ anfragen: open }), { retain: true });
const currentIds = new Set(open.map(f => String(f.id)));
// Neue Buttons für neu hinzugekommene offene Anfragen anlegen
for (const fav of open) {
const id = String(fav.id);
if (!publishedMediaAckIds.has(id)) {
const label = `Media Quittieren: ${fav.title}${fav.username ? ' (' + fav.username + ')' : ''}`.slice(0, 255);
client.publish(`homeassistant/button/dickendock_media_ack_${id}/config`, JSON.stringify({
name: label,
unique_id: `dickendock_media_ack_${id}`,
command_topic: `${MEDIA_ACK_TOPIC_PREFIX}${id}/set`,
icon: 'mdi:check-circle-outline',
availability_topic: AVAILABILITY_TOPIC,
device: DEVICE,
}), { retain: true });
}
}
// Buttons für inzwischen quittierte/gelöschte Anfragen zurückziehen (leeres retained Payload)
for (const id of publishedMediaAckIds) {
if (!currentIds.has(id)) {
client.publish(`homeassistant/button/dickendock_media_ack_${id}/config`, '', { retain: true });
}
}
publishedMediaAckIds = currentIds;
}
// Umsatz eines einzelnen Auftrags — Festpreis falls gesetzt, sonst Summe der
// Positions-Festpreise (spiegelt die Logik aus statistik/routes.js)
function getOrderRevenue(order) {
if (order.custom_price != null && order.custom_price > 0) return parseFloat(order.custom_price);
const r = db.prepare(`
SELECT COALESCE(SUM(custom_price * stueckzahl), 0) AS s
FROM order_items WHERE order_id=? AND custom_price IS NOT NULL
`).get(order.id);
return r?.s || 0;
}
// Grundkosten (Material/Zeit-Basis) eines Auftrags — spiegelt statistik/routes.js
function getOrderBaseCost(orderId) {
const r = db.prepare(`
SELECT COALESCE(SUM(COALESCE(c.preis_freundschaft, oi.preis_freundschaft, 0) * oi.stueckzahl), 0) AS s
FROM order_items oi
LEFT JOIN calculations c ON c.id = oi.calculation_id
WHERE oi.order_id = ?
`).get(orderId);
return r?.s || 0;
}
// Wird jede Minute vom Scheduler aufgerufen — umfangreiche 3D-Druck-Statistik,
// systemweit über alle Nutzer, jeweils für den laufenden Monat und komplett (all-time)
function publishDruckStatistik() {
if (!client?.connected) return;
const now = new Date();
const pad = n => String(n).padStart(2, '0');
const monatsAnfang = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-01`;
const heute = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const allOrders = db.prepare('SELECT * FROM orders').all();
const paidAll = allOrders.filter(o => !!o.bezahlt);
const paidMonat = paidAll.filter(o => {
const d = (o.bezahlt_am || o.created_at || '').slice(0, 10);
return d >= monatsAnfang && d <= heute;
});
const umsatzGesamt = paidAll.reduce((s, o) => s + getOrderRevenue(o), 0);
const umsatzMonat = paidMonat.reduce((s, o) => s + getOrderRevenue(o), 0);
const kostenGesamt = paidAll.reduce((s, o) => s + getOrderBaseCost(o.id), 0);
const kostenMonat = paidMonat.reduce((s, o) => s + getOrderBaseCost(o.id), 0);
const rohgewinnGesamt = umsatzGesamt - kostenGesamt;
const rohgewinnMonat = umsatzMonat - kostenMonat;
const ausgabenGesamt = db.prepare('SELECT COALESCE(SUM(amount),0) AS s FROM expenses').get().s;
const ausgabenMonat = db.prepare(
'SELECT COALESCE(SUM(amount),0) AS s FROM expenses WHERE date>=? AND date<=?'
).get(monatsAnfang, heute).s;
const nettoGesamt = rohgewinnGesamt - ausgabenGesamt;
const nettoMonat = rohgewinnMonat - ausgabenMonat;
const byStatus = { warteliste: 0, in_arbeit: 0, fertig: 0, bezahlt: 0, abgeschlossen: 0 };
for (const o of allOrders) {
if (o.bezahlt && o.abgeholt) byStatus.abgeschlossen++;
else if (o.bezahlt) byStatus.bezahlt++;
else if (o.status in byStatus) byStatus[o.status]++;
}
const offen = allOrders.length - byStatus.abgeschlossen;
// Konkrete offene Bestellungen (nicht abgeschlossen) mit Besitzer, für die Attribute
const offeneBestellungen = db.prepare(`
SELECT o.id, o.name, o.status, o.bezahlt, o.abgeholt, o.created_at, u.username
FROM orders o JOIN users u ON u.id = o.user_id
WHERE NOT (o.bezahlt = 1 AND o.abgeholt = 1)
ORDER BY o.created_at DESC
`).all();
const pub = (topic, val) => client.publish(`${BASE_TOPIC}/${topic}`, String(val), { retain: true });
pub('druck/umsatz_monat', round2(umsatzMonat));
pub('druck/umsatz_gesamt', round2(umsatzGesamt));
pub('druck/grundkosten_monat', round2(kostenMonat));
pub('druck/grundkosten_gesamt', round2(kostenGesamt));
pub('druck/rohgewinn_monat', round2(rohgewinnMonat));
pub('druck/rohgewinn_gesamt', round2(rohgewinnGesamt));
pub('druck/ausgaben_monat', round2(ausgabenMonat));
pub('druck/ausgaben_gesamt', round2(ausgabenGesamt));
pub('druck/nettogewinn_monat', round2(nettoMonat));
pub('druck/nettogewinn_gesamt', round2(nettoGesamt));
pub('druck/bestellungen_offen', offen);
client.publish(`${BASE_TOPIC}/druck/bestellungen_offen_attributes`, JSON.stringify({
bestellungen: offeneBestellungen,
}), { retain: true });
pub('druck/bestellungen_warteliste', byStatus.warteliste);
pub('druck/bestellungen_in_arbeit', byStatus.in_arbeit);
pub('druck/bestellungen_fertig', byStatus.fertig);
pub('druck/bestellungen_bezahlt', byStatus.bezahlt);
pub('druck/bestellungen_abgeschlossen', byStatus.abgeschlossen);
pub('druck/bestellungen_gesamt', allOrders.length);
}
// Wird jede Minute vom Scheduler aufgerufen — offene (noch nicht versendete,
// in der Zukunft liegende) Push-Erinnerungen aus dem Dashboard, systemweit
function publishPushErinnerungen() {
if (!client?.connected) return;
const offen = db.prepare(`
SELECT s.message, s.scheduled_at, u.username
FROM push_schedules s JOIN users u ON u.id = s.user_id
WHERE s.sent = 0 AND s.scheduled_at > datetime('now','localtime')
ORDER BY s.scheduled_at ASC
`).all();
client.publish(`${BASE_TOPIC}/dashboard/push_erinnerungen`, String(offen.length), { retain: true });
client.publish(`${BASE_TOPIC}/dashboard/push_erinnerungen_attributes`, JSON.stringify({
erinnerungen: offen,
}), { retain: true });
}
// Wird jede Minute vom Scheduler aufgerufen — alle Kanban-Karten über alle
// Nutzer-Boards hinweg (Kanban ist pro Nutzer, hier systemweit aggregiert)
function publishKanban() {
if (!client?.connected) return;
const karten = db.prepare(`
SELECT k.title, k.priority, col.title AS spalte, u.username
FROM kanban_cards k
JOIN kanban_columns col ON col.id = k.column_id
JOIN users u ON u.id = k.user_id
ORDER BY u.username ASC, col.position ASC, k.position ASC
`).all();
const proNutzer = {};
for (const k of karten) proNutzer[k.username] = (proNutzer[k.username] || 0) + 1;
client.publish(`${BASE_TOPIC}/kanban/karten`, String(karten.length), { retain: true });
client.publish(`${BASE_TOPIC}/kanban/karten_attributes`, JSON.stringify({
karten,
pro_nutzer: proNutzer,
}), { retain: true });
}
// Wird von index.js gesetzt, um Zirkel-Requires zu vermeiden
function setKoepiCheckHandler(fn) { koepiCheckHandler = fn; }
function setMediaAckHandler(fn) { mediaAckHandler = fn; }
function setMediaAckAllHandler(fn) { mediaAckAllHandler = fn; }
module.exports = {
connectMqtt,
publishKoepiState,
publishPresence,
publishMediaAnfragen,
publishDruckStatistik,
publishPushErinnerungen,
publishKanban,
setKoepiCheckHandler,
setMediaAckHandler,
setMediaAckAllHandler,
};

View File

@@ -1,85 +0,0 @@
const db = require('./db');
// Sehr einfache, kostenlose IP-Geolocation ohne API-Key (ip-api.com, ~45
// Anfragen/Minute im Free-Tier — für dieses Nutzungsszenario völlig ausreichend).
// Liefert bei privaten/lokalen IPs oder Fehlern bewusst leeren String zurück,
// statt den eigentlichen Log-Eintrag zu blockieren.
async function lookupLocation(ip) {
if (!ip) return '';
const clean = ip.replace('::ffff:', ''); // IPv4-mapped IPv6-Adressen bereinigen
if (!clean || clean === '::1' || clean.startsWith('127.') || clean.startsWith('192.168.') || clean.startsWith('10.')) {
return ''; // lokale/private Adresse, Geolocation ergibt keinen Sinn
}
try {
const res = await fetch(`http://ip-api.com/json/${encodeURIComponent(clean)}?fields=status,country,city`);
if (!res.ok) return '';
const data = await res.json();
if (data.status !== 'success') return '';
return [data.city, data.country].filter(Boolean).join(', ');
} catch {
return '';
}
}
// Sehr einfache User-Agent-Erkennung (kein externes Paket nötig) — liefert
// z.B. "iPhone · Safari" oder "Windows · Firefox". Reihenfolge ist wichtig:
// Edge/Samsung/Opera/Firefox enthalten selbst oft "Chrome" bzw. "Safari" im
// User-Agent, müssen also VOR diesen geprüft werden.
function parseDevice(ua) {
if (!ua) return '';
let os = '';
if (/iPhone/i.test(ua)) os = 'iPhone';
else if (/iPad/i.test(ua)) os = 'iPad';
else if (/Android/i.test(ua)) os = 'Android';
else if (/Windows/i.test(ua)) os = 'Windows';
else if (/Macintosh|Mac OS X/i.test(ua)) os = 'Mac';
else if (/Linux/i.test(ua)) os = 'Linux';
let browser = '';
if (/EdgA|Edge|Edg\//i.test(ua)) browser = 'Edge';
else if (/SamsungBrowser/i.test(ua)) browser = 'Samsung Internet';
else if (/OPR\/|Opera/i.test(ua)) browser = 'Opera';
else if (/Firefox/i.test(ua)) browser = 'Firefox';
else if (/CriOS/i.test(ua)) browser = 'Chrome'; // Chrome auf iOS
else if (/Chrome/i.test(ua)) browser = 'Chrome';
else if (/Safari/i.test(ua)) browser = 'Safari';
return [os, browser].filter(Boolean).join(' · ');
}
// Wird von öffentlichen (loginfreien) Routen aufgerufen, sobald sie besucht
// werden. linkType z.B. 'koepi_share'. Läuft bewusst asynchron/"fire and
// forget" im Hintergrund — ein Fehler hier darf niemals die eigentliche
// öffentliche Anfrage blockieren oder verlangsamen.
//
// Dedup: dieselbe IP, die denselben Link innerhalb der letzten 5 Minuten schon
// besucht hat, wird nicht erneut geloggt (verhindert Log-Spam bei mehreren
// Klicks/Tab-Wechseln hintereinander, spart nebenbei auch unnötige
// Geolocation-Abfragen).
const DEDUP_WINDOW_MINUTES = 5;
function logPublicAccess({ linkType, path, ip, userAgent, linkName }) {
try {
const recent = db.prepare(`
SELECT 1 FROM public_access_log
WHERE ip = ? AND path = ? AND created_at >= datetime('now','localtime',?)
LIMIT 1
`).get(ip || '', path || '', `-${DEDUP_WINDOW_MINUTES} minutes`);
if (recent) return; // kürzlich schon geloggt, nichts weiter tun
} catch (e) {
console.error('Public-Access-Log Dedup-Fehler:', e.message);
}
const device = parseDevice(userAgent);
lookupLocation(ip).then(location => {
try {
db.prepare(`
INSERT INTO public_access_log (link_type, path, ip, location, device, link_name, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now','localtime'))
`).run(linkType || '', path || '', ip || '', location || '', device || '', linkName || '');
} catch (e) {
console.error('Public-Access-Log Fehler:', e.message);
}
}).catch(() => {});
}
module.exports = { logPublicAccess };

View File

@@ -1,17 +0,0 @@
const db = require('./db');
// Wird von jeder Stelle im Code aufgerufen, die eine Pushover-Nachricht
// verschickt — unabhängig vom Versand selbst, damit ein Fehler im Logging
// nie den eigentlichen Push verhindert.
function logPush({ userId = null, title = '', message = '', priority = 0, source = '', success = true }) {
try {
db.prepare(`
INSERT INTO push_log (user_id, title, message, priority, source, success, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now','localtime'))
`).run(userId, title, message, priority ?? 0, source, success ? 1 : 0);
} catch (e) {
console.error('Push-Log Fehler:', e.message);
}
}
module.exports = { logPush };

View File

@@ -1,604 +0,0 @@
const express = require('express');
const fs = require('fs');
const bcrypt = require('bcryptjs');
const multer = require('multer');
const db = require('../db');
const { logPush } = require('../pushLog');
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 });
}
});
// GET /api/admin/users alle Benutzer mit Statistiken
router.get('/users', authenticate, requireAdmin, (req, res) => {
const users = db.prepare(`
SELECT u.id, u.username, u.role, u.created_at, u.last_active_at, u.hidden, u.hidden_tools,
CASE WHEN p.user_id IS NOT NULL THEN 1 ELSE 0 END as has_pushover
FROM users u
LEFT JOIN pushover_settings p ON p.user_id = u.id
ORDER BY u.role DESC, u.username
`).all();
const stats = users.map(u => {
const archiv = db.prepare('SELECT COUNT(*) c FROM calculations WHERE user_id=?').get(u.id).c;
const bestellung = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=?').get(u.id).c;
const snippets_c = db.prepare('SELECT COUNT(*) c FROM snippets WHERE user_id=?').get(u.id).c;
const todos = db.prepare('SELECT COUNT(*) c FROM todos WHERE user_id=?').get(u.id).c;
const links = db.prepare('SELECT COUNT(*) c FROM quick_links WHERE user_id=?').get(u.id).c;
const files = db.prepare('SELECT COUNT(*) c FROM files WHERE user_id=?').get(u.id).c;
const noteLen = (db.prepare('SELECT content FROM notes WHERE user_id=?').get(u.id)?.content || '').length;
const icals = db.prepare('SELECT COUNT(*) c FROM calendar_feeds WHERE user_id=?').get(u.id).c;
let hidden_tools = [];
try { hidden_tools = JSON.parse(u.hidden_tools || '[]'); } catch {}
return { ...u, hidden_tools, archiv, bestellung, snippets_c, todos, links, files, icals, noteLen };
});
res.json(stats);
});
// POST /api/admin/users neuen Benutzer anlegen
router.post('/users', authenticate, requireAdmin, (req, res) => {
const { username, password, role = 'user' } = req.body;
if (!username?.trim() || username.trim().length < 3)
return res.status(400).json({ error: 'Benutzername mind. 3 Zeichen' });
if (!password || password.length < 8)
return res.status(400).json({ error: 'Passwort mind. 8 Zeichen' });
if (!['user','admin'].includes(role))
return res.status(400).json({ error: 'Ungültige Rolle' });
const exists = db.prepare('SELECT id FROM users WHERE username=?').get(username.trim());
if (exists) return res.status(400).json({ error: 'Benutzername bereits vergeben' });
const r = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?,?,?)')
.run(username.trim(), bcrypt.hashSync(password, 12), role);
res.json({ id: r.lastInsertRowid, username: username.trim(), role });
});
// DELETE /api/admin/users/:id Benutzer löschen
router.delete('/users/:id', authenticate, requireAdmin, (req, res) => {
const id = parseInt(req.params.id);
if (id === req.user.id) return res.status(400).json({ error: 'Du kannst dich nicht selbst löschen' });
const user = db.prepare('SELECT * FROM users WHERE id=?').get(id);
if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
// Sicherstellen dass mindestens ein Admin bleibt
if (user.role === 'admin') {
const adminCount = db.prepare("SELECT COUNT(*) c FROM users WHERE role='admin'").get().c;
if (adminCount <= 1) return res.status(400).json({ error: 'Mindestens ein Administrator muss verbleiben' });
}
// Cascade: alle Daten des Users löschen
db.prepare('DELETE FROM calculations WHERE user_id=?').run(id);
db.prepare('DELETE FROM orders WHERE user_id=?').run(id);
db.prepare('DELETE FROM todos WHERE user_id=?').run(id);
db.prepare('DELETE FROM quick_links WHERE user_id=?').run(id);
db.prepare('DELETE FROM notes WHERE user_id=?').run(id);
db.prepare('DELETE FROM users WHERE id=?').run(id);
res.json({ success: true });
});
// PUT /api/admin/users/:id/reset-password Passwort zurücksetzen
router.put('/users/:id/reset-password', authenticate, requireAdmin, (req, res) => {
const { newPassword } = req.body;
if (!newPassword || newPassword.length < 8)
return res.status(400).json({ error: 'Mind. 8 Zeichen' });
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'Nicht gefunden' });
db.prepare('UPDATE users SET password_hash=? WHERE id=?').run(bcrypt.hashSync(newPassword, 12), req.params.id);
res.json({ success: true });
});
// ── Login-Sicherheit Admin-Endpoints ─────────────────────────────────────────
const getSetting = k => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)?.value;
// GET gesperrte Konten mit IPs
router.get('/lockouts', authenticate, requireAdmin, (req, res) => {
const locked = db.prepare(`
SELECT id, username, failed_attempts, locked_until, last_failed_at
FROM users
WHERE locked_until IS NOT NULL
ORDER BY locked_until DESC
`).all();
// IPs aus login_attempts hinzufügen
const result = locked.map(u => {
const ips = db.prepare(`
SELECT DISTINCT ip FROM login_attempts
WHERE username=? AND success=0
ORDER BY created_at DESC LIMIT 10
`).all(u.username).map(r => r.ip).filter(Boolean);
return { ...u, ips };
});
res.json(result);
});
// DELETE Sperre aufheben + Login-Versuche löschen
router.delete('/lockouts/:userId', authenticate, requireAdmin, (req, res) => {
const user = db.prepare('SELECT username FROM users WHERE id=?').get(req.params.userId);
if (user) {
db.prepare('DELETE FROM login_attempts WHERE username=?').run(user.username);
}
db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?')
.run(req.params.userId);
res.json({ ok: true });
});
// GET Login-Einstellungen
router.get('/security-settings', authenticate, requireAdmin, (req, res) => {
res.json({
login_max_attempts: getSetting('login_max_attempts') || '5',
login_lockout_minutes: getSetting('login_lockout_minutes') || '30',
});
});
// PUT Login-Einstellungen
router.put('/security-settings', authenticate, requireAdmin, (req, res) => {
const { login_max_attempts, login_lockout_minutes } = req.body;
for (const [k, v] of [['login_max_attempts', login_max_attempts], ['login_lockout_minutes', login_lockout_minutes]]) {
if (v !== undefined)
db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)').run(k, String(parseInt(v) || 0));
}
res.json({ ok: true });
});
// GET Login-Verlauf (letzte 50)
router.get('/login-log', authenticate, requireAdmin, (req, res) => {
res.json(db.prepare(`
SELECT * FROM login_attempts ORDER BY created_at DESC LIMIT 50
`).all());
});
// Toggle hidden
router.put('/users/:id/hidden', authenticate, requireAdmin, (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'Nicht gefunden' });
const newHidden = user.hidden ? 0 : 1;
db.prepare('UPDATE users SET hidden=? WHERE id=?').run(newHidden, user.id);
res.json({ hidden: newHidden });
});
// PUT /api/admin/users/:id/hidden-tools Sidebar-Sichtbarkeit für einen User setzen
// body: { hidden_tools: ['schocken', 'paywallkiller', ...] } (tool-ids aus toolRegistry.js)
router.put('/users/:id/hidden-tools', authenticate, requireAdmin, (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'Nicht gefunden' });
const { hidden_tools } = req.body;
if (!Array.isArray(hidden_tools)) return res.status(400).json({ error: 'hidden_tools muss ein Array sein' });
const cleaned = [...new Set(hidden_tools.filter(t => typeof t === 'string'))];
db.prepare('UPDATE users SET hidden_tools=? WHERE id=?').run(JSON.stringify(cleaned), user.id);
res.json({ hidden_tools: cleaned });
});
// Test-Pushover an beliebigen User senden
router.post('/users/:id/test-push', authenticate, requireAdmin, async (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'Nicht gefunden' });
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(user.id);
if (!poCfg?.app_token || !poCfg?.user_key) return res.status(400).json({ error: 'Kein Pushover eingerichtet' });
const { message = 'Wat is mit meinen Fische?' } = req.body;
const title = '📣 DockStation';
try {
const r = await fetch('https://api.pushover.net/1/messages.json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: poCfg.app_token, user: poCfg.user_key, title, message, priority: 0 }),
});
const d = await r.json();
if (d.status === 1) {
logPush({ userId: user.id, title, message, priority: 0, source: 'admin-test' });
res.json({ ok: true });
} else {
logPush({ userId: user.id, title, message, priority: 0, source: 'admin-test', success: false });
res.status(500).json({ error: d.errors?.join(', ') || 'Pushover-Fehler' });
}
} catch(e) {
logPush({ userId: user.id, title, message, priority: 0, source: 'admin-test', success: false });
res.status(500).json({ error: e.message });
}
});
// ── WebDAV / Synology NAS Einstellungen ───────────────────────────────────
router.get('/webdav', authenticate, requireAdmin, (req, res) => {
const get = k => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)?.value || '';
res.json({
url: get('webdav_url'),
user: get('webdav_user'),
password: get('webdav_password'),
basePath: get('webdav_base_path') || '/dickendock',
enabled: get('webdav_enabled') === '1',
});
});
router.put('/webdav', authenticate, requireAdmin, (req, res) => {
const { url, user, password, basePath, enabled } = req.body;
const set = (k, v) => db.prepare('INSERT OR REPLACE INTO admin_settings (key,value) VALUES (?,?)').run(k, v || '');
set('webdav_url', url);
set('webdav_user', user);
set('webdav_password', password);
set('webdav_base_path', basePath || '/dickendock');
set('webdav_enabled', enabled ? '1' : '0');
res.json({ ok: true });
});
router.post('/webdav/test', authenticate, requireAdmin, async (req, res) => {
// Temporär die gesendeten Werte zum Testen nutzen (noch nicht gespeichert)
const { url, user, password, basePath } = req.body;
try {
const https = require('https');
const http = require('http');
const base = new URL(url);
const testPath = (base.pathname.replace(/\/$/, '') + (basePath || '/dickendock')).replace(/\/+/g, '/');
const mod = base.protocol === 'https:' ? https : http;
const auth = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
await new Promise((resolve, reject) => {
const req2 = mod.request({
hostname: base.hostname,
port: base.port || (base.protocol === 'https:' ? 443 : 80),
path: testPath,
method: 'PROPFIND',
headers: { 'Authorization': auth, 'Depth': '0', 'Content-Type': 'application/xml' },
rejectUnauthorized: false,
timeout: 8000,
}, r => { r.resume(); r.statusCode < 400 || r.statusCode === 404 ? resolve(r.statusCode) : reject(new Error(`HTTP ${r.statusCode}`)); });
req2.on('error', reject);
req2.on('timeout', () => { req2.destroy(); reject(new Error('Timeout')); });
req2.write('<?xml version="1.0"?><propfind xmlns="DAV:"><prop><displayname/></prop></propfind>');
req2.end();
});
res.json({ ok: true });
} catch(e) {
res.status(502).json({ error: e.message });
}
});
// ── WebDAV Sync: alle lokalen Dateien auf NAS kopieren ────────────────────
router.post('/webdav/sync', authenticate, requireAdmin, async (req, res) => {
const webdav = require('../tools/dateien/webdav');
const nodePath = require('path');
const fs = require('fs');
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
const cfg = webdav.getConfig();
if (!cfg.enabled) return res.status(400).json({ error: 'WebDAV nicht aktiviert' });
const results = { folders: 0, ok: 0, failed: 0, errors: [] };
try {
// ── Ordnerpfad rekursiv auflösen ───────────────────────────────────────
function getFolderPath(folderId) {
if (!folderId) return '';
const parts = [];
let cur = folderId;
const seen = new Set();
while (cur) {
if (seen.has(cur)) break; seen.add(cur);
const fo = db.prepare('SELECT name, parent_id FROM folders WHERE id=?').get(cur);
if (!fo) break;
parts.unshift(fo.name.replace(/[\/]/g, '_'));
cur = fo.parent_id;
}
return parts.join('/');
}
// ── Schritt 1: User-Basisordner + alle DB-Ordner anlegen ───────────────
const users = db.prepare('SELECT id, username FROM users ORDER BY id').all();
for (const user of users) {
const userBase = webdav.userPath(cfg, user.username);
try { await webdav.mkdirp(userBase); results.folders++; } catch {}
try { await webdav.mkdirp(`${userBase}/_Upload-Freigaben`); } catch {}
// Ordner iterativ sortieren (flach, nach Tiefe)
const allFolders = db.prepare('SELECT id, name, parent_id FROM folders WHERE user_id=? ORDER BY id').all(user.id);
const depthCache = {};
function depth(fid) {
if (!fid) return 0;
if (depthCache[fid] !== undefined) return depthCache[fid];
const fo = allFolders.find(f => f.id === fid);
depthCache[fid] = fo ? 1 + depth(fo.parent_id) : 0;
return depthCache[fid];
}
const sorted = [...allFolders].sort((a, b) => depth(a.id) - depth(b.id));
for (const folder of sorted) {
const fp = getFolderPath(folder.id);
if (fp) try { await webdav.mkdirp(`${userBase}/${fp}`); results.folders++; } catch {}
}
}
// ── Schritt 2: Dateien hochladen ───────────────────────────────────────
const files = db.prepare(`
SELECT f.*, u.username FROM files f
JOIN users u ON u.id = f.user_id
ORDER BY f.user_id, f.folder_id, f.id
`).all();
for (const file of files) {
const localPath = nodePath.join(UPLOAD_DIR, file.filename);
if (!fs.existsSync(localPath)) {
results.failed++;
results.errors.push(`${file.originalname}: lokale Datei fehlt`);
continue;
}
try {
const buf = fs.readFileSync(localPath);
const fp = getFolderPath(file.folder_id);
const base = webdav.userPath(cfg, file.username);
const davPath = fp ? `${base}/${fp}/${file.originalname}` : `${base}/${file.originalname}`;
await webdav.uploadFile(buf, davPath, file.mimetype || 'application/octet-stream');
results.ok++;
} catch(e) {
results.failed++;
results.errors.push(`${file.originalname}: ${e.message}`);
}
}
res.json({
total: files.length,
...results,
message: `${results.folders} Ordner angelegt, ${results.ok} Dateien kopiert${results.failed ? ', ' + results.failed + ' fehlgeschlagen' : ' alles erfolgreich ✓'}`,
});
} catch(err) {
console.error('[webdav sync error]', err);
res.status(500).json({ error: err.message, ...results });
}
});
// ── Waisen aufräumen ──────────────────────────────────────────────────────
// Findet und löscht:
// 1. Dateien auf Disk die nicht in der DB sind (Disk-Waisen)
// 2. DB-Einträge die keine Datei auf Disk haben (DB-Waisen)
router.post('/cleanup-orphans', authenticate, requireAdmin, (req, res) => {
const { dry_run = false } = req.body;
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
const path = require('path');
const fs = require('fs');
const results = {
disk_orphans: [], // auf Disk aber nicht in DB
db_orphans: [], // in DB aber nicht auf Disk
disk_deleted: 0,
db_deleted: 0,
};
try {
// Alle Dateinamen aus DB
const dbFiles = db.prepare('SELECT id, filename, originalname, size FROM files').all();
const dbFilenames = new Set(dbFiles.map(f => f.filename));
// Alle Dateien auf Disk
const diskFiles = fs.readdirSync(UPLOAD_DIR).filter(f => !f.startsWith('.'));
// 1. Disk-Waisen: auf Disk aber nicht in DB
for (const diskFile of diskFiles) {
if (!dbFilenames.has(diskFile)) {
const fullPath = path.join(UPLOAD_DIR, diskFile);
const stat = fs.statSync(fullPath);
results.disk_orphans.push({ filename: diskFile, size: stat.size });
if (!dry_run) {
try { fs.unlinkSync(fullPath); results.disk_deleted++; } catch(e) {
console.error('[cleanup] disk unlink failed:', diskFile, e.message);
}
}
}
}
// 2. DB-Waisen: in DB aber nicht auf Disk
const diskFileset = new Set(diskFiles);
for (const dbFile of dbFiles) {
if (!diskFileset.has(dbFile.filename)) {
results.db_orphans.push({ id: dbFile.id, filename: dbFile.filename, originalname: dbFile.originalname });
if (!dry_run) {
db.prepare('DELETE FROM files WHERE id=?').run(dbFile.id);
results.db_deleted++;
}
}
}
res.json({
dry_run,
disk_orphans_count: results.disk_orphans.length,
db_orphans_count: results.db_orphans.length,
disk_deleted: results.disk_deleted,
db_deleted: results.db_deleted,
disk_orphans: results.disk_orphans.slice(0, 50),
db_orphans: results.db_orphans.slice(0, 50),
});
} catch(err) {
res.status(500).json({ error: err.message });
}
});
// ── Disk-Analyse ──────────────────────────────────────────────────────────
router.get('/disk-analysis', authenticate, requireAdmin, (req, res) => {
const fs = require('fs');
const path = require('path');
function dirSize(dirPath) {
let total = 0;
try {
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
const full = path.join(dirPath, entry.name);
if (entry.isDirectory()) total += dirSize(full);
else try { total += fs.statSync(full).size; } catch {}
}
} catch {}
return total;
}
function fmt(b) {
if (b < 1024) return b + ' B';
if (b < 1024**2) return (b/1024).toFixed(1) + ' KB';
if (b < 1024**3) return (b/1024**2).toFixed(1) + ' MB';
return (b/1024**3).toFixed(2) + ' GB';
}
// Alle relevanten Verzeichnisse scannen
const locations = [
'/data',
'/data/uploads',
'/tmp',
'/app',
'/root',
'/var/log',
'/var/lib/docker',
];
const result = {};
for (const loc of locations) {
if (fs.existsSync(loc)) {
result[loc] = { raw: dirSize(loc), fmt: fmt(dirSize(loc)) };
}
}
// DB-Größe
const dbPath = process.env.DB_PATH || '/data/db.sqlite';
if (fs.existsSync(dbPath)) {
result['database'] = { raw: fs.statSync(dbPath).size, fmt: fmt(fs.statSync(dbPath).size) };
}
// /tmp Details
const tmpFiles = [];
try {
for (const f of fs.readdirSync('/tmp')) {
try {
const stat = fs.statSync('/tmp/' + f);
if (stat.size > 1024*1024) tmpFiles.push({ name: f, size: fmt(stat.size) });
} catch {}
}
} catch {}
// Node modules Größe
const nodeModules = '/app/node_modules';
const nodeSize = fs.existsSync(nodeModules) ? dirSize(nodeModules) : 0;
// Top 10 größte Dateien in /data
const bigFiles = [];
function scanBig(dir) {
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) scanBig(full);
else try {
const s = fs.statSync(full).size;
if (s > 5*1024*1024) bigFiles.push({ path: full, size: fmt(s), raw: s });
} catch {}
}
} catch {}
}
scanBig('/data');
bigFiles.sort((a,b) => b.raw - a.raw);
res.json({ locations: result, tmpFiles, nodeModulesMb: fmt(nodeSize), bigFiles: bigFiles.slice(0,20) });
});
// GET /push-logs Übersicht aller verschickten Pushover-Nachrichten (Admin)
router.get('/push-logs', authenticate, requireAdmin, (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 200, 1000);
const logs = db.prepare(`
SELECT pl.id, pl.user_id, u.username, pl.title, pl.message, pl.priority,
pl.source, pl.success, pl.created_at
FROM push_log pl
LEFT JOIN users u ON u.id = pl.user_id
ORDER BY pl.id DESC
LIMIT ?
`).all(limit);
res.json(logs);
});
// GET /logs kombinierte Übersicht: Pushover-Nachrichten + Besuche
// öffentlicher Links, für den Logs-Bereich mit Filter-Dropdown (Admin)
router.get('/logs', authenticate, requireAdmin, (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 200, 1000);
const pushRows = db.prepare(`
SELECT pl.id, pl.user_id, u.username, pl.title, pl.message, pl.priority,
pl.source, pl.success, pl.created_at
FROM push_log pl
LEFT JOIN users u ON u.id = pl.user_id
ORDER BY pl.id DESC
LIMIT ?
`).all(limit).map(r => ({
logType: 'pushover',
id: `push-${r.id}`,
created_at: r.created_at,
username: r.username,
title: r.title,
message: r.message,
priority: r.priority,
source: r.source,
success: !!r.success,
}));
const accessRows = db.prepare(`
SELECT id, link_type, path, ip, location, device, link_name, created_at
FROM public_access_log
ORDER BY id DESC
LIMIT ?
`).all(limit).map(r => ({
logType: 'public_access',
id: `access-${r.id}`,
created_at: r.created_at,
linkType: r.link_type,
path: r.path,
ip: r.ip,
location: r.location,
device: r.device,
linkName: r.link_name,
}));
const combined = [...pushRows, ...accessRows]
.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''))
.slice(0, limit);
res.json(combined);
});
// DELETE /logs/:id einzelnen Log-Eintrag löschen (Admin). Die ID trägt ein
// Präfix ("push-123" / "access-45"), damit klar ist, aus welcher Tabelle
// gelöscht werden muss.
router.delete('/logs/:id', authenticate, requireAdmin, (req, res) => {
const raw = req.params.id;
const [prefix, numStr] = raw.split('-');
const numId = parseInt(numStr, 10);
if (!numId) return res.status(400).json({ error: 'Ungültige ID' });
if (prefix === 'push') {
db.prepare('DELETE FROM push_log WHERE id=?').run(numId);
} else if (prefix === 'access') {
db.prepare('DELETE FROM public_access_log WHERE id=?').run(numId);
} else {
return res.status(400).json({ error: 'Ungültige ID' });
}
res.json({ ok: true });
});
// GET /console-log/download Live-Konsolen-Mitschnitt (letzte 24h) als
// Datei herunterladen (Admin)
router.get('/console-log/download', authenticate, requireAdmin, (req, res) => {
const { getLogFilePath } = require('../consoleLog');
const filePath = getLogFilePath();
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Noch keine Logs vorhanden' });
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
res.download(filePath, `dickendock-console-${stamp}.log`);
});
module.exports = router;

View File

@@ -1,179 +0,0 @@
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';
const getSetting = k => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)?.value;
// ── Helpers ───────────────────────────────────────────────────────────────────
function logAttempt(username, ip, success) {
db.prepare("INSERT INTO login_attempts (username, ip, success, created_at) VALUES (?,?,?,datetime('now','localtime'))")
.run(username, ip || 'unknown', success ? 1 : 0);
}
function getClientIp(req) {
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || 'unknown';
}
function checkAndLock(user) {
const maxAttempts = parseInt(getSetting('login_max_attempts') || '5');
const lockoutMinutes = parseInt(getSetting('login_lockout_minutes') || '30');
// Gesperrt?
if (user.locked_until) {
const until = new Date(user.locked_until.replace(' ', 'T'));
if (until > new Date()) return { locked: true, until };
// Sperre abgelaufen → zurücksetzen
db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?').run(user.id);
}
// Fehlversuch zählen
const newAttempts = (user.failed_attempts || 0) + 1;
if (newAttempts >= maxAttempts) {
db.prepare(`UPDATE users SET failed_attempts=?, locked_until=datetime('now','localtime','+${lockoutMinutes} minutes'), last_failed_at=datetime('now','localtime') WHERE id=?`)
.run(newAttempts, user.id);
const until = new Date(Date.now() + lockoutMinutes * 60 * 1000);
return { locked: true, until, newLock: true };
}
db.prepare("UPDATE users SET failed_attempts=?, last_failed_at=datetime('now','localtime') WHERE id=?")
.run(newAttempts, user.id);
return { locked: false, remaining: maxAttempts - newAttempts };
}
// ── POST /api/auth/login ──────────────────────────────────────────────────────
router.post('/login', (req, res) => {
const { username, password } = req.body;
const ip = getClientIp(req);
if (!username || !password)
return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' });
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
// Unbekannter User → trotzdem loggen (kein Timing-Angriff)
if (!user) {
logAttempt(username, ip, false);
return res.status(401).json({ error: 'Benutzername oder Passwort falsch' });
}
// Gesperrt?
if (user.locked_until) {
const until = new Date(user.locked_until.replace(' ', 'T'));
if (until > new Date()) {
logAttempt(username, ip, false);
const min = Math.ceil((until - Date.now()) / 60000);
return res.status(403).json({
error: `Konto gesperrt. Noch ${min} Minute${min !== 1 ? 'n' : ''} warten.`,
locked: true,
lockedUntil: until.toISOString(),
});
}
// Abgelaufen → zurücksetzen + Attempts löschen
db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?').run(user.id);
db.prepare('DELETE FROM login_attempts WHERE username=?').run(username);
}
// Passwort prüfen
const valid = bcrypt.compareSync(password, user.password_hash);
logAttempt(username, ip, valid);
if (!valid) {
const result = checkAndLock(user);
if (result.locked) {
const min = parseInt(getSetting('login_lockout_minutes') || '30');
return res.status(403).json({
error: result.newLock
? `Zu viele Fehlversuche. Konto für ${min} Minuten gesperrt.`
: `Konto gesperrt. Bitte später versuchen.`,
locked: true,
});
}
return res.status(401).json({
error: `Benutzername oder Passwort falsch. Noch ${result.remaining} Versuch${result.remaining !== 1 ? 'e' : ''}.`,
});
}
// Erfolgreich → Zähler zurücksetzen + Attempts löschen
db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?').run(user.id);
db.prepare('DELETE FROM login_attempts WHERE username=?').run(username);
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 });
});
// ── POST /api/auth/change-username ───────────────────────────────────────────
router.post('/change-username', authenticate, (req, res) => {
const { newUsername, password } = req.body;
if (!newUsername || newUsername.trim().length < 3)
return res.status(400).json({ error: 'Mind. 3 Zeichen' });
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.user.id);
if (!bcrypt.compareSync(password, user.password_hash))
return res.status(400).json({ error: 'Passwort falsch' });
const exists = db.prepare('SELECT id FROM users WHERE username=? AND id!=?').get(newUsername.trim(), req.user.id);
if (exists) return res.status(400).json({ error: 'Benutzername bereits vergeben' });
db.prepare('UPDATE users SET username=? WHERE id=?').run(newUsername.trim(), req.user.id);
const token = jwt.sign({ id: user.id, username: newUsername.trim(), role: user.role }, SECRET, { expiresIn: '7d' });
res.json({ token, user: { id: user.id, username: newUsername.trim(), role: user.role } });
});
// ── DELETE /api/auth/account ──────────────────────────────────────────────────
router.delete('/account', authenticate, (req, res) => {
const { password } = req.body;
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.user.id);
if (!password || !bcrypt.compareSync(password, user.password_hash))
return res.status(400).json({ error: 'Passwort falsch' });
if (user.role === 'admin') {
const adminCount = db.prepare("SELECT COUNT(*) c FROM users WHERE role='admin'").get().c;
if (adminCount <= 1) return res.status(400).json({ error: 'Letzter Admin kann nicht gelöscht werden' });
}
db.prepare('DELETE FROM users WHERE id=?').run(req.user.id);
res.json({ success: true });
});
// ── GET/PUT /api/auth/preferences ─────────────────────────────────────────────
router.get('/preferences', authenticate, (req, res) => {
const user = db.prepare('SELECT preferences FROM users WHERE id=?').get(req.user.id);
try { res.json(JSON.parse(user?.preferences || '{}')); }
catch { res.json({}); }
});
router.put('/preferences', authenticate, (req, res) => {
const user = db.prepare('SELECT preferences FROM users WHERE id=?').get(req.user.id);
let current = {};
try { current = JSON.parse(user?.preferences || '{}'); } catch {}
const merged = { ...current, ...req.body };
db.prepare('UPDATE users SET preferences=? WHERE id=?').run(JSON.stringify(merged), req.user.id);
res.json(merged);
});
// ── GET /api/auth/hidden-tools ────────────────────────────────────────────────
// Liefert die vom Admin für diesen Nutzer ausgeblendeten Sidebar-Bereiche.
// Bewusst NICHT über /preferences (das ist selbst-editierbar) — Sichtbarkeit
// darf nur der Admin ändern.
router.get('/hidden-tools', authenticate, (req, res) => {
const user = db.prepare('SELECT hidden_tools FROM users WHERE id=?').get(req.user.id);
try { res.json(JSON.parse(user?.hidden_tools || '[]')); }
catch { res.json([]); }
});
module.exports = router;

View File

@@ -1,69 +0,0 @@
const express = require('express');
const https = require('https');
const http = require('http');
const db = require('../db');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
// Fetch mit Redirect-Unterstützung
function fetchWithRedirects(url, hops = 0) {
return new Promise((resolve, reject) => {
if (hops > 5) return reject(new Error('Zu viele Redirects'));
let parsed;
try { parsed = new URL(url); } catch { return reject(new Error('Ungültige URL')); }
const lib = parsed.protocol === 'https:' ? https : http;
const req = lib.get(url, {
headers: { 'User-Agent': 'Mozilla/5.0 DickenDock/1.0', 'Accept': 'text/calendar, text/plain, */*' }
}, res => {
if ([301,302,303,307,308].includes(res.statusCode) && res.headers.location) {
const next = res.headers.location.startsWith('http')
? res.headers.location
: new URL(res.headers.location, url).href;
res.destroy();
return fetchWithRedirects(next, hops + 1).then(resolve).catch(reject);
}
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
let data = '';
res.setEncoding('utf8');
res.on('data', d => { data += d; if (data.length > 3*1024*1024) { res.destroy(); reject(new Error('Feed zu groß')); } });
res.on('end', () => resolve(data));
});
req.on('error', reject);
req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); });
});
}
// GET /fetch?url=...
router.get('/fetch', authenticate, async (req, res) => {
const { url } = req.query;
if (!url) return res.status(400).json({ error: 'URL fehlt' });
try { new URL(url); } catch { return res.status(400).json({ error: 'Ungültige URL' }); }
try {
const data = await fetchWithRedirects(url);
if (!data.includes('BEGIN:VCALENDAR'))
return res.status(502).json({ error: 'Kein gültiger iCal-Feed' });
res.type('text/calendar; charset=utf-8').send(data);
} catch(e) { res.status(502).json({ error: e.message }); }
});
// GET /feeds
router.get('/feeds', authenticate, (req, res) => {
res.json(db.prepare('SELECT * FROM calendar_feeds WHERE user_id=? ORDER BY id').all(req.user.id));
});
// POST /feeds
router.post('/feeds', authenticate, (req, res) => {
const { name, url, color='#4ecdc4' } = req.body;
if (!name?.trim() || !url?.trim()) return res.status(400).json({ error: 'Name und URL erforderlich' });
const r = db.prepare('INSERT INTO calendar_feeds (user_id,name,url,color) VALUES (?,?,?,?)').run(req.user.id, name.trim(), url.trim(), color);
res.json(db.prepare('SELECT * FROM calendar_feeds WHERE id=?').get(r.lastInsertRowid));
});
// DELETE /feeds/:id
router.delete('/feeds/:id', authenticate, (req, res) => {
const r = db.prepare('DELETE FROM calendar_feeds 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;

View File

@@ -1,330 +0,0 @@
const express = require('express');
const db = require('../db');
const { authenticate, requireAdmin } = require('../middleware/auth');
const router = express.Router();
const uid = req => req.user.id;
// ── Quick Links ───────────────────────────────────────────────────────────────
router.get('/links', authenticate, (req, res) => {
const id = uid(req);
// Rückwärtskomp: bestehende quick_links + neue link_list-Einträge mit in_quickaccess=1 + Ordner mit in_quickaccess=1
const legacy = db.prepare("SELECT *, 'quicklink' as item_type FROM quick_links WHERE user_id=? ORDER BY sort_order,id").all(id);
const listLinks = db.prepare("SELECT *, 'link_list' as item_type FROM link_list WHERE user_id=? AND in_quickaccess=1 ORDER BY sort_order,id").all(id);
const folders = db.prepare("SELECT *, 'folder' as item_type FROM link_list_folders WHERE user_id=? AND in_quickaccess=1 ORDER BY sort_order,id").all(id);
res.json([...legacy, ...listLinks, ...folders]);
});
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 });
});
router.put('/links-order', authenticate, (req, res) => {
const { ids } = req.body;
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids erforderlich' });
const update = db.prepare('UPDATE quick_links SET sort_order=? WHERE id=? AND user_id=?');
db.transaction(() => { ids.forEach((id, i) => update.run(i, id, uid(req))); })();
res.json({ ok: 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() });
});
// ── Statistiken ───────────────────────────────────────────────────────────────
router.get('/stats', authenticate, (req, res) => {
const uid = req.user.id;
const orders = db.prepare(`
SELECT status, bezahlt,
SUM(COALESCE(custom_price_sum_view,0)) AS revenue
FROM (
SELECT o.status, o.bezahlt,
SUM(COALESCE(oi.custom_price,0)*COALESCE(oi.stueckzahl,0)) AS custom_price_sum_view
FROM orders o
LEFT JOIN order_items oi ON oi.order_id=o.id
WHERE o.user_id=?
GROUP BY o.id
)
GROUP BY status, bezahlt
`).all(uid);
const totalOrders = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=?').get(uid).c;
const bezahltOrders = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=? AND bezahlt=1 AND (abgeholt=0 OR abgeholt IS NULL)').get(uid).c;
// Revenue: Gesamt-Festpreis hat Priorität, sonst Summe der Positions-Festpreise
const paidOrders = db.prepare('SELECT * FROM orders WHERE user_id=? AND bezahlt=1').all(uid);
let totalRevenue = 0;
for (const o of paidOrders) {
if (o.custom_price != null) {
totalRevenue += parseFloat(o.custom_price);
} else {
const sum = db.prepare('SELECT COALESCE(SUM(custom_price*stueckzahl),0) s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL').get(o.id).s;
totalRevenue += sum;
}
}
const openOrders = db.prepare("SELECT * FROM orders WHERE user_id=? AND bezahlt=0 AND status!='warteliste'").all(uid);
let offeneRevenue = 0;
for (const o of openOrders) {
if (o.custom_price != null) {
offeneRevenue += parseFloat(o.custom_price);
} else {
const sum = db.prepare('SELECT COALESCE(SUM(custom_price*stueckzahl),0) s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL').get(o.id).s;
offeneRevenue += sum;
}
}
const thisMonth = new Date();
thisMonth.setDate(1); thisMonth.setHours(0,0,0,0);
const ordersThisMonth = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=? AND created_at>=?').get(uid, thisMonth.toISOString()).c;
const totalCalcs = db.prepare('SELECT COUNT(*) c FROM calculations WHERE user_id=?').get(uid).c;
const byStatus = { warteliste:0, in_arbeit:0, fertig:0 };
for (const r of db.prepare('SELECT status, COUNT(*) c FROM orders WHERE user_id=? AND bezahlt=0 GROUP BY status').all(uid)) {
byStatus[r.status] = r.c;
}
// Grundkosten berechnen (preis_freundschaft = Selbstkosten pro Stück aus Kalkulation)
function getBaseCost(orderId) {
const items = db.prepare(`
SELECT oi.stueckzahl, oi.custom_price,
COALESCE(c.preis_freundschaft, oi.preis_freundschaft) as basis
FROM order_items oi
LEFT JOIN calculations c ON c.id = oi.calculation_id
WHERE oi.order_id = ?
`).all(orderId);
return items.reduce((sum, i) => sum + (i.basis * i.stueckzahl), 0);
}
function getRevenue(order) {
if (order.custom_price != null) return parseFloat(order.custom_price);
return db.prepare('SELECT COALESCE(SUM(custom_price*stueckzahl),0) s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL').get(order.id).s;
}
let totalProfit = 0;
for (const o of paidOrders) {
const rev = getRevenue(o);
const cost = getBaseCost(o.id);
totalProfit += rev - cost;
}
let offeneProfit = 0;
for (const o of openOrders) {
const rev = getRevenue(o);
const cost = getBaseCost(o.id);
offeneProfit += rev - cost;
}
res.json({ totalOrders, bezahltOrders, totalRevenue, offeneRevenue, ordersThisMonth, totalCalcs, byStatus, totalProfit, offeneProfit });
});
// ── Ideen-Board ───────────────────────────────────────────────────────────────
router.get('/board', authenticate, (req, res) => {
const u = uid(req);
const items = db.prepare(`
SELECT b.*, us.username as author
FROM board_items b JOIN users us ON us.id=b.user_id
ORDER BY b.type ASC, b.created_at DESC
`).all();
const lastRead = db.prepare('SELECT last_read FROM board_reads WHERE user_id=?').get(u)?.last_read;
const unread = lastRead
? db.prepare("SELECT COUNT(*) c FROM board_items WHERE created_at > ? AND user_id != ?").get(lastRead, u).c
: db.prepare('SELECT COUNT(*) c FROM board_items WHERE user_id != ?').get(u).c;
res.json({ items, unread });
});
router.get('/board/unread', authenticate, (req, res) => {
const u = uid(req);
const lastRead = db.prepare('SELECT last_read FROM board_reads WHERE user_id=?').get(u)?.last_read;
const unread = lastRead
? db.prepare("SELECT COUNT(*) c FROM board_items WHERE created_at > ? AND user_id != ?").get(lastRead, u).c
: db.prepare('SELECT COUNT(*) c FROM board_items WHERE user_id != ?').get(u).c;
const unreadPromoted = lastRead
? db.prepare("SELECT COUNT(*) c FROM board_items WHERE promoted_at > ?").get(lastRead).c
: db.prepare("SELECT COUNT(*) c FROM board_items WHERE promoted_at IS NOT NULL").get().c;
res.json({ unread, unreadPromoted });
});
router.post('/board/read', authenticate, (req, res) => {
const u = uid(req);
db.prepare(`INSERT OR REPLACE INTO board_reads (user_id, last_read) VALUES (?, datetime('now','localtime'))`).run(u);
res.json({ ok: true });
});
router.post('/board', authenticate, (req, res) => {
const u = uid(req);
const { type, title, description = '' } = req.body;
if (!['roadmap','wish'].includes(type)) return res.status(400).json({ error: 'Ungültiger Typ' });
if (type === 'roadmap' && req.user.role !== 'admin') return res.status(403).json({ error: 'Nur Admins' });
if (!title?.trim()) return res.status(400).json({ error: 'Titel erforderlich' });
const r = db.prepare(`INSERT INTO board_items (type, user_id, title, description, created_at) VALUES (?,?,?,?,datetime('now','localtime'))`)
.run(type, u, title.trim(), description.trim());
res.json({ ...db.prepare('SELECT * FROM board_items WHERE id=?').get(r.lastInsertRowid),
author: req.user.username });
});
router.put('/board/:id', authenticate, (req, res) => {
const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id);
if (!item) return res.status(404).json({ error: 'Nicht gefunden' });
const isAdmin = req.user.role === 'admin';
const isOwn = item.user_id === uid(req);
if (!isAdmin && (!isOwn || item.type === 'roadmap')) return res.status(403).json({ error: 'Kein Zugriff' });
const { title, description } = req.body;
db.prepare('UPDATE board_items SET title=?, description=? WHERE id=?')
.run(title ?? item.title, description ?? item.description, item.id);
res.json({ ...db.prepare('SELECT * FROM board_items WHERE id=?').get(item.id), author: req.user.username });
});
router.delete('/board/:id', authenticate, (req, res) => {
const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id);
if (!item) return res.status(404).json({ error: 'Nicht gefunden' });
const isAdmin = req.user.role === 'admin';
const isOwn = item.user_id === uid(req);
if (!isAdmin && !isOwn) return res.status(403).json({ error: 'Kein Zugriff' });
db.prepare('DELETE FROM board_items WHERE id=?').run(item.id);
res.json({ ok: true });
});
// Wunsch zur Roadmap befördern (Admin only)
router.post('/board/:id/promote', authenticate, (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id);
if (!item) return res.status(404).json({ error: 'Nicht gefunden' });
if (item.type !== 'wish') return res.status(400).json({ error: 'Nur Wünsche können befördert werden' });
db.prepare("UPDATE board_items SET type='roadmap', promoted_from_wish=1, promoted_at=datetime('now','localtime') WHERE id=?").run(item.id);
const updated = db.prepare(`
SELECT b.*, u.username as author FROM board_items b
JOIN users u ON u.id=b.user_id WHERE b.id=?
`).get(item.id);
res.json(updated);
});
// ── Changelog ─────────────────────────────────────────────────────────────────
router.get('/changelog', authenticate, (req, res) => {
res.json(db.prepare('SELECT * FROM changelog ORDER BY created_at DESC').all());
});
router.get('/changelog/unread', authenticate, (req, res) => {
const u = req.user.id;
const lastRead = db.prepare('SELECT last_read FROM changelog_reads WHERE user_id=?').get(u)?.last_read;
const count = lastRead
? db.prepare('SELECT COUNT(*) c FROM changelog WHERE created_at > ?').get(lastRead).c
: db.prepare('SELECT COUNT(*) c FROM changelog').get().c;
res.json({ unread: count });
});
router.post('/changelog/read', authenticate, (req, res) => {
db.prepare(`INSERT OR REPLACE INTO changelog_reads (user_id, last_read) VALUES (?, datetime('now','localtime'))`)
.run(req.user.id);
res.json({ ok: true });
});
router.post('/changelog', authenticate, requireAdmin, (req, res) => {
const { version, title, body, build_time } = req.body;
if (!version?.trim() || !title?.trim()) return res.status(400).json({ error: 'Version und Titel erforderlich' });
const r = db.prepare(`INSERT INTO changelog (version, title, body, build_time, created_at) VALUES (?,?,?,?,datetime('now','localtime'))`)
.run(version.trim(), title.trim(), body?.trim() || '', build_time || null);
res.json(db.prepare('SELECT * FROM changelog WHERE id=?').get(r.lastInsertRowid));
});
router.delete('/changelog/:id', authenticate, requireAdmin, (req, res) => {
db.prepare('DELETE FROM changelog WHERE id=?').run(req.params.id);
res.json({ ok: true });
});
// ── Push-Zeitplaner ───────────────────────────────────────────────────────────
router.get('/push-schedules', authenticate, (req, res) => {
const rows = db.prepare(`
SELECT * FROM push_schedules
WHERE user_id=? AND sent=0 AND scheduled_at > datetime('now','localtime')
ORDER BY scheduled_at ASC
`).all(uid(req));
res.json(rows);
});
router.post('/push-schedules', authenticate, (req, res) => {
const { message, scheduled_at } = req.body;
if (!message?.trim() || !scheduled_at) return res.status(400).json({ error: 'Nachricht und Zeitpunkt erforderlich' });
// Normalisieren: T→Leerzeichen, Sekunden ergänzen
const normalized = scheduled_at.replace('T',' ') + (scheduled_at.length <= 16 ? ':00' : '');
const r = db.prepare(`
INSERT INTO push_schedules (user_id, message, scheduled_at, created_at)
VALUES (?, ?, ?, datetime('now','localtime'))
`).run(uid(req), message.trim(), normalized);
res.json(db.prepare('SELECT * FROM push_schedules WHERE id=?').get(r.lastInsertRowid));
});
router.put('/push-schedules/:id', authenticate, (req, res) => {
const row = db.prepare('SELECT * FROM push_schedules WHERE id=? AND user_id=?').get(req.params.id, uid(req));
if (!row) return res.status(404).json({ error: 'Nicht gefunden' });
const { message, scheduled_at } = req.body;
const normalized = scheduled_at ? scheduled_at.replace('T',' ') + (scheduled_at.length <= 16 ? ':00' : '') : row.scheduled_at;
db.prepare('UPDATE push_schedules SET message=?, scheduled_at=?, sent=0 WHERE id=?')
.run(message ?? row.message, normalized, row.id);
res.json(db.prepare('SELECT * FROM push_schedules WHERE id=?').get(row.id));
});
router.delete('/push-schedules/:id', authenticate, (req, res) => {
db.prepare('DELETE FROM push_schedules WHERE id=? AND user_id=?').run(req.params.id, uid(req));
res.json({ ok: true });
});
// Unquittierte Favoriten für Admin-Dashboard-Badge
router.get('/media-favorites/unacked', authenticate, (req, res) => {
if (req.user.role !== 'admin') return res.json({ count: 0 });
const count = db.prepare('SELECT COUNT(*) as n FROM movie_favorites WHERE acknowledged=0').get();
res.json({ count: count.n });
});
module.exports = router;

View File

@@ -1,260 +0,0 @@
const express = require('express');
const https = require('https');
const http = require('http');
const db = require('../db');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
const safe = fn => { try { return fn(); } catch { return []; } };
const safeGet = fn => { try { return fn(); } catch { return null; } };
// ── iCal Fetch + Parse ────────────────────────────────────────────────────────
function fetchIcal(url) {
return new Promise(resolve => {
try {
const parsed = new URL(url);
const lib = parsed.protocol === 'https:' ? https : http;
const req = lib.get(url, {
headers: { 'User-Agent': 'Mozilla/5.0 DickenDock/1.0', 'Accept': 'text/calendar,*/*' }
}, res => {
// Redirects verfolgen
if ([301,302,303,307,308].includes(res.statusCode) && res.headers.location) {
res.destroy();
const next = res.headers.location.startsWith('http')
? res.headers.location : new URL(res.headers.location, url).href;
fetchIcal(next).then(resolve);
return;
}
let data = '';
res.on('data', d => { data += d; if (data.length > 1e6) { res.destroy(); resolve(null); } });
res.on('end', () => resolve(data.includes('BEGIN:VCALENDAR') ? data : null));
});
req.on('error', () => resolve(null));
req.setTimeout(10000, () => { req.destroy(); resolve(null); });
} catch { resolve(null); }
});
}
function parseIcal(raw) {
const events = [];
const parts = raw.split('BEGIN:VEVENT');
for (let i = 1; i < parts.length; i++) {
const b = parts[i];
const get = key => {
// Handle folded lines and key variants like DTSTART;TZID=...
const m = b.match(new RegExp(key + '[^:]*:([^\\r\\n]+)'));
return m ? m[1].replace(/\\n/g,' ').replace(/\\,/g,',').replace(/\\;/g,';').trim() : '';
};
const summary = get('SUMMARY');
if (!summary) continue;
events.push({ summary, description:get('DESCRIPTION'), location:get('LOCATION'), start:get('DTSTART'), end:get('DTEND') });
}
return events;
}
// ── Kalender-Sync Endpoint (POST /api/search/sync-calendar) ──────────────────
// Frontend ruft das beim Dashboard-Laden auf — kein Sync beim Suchen selbst
router.post('/sync-calendar', authenticate, async (req, res) => {
const uid = req.user.id;
const force = req.body?.force === true;
const feeds = safeGet(() => db.prepare('SELECT * FROM calendar_feeds WHERE user_id=?').all(uid)) || [];
if (!feeds.length) return res.json({ ok: true, synced: 0, message: 'Keine Kalender-Abos vorhanden' });
// Alter des Caches prüfen
// Alte Cache-Prüfung: Wenn noch historische Events drin (start_dt < 2000), force-sync
const hasOldData = safeGet(() =>
db.prepare("SELECT 1 FROM calendar_event_cache WHERE user_id=? AND start_dt < '20200101' LIMIT 1").get(uid)
);
if (!force && !hasOldData) {
const lastSync = safeGet(() =>
db.prepare("SELECT MAX(synced_at) as s FROM calendar_event_cache WHERE user_id=?").get(uid)?.s
);
if (lastSync && (Date.now() - new Date(lastSync).getTime()) < 10 * 60 * 1000) {
return res.json({ ok: true, synced: 0, message: 'Cache noch frisch' });
}
}
let totalSynced = 0;
const errors = [];
for (const feed of feeds) {
try {
const raw = await fetchIcal(feed.url);
if (!raw) { errors.push(`${feed.name}: kein gültiger iCal-Feed`); continue; }
const events = parseIcal(raw);
if (!events.length) { errors.push(`${feed.name}: 0 Events geparst`); continue; }
// Nur zukünftige Events speichern (ab gestern rückwärts 1 Tag für Tagesevents)
// start_dt Format: YYYYMMDD oder YYYYMMDDTHHmmssZ → string-vergleichbar
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const minDt = yesterday.toISOString().replace(/-/g,'').slice(0,8); // '20260604'
const futureEvents = events.filter(ev => !ev.start || ev.start >= minDt);
// prepare INSIDE try-catch wirft wenn Tabelle nicht existiert
const ins = db.prepare(`
INSERT INTO calendar_event_cache
(feed_id, user_id, summary, description, location, start_dt, end_dt, synced_at)
VALUES (?,?,?,?,?,?,?,datetime('now','localtime'))
`);
db.transaction(() => {
db.prepare('DELETE FROM calendar_event_cache WHERE feed_id=?').run(feed.id);
futureEvents.slice(0, 500).forEach(ev =>
ins.run(feed.id, uid, ev.summary||'', ev.description||'', ev.location||'', ev.start||'', ev.end||'')
);
})();
totalSynced += futureEvents.length;
} catch(e) {
console.error(`[CalendarSync] Feed "${feed.name}" (${feed.url}): ${e.message}`);
errors.push(`${feed.name}: ${e.message}`);
}
}
console.log(`[CalendarSync] uid=${uid} synced=${totalSynced} feeds=${feeds.length} errors=${errors.length}`);
res.json({ ok: true, synced: totalSynced, feeds: feeds.length, errors });
});
// ── Suche ─────────────────────────────────────────────────────────────────────
router.get('/', authenticate, (req, res) => {
const q = (req.query.q || '').trim();
const uid = req.user.id;
if (q.length < 2) return res.json({
links:[], folders:[], snippets:[], calculations:[], orders:[],
todos:[], notes:[], board_items:[], changelog:[],
files:[], file_folders:[], push_schedules:[],
calendar_feeds:[], calendar_events:[], qr_codes:[]
});
const like = `%${q.toLowerCase()}%`;
const links = safe(() => db.prepare(`
SELECT id, title, url, icon, description FROM link_list
WHERE user_id=? AND (LOWER(title) LIKE ? OR LOWER(url) LIKE ? OR LOWER(description) LIKE ?)
ORDER BY title LIMIT 8
`).all(uid, like, like, like));
const folders = safe(() => db.prepare(`
SELECT id, name, icon FROM link_list_folders
WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5
`).all(uid, like));
const snippets = safe(() => db.prepare(`
SELECT id, title, description, language FROM snippets
WHERE user_id=? AND (LOWER(title) LIKE ? OR LOWER(description) LIKE ?)
ORDER BY title LIMIT 6
`).all(uid, like, like));
const calculations = safe(() => db.prepare(`
SELECT id, name FROM calculations
WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5
`).all(uid, like));
const orders = safe(() => db.prepare(`
SELECT id, name, status FROM orders
WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5
`).all(uid, like));
const todos = safe(() => db.prepare(`
SELECT id, text, done FROM todos
WHERE user_id=? AND LOWER(text) LIKE ?
ORDER BY done, created_at DESC LIMIT 6
`).all(uid, like));
const noteRow = safeGet(() => db.prepare(
'SELECT content FROM notes WHERE user_id=? AND LOWER(content) LIKE ?'
).get(uid, like));
const notes = noteRow ? [(() => {
const idx = noteRow.content.toLowerCase().indexOf(q.toLowerCase());
const s = Math.max(0, idx - 30);
return { id:'note', preview: (s>0?'…':'') + noteRow.content.slice(s, idx+60) + '…' };
})()] : [];
const board_items = safe(() => db.prepare(`
SELECT id, title, description, type FROM board_items
WHERE user_id=? AND (LOWER(title) LIKE ? OR LOWER(description) LIKE ?)
ORDER BY created_at DESC LIMIT 5
`).all(uid, like, like));
const changelog = safe(() => db.prepare(`
SELECT id, version, title, body FROM changelog
WHERE LOWER(title) LIKE ? OR LOWER(body) LIKE ?
ORDER BY created_at DESC LIMIT 4
`).all(like, like));
const files = safe(() => db.prepare(`
SELECT id, originalname, mimetype, size FROM files
WHERE user_id=? AND (LOWER(originalname) LIKE ? OR LOWER(filename) LIKE ?)
ORDER BY originalname LIMIT 6
`).all(uid, like, like));
const file_folders = safe(() => db.prepare(`
SELECT id, name FROM folders
WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5
`).all(uid, like));
const push_schedules = safe(() => db.prepare(`
SELECT id, message, scheduled_at FROM push_schedules
WHERE user_id=? AND LOWER(message) LIKE ? AND sent=0
ORDER BY scheduled_at LIMIT 5
`).all(uid, like));
const calendar_feeds = safe(() => db.prepare(`
SELECT id, name, url FROM calendar_feeds
WHERE user_id=? AND (LOWER(name) LIKE ? OR LOWER(url) LIKE ?)
ORDER BY name LIMIT 4
`).all(uid, like, like));
const calendar_events = safe(() => db.prepare(`
SELECT c.id, c.summary, c.description, c.location, c.start_dt, f.name as feed_name
FROM calendar_event_cache c
JOIN calendar_feeds f ON f.id=c.feed_id
WHERE c.user_id=? AND f.user_id=?
AND (LOWER(c.summary) LIKE ? OR LOWER(c.description) LIKE ? OR LOWER(c.location) LIKE ?)
AND c.start_dt >= strftime('%Y%m%d', 'now')
ORDER BY c.start_dt LIMIT 8
`).all(uid, uid, like, like, like));
const qr_codes = safe(() => db.prepare(`
SELECT id, label, url FROM qr_codes
WHERE user_id=? AND (LOWER(label) LIKE ? OR LOWER(url) LIKE ?)
ORDER BY created_at DESC LIMIT 5
`).all(uid, like, like));
// Gebietseroberung-Spiele
const geo_games = safe(() => db.prepare(`
SELECT g.id, u1.username as owner_name, u2.username as opp_name, g.status, g.current_turn
FROM geo_games g
JOIN users u1 ON u1.id=g.owner_id
JOIN users u2 ON u2.id=g.opponent_id
WHERE (g.owner_id=? OR g.opponent_id=?)
AND (LOWER(u1.username) LIKE ? OR LOWER(u2.username) LIKE ?)
ORDER BY g.updated_at DESC LIMIT 5
`).all(uid, uid, like, like));
// Andere Benutzer für Chat-Navigation (nur Benutzernamen, keine sensiblen Daten)
const users = safe(() => db.prepare(`
SELECT id, username FROM users
WHERE id != ? AND LOWER(username) LIKE ?
ORDER BY username LIMIT 5
`).all(uid, like));
res.json({ links, folders, snippets, calculations, orders, todos, notes,
board_items, changelog, files, file_folders, push_schedules,
calendar_feeds, calendar_events, qr_codes, users, geo_games });
});
// Debug-Endpoint: Kalender-Cache inspizieren
router.get('/calendar-debug', authenticate, (req, res) => {
const uid = req.user.id;
const count = db.prepare('SELECT COUNT(*) as n FROM calendar_event_cache WHERE user_id=?').get(uid);
const samples = db.prepare('SELECT summary, start_dt FROM calendar_event_cache WHERE user_id=? ORDER BY start_dt LIMIT 10').all(uid);
const today = db.prepare("SELECT strftime('%Y%m%d','now') as d").get();
const future = db.prepare("SELECT COUNT(*) as n FROM calendar_event_cache WHERE user_id=? AND start_dt >= strftime('%Y%m%d','now')").get(uid);
const search = db.prepare("SELECT summary, start_dt FROM calendar_event_cache WHERE user_id=? AND LOWER(summary) LIKE '%arzt%'").all(uid);
res.json({ count: count.n, today: today.d, future_count: future.n, samples, arzt_matches: search });
});
module.exports = router;

View File

@@ -1,40 +0,0 @@
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

@@ -1,169 +0,0 @@
const express = require('express');
const db = require('../../db');
const { authenticate } = require('../../middleware/auth');
const router = express.Router();
const uid = req => req.user.id;
// Status der Bestellung automatisch aus den Stück-Zählern berechnen
const autoUpdateOrderStatus = (orderId) => {
const items = db.prepare('SELECT * FROM order_items WHERE order_id=?').all(orderId);
if (!items.length) return;
const totalPieces = items.reduce((s,i) => s + (i.stueckzahl||0), 0);
const fertigPieces = items.reduce((s,i) => s + (i.qty_fertig||0), 0);
const arbeitPieces = items.reduce((s,i) => s + (i.qty_in_arbeit||0), 0);
let newStatus = 'warteliste';
if (fertigPieces >= totalPieces && totalPieces > 0) newStatus = 'fertig';
else if (fertigPieces > 0 || arbeitPieces > 0) newStatus = 'in_arbeit';
db.prepare('UPDATE orders SET status=?,updated_at=CURRENT_TIMESTAMP WHERE id=?').run(newStatus, orderId);
};
const loadOrder = (id, userId) => {
const order = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(id, userId);
if (!order) return null;
order.items = db.prepare('SELECT * FROM order_items WHERE order_id=? ORDER BY id').all(id);
return order;
};
// GET / Liste mit Zählern + Items
router.get('/', authenticate, (req, res) => {
const orders = db.prepare(`
SELECT o.*,
COUNT(oi.id) AS item_count,
SUM(COALESCE(oi.qty_fertig,0)) AS qty_fertig_total,
SUM(COALESCE(oi.stueckzahl,0)) AS qty_total,
SUM(COALESCE(oi.custom_price,0) * COALESCE(oi.stueckzahl,0)) AS custom_price_sum,
SUM(COALESCE(oi.stunden,0) * COALESCE(oi.stueckzahl,0)) AS stunden_total
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.user_id = ?
GROUP BY o.id
ORDER BY o.created_at DESC
`).all(uid(req));
// Attach items to each order
const items = db.prepare('SELECT * FROM order_items WHERE order_id IN (SELECT id FROM orders WHERE user_id=?) ORDER BY order_id, id').all(uid(req));
const itemsByOrder = {};
for (const item of items) {
if (!itemsByOrder[item.order_id]) itemsByOrder[item.order_id] = [];
itemsByOrder[item.order_id].push(item);
}
for (const order of orders) order.items = itemsByOrder[order.id] || [];
res.json(orders);
});
router.get('/:id', authenticate, (req, res) => {
const order = loadOrder(req.params.id, uid(req));
if (!order) return res.status(404).json({ error: 'Nicht gefunden' });
res.json(order);
});
router.post('/', authenticate, (req, res) => {
const { name, bemerkung='', status='warteliste', custom_price=null } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' });
const r = db.prepare('INSERT INTO orders (user_id,name,bemerkung,status,custom_price) VALUES (?,?,?,?,?)')
.run(uid(req), name.trim(), bemerkung, status, custom_price);
res.json(loadOrder(r.lastInsertRowid, uid(req)));
});
router.put('/:id', authenticate, (req, res) => {
const ex = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(req.params.id, uid(req));
if (!ex) return res.status(404).json({ error: 'Nicht gefunden' });
const { name=ex.name, bemerkung=ex.bemerkung, status=ex.status, custom_price=ex.custom_price } = req.body;
// Bezahlt-Status mit Datum (optional überschreibbar)
let bezahlt = ex.bezahlt;
let bezahlt_am = ex.bezahlt_am;
if (req.body.bezahlt !== undefined) {
bezahlt = req.body.bezahlt ? 1 : 0;
bezahlt_am = req.body.bezahlt
? (req.body.bezahlt_am_override || new Date().toISOString())
: null;
}
// Abgeholt-Status mit Datum (optional überschreibbar)
let abgeholt = ex.abgeholt;
let abgeholt_am = ex.abgeholt_am;
if (req.body.abgeholt !== undefined) {
abgeholt = req.body.abgeholt ? 1 : 0;
abgeholt_am = req.body.abgeholt
? (req.body.abgeholt_am_override || new Date().toISOString())
: null;
}
db.prepare('UPDATE orders SET name=?,bemerkung=?,status=?,custom_price=?,bezahlt=?,bezahlt_am=?,abgeholt=?,abgeholt_am=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=?')
.run(name, bemerkung, status, custom_price, bezahlt, bezahlt_am, abgeholt, abgeholt_am, req.params.id, uid(req));
res.json(loadOrder(req.params.id, uid(req)));
});
router.delete('/:id', authenticate, (req, res) => {
const r = db.prepare('DELETE FROM orders 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 });
});
router.post('/:id/items', authenticate, (req, res) => {
const order = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(req.params.id, uid(req));
if (!order) return res.status(404).json({ error: 'Nicht gefunden' });
const { calculation_id, stueckzahl=1, custom_price=null } = req.body;
const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(calculation_id, uid(req));
if (!calc) return res.status(404).json({ error: 'Archiv-Eintrag nicht gefunden' });
const existing = db.prepare('SELECT * FROM order_items WHERE order_id=? AND calculation_id=?').get(req.params.id, calculation_id);
if (existing) {
const newQty = existing.stueckzahl + stueckzahl;
db.prepare('UPDATE order_items SET stueckzahl=?,qty_warteliste=qty_warteliste+? WHERE id=?').run(newQty, stueckzahl, existing.id);
} else {
db.prepare(`INSERT INTO order_items
(order_id,calculation_id,calc_name,preis_freundschaft,preis_normal,preis_auftrag,
stueckzahl,custom_price,status,qty_warteliste,qty_in_arbeit,qty_fertig,stunden)
VALUES (?,?,?,?,?,?,?,?,'warteliste',?,0,0,?)`)
.run(req.params.id, calc.id, calc.name, calc.preis_freundschaft, calc.preis_normal, calc.preis_auftrag,
stueckzahl, custom_price, stueckzahl, calc.stunden || 0);
}
autoUpdateOrderStatus(req.params.id);
res.json(loadOrder(req.params.id, uid(req)));
});
router.put('/:id/items/:itemId', authenticate, (req, res) => {
const order = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(req.params.id, uid(req));
if (!order) return res.status(404).json({ error: 'Nicht gefunden' });
const item = db.prepare('SELECT * FROM order_items WHERE id=? AND order_id=?').get(req.params.itemId, req.params.id);
if (!item) return res.status(404).json({ error: 'Position nicht gefunden' });
// Stückzahl geändert → Warteliste anpassen
if (req.body.stueckzahl !== undefined) {
const newQty = Math.max(1, req.body.stueckzahl);
const diff = newQty - item.stueckzahl;
const newWarte = Math.max(0, item.qty_warteliste + diff);
db.prepare('UPDATE order_items SET stueckzahl=?,qty_warteliste=? WHERE id=?').run(newQty, newWarte, item.id);
}
// Status-Zähler direkt setzen
if (req.body.qty_warteliste !== undefined || req.body.qty_in_arbeit !== undefined || req.body.qty_fertig !== undefined) {
const fresh = db.prepare('SELECT * FROM order_items WHERE id=?').get(item.id);
const w = req.body.qty_warteliste ?? fresh.qty_warteliste;
const a = req.body.qty_in_arbeit ?? fresh.qty_in_arbeit;
const f = req.body.qty_fertig ?? fresh.qty_fertig;
// Summe darf stueckzahl nicht überschreiten
const total = w + a + f;
if (total <= fresh.stueckzahl) {
db.prepare('UPDATE order_items SET qty_warteliste=?,qty_in_arbeit=?,qty_fertig=? WHERE id=?').run(w, a, f, item.id);
}
}
// Festpreis
if (req.body.custom_price !== undefined) {
db.prepare('UPDATE order_items SET custom_price=? WHERE id=?').run(req.body.custom_price, item.id);
}
autoUpdateOrderStatus(req.params.id);
res.json(loadOrder(req.params.id, uid(req)));
});
router.delete('/:id/items/:itemId', authenticate, (req, res) => {
const order = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(req.params.id, uid(req));
if (!order) return res.status(404).json({ error: 'Nicht gefunden' });
db.prepare('DELETE FROM order_items WHERE id=? AND order_id=?').run(req.params.itemId, req.params.id);
autoUpdateOrderStatus(req.params.id);
res.json(loadOrder(req.params.id, uid(req)));
});
module.exports = router;

View File

@@ -1,103 +0,0 @@
// Öffentliche Endpunkte kein Login nötig
const express = require('express');
const bcrypt = require('bcryptjs');
const path = require('path');
const fs = require('fs');
const db = require('../../db');
const { logPublicAccess } = require('../../publicAccessLog');
const router = express.Router();
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
function getShare(token) {
return db.prepare(`
SELECT s.*,
f.originalname as file_name, f.size as file_size, f.mimetype as mime_type, f.filename as file_path,
fo.name as folder_name
FROM public_file_shares s
LEFT JOIN files f ON f.id = s.file_id
LEFT JOIN folders fo ON fo.id = s.folder_id
WHERE s.token = ?
`).get(token);
}
function isExpired(s) {
return s.expires_at && s.expires_at < Math.floor(Date.now() / 1000);
}
// GET /:token Metadaten
router.get('/:token', (req, res) => {
const s = getShare(req.params.token);
if (!s) return res.status(404).json({ error: 'Link nicht gefunden' });
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
logPublicAccess({ linkType: 'file_share', path: `/s/${req.params.token}`, ip: req.ip, userAgent: req.headers['user-agent'] });
res.json({
label: s.label,
file_name: s.file_name,
file_size: s.file_size,
mime_type: s.mime_type,
folder_name: s.folder_name,
is_folder: !!s.folder_id,
needs_password: !!s.password_hash,
expires_at: s.expires_at,
});
});
// POST /:token/verify nur Passwort prüfen, kein Download
router.post('/:token/verify', async (req, res) => {
const s = getShare(req.params.token);
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
const ok = await bcrypt.compare(req.body?.password || '', s.password_hash);
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
res.json({ ok: true, file_name: s.file_name, file_size: s.file_size, mime_type: s.mime_type, is_folder: !!s.folder_id });
});
// POST /:token/download Datei herunterladen
router.post('/:token/download', async (req, res) => {
const s = getShare(req.params.token);
if (!s || !s.file_id) return res.status(404).json({ error: 'Nicht gefunden' });
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
const ok = await bcrypt.compare(req.body?.password || '', s.password_hash);
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
const filePath = path.join(UPLOAD_DIR, s.file_path);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' });
db.prepare('UPDATE public_file_shares SET download_count = download_count + 1 WHERE id=?').run(s.id);
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(s.file_name)}"`);
res.setHeader('Content-Type', s.mime_type || 'application/octet-stream');
fs.createReadStream(filePath).pipe(res);
});
// POST /:token/folder Ordnerinhalt
router.post('/:token/folder', async (req, res) => {
const s = getShare(req.params.token);
if (!s || !s.folder_id) return res.status(404).json({ error: 'Nicht gefunden' });
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
const ok = await bcrypt.compare(req.body?.password || '', s.password_hash);
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
const files = db.prepare(`
SELECT id, originalname as name, size, mimetype as mime_type, filename as file_path FROM files
WHERE folder_id=? AND user_id=? ORDER BY name
`).all(s.folder_id, s.user_id);
db.prepare('UPDATE public_file_shares SET download_count = download_count + 1 WHERE id=?').run(s.id);
res.json({ folder_name: s.folder_name, files, token: s.token });
});
// POST /:token/folder/:fileId Einzelne Datei aus Ordner
router.post('/:token/folder/:fileId', async (req, res) => {
const s = getShare(req.params.token);
if (!s || !s.folder_id) return res.status(404).json({ error: 'Nicht gefunden' });
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
const ok = await bcrypt.compare(req.body?.password || '', s.password_hash);
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
const file = db.prepare('SELECT * FROM files WHERE id=? AND folder_id=? AND user_id=?')
.get(req.params.fileId, s.folder_id, s.user_id);
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' });
const filePath = path.join(UPLOAD_DIR, file.file_path || file.filename);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' });
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.name || file.originalname)}"`);
res.setHeader('Content-Type', file.mime_type || file.mimetype || 'application/octet-stream');
fs.createReadStream(filePath).pipe(res);
});
module.exports = router;

View File

@@ -1,88 +0,0 @@
// Authentifizierte Endpunkte zum Verwalten öffentlicher Datei-Links
const express = require('express');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const db = require('../../db');
const { authenticate } = require('../../middleware/auth');
const router = express.Router();
function generateToken() { return crypto.randomBytes(24).toString('base64url'); }
// GET / eigene Shares auflisten
router.get('/', authenticate, (req, res) => {
try {
const shares = db.prepare(`
SELECT s.id, s.token, s.user_id, s.file_id, s.folder_id, s.label,
s.password_hash, s.expires_at, s.created_at, s.download_count,
f.originalname as file_name, f.size as file_size,
fo.name as folder_name
FROM public_file_shares s
LEFT JOIN files f ON f.id = s.file_id
LEFT JOIN folders fo ON fo.id = s.folder_id
WHERE s.user_id = ?
ORDER BY s.created_at DESC
`).all(req.user.id);
res.json({ shares });
} catch(e) {
console.error('[public-shares GET]', e.message);
res.status(500).json({ error: e.message });
}
});
// POST / neuen Share erstellen
router.post('/', authenticate, async (req, res) => {
const { file_id, folder_id, password, expires_hours, label } = req.body;
if (!file_id && !folder_id) return res.status(400).json({ error: 'file_id oder folder_id erforderlich' });
if (!password || !password.trim()) return res.status(400).json({ error: 'Passwort ist Pflicht' });
if (!expires_hours || Number(expires_hours) <= 0) return res.status(400).json({ error: 'Ablaufzeit ist Pflicht' });
if (file_id) {
const f = db.prepare('SELECT id FROM files WHERE id=? AND user_id=?').get(file_id, req.user.id);
if (!f) return res.status(403).json({ error: 'Keine Berechtigung' });
}
if (folder_id) {
const fo = db.prepare('SELECT id FROM folders WHERE id=? AND user_id=?').get(folder_id, req.user.id);
if (!fo) return res.status(403).json({ error: 'Keine Berechtigung' });
}
const token = generateToken();
const password_hash = await bcrypt.hash(password.trim(), 10);
const expires_at = Math.floor(Date.now() / 1000) + Number(expires_hours) * 3600;
const r = db.prepare(`
INSERT INTO public_file_shares (token, user_id, file_id, folder_id, label, password_hash, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(token, req.user.id, file_id || null, folder_id || null, label || null, password_hash, expires_at);
const share = db.prepare('SELECT * FROM public_file_shares WHERE id=?').get(r.lastInsertRowid);
res.json({ share, token });
});
// DELETE /:id Share löschen
router.delete('/:id', authenticate, (req, res) => {
const s = db.prepare('SELECT id FROM public_file_shares WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
db.prepare('DELETE FROM public_file_shares WHERE id=?').run(s.id);
res.json({ ok: true });
});
// Debug: Tabellenstatus + eigene Shares mit Token
router.get('/debug-table', (req, res) => {
try {
const exists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='public_file_shares'").get();
const count = exists ? db.prepare('SELECT COUNT(*) as n FROM public_file_shares').get() : null;
const cols = exists ? db.pragma('table_info(public_file_shares)').map(c=>c.name) : [];
const sample = exists ? db.prepare('SELECT id, token, user_id, file_id, folder_id, expires_at, download_count FROM public_file_shares LIMIT 5').all() : [];
// Test: ersten Token direkt abfragen wie public endpoint es tut
let publicTest = null;
if (sample.length > 0) {
const t = sample[0].token;
const s = db.prepare('SELECT s.*, f.originalname as file_name, fo.name as folder_name FROM public_file_shares s LEFT JOIN files f ON f.id = s.file_id LEFT JOIN folders fo ON fo.id = s.folder_id WHERE s.token=?').get(t);
const now = Math.floor(Date.now()/1000);
publicTest = { token: t, expires_at: s?.expires_at, now, isExpired: s?.expires_at < now, typeof_expires: typeof s?.expires_at };
}
res.json({ exists: !!exists, count: count?.n, cols, sample, publicTest });
} catch(e) { res.status(500).json({ error: e.message }); }
});
module.exports = router;

View File

@@ -1,444 +0,0 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const db = require('../../db');
const { authenticate, requireAdmin } = require('../../middleware/auth');
const router = express.Router();
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const webdav = require('./webdav');
// ── WebDAV Hilfsfunktionen ────────────────────────────────────────────────
function getFolderPathById(folderId) {
if (!folderId) return '';
const parts = [];
let cur = folderId;
const seen = new Set();
while (cur) {
if (seen.has(cur)) break; seen.add(cur);
const fo = db.prepare('SELECT name, parent_id FROM folders WHERE id=?').get(cur);
if (!fo) break;
parts.unshift(fo.name.replace(/[\/]/g, '_'));
cur = fo.parent_id;
}
return parts.join('/');
}
function davFileUrl(username, folderId, filename) {
const cfg = webdav.getConfig();
if (!cfg.enabled) return null;
const base = webdav.userPath(cfg, username);
const folder = getFolderPathById(folderId);
return folder ? `${base}/${folder}/${filename}` : `${base}/${filename}`;
}
function davFolderUrl(username, folderId) {
const cfg = webdav.getConfig();
if (!cfg.enabled) return null;
const base = webdav.userPath(cfg, username);
const folder = getFolderPathById(folderId);
return folder ? `${base}/${folder}` : base;
}
function getUserName(userId) {
return db.prepare('SELECT username FROM users WHERE id=?').get(userId)?.username || String(userId);
}
const getSetting = key => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(key)?.value;
const diskStorage = multer.diskStorage({
destination: UPLOAD_DIR,
filename: (req, file, cb) => cb(null, `${Date.now()}-${Math.random().toString(36).slice(2)}${path.extname(file.originalname)}`),
});
const memStorage = multer.memoryStorage();
const storage = diskStorage; // Standard: Disk
const getUpload = () => multer({
storage,
limits: { fileSize: parseInt(getSetting('file_max_size_mb') || '50') * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const allowed = (getSetting('file_allowed_ext') || '').split(',').map(e => e.trim().toLowerCase()).filter(Boolean);
if (!allowed.length) return cb(null, true);
const ext = path.extname(file.originalname).toLowerCase();
allowed.includes(ext) ? cb(null, true) : cb(new Error(`Dateityp nicht erlaubt: ${ext}`));
},
});
// Walk folder tree upward to check if any ancestor is shared with user
function isInSharedFolder(folderId, uid) {
let cur = folderId;
while (cur) {
if (db.prepare('SELECT id FROM folder_shares WHERE folder_id=? AND shared_with=?').get(cur, uid)) return true;
const p = db.prepare('SELECT parent_id FROM folders WHERE id=?').get(cur);
cur = p?.parent_id || null;
}
return false;
}
const enrichFolder = (folder) => ({
...folder,
fileCount: db.prepare('SELECT COUNT(*) c FROM files WHERE folder_id=?').get(folder.id).c,
subCount: db.prepare('SELECT COUNT(*) c FROM folders WHERE parent_id=?').get(folder.id).c,
totalSize: db.prepare('SELECT COALESCE(SUM(size),0) s FROM files WHERE folder_id=?').get(folder.id).s,
});
// ── Ordner ────────────────────────────────────────────────────────────────────
router.get('/folders', authenticate, (req, res) => {
const uid = req.user.id;
const parentId = req.query.parent_id ? parseInt(req.query.parent_id) : null;
// Own folders in this directory
const own = parentId
? db.prepare('SELECT * FROM folders WHERE user_id=? AND parent_id=? ORDER BY name').all(uid, parentId)
: db.prepare('SELECT * FROM folders WHERE user_id=? AND parent_id IS NULL ORDER BY name').all(uid);
// Shared folders
let shared = [];
if (!parentId) {
shared = db.prepare(`
SELECT f.*, u.username as owner,
GROUP_CONCAT(su.username) as shared_with_names,
MIN(fs.created_at) as shared_at
FROM folders f JOIN users u ON u.id=f.user_id
JOIN folder_shares fs ON fs.folder_id=f.id
JOIN users su ON su.id=fs.shared_with
WHERE fs.shared_with=?
GROUP BY f.id ORDER BY f.name
`).all(uid);
} else if (isInSharedFolder(parentId, uid)) {
const parentFolder = db.prepare('SELECT * FROM folders WHERE id=?').get(parentId);
if (parentFolder) {
shared = db.prepare('SELECT f.*, u.username as owner FROM folders f JOIN users u ON u.id=f.user_id WHERE f.parent_id=? AND f.user_id!=? ORDER BY f.name').all(parentId, uid);
}
}
// Folders shared BY me (root only)
const sharedByMe = parentId ? [] : db.prepare(`
SELECT DISTINCT f.*, u.username as owner,
GROUP_CONCAT(su.username) as shared_with_names,
MIN(fs.created_at) as shared_at
FROM folders f JOIN users u ON u.id=f.user_id
JOIN folder_shares fs ON fs.folder_id=f.id
JOIN users su ON su.id=fs.shared_with
WHERE f.user_id=?
GROUP BY f.id ORDER BY f.name
`).all(uid);
res.json({ own: own.map(enrichFolder), shared: shared.map(enrichFolder), sharedByMe: sharedByMe.map(enrichFolder) });
});
router.post('/folders', authenticate, (req, res) => {
const { name, parent_id } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' });
const parentId = parent_id ? parseInt(parent_id) : null;
if (parentId) {
const parent = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(parentId, req.user.id);
if (!parent) return res.status(404).json({ error: 'Überordner nicht gefunden' });
}
const r = db.prepare('INSERT INTO folders (user_id,name,parent_id) VALUES (?,?,?)').run(req.user.id, name.trim(), parentId);
const newFolder = db.prepare('SELECT * FROM folders WHERE id=?').get(r.lastInsertRowid);
// WebDAV: Ordner anlegen
const _davFolderCreate = davFolderUrl(getUserName(req.user.id), newFolder.id);
if (_davFolderCreate) webdav.mkdirp(_davFolderCreate).catch(e => console.warn('[webdav] mkdir:', e.message));
res.json(enrichFolder(newFolder));
});
router.delete('/folders/:id', authenticate, (req, res) => {
const uid = req.user.id;
const folder = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(req.params.id, uid);
if (!folder) return res.status(404).json({ error: 'Nicht gefunden' });
// Alle Unterordner rekursiv sammeln
function collectSubfolderIds(parentId) {
const children = db.prepare('SELECT id FROM folders WHERE parent_id=? AND user_id=?').all(parentId, uid);
let ids = [parentId];
for (const c of children) ids = ids.concat(collectSubfolderIds(c.id));
return ids;
}
const allFolderIds = collectSubfolderIds(folder.id);
// WebDAV: Ordner löschen (vor DB-Löschung damit Pfad noch auflösbar)
const _davFolderDel = davFolderUrl(getUserName(uid), folder.id);
if (_davFolderDel) webdav.deleteFile(_davFolderDel).catch(e => console.warn('[webdav] rmdir:', e.message));
// Alle Dateien in allen Unterordnern löschen (Disk + DB)
for (const fid of allFolderIds) {
const files = db.prepare('SELECT * FROM files WHERE folder_id=? AND user_id=?').all(fid, uid);
for (const f of files) { try { fs.unlinkSync(path.join(UPLOAD_DIR, f.filename)); } catch {} }
db.prepare('DELETE FROM files WHERE folder_id=? AND user_id=?').run(fid, uid);
}
// Alle Unterordner + Hauptordner aus DB löschen
for (const fid of allFolderIds.reverse()) {
db.prepare('DELETE FROM folders WHERE id=?').run(fid);
}
res.json({ success: true });
});
router.get('/folders/:id/shares', authenticate, (req, res) => {
const folder = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!folder) return res.status(404).json({ error: 'Nicht gefunden' });
res.json(db.prepare('SELECT u.id,u.username,fs.created_at as shared_at FROM folder_shares fs JOIN users u ON u.id=fs.shared_with WHERE fs.folder_id=?').all(folder.id));
});
router.post('/folders/:id/share', authenticate, (req, res) => {
const folder = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!folder) return res.status(404).json({ error: 'Nicht gefunden' });
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
db.prepare('INSERT OR IGNORE INTO folder_shares (folder_id,shared_by,shared_with) VALUES (?,?,?)').run(folder.id, req.user.id, target.id);
res.json({ success: true });
});
router.delete('/folders/:id/share/:userId', authenticate, (req, res) => {
const folder = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!folder) return res.status(404).json({ error: 'Nicht gefunden' });
db.prepare('DELETE FROM folder_shares WHERE folder_id=? AND shared_with=?').run(folder.id, req.params.userId);
res.json({ success: true });
});
// ── Dateien ───────────────────────────────────────────────────────────────────
router.get('/', authenticate, (req, res) => {
const uid = req.user.id;
const folderId = req.query.folder_id ? parseInt(req.query.folder_id) : null;
// Own files in this directory
const own = folderId
? db.prepare('SELECT f.*, u.username as owner FROM files f JOIN users u ON u.id=f.user_id WHERE f.user_id=? AND f.folder_id=? ORDER BY f.created_at DESC').all(uid, folderId)
: db.prepare('SELECT f.*, u.username as owner FROM files f JOIN users u ON u.id=f.user_id WHERE f.user_id=? AND f.folder_id IS NULL ORDER BY f.created_at DESC').all(uid);
// Files shared BY me
const sharedByMe = folderId ? [] : db.prepare(`
SELECT DISTINCT f.*, u.username as owner,
GROUP_CONCAT(su.username) as shared_with_names,
MIN(s.created_at) as shared_at,
MAX(s.accessed_at) as accessed_at,
MAX(CASE WHEN s.password_hash IS NOT NULL THEN 1 ELSE 0 END) as has_password
FROM files f JOIN users u ON u.id=f.user_id
JOIN file_shares s ON s.file_id=f.id
JOIN users su ON su.id=s.shared_with
WHERE f.user_id=?
GROUP BY f.id ORDER BY f.created_at DESC
`).all(uid);
// Files shared with me
let shared = [];
if (!folderId) {
shared = db.prepare(`
SELECT f.*, u.username as owner, sb.username as shared_by_name,
CASE WHEN s.password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password
FROM files f JOIN users u ON u.id=f.user_id
JOIN file_shares s ON s.file_id=f.id
JOIN users sb ON sb.id=s.shared_by
WHERE s.shared_with=?
ORDER BY f.created_at DESC
`).all(uid);
} else if (isInSharedFolder(folderId, uid)) {
const folder = db.prepare('SELECT * FROM folders WHERE id=?').get(folderId);
if (folder) {
shared = db.prepare('SELECT f.*, u.username as owner FROM files f JOIN users u ON u.id=f.user_id WHERE f.folder_id=? AND f.user_id=? ORDER BY f.created_at DESC').all(folderId, folder.user_id);
}
}
res.json({ own, shared, sharedByMe });
});
router.post('/', authenticate, (req, res) => {
const uid = req.user.id;
const maxSizeMb = parseInt(getSetting('file_max_size_mb') || '50');
const totalLimitMb= parseInt(getSetting('file_total_size_mb') || '500');
const usedBytes = db.prepare('SELECT COALESCE(SUM(size),0) AS s FROM files WHERE user_id=?').get(uid).s;
if (usedBytes >= totalLimitMb * 1024 * 1024)
return res.status(400).json({ error: `Gesamtlimit von ${totalLimitMb} MB erreicht` });
getUpload().single('file')(req, res, err => {
if (err) {
if (req.file) try { fs.unlinkSync(req.file.path); } catch {}
return res.status(400).json({ error: err.message });
}
if (!req.file) return res.status(400).json({ error: 'Keine Datei' });
const folderId = req.body.folder_id ? parseInt(req.body.folder_id) : null;
const r = db.prepare('INSERT INTO files (user_id,filename,originalname,mimetype,size,folder_id) VALUES (?,?,?,?,?,?)')
.run(uid, req.file.filename, req.file.originalname, req.file.mimetype||'application/octet-stream', req.file.size, folderId);
const newFile = db.prepare('SELECT f.*,u.username as owner FROM files f JOIN users u ON u.id=f.user_id WHERE f.id=?').get(r.lastInsertRowid);
// WebDAV: Datei asynchron spiegeln
const _davPath = davFileUrl(getUserName(uid), folderId, req.file.originalname);
if (_davPath) {
fs.readFile(path.join(UPLOAD_DIR, req.file.filename), (err, buf) => {
if (!err) webdav.uploadFile(buf, _davPath, req.file.mimetype).catch(e => console.error('[webdav upload]', e.message));
});
}
res.json(newFile);
});
});
// ── 3D-Modelle Hilfsendpoints ─────────────────────────────────────────────────
// Sucht einen Ordner ohne ihn anzulegen gibt null zurück wenn nicht gefunden
router.get('/find-folder', authenticate, (req, res) => {
const uid = req.user.id;
const { name, parent_id } = req.query;
if (!name?.trim()) return res.json(null);
const parentId = parent_id ? parseInt(parent_id) : null;
const folder = parentId
? db.prepare('SELECT * FROM folders WHERE user_id=? AND name=? AND parent_id=?').get(uid, name.trim(), parentId)
: db.prepare('SELECT * FROM folders WHERE user_id=? AND name=? AND parent_id IS NULL').get(uid, name.trim());
res.json(folder ? enrichFolder(folder) : null);
});
// Stellt sicher dass ein Ordner existiert (Name + optionaler parent), gibt ihn zurück
router.post('/ensure-folder', authenticate, (req, res) => {
const uid = req.user.id;
const { name, parent_id } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' });
const parentId = parent_id ? parseInt(parent_id) : null;
// Suche existierenden Ordner
const existing = parentId
? db.prepare('SELECT * FROM folders WHERE user_id=? AND name=? AND parent_id=?').get(uid, name.trim(), parentId)
: db.prepare('SELECT * FROM folders WHERE user_id=? AND name=? AND parent_id IS NULL').get(uid, name.trim());
if (existing) return res.json(enrichFolder(existing));
const r = db.prepare('INSERT INTO folders (user_id,name,parent_id) VALUES (?,?,?)').run(uid, name.trim(), parentId);
res.json(enrichFolder(db.prepare('SELECT * FROM folders WHERE id=?').get(r.lastInsertRowid)));
});
// Upload 3D-Dateien (ohne Ext-Beschränkung, für den Kalkulator)
const upload3d = multer({
storage,
limits: { fileSize: parseInt(process.env.MAX_3D_SIZE_MB || '200') * 1024 * 1024 },
});
router.post('/upload-3d', authenticate, (req, res) => {
upload3d.array('files', 20)(req, res, err => {
if (err) {
if (req.files?.length) req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch {} });
return res.status(400).json({ error: err.message });
}
if (!req.files?.length) return res.status(400).json({ error: 'Keine Dateien' });
const uid = req.user.id;
const folderId = req.body.folder_id ? parseInt(req.body.folder_id) : null;
const inserted = [];
for (const f of req.files) {
const r = db.prepare('INSERT INTO files (user_id,filename,originalname,mimetype,size,folder_id) VALUES (?,?,?,?,?,?)')
.run(uid, f.filename, f.originalname, f.mimetype || 'application/octet-stream', f.size, folderId);
inserted.push(r.lastInsertRowid);
}
res.json({ uploaded: inserted.length });
});
});
router.get('/storage', authenticate, (req, res) => {
const uid = req.user.id;
const used = db.prepare('SELECT COALESCE(SUM(size),0) AS s FROM files WHERE user_id=?').get(uid).s;
const count = db.prepare('SELECT COUNT(*) AS c FROM files WHERE user_id=?').get(uid).c;
const maxSizeMb = parseInt(getSetting('file_max_size_mb') || '50');
const totalLimitMb = parseInt(getSetting('file_total_size_mb') || '500');
res.json({ used, count, maxSizeMb, totalLimitMb });
});
router.get('/:id/download', authenticate, async (req, res) => {
const uid = req.user.id;
const file = db.prepare('SELECT * FROM files WHERE id=?').get(req.params.id);
if (!file) return res.status(404).json({ error: 'Nicht gefunden' });
const isOwner = file.user_id === uid;
const share = db.prepare('SELECT * FROM file_shares WHERE file_id=? AND shared_with=?').get(file.id, uid);
const inSharedDir = file.folder_id && isInSharedFolder(file.folder_id, uid);
if (!isOwner && !share && !inSharedDir) return res.status(403).json({ error: 'Kein Zugriff' });
// Passwortprüfung für Shares (Owner braucht kein Passwort)
if (!isOwner && share?.password_hash) {
const bcrypt = require('bcryptjs');
const pw = req.query.password || req.headers['x-share-password'] || '';
const ok = pw && await bcrypt.compare(pw, share.password_hash);
if (!ok) return res.status(403).json({
error: 'Falsches Passwort',
passwordRequired: !pw // true = noch kein PW eingegeben, false = falsches PW
});
}
const fp = path.join(UPLOAD_DIR, file.filename);
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'Datei fehlt' });
// Zugriff tracken (immer aktualisieren damit es sicher gesetzt wird)
if (share) {
db.prepare("UPDATE file_shares SET accessed_at = datetime('now','localtime') WHERE file_id=? AND shared_with=?")
.run(file.id, uid);
}
res.download(fp, file.originalname);
});
router.delete('/:id', authenticate, (req, res) => {
const file = db.prepare('SELECT * FROM files WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!file) return res.status(404).json({ error: 'Nicht gefunden' });
// WebDAV: Datei löschen
const _davFileDel = davFileUrl(getUserName(file.user_id), file.folder_id, file.originalname);
if (_davFileDel) webdav.deleteFile(_davFileDel).catch(e => console.warn('[webdav] delete:', e.message));
try { fs.unlinkSync(path.join(UPLOAD_DIR, file.filename)); } catch {}
db.prepare('DELETE FROM files WHERE id=?').run(file.id);
res.json({ success: true });
});
router.get('/:id/shares', authenticate, (req, res) => {
const file = db.prepare('SELECT * FROM files WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!file) return res.status(404).json({ error: 'Nicht gefunden' });
res.json(db.prepare(`
SELECT u.id, u.username, s.created_at as shared_at,
s.accessed_at,
CASE WHEN s.password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password
FROM file_shares s JOIN users u ON u.id=s.shared_with
WHERE s.file_id=?
`).all(file.id));
});
router.post('/:id/share', authenticate, async (req, res) => {
const file = db.prepare('SELECT * FROM files WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!file) return res.status(404).json({ error: 'Nicht gefunden' });
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
let passwordHash = null;
if (req.body.password) {
const bcrypt = require('bcryptjs');
passwordHash = await bcrypt.hash(req.body.password, 10);
}
const existing = db.prepare('SELECT id FROM file_shares WHERE file_id=? AND shared_with=?').get(file.id, target.id);
if (existing) {
db.prepare('UPDATE file_shares SET password_hash=?, accessed_at=NULL WHERE file_id=? AND shared_with=?')
.run(passwordHash, file.id, target.id);
} else {
db.prepare('INSERT INTO file_shares (file_id,shared_by,shared_with,password_hash) VALUES (?,?,?,?)')
.run(file.id, req.user.id, target.id, passwordHash);
}
res.json({ success: true });
});
router.delete('/:id/share/:userId', authenticate, (req, res) => {
const file = db.prepare('SELECT * FROM files WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!file) return res.status(404).json({ error: 'Nicht gefunden' });
db.prepare('DELETE FROM file_shares WHERE file_id=? AND shared_with=?').run(file.id, req.params.userId);
res.json({ success: true });
});
// Settings
router.get('/settings', authenticate, requireAdmin, (req, res) => {
const s = db.prepare('SELECT * FROM admin_settings').all();
const obj = {}; for (const r of s) obj[r.key] = r.value;
res.json(obj);
});
router.put('/settings', authenticate, requireAdmin, (req, res) => {
for (const key of ['file_max_size_mb','file_total_size_mb','file_allowed_ext']) {
if (req.body[key] !== undefined)
db.prepare('INSERT OR REPLACE INTO admin_settings (key,value) VALUES (?,?)').run(key, String(req.body[key]));
}
res.json({ success: true });
});
module.exports = router;

View File

@@ -1,173 +0,0 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const db = require('../../db');
const { logPublicAccess } = require('../../publicAccessLog');
const { authenticate, requireAdmin } = require('../../middleware/auth');
const router = express.Router();
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
function generateToken() { return crypto.randomBytes(20).toString('base64url'); }
function getOrCreateUploadFolder(userId) {
let f = db.prepare("SELECT * FROM folders WHERE user_id=? AND is_upload_folder=1 LIMIT 1").get(userId);
if (!f) {
const r = db.prepare("INSERT INTO folders (user_id,name,is_upload_folder,created_at) VALUES (?,?,1,datetime('now','localtime'))").run(userId,'Upload');
f = db.prepare('SELECT * FROM folders WHERE id=?').get(r.lastInsertRowid);
}
return f;
}
function canUseUploadShare(userId) {
const u = db.prepare('SELECT role,allow_upload_share FROM users WHERE id=?').get(userId);
return u && (u.role==='admin' || !!u.allow_upload_share);
}
// ── Eigene Shares ─────────────────────────────────────────────────────────────
router.get('/', authenticate, (req, res) => {
if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error:'Keine Berechtigung' });
const folder = getOrCreateUploadFolder(req.user.id);
const shares = db.prepare(`
SELECT s.*, COUNT(l.id) as upload_count
FROM upload_shares s
LEFT JOIN upload_share_logs l ON l.share_id=s.id
WHERE s.user_id=?
GROUP BY s.id ORDER BY s.created_at DESC
`).all(req.user.id);
res.json({ shares, folder });
});
router.post('/', authenticate, (req, res) => {
if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error:'Keine Berechtigung' });
const { password, expires_hours=24, max_size_mb=10 } = req.body;
if (!password?.trim()) return res.status(400).json({ error:'Passwort erforderlich' });
const folder = getOrCreateUploadFolder(req.user.id);
const token = generateToken();
const hash = bcrypt.hashSync(password.trim(), 10);
const expires = new Date(Date.now() + Number(expires_hours)*3600000).toISOString();
const r = db.prepare(`
INSERT INTO upload_shares (user_id,token,password_hash,expires_at,max_size_mb,folder_id,created_at)
VALUES (?,?,?,?,?,?,datetime('now','localtime'))
`).run(req.user.id, token, hash, expires, Number(max_size_mb), folder.id);
res.json(db.prepare('SELECT * FROM upload_shares WHERE id=?').get(r.lastInsertRowid));
});
router.delete('/:id', authenticate, (req, res) => {
const s = db.prepare('SELECT * FROM upload_shares WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!s) return res.status(404).json({ error:'Nicht gefunden' });
db.prepare("UPDATE upload_shares SET is_active=0, deactivated_reason='manual' WHERE id=?").run(s.id);
res.json({ ok:true });
});
router.delete('/:id/remove', authenticate, (req, res) => {
const s = db.prepare('SELECT * FROM upload_shares WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!s) return res.status(404).json({ error:'Nicht gefunden' });
db.prepare('DELETE FROM upload_shares WHERE id=?').run(s.id);
res.json({ ok:true });
});
router.get('/logs', authenticate, (req, res) => {
if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error:'Keine Berechtigung' });
const logs = db.prepare(`
SELECT l.*, s.expires_at, s.max_size_mb
FROM upload_share_logs l
JOIN upload_shares s ON s.id=l.share_id
WHERE l.user_id=? ORDER BY l.uploaded_at DESC LIMIT 100
`).all(req.user.id);
res.json(logs);
});
// ── Admin ─────────────────────────────────────────────────────────────────────
router.get('/admin/logs', authenticate, requireAdmin, (req, res) => {
const logs = db.prepare(`
SELECT l.*, s.expires_at, s.max_size_mb, u.username
FROM upload_share_logs l
JOIN upload_shares s ON s.id=l.share_id
JOIN users u ON u.id=l.user_id
ORDER BY l.uploaded_at DESC LIMIT 500
`).all();
res.json(logs);
});
router.get('/admin/shares', authenticate, requireAdmin, (req, res) => {
const shares = db.prepare(`
SELECT s.*, u.username, COUNT(l.id) as upload_count
FROM upload_shares s JOIN users u ON u.id=s.user_id
LEFT JOIN upload_share_logs l ON l.share_id=s.id
GROUP BY s.id ORDER BY s.created_at DESC
`).all();
res.json(shares);
});
router.post('/admin/permission', authenticate, requireAdmin, (req, res) => {
const { user_id, allow } = req.body;
db.prepare('UPDATE users SET allow_upload_share=? WHERE id=?').run(allow?1:0, user_id);
res.json({ ok:true });
});
// ── Öffentlich: Share-Info ────────────────────────────────────────────────────
router.get('/public/:token', (req, res) => {
const s = db.prepare("SELECT id,expires_at,max_size_mb,is_active,failed_attempts,deactivated_reason FROM upload_shares WHERE token=?").get(req.params.token);
if (!s || !s.is_active) return res.status(404).json({ error: s?.deactivated_reason==='too_many_attempts' ? 'Link wegen zu vieler Fehlversuche gesperrt' : 'Link ungültig oder deaktiviert' });
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' });
logPublicAccess({ linkType: 'upload_share', path: `/u/${req.params.token}`, ip: req.ip, userAgent: req.headers['user-agent'] });
res.json({ ok:true, max_size_mb:s.max_size_mb, expires_at:s.expires_at });
});
// ── Öffentlich: Passwort prüfen ───────────────────────────────────────────────
router.post('/public/:token/verify', (req, res) => {
const s = db.prepare("SELECT * FROM upload_shares WHERE token=?").get(req.params.token);
if (!s || !s.is_active) return res.status(404).json({ error:'Link ungültig' });
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' });
if (!bcrypt.compareSync(req.body?.password || '', s.password_hash)) {
const attempts = (s.failed_attempts||0) + 1;
if (attempts >= 3) {
// Link sperren
db.prepare("UPDATE upload_shares SET is_active=0, failed_attempts=?, deactivated_reason='too_many_attempts' WHERE id=?").run(attempts, s.id);
return res.status(401).json({ error:'Zu viele Fehlversuche Link wurde gesperrt', locked:true });
}
db.prepare("UPDATE upload_shares SET failed_attempts=? WHERE id=?").run(attempts, s.id);
return res.status(401).json({ error:'Falsches Passwort', attempts_left: 3-attempts });
}
// Erfolgreich Fehlversuche zurücksetzen
db.prepare("UPDATE upload_shares SET failed_attempts=0 WHERE id=?").run(s.id);
res.json({ ok:true, max_size_mb:s.max_size_mb, expires_at:s.expires_at });
});
// ── Öffentlich: Upload ────────────────────────────────────────────────────────
router.post('/public/:token/upload', (req, res) => {
const s = db.prepare("SELECT * FROM upload_shares WHERE token=?").get(req.params.token);
if (!s || !s.is_active) return res.status(404).json({ error:'Link ungültig' });
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' });
const pw = req.headers['x-upload-password'] || '';
if (!bcrypt.compareSync(pw, s.password_hash)) return res.status(401).json({ error:'Nicht autorisiert' });
const storage = multer.diskStorage({ destination: UPLOAD_DIR });
const upload = multer({ storage, limits:{ fileSize: s.max_size_mb*1024*1024 } }).single('file');
upload(req, res, err => {
if (err) {
if (err.code==='LIMIT_FILE_SIZE') return res.status(413).json({ error:`Datei zu groß (max ${s.max_size_mb} MB)` });
return res.status(500).json({ error:err.message });
}
if (!req.file) return res.status(400).json({ error:'Keine Datei' });
try {
db.prepare(`INSERT INTO files (user_id,filename,originalname,mimetype,size,folder_id,created_at) VALUES (?,?,?,?,?,?,datetime('now','localtime'))`).run(s.user_id,req.file.filename,req.file.originalname,req.file.mimetype,req.file.size,s.folder_id);
db.prepare(`INSERT INTO upload_share_logs (share_id,user_id,originalname,size,ip_address,uploaded_at) VALUES (?,?,?,?,?,datetime('now','localtime'))`).run(s.id,s.user_id,req.file.originalname,req.file.size,req.ip||'');
res.json({ ok:true, filename:req.file.originalname });
} catch(e) {
try { fs.unlinkSync(path.join(UPLOAD_DIR, req.file.filename)); } catch {}
res.status(500).json({ error:e.message });
}
});
});
module.exports = router;

View File

@@ -1,169 +0,0 @@
/**
* WebDAV Storage Backend für Synology NAS
* Abstrahiert alle Datei-Operationen lokal oder WebDAV
*/
const https = require('https');
const http = require('http');
const fs = require('fs');
const path = require('path');
const db = require('../../db');
function getSetting(key) {
return db.prepare('SELECT value FROM admin_settings WHERE key=?').get(key)?.value || '';
}
function getConfig() {
return {
url: getSetting('webdav_url'), // z.B. http://192.168.1.100:5005
user: getSetting('webdav_user'),
password: getSetting('webdav_password'),
basePath: getSetting('webdav_base_path') || '/dickendock', // z.B. /dickendock
enabled: getSetting('webdav_enabled') === '1',
};
}
function authHeader(cfg) {
return 'Basic ' + Buffer.from(`${cfg.user}:${cfg.password}`).toString('base64');
}
// WebDAV Request ausführen
function davRequest(method, davPath, opts = {}) {
return new Promise((resolve, reject) => {
const cfg = getConfig();
const base = new URL(cfg.url);
const fullPath = base.pathname.replace(/\/$/, '') + davPath;
const mod = base.protocol === 'https:' ? https : http;
const headers = {
'Authorization': authHeader(cfg),
'Accept': '*/*',
...opts.headers,
};
const reqOpts = {
hostname: base.hostname,
port: base.port || (base.protocol === 'https:' ? 443 : 80),
path: fullPath,
method,
headers,
rejectUnauthorized: false, // Synology Self-Signed Certs
};
if (opts.body) {
headers['Content-Length'] = Buffer.byteLength(opts.body);
}
const req = mod.request(reqOpts, res => {
let data = '';
res.setEncoding('binary');
res.on('data', d => data += d);
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }));
});
req.on('error', reject);
if (opts.body) req.write(opts.body);
req.end();
});
}
// ── Öffentliche API ────────────────────────────────────────────────────────
/**
* Ordner auf WebDAV anlegen (inkl. alle Parent-Ordner)
*/
async function mkdirp(davPath) {
const parts = davPath.split('/').filter(Boolean);
let current = '';
for (const part of parts) {
current += '/' + part;
const r = await davRequest('MKCOL', current);
// 201 = erstellt, 301/405/409/423 = schon vorhanden oder Konflikt (ignorieren)
const ok = [201, 301, 405, 409, 423].includes(r.status);
if (!ok) {
console.warn(`[webdav mkdirp] MKCOL ${current}${r.status} (ignoriert)`);
}
}
}
/**
* User-Basispfad auf WebDAV: /dickendock/{username}
*/
function userPath(cfg, username) {
return cfg.basePath.replace(/\/$/, '') + '/' + username.replace(/[^a-zA-Z0-9_-]/g, '_');
}
/**
* Datei auf WebDAV hochladen
* @param {Buffer} buffer - Dateiinhalt
* @param {string} davPath - Zielpfad auf WebDAV
* @param {string} mimeType
*/
async function uploadFile(buffer, davPath, mimeType = 'application/octet-stream') {
try { await mkdirp(davPath.split('/').slice(0, -1).join('/')); } catch(e) {
console.warn('[webdav] mkdirp warning:', e.message);
}
const r = await new Promise((resolve, reject) => {
const cfg = getConfig();
const base = new URL(cfg.url);
const fullPath = base.pathname.replace(/\/$/, '') + davPath;
const mod = base.protocol === 'https:' ? https : http;
const headers = {
'Authorization': authHeader(cfg),
'Content-Type': mimeType,
'Content-Length': buffer.length,
};
const req = mod.request({
hostname: base.hostname,
port: base.port || (base.protocol === 'https:' ? 443 : 80),
path: fullPath,
method: 'PUT',
headers,
rejectUnauthorized: false,
}, res => { res.resume(); resolve({ status: res.statusCode }); });
req.on('error', reject);
req.write(buffer);
req.end();
});
if (r.status !== 201 && r.status !== 204) {
throw new Error(`PUT ${davPath}${r.status}`);
}
}
/**
* Datei von WebDAV downloaden (gibt Buffer zurück)
*/
async function downloadFile(davPath) {
const r = await davRequest('GET', davPath);
if (r.status !== 200) throw new Error(`GET ${davPath}${r.status}`);
return Buffer.from(r.body, 'binary');
}
/**
* Datei/Ordner auf WebDAV löschen
*/
async function deleteFile(davPath) {
const r = await davRequest('DELETE', davPath);
if (r.status !== 204 && r.status !== 200 && r.status !== 404) {
throw new Error(`DELETE ${davPath}${r.status}`);
}
}
/**
* Verbindung testen
*/
async function testConnection() {
const cfg = getConfig();
if (!cfg.url || !cfg.user || !cfg.password) throw new Error('WebDAV nicht konfiguriert');
const r = await davRequest('PROPFIND', cfg.basePath || '/', {
headers: { 'Depth': '0', 'Content-Type': 'application/xml' },
body: '<?xml version="1.0"?><propfind xmlns="DAV:"><prop><displayname/></prop></propfind>',
});
if (r.status !== 207 && r.status !== 200) throw new Error(`Verbindung fehlgeschlagen: HTTP ${r.status}`);
return true;
}
module.exports = { getConfig, userPath, mkdirp, uploadFile, downloadFile, deleteFile, testConnection };

View File

@@ -1,617 +0,0 @@
const express = require('express');
const db = require('../../db');
const { logPush } = require('../../pushLog');
const { authenticate } = require('../../middleware/auth');
const router = express.Router();
// ── DB-Migration ──────────────────────────────────────────────────────────────
(function migrate() {
const cols = db.pragma('table_info(geo_games)').map(c => c.name);
if (!cols.length) {
db.exec(`
CREATE TABLE IF NOT EXISTS geo_games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL,
opponent_id INTEGER NOT NULL,
grid TEXT NOT NULL,
terrain TEXT NOT NULL DEFAULT '[]',
current_turn INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
winner_id INTEGER,
move_count INTEGER NOT NULL DEFAULT 0,
last_event TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
`);
}
for (const [col, def] of [
['move_count', 'INTEGER NOT NULL DEFAULT 0'],
['terrain', 'TEXT'],
['last_event', 'TEXT'],
['owner_head', 'TEXT'],
['opp_head', 'TEXT'],
['owner_scouts', 'TEXT'],
['opp_scouts', 'TEXT'],
['seen_by', 'TEXT'],
]) {
if (!cols.includes(col)) db.exec(`ALTER TABLE geo_games ADD COLUMN ${col} ${def}`);
}
// fog_owner/fog_opp waren alte Spalten, ignorieren
})();
// ── Konstanten ────────────────────────────────────────────────────────────────
const GRID = 20;
const DIRS8 = [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]];
const T_NORMAL = 0;
const T_GOLD = 1;
const T_MINE = 2;
const T_ROCK = 3;
// ── Terrain generieren ────────────────────────────────────────────────────────
function generateTerrain(ownerHead, oppHead) {
const t = Array(GRID).fill(null).map(() => Array(GRID).fill(T_NORMAL));
const safe = new Set();
// 3x3 Sicherheitszone um beide Startecken
for (const headStr of [ownerHead, oppHead]) {
const [hr,hc] = headStr.split(',').map(Number);
for (let dr=-2;dr<=2;dr++) for (let dc=-2;dc<=2;dc++) {
const r=hr+dr, c=hc+dc;
if (r>=0&&r<GRID&&c>=0&&c<GRID) safe.add(`${r},${c}`);
}
}
const cells = [];
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++) if (!safe.has(`${r},${c}`)) cells.push([r,c]);
for (let i=cells.length-1;i>0;i--) { const j=Math.floor(Math.random()*(i+1)); [cells[i],cells[j]]=[cells[j],cells[i]]; }
let idx=0;
for (let i=0;i<12&&idx<cells.length;i++,idx++) t[cells[idx][0]][cells[idx][1]] = T_GOLD;
for (let i=0;i<10&&idx<cells.length;i++,idx++) t[cells[idx][0]][cells[idx][1]] = T_MINE;
for (let i=0;i<18&&idx<cells.length;i++,idx++) t[cells[idx][0]][cells[idx][1]] = T_ROCK;
return t;
}
// ── Fog of War ────────────────────────────────────────────────────────────────
function computeFog(grid, playerId, scouts = []) {
const visible = Array(GRID).fill(null).map(() => Array(GRID).fill(false));
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++) {
if (grid[r][c] === playerId) {
visible[r][c] = true;
for (const [dr,dc] of DIRS8) {
const nr=r+dr, nc=c+dc;
if (nr>=0&&nr<GRID&&nc>=0&&nc<GRID) visible[nr][nc] = true;
}
}
}
// Scout-Aufdeckungen: 3x3 um jeden Scout-Mittelpunkt
for (const [sr,sc] of scouts) {
for (let dr=-1;dr<=1;dr++) for (let dc=-1;dc<=1;dc++) {
const nr=sr+dr, nc=sc+dc;
if (nr>=0&&nr<GRID&&nc>=0&&nc<GRID) visible[nr][nc] = true;
}
}
return visible;
}
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
function createGrid(ownerId, oppId) {
const corners = [[0,0],[0,GRID-1],[GRID-1,0],[GRID-1,GRID-1]];
for (let i=corners.length-1;i>0;i--) {
const j=Math.floor(Math.random()*(i+1));
[corners[i],corners[j]]=[corners[j],corners[i]];
}
const [or,oc] = corners[0];
const [pr,pc] = corners[1];
const g = Array(GRID).fill(null).map(() => Array(GRID).fill(0));
g[or][oc] = ownerId;
g[pr][pc] = oppId;
return { grid:g, ownerHead:`${or},${oc}`, oppHead:`${pr},${pc}` };
}
// Prüft ob [row,col] an den Kopf [hr,hc] angrenzt (8 Richtungen)
function isAdjacentToHead(row, col, hr, hc) {
return Math.abs(row - hr) <= 1 && Math.abs(col - hc) <= 1 && !(row === hr && col === hc);
}
// Fallback für canMove: prüft ob Spieler noch irgendwo vom Kopf aus ziehen kann
function isAdjacent(grid, row, col, playerId) {
for (const [dr,dc] of DIRS8) {
const r=row+dr, c=col+dc;
if (r>=0&&r<GRID&&c>=0&&c<GRID&&grid[r][c]===playerId) return true;
}
return false;
}
function canMove(grid, terrain, playerId, headRow, headCol) {
// Schlangen-Regel: nur Felder die an den Kopf grenzen sind erreichbar
if (headRow !== undefined && headCol !== undefined) {
for (const [dr,dc] of DIRS8) {
const r=headRow+dr, c=headCol+dc;
if (r>=0&&r<GRID&&c>=0&&c<GRID&&grid[r][c]===0&&terrain[r][c]!==T_ROCK) return true;
}
return false;
}
// Fallback (alte Spiele ohne head)
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++)
if (grid[r][c]===0 && terrain[r][c]!==T_ROCK && isAdjacent(grid,r,c,playerId)) return true;
return false;
}
// Score auf dem vollständigen (ungemaskten) Grid berechnen
function calcScore(grid, terrain, playerId) {
let score = 0;
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++) {
if (grid[r][c] !== playerId) continue;
if (terrain[r][c] === T_GOLD) score += 3;
else score += 1; // Mine zählt als normales Feld (+1), Strafe kommt durch Explosion der Nachbarn
}
return Math.max(0, score);
}
// ── Minenexplosion: alle 8 Nachbarn der Mine werden zerstört ─────────────────
// Gibt zurück: { grid, destroyedByOwner, destroyedByOpp, blastCells }
// Kettenexplosion: Mine explodiert, alle 8 Nachbarn werden zu Felsen.
// Wenn ein Nachbar auch eine Mine ist, explodiert diese ebenfalls (rekursiv).
// exploded = Set von bereits gezündeten Minen-Positionen (verhindert Endlosschleife)
function explodeMine(grid, terrain, row, col, ownerId, oppId, exploded = new Set()) {
const key = `${row},${col}`;
if (exploded.has(key)) return { destroyedByOwner:0, destroyedByOpp:0, blastCells:[] };
exploded.add(key);
let destroyedByOwner = 0;
let destroyedByOpp = 0;
const blastCells = [];
const chainMines = [];
// Das Mine-Feld selbst wird auch zu Felsen
grid[row][col] = 0;
terrain[row][col] = T_ROCK;
blastCells.push([row, col]);
// Alle 8 Nachbarn werden zu Felsen — erst Ketten-Minen merken, dann umwandeln
for (const [dr,dc] of DIRS8) {
const r=row+dr, c=col+dc;
if (r<0||r>=GRID||c<0||c>=GRID) continue;
if (terrain[r][c] === T_ROCK) continue; // schon Felsen
// Ketten-Mine merken BEVOR terrain geändert wird
if (terrain[r][c] === T_MINE && !exploded.has(`${r},${c}`)) {
chainMines.push([r,c]);
}
// Punktabzug für besetzte Felder
if (grid[r][c] === ownerId) destroyedByOwner++;
else if (grid[r][c] === oppId) destroyedByOpp++;
// Feld wird zu Felsen
grid[r][c] = 0;
terrain[r][c] = T_ROCK;
blastCells.push([r,c]);
}
// Kettenexplosionen
for (const [mr,mc] of chainMines) {
const chain = explodeMine(grid, terrain, mr, mc, ownerId, oppId, exploded);
destroyedByOwner += chain.destroyedByOwner;
destroyedByOpp += chain.destroyedByOpp;
blastCells.push(...chain.blastCells);
}
return { destroyedByOwner, destroyedByOpp, blastCells };
}
function sendPush(userId, title, message) {
const cfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId);
if (!cfg?.app_token || !cfg?.user_key) return;
fetch('https://api.pushover.net/1/messages.json', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ token:cfg.app_token, user:cfg.user_key, title, message, priority:0 }),
}).catch(() => {});
logPush({ userId, title, message, priority: 0, source: 'gebietseroberung' });
}
// Spiel laden + Fog anwenden + echte Scores beifügen
function getGameForUser(id, requesterId) {
const game = db.prepare(`
SELECT g.*, u1.username as owner_name, u2.username as opp_name
FROM geo_games g
JOIN users u1 ON u1.id = g.owner_id
JOIN users u2 ON u2.id = g.opponent_id
WHERE g.id = ?
`).get(id);
if (!game) return null;
const grid = JSON.parse(game.grid);
const terrain = JSON.parse(game.terrain || '[]');
// Echte Scores auf ungemasktem Grid berechnen
const ownerScore = terrain.length ? calcScore(grid, terrain, game.owner_id) : 0;
const oppScore = terrain.length ? calcScore(grid, terrain, game.opponent_id) : 0;
// Dem Requester mitteilen welcher Score seiner ist
const myScore = requesterId === game.owner_id ? ownerScore : oppScore;
const oppSc = requesterId === game.owner_id ? oppScore : ownerScore;
const myHead = requesterId === game.owner_id ? game.owner_head : game.opp_head;
const oppHead = requesterId === game.owner_id ? game.opp_head : game.owner_head;
// Altes Spiel ohne Terrain: direkt zurückgeben, kein Fog
if (!terrain.length) {
return { ...game, myScore:0, oppScore:0, myHead, oppHead };
}
// Scouts des anfragenden Spielers laden
const myScoutsRaw = requesterId === game.owner_id ? game.owner_scouts : game.opp_scouts;
const myScouts = myScoutsRaw ? JSON.parse(myScoutsRaw) : [];
// Bei beendetem Spiel: vollständiges Grid ohne Fog
let maskedGrid, maskedTerrain;
if (game.status === 'finished') {
maskedGrid = grid;
maskedTerrain = terrain;
} else {
const visible = computeFog(grid, requesterId, myScouts);
maskedGrid = grid.map((row,r) => row.map((cell,c) => visible[r][c] ? cell : (cell===requesterId ? cell : 0)));
maskedTerrain = terrain.map((row,r) => row.map((cell,c) => visible[r][c] ? cell : -1));
}
return {
...game,
grid: JSON.stringify(maskedGrid),
terrain: JSON.stringify(maskedTerrain),
myScore,
oppScore: oppSc,
myHead,
oppHead,
myScouts,
};
}
const uid = req => req.user.id;
// ── Alle Spiele ───────────────────────────────────────────────────────────────
router.get('/', authenticate, (req, res) => {
const me = uid(req);
const games = db.prepare(`
SELECT g.*, u1.username as owner_name, u2.username as opp_name
FROM geo_games g
JOIN users u1 ON u1.id = g.owner_id
JOIN users u2 ON u2.id = g.opponent_id
WHERE (g.owner_id=? OR g.opponent_id=?)
AND NOT (g.status='finished' AND g.move_count < 2)
ORDER BY g.updated_at DESC
`).all(me, me);
res.json(games);
});
// ── Meine Züge + ungesehene Spielenden ──────────────────────────────────────
router.get('/my-turns', authenticate, (req, res) => {
const me = uid(req);
// Aktive Spiele wo ich dran bin
const { count: turns } = db.prepare(`
SELECT COUNT(*) as count FROM geo_games
WHERE status='active' AND current_turn=?
`).get(me);
// Beendete Spiele wo ich beteiligt bin aber noch nicht gesehen habe
const games = db.prepare(`
SELECT id, seen_by FROM geo_games
WHERE status='finished' AND move_count >= 2
AND (owner_id=? OR opponent_id=?)
`).all(me, me);
const unseen = games.filter(g => {
const seen = g.seen_by ? JSON.parse(g.seen_by) : [];
return !seen.includes(me);
}).length;
res.json({ count: turns + unseen });
});
// ── Topliste ──────────────────────────────────────────────────────────────────
router.get('/leaderboard', authenticate, (req, res) => {
const rows = db.prepare(`
SELECT u.username, COUNT(*) as wins
FROM geo_games g
JOIN users u ON u.id = g.winner_id
WHERE g.status='finished' AND g.winner_id IS NOT NULL AND g.move_count >= 2
GROUP BY g.winner_id ORDER BY wins DESC LIMIT 20
`).all();
res.json(rows);
});
// ── User ──────────────────────────────────────────────────────────────────────
router.get('/users', authenticate, (req, res) => {
const me = uid(req);
const isAdmin = req.user?.role === 'admin';
const users = isAdmin
? db.prepare('SELECT id, username FROM users WHERE id != ? ORDER BY username').all(me)
: db.prepare('SELECT id, username FROM users WHERE id != ? AND hidden != 1 ORDER BY username').all(me);
res.json(users);
});
// ── Einzelnes Spiel ───────────────────────────────────────────────────────────
router.get('/:id', authenticate, (req, res) => {
const me = uid(req);
const game = getGameForUser(req.params.id, me);
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' });
res.json(game);
});
// ── Neues Spiel ───────────────────────────────────────────────────────────────
router.post('/', authenticate, (req, res) => {
const me = uid(req);
const { opponent_id } = req.body;
if (!opponent_id) return res.status(400).json({ error: 'Gegner fehlt' });
if (Number(opponent_id) === me) return res.status(400).json({ error: 'Du kannst nicht gegen dich spielen' });
const opp = db.prepare('SELECT id, username FROM users WHERE id=?').get(opponent_id);
if (!opp) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
const { grid, ownerHead, oppHead } = createGrid(me, Number(opponent_id));
const terrain = generateTerrain(ownerHead, oppHead);
const result = db.prepare(`
INSERT INTO geo_games (owner_id, opponent_id, grid, terrain, current_turn, move_count, owner_head, opp_head)
VALUES (?, ?, ?, ?, ?, 0, ?, ?)
`).run(me, Number(opponent_id), JSON.stringify(grid), JSON.stringify(terrain), me, ownerHead, oppHead);
const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand';
sendPush(Number(opponent_id), '⬡ Hex Wars', `${myName} hat dich zu einem Spiel eingeladen! Du bist zuerst dran.`);
res.json({ id: result.lastInsertRowid });
});
// ── Zug machen ────────────────────────────────────────────────────────────────
router.post('/:id/move', authenticate, (req, res) => {
const me = uid(req);
const { row, col } = req.body;
if (row === undefined || col === undefined) return res.status(400).json({ error: 'row/col fehlt' });
const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id);
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' });
if (game.status !== 'active') return res.status(400).json({ error: 'Spiel beendet' });
if (game.current_turn !== me) return res.status(400).json({ error: 'Nicht dein Zug' });
const grid = JSON.parse(game.grid);
const terrain = JSON.parse(game.terrain || '[]');
if (row<0||row>=GRID||col<0||col>=GRID) return res.status(400).json({ error: 'Ungültige Position' });
if (grid[row][col] !== 0) return res.status(400).json({ error: 'Zelle belegt' });
if (terrain.length && terrain[row][col] === T_ROCK) return res.status(400).json({ error: 'Felsen — nicht betretbar' });
// Schlangen-Regel: Zug muss an den eigenen Kopf angrenzen
const myHeadStr = game.owner_id === me ? game.owner_head : game.opp_head;
const [myHR, myHC] = myHeadStr ? myHeadStr.split(',').map(Number) : [null, null];
if (myHR !== null && !isAdjacentToHead(row, col, myHR, myHC))
return res.status(400).json({ error: 'Nur an deinen Kopf anbauen!' });
if (myHR === null && !isAdjacent(grid, row, col, me))
return res.status(400).json({ error: 'Nicht angrenzend' });
const opponent = game.owner_id === me ? game.opponent_id : game.owner_id;
const newMoves = (game.move_count || 0) + 1;
const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand';
const oppName = db.prepare('SELECT username FROM users WHERE id=?').get(opponent)?.username || 'Jemand';
// Zug ausführen
grid[row][col] = me;
// Neuen Kopf setzen
let newOwnerHead = game.owner_head;
let newOppHead = game.opp_head;
if (game.owner_id === me) newOwnerHead = `${row},${col}`;
else newOppHead = `${row},${col}`;
let lastEvent = null;
let mineHit = false;
let blastCells = [];
let destroyedOwn = 0;
let destroyedOpp = 0;
// ── Mine betreten: Explosion der 8 Nachbarn ──────────────────────────────
if (terrain.length && terrain[row][col] === T_MINE) {
mineHit = true;
const explosion = explodeMine(grid, terrain, row, col, me, opponent);
blastCells = explosion.blastCells;
destroyedOwn = explosion.destroyedByOwner;
destroyedOpp = explosion.destroyedByOpp;
lastEvent = JSON.stringify({ type:'mine', player:me, row, col, blastCells, destroyedOwn, destroyedOpp });
// Kopf zurücksetzen: Mine-Feld gehört dem Spieler, aber Kopf soll auf das letzte
// noch existierende eigene Feld vor dem Mine-Zug zeigen.
// Der alte Kopf (myHR, myHC) wird durch explodeMine nicht zerstört (Explosion trifft Nachbarn),
// also ist er noch vorhanden — Kopf bleibt beim alten Kopf (vor dem Mine-Zug).
// Falls kein alter Kopf bekannt: suche nächstes eigenes Feld neben der Mine.
// Kopf nach Explosion: erstes eigenes Feld das AUSSERHALB des 3x3-Radius der Mine liegt
// (Mine + 8 Nachbarn sind alle Felsen — der Kopf muss weiter weg sein)
let safeHead = null;
// Alle eigenen Felder sammeln und nach Entfernung zur Mine sortieren
const ownCells = [];
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++) {
if (grid[r][c] === me) {
const outside = Math.abs(r-row)>1 || Math.abs(c-col)>1;
if (outside) ownCells.push([r, c, Math.abs(r-row)+Math.abs(c-col)]);
}
}
// Nächstes eigenes Feld außerhalb des Radius (Manhattan-Distanz minimal)
if (ownCells.length > 0) {
ownCells.sort((a,b) => a[2]-b[2]);
safeHead = `${ownCells[0][0]},${ownCells[0][1]}`;
}
if (safeHead) {
if (game.owner_id === me) newOwnerHead = safeHead;
else newOppHead = safeHead;
}
}
// ── Mine ins Sichtfeld gekommen: Explosion prüfen ────────────────────────
// Alle Minen die jetzt im Sichtfeld von BEIDEN Spielern liegen und noch nicht explodiert sind
// werden gezündet sobald ein Spieler sie sieht (d.h. sie grenzen an sein Gebiet)
// Dieses Feature: Mine explodiert wenn sie ins Sichtfeld kommt (nicht wenn betreten)
// → wird beim Betreten gehandhabt (oben), kein extra Trigger nötig
// Nächster Zug
let nextTurn = mineHit ? opponent : opponent; // Mine = Zug verlieren
let status = 'active';
let winnerId = null;
// Effektiver Kopf nach Zug (bei Mine: zurückgesetzter Kopf, sonst neue Position)
const oppHeadStr = game.owner_id === me ? game.opp_head : game.owner_head;
const [oppHR, oppHC] = oppHeadStr ? oppHeadStr.split(',').map(Number) : [null, null];
const effectiveMyHeadStr = game.owner_id === me ? newOwnerHead : newOppHead;
const [effectiveMyHR, effectiveMyHC] = effectiveMyHeadStr
? effectiveMyHeadStr.split(',').map(Number) : [row, col];
// Spielende: niemand kann mehr ziehen
if (!canMove(grid, terrain, opponent, oppHR, oppHC) && !canMove(grid, terrain, me, effectiveMyHR, effectiveMyHC)) {
status = 'finished';
} else if (!canMove(grid, terrain, opponent, oppHR, oppHC)) {
nextTurn = me;
}
// Schnellsieg: jemand hat doppelt so viele Punkte wie der Gegner (mind. 10 Züge gespielt)
if (status === 'active' && newMoves >= 10) {
const ms = calcScore(grid, terrain, me);
const os = calcScore(grid, terrain, opponent);
if (ms >= os * 2 && os > 0) { status = 'finished'; }
else if (os >= ms * 2 && ms > 0) { status = 'finished'; }
}
if (status === 'finished') {
const ms = calcScore(grid, terrain, me);
const os = calcScore(grid, terrain, opponent);
winnerId = ms > os ? me : (os > ms ? opponent : null);
}
// last_event: nach jedem Zug das vorherige Event clearen (war vom letzten Zug)
// Nur das neue Event (falls Mine) bleibt stehen
const finalEvent = lastEvent; // null wenn kein Mine-Treffer
db.prepare(`
UPDATE geo_games SET grid=?, terrain=?, current_turn=?, status=?, winner_id=?, move_count=?, last_event=?,
owner_head=?, opp_head=?, updated_at=datetime('now','localtime') WHERE id=?
`).run(JSON.stringify(grid), JSON.stringify(terrain), nextTurn, status, winnerId, newMoves, finalEvent, newOwnerHead, newOppHead, game.id);
// Pushover
if (status === 'finished') {
const ms = calcScore(grid, terrain, me);
const os = calcScore(grid, terrain, opponent);
const reason = newMoves >= 10 && (ms >= os*2 || os >= ms*2) ? ' (Dominanzsieg!)' : '';
if (winnerId === me) {
sendPush(opponent, '⬡ Hex Wars beendet', `${myName} hat gewonnen!${reason} ${ms} vs ${os} Punkte.`);
sendPush(me, '⬡ Hex Wars gewonnen!', `Du hast gewonnen!${reason} ${ms} vs ${os} Punkte. 🎉`);
} else if (winnerId === opponent) {
sendPush(me, '⬡ Hex Wars beendet', `${oppName} hat gewonnen.${reason} ${ms} vs ${os} Punkte.`);
sendPush(opponent, '⬡ Hex Wars gewonnen!', `Du hast gewonnen!${reason} ${os} vs ${ms} Punkte. 🎉`);
} else {
sendPush(me, '⬡ Hex Wars — Unentschieden!', `${ms} vs ${os} Punkte.`);
sendPush(opponent, '⬡ Hex Wars — Unentschieden!', `${os} vs ${ms} Punkte.`);
}
} else {
if (mineHit) {
sendPush(me, '⬡ Hex Wars — 💣 Mine!', `Du hast eine Mine getroffen! Explosion zerstörte ${destroyedOwn + destroyedOpp} Felder. Du verlierst deinen nächsten Zug.`);
sendPush(opponent, '⬡ Hex Wars — 💥 Explosion!', `${myName} hat eine Mine getroffen! ${destroyedOpp} deiner Felder wurden zerstört. Du bist dran!`);
} else if (nextTurn === opponent) {
sendPush(opponent, '⬡ Hex Wars — Du bist dran!', `${myName} hat gezogen!`);
}
}
res.json(getGameForUser(game.id, me));
});
// ── Spiel als gesehen markieren ──────────────────────────────────────────────
router.post('/:id/seen', authenticate, (req, res) => {
const me = uid(req);
const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id);
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' });
const seen = game.seen_by ? JSON.parse(game.seen_by) : [];
if (!seen.includes(me)) {
seen.push(me);
db.prepare('UPDATE geo_games SET seen_by=? WHERE id=?').run(JSON.stringify(seen), game.id);
}
res.json({ ok: true });
});
// ── Scout: 3x3 Bereich aufdecken (kostet Zug) ───────────────────────────────
router.post('/:id/scout', authenticate, (req, res) => {
const me = uid(req);
const { row, col } = req.body;
if (row === undefined || col === undefined) return res.status(400).json({ error: 'row/col fehlt' });
const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id);
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' });
if (game.status !== 'active') return res.status(400).json({ error: 'Spiel beendet' });
if (game.current_turn !== me) return res.status(400).json({ error: 'Nicht dein Zug' });
const opponent = game.owner_id === me ? game.opponent_id : game.owner_id;
const newMoves = (game.move_count || 0) + 1;
// Scouts des Spielers laden und neuen hinzufügen
const isOwner = game.owner_id === me;
const scoutsKey = isOwner ? 'owner_scouts' : 'opp_scouts';
const existingRaw = game[scoutsKey];
const existing = existingRaw ? JSON.parse(existingRaw) : [];
existing.push([row, col]);
const newScoutsStr = JSON.stringify(existing);
// Zug weitergeben (Scout kostet Zug)
const grid = JSON.parse(game.grid);
const terrain = JSON.parse(game.terrain || '[]');
let status = 'active', winnerId = null;
// Schnellsieg-Check
if (newMoves >= 10 && terrain.length) {
const ms = calcScore(grid, terrain, me);
const os = calcScore(grid, terrain, opponent);
if (ms >= os*2 && os > 0) status = 'finished';
else if (os >= ms*2 && ms > 0) status = 'finished';
if (status === 'finished') {
winnerId = ms > os ? me : (os > ms ? opponent : null);
}
}
const updateFields = isOwner
? 'owner_scouts=?, current_turn=?, status=?, winner_id=?, move_count=?, last_event=NULL'
: 'opp_scouts=?, current_turn=?, status=?, winner_id=?, move_count=?, last_event=NULL';
db.prepare(`UPDATE geo_games SET ${updateFields}, updated_at=datetime('now','localtime') WHERE id=?`)
.run(newScoutsStr, opponent, status, winnerId, newMoves, game.id);
const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand';
sendPush(opponent, '⬡ Hex Wars — Du bist dran!', `${myName} hat gekundschaftet — jetzt bist du dran!`);
res.json(getGameForUser(game.id, me));
});
// ── Aufgeben ──────────────────────────────────────────────────────────────────
router.post('/:id/resign', authenticate, (req, res) => {
const me = uid(req);
const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id);
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' });
if (game.status !== 'active') return res.status(400).json({ error: 'Spiel bereits beendet' });
const winner = game.owner_id === me ? game.opponent_id : game.owner_id;
// move_count auf min. 2 setzen damit Spiel in Liste + Leaderboard erscheint
// seen_by: aufgebender Spieler hat es gesehen, Gewinner noch nicht
const newMoveCount = Math.max(game.move_count || 0, 2);
const seenBy = JSON.stringify([me]); // nur der Aufgebende hat es "gesehen"
db.prepare(`
UPDATE geo_games SET status='finished', winner_id=?, move_count=?, seen_by=?,
updated_at=datetime('now','localtime') WHERE id=?
`).run(winner, newMoveCount, seenBy, game.id);
const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand';
sendPush(winner, '⬡ Hex Wars gewonnen!', `${myName} hat aufgegeben. Du gewinnst! 🎉`);
res.json({ ok: true });
});
// ── Löschen (Admin) ───────────────────────────────────────────────────────────
router.delete('/:id', authenticate, (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id);
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
if (game.status !== 'finished') return res.status(400).json({ error: 'Nur beendete Spiele können gelöscht werden' });
db.prepare('DELETE FROM geo_games WHERE id=?').run(req.params.id);
res.json({ ok: true });
});
module.exports = router;

View File

@@ -1,191 +0,0 @@
// Tool: 3D-Druck-Kalkulator
const express = require('express');
const db = require('../../db');
const { authenticate } = require('../../middleware/auth');
const router = express.Router();
router.get('/', authenticate, (req, res) => {
const uid = req.user.id;
const items = db.prepare('SELECT * FROM calculations WHERE user_id=? ORDER BY created_at DESC').all(uid);
const baseFolder = db.prepare(
"SELECT id FROM folders WHERE user_id=? AND name='3D-Modelle' AND parent_id IS NULL"
).get(uid);
let filesMap = {};
if (baseFolder) {
const rows = db.prepare(`
SELECT f.name, COUNT(fi.id) AS cnt FROM folders f
LEFT JOIN files fi ON fi.folder_id = f.id
WHERE f.parent_id = ? AND f.user_id = ? GROUP BY f.id
`).all(baseFolder.id, uid);
for (const r of rows) filesMap[r.name] = r.cnt > 0;
}
// Geteilt mit mir
const shared = db.prepare(`
SELECT c.*, u.username as owner_name, 1 as is_shared
FROM calculations c
JOIN calculation_shares cs ON cs.calc_id = c.id
JOIN users u ON u.id = c.user_id
WHERE cs.shared_with = ?
ORDER BY c.created_at DESC
`).all(uid);
// Geteilt von mir
const sharedByMe = db.prepare(`
SELECT c.*, u.username as shared_with_name, cs.created_at as shared_at
FROM calculations c
JOIN calculation_shares cs ON cs.calc_id = c.id
JOIN users u ON u.id = cs.shared_with
WHERE c.user_id = ?
ORDER BY c.name ASC
`).all(uid);
// has_files auch für shared items prüfen (anhand owner's Ordner)
const sharedWithFiles = shared.map(i => {
const ownerBase = db.prepare(
"SELECT id FROM folders WHERE user_id=? AND name='3D-Modelle' AND parent_id IS NULL"
).get(i.user_id);
let has_files = false;
if (ownerBase) {
const sub = db.prepare(
'SELECT id FROM folders WHERE user_id=? AND name=? AND parent_id=?'
).get(i.user_id, i.name, ownerBase.id);
if (sub) {
const cnt = db.prepare('SELECT COUNT(*) AS c FROM files WHERE folder_id=?').get(sub.id);
has_files = cnt.c > 0;
}
}
return { ...i, has_files };
});
res.json({
own: items.map(i => ({ ...i, has_files: !!filesMap[i.name] })),
shared: sharedWithFiles,
sharedByMe: sharedByMe.map(i => ({ ...i, has_files: !!filesMap[i.name] })),
});
});
// Dateien eines Modells abrufen (für Owner + shared users)
router.get('/:id/files', authenticate, (req, res) => {
const uid = req.user.id;
const calc = db.prepare('SELECT * FROM calculations WHERE id=?').get(req.params.id);
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
const isOwner = calc.user_id === uid;
const isShared = db.prepare('SELECT id FROM calculation_shares WHERE calc_id=? AND shared_with=?').get(calc.id, uid);
if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' });
const baseFolder = db.prepare(
"SELECT id FROM folders WHERE user_id=? AND name='3D-Modelle' AND parent_id IS NULL"
).get(calc.user_id);
if (!baseFolder) return res.json([]);
const sub = db.prepare('SELECT id FROM folders WHERE user_id=? AND name=? AND parent_id=?')
.get(calc.user_id, calc.name, baseFolder.id);
if (!sub) return res.json([]);
res.json(db.prepare('SELECT * FROM files WHERE folder_id=? ORDER BY created_at ASC').all(sub.id));
});
// Einzelne Datei herunterladen (Owner + shared users)
router.get('/:id/files/:fileId/download', authenticate, (req, res) => {
const uid = req.user.id;
const calc = db.prepare('SELECT * FROM calculations WHERE id=?').get(req.params.id);
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
const isOwner = calc.user_id === uid;
const isShared = db.prepare('SELECT id FROM calculation_shares WHERE calc_id=? AND shared_with=?').get(calc.id, uid);
if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' });
const file = db.prepare('SELECT * FROM files WHERE id=?').get(req.params.fileId);
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' });
const path = require('path');
const fs = require('fs');
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
const fp = path.join(UPLOAD_DIR, file.filename);
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'Datei fehlt' });
res.download(fp, file.originalname);
});
// Share-Management
router.get('/:id/shares', authenticate, (req, res) => {
const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
res.json(db.prepare(`
SELECT u.id, u.username, cs.created_at as shared_at
FROM calculation_shares cs JOIN users u ON u.id = cs.shared_with
WHERE cs.calc_id = ?
`).all(calc.id));
});
router.post('/:id/share', authenticate, (req, res) => {
const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
db.prepare("INSERT OR IGNORE INTO calculation_shares (calc_id, shared_by, shared_with, created_at) VALUES (?,?,?,datetime('now','localtime'))")
.run(calc.id, req.user.id, target.id);
res.json({ ok: true });
});
router.delete('/:id/share/:userId', authenticate, (req, res) => {
const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
db.prepare('DELETE FROM calculation_shares WHERE calc_id=? AND shared_with=?').run(calc.id, req.params.userId);
res.json({ ok: true });
});
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,
image = null, bemerkung = '' } = 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,image,bemerkung)
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, image, bemerkung);
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,
image = ex.image, bemerkung = ex.bemerkung } = 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=?,image=?,bemerkung=?,
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, image, bemerkung,
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;

View File

@@ -1,155 +0,0 @@
const express = require('express');
const db = require('../../db');
const { authenticate } = require('../../middleware/auth');
const router = express.Router();
// ── DB-Migration ──────────────────────────────────────────────────────────────
(function migrate() {
db.exec(`
CREATE TABLE IF NOT EXISTS kanban_columns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#4ecdc4',
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS kanban_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
column_id INTEGER NOT NULL REFERENCES kanban_columns(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
priority TEXT NOT NULL DEFAULT 'none',
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
`);
// Nachrüsten falls Tabelle schon existiert aber color fehlt
const cols = db.pragma('table_info(kanban_columns)').map(c => c.name);
if (!cols.includes('color')) {
db.exec("ALTER TABLE kanban_columns ADD COLUMN color TEXT NOT NULL DEFAULT '#4ecdc4'");
}
})();
const uid = req => req.user.id;
// ── Alle Spalten + Karten laden ───────────────────────────────────────────────
router.get('/board', authenticate, (req, res) => {
const me = uid(req);
const columns = db.prepare(
'SELECT * FROM kanban_columns WHERE user_id=? ORDER BY position ASC, id ASC'
).all(me);
const cards = db.prepare(
'SELECT * FROM kanban_cards WHERE user_id=? ORDER BY position ASC, id ASC'
).all(me);
const colMap = {};
for (const c of columns) { c.cards = []; colMap[c.id] = c; }
for (const card of cards) {
if (colMap[card.column_id]) colMap[card.column_id].cards.push(card);
}
res.json({ columns });
});
// ── Spalte erstellen ──────────────────────────────────────────────────────────
router.post('/columns', authenticate, (req, res) => {
const { title, color = '#4ecdc4' } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'Titel fehlt' });
const me = uid(req);
const maxPos = db.prepare('SELECT MAX(position) as m FROM kanban_columns WHERE user_id=?').get(me);
const pos = (maxPos?.m ?? -1) + 1;
const r = db.prepare(
"INSERT INTO kanban_columns (user_id,title,color,position) VALUES (?,?,?,?)"
).run(me, title.trim(), color, pos);
res.json(db.prepare('SELECT * FROM kanban_columns WHERE id=?').get(r.lastInsertRowid));
});
// ── Spalte aktualisieren (Titel + Farbe) ─────────────────────────────────────
// Feste Route vor :param (Lesson Learned)
router.post('/columns/reorder', authenticate, (req, res) => {
const { order } = req.body;
if (!Array.isArray(order)) return res.status(400).json({ error: 'order fehlt' });
const me = uid(req);
const upd = db.prepare('UPDATE kanban_columns SET position=? WHERE id=? AND user_id=?');
db.transaction(() => { for (let i = 0; i < order.length; i++) upd.run(i, order[i], me); })();
res.json({ ok: true });
});
router.patch('/columns/:id', authenticate, (req, res) => {
const me = uid(req);
const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(req.params.id, me);
if (!col) return res.status(404).json({ error: 'Spalte nicht gefunden' });
const title = req.body.title !== undefined ? req.body.title.trim() : col.title;
const color = req.body.color !== undefined ? req.body.color : col.color;
if (!title) return res.status(400).json({ error: 'Titel fehlt' });
db.prepare('UPDATE kanban_columns SET title=?, color=? WHERE id=?').run(title, color, col.id);
res.json({ ok: true });
});
router.delete('/columns/:id', authenticate, (req, res) => {
const me = uid(req);
const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(req.params.id, me);
if (!col) return res.status(404).json({ error: 'Spalte nicht gefunden' });
db.prepare('DELETE FROM kanban_columns WHERE id=?').run(col.id);
res.json({ ok: true });
});
// ── Karten ────────────────────────────────────────────────────────────────────
router.post('/cards', authenticate, (req, res) => {
const { column_id, title, description = '', priority = 'none' } = req.body;
if (!column_id || !title?.trim()) return res.status(400).json({ error: 'column_id und title erforderlich' });
const me = uid(req);
const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(column_id, me);
if (!col) return res.status(404).json({ error: 'Spalte nicht gefunden' });
const maxPos = db.prepare('SELECT MAX(position) as m FROM kanban_cards WHERE column_id=?').get(column_id);
const pos = (maxPos?.m ?? -1) + 1;
const r = db.prepare(
"INSERT INTO kanban_cards (column_id,user_id,title,description,priority,position) VALUES (?,?,?,?,?,?)"
).run(column_id, me, title.trim(), description.trim(), priority, pos);
res.json(db.prepare('SELECT * FROM kanban_cards WHERE id=?').get(r.lastInsertRowid));
});
router.patch('/cards/:id', authenticate, (req, res) => {
const me = uid(req);
const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me);
if (!card) return res.status(404).json({ error: 'Karte nicht gefunden' });
const title = req.body.title !== undefined ? req.body.title.trim() : card.title;
const description = req.body.description !== undefined ? req.body.description.trim() : card.description;
const priority = req.body.priority !== undefined ? req.body.priority : card.priority;
if (!title) return res.status(400).json({ error: 'Titel darf nicht leer sein' });
db.prepare(
"UPDATE kanban_cards SET title=?,description=?,priority=?,updated_at=datetime('now','localtime') WHERE id=?"
).run(title, description, priority, card.id);
res.json(db.prepare('SELECT * FROM kanban_cards WHERE id=?').get(card.id));
});
router.delete('/cards/:id', authenticate, (req, res) => {
const me = uid(req);
const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me);
if (!card) return res.status(404).json({ error: 'Karte nicht gefunden' });
db.prepare('DELETE FROM kanban_cards WHERE id=?').run(card.id);
res.json({ ok: true });
});
router.post('/cards/:id/move', authenticate, (req, res) => {
const { column_id, position } = req.body;
const me = uid(req);
const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me);
if (!card) return res.status(404).json({ error: 'Karte nicht gefunden' });
const targetColId = column_id !== undefined ? column_id : card.column_id;
const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(targetColId, me);
if (!col) return res.status(404).json({ error: 'Zielspalte nicht gefunden' });
const siblings = db.prepare(
'SELECT id FROM kanban_cards WHERE column_id=? AND id!=? ORDER BY position ASC, id ASC'
).all(targetColId, card.id).map(r => r.id);
const targetPos = position !== undefined
? Math.max(0, Math.min(position, siblings.length))
: siblings.length;
siblings.splice(targetPos, 0, card.id);
const upd = db.prepare('UPDATE kanban_cards SET column_id=?, position=? WHERE id=?');
db.transaction(() => { for (let i = 0; i < siblings.length; i++) upd.run(targetColId, i, siblings[i]); })();
res.json({ ok: true });
});
module.exports = router;

View File

@@ -1,954 +0,0 @@
const express = require('express');
const https = require('https');
const crypto = require('crypto');
const db = require('../../db');
const mqttClient = require('../../mqtt');
const { logPush } = require('../../pushLog');
const { logPublicAccess } = require('../../publicAccessLog');
const { authenticate } = require('../../middleware/auth');
const router = express.Router();
// ── Cache ─────────────────────────────────────────────────────────────────────
const cache = {};
function cacheGet(k) { const e=cache[k]; if(!e||Date.now()>e.ex) { delete cache[k]; return null; } return e.d; }
function cacheSet(k,d,ms=60*60*1000) { cache[k]={d,ex:Date.now()+ms}; }
// ── marktguru Cookie (Standort Duisburg 47259) ────────────────────────────────
const MG_SETTINGS = encodeURIComponent(JSON.stringify({
location: { name:'Duisburg', uniqueName:'duisburg', longitude:6.7625, latitude:51.4332, zipCodes:['47259'] },
locationSource: 'manual', hidePageflipMenu: false, cookieAccept: true, userGroup: 35,
}));
// ── Gewünschte Händler (grobe Vorfilterung, bevor die genaue Filiale geprüft wird) ─
const TARGET_RETAILERS = ['edeka','e center','rewe','netto','kaufland','penny','trinkgut','lidl','aldi','hornbach'];
function isTargetRetailer(name) {
if (!name) return false;
const n = name.toLowerCase();
return TARGET_RETAILERS.some(r => n.includes(r));
}
// Straßennamen robust vergleichbar machen (Groß-/Kleinschreibung, "straße"/"str.",
// Kommas/Leerzeichen/Punkte egal)
function normalizeAddress(s) {
return (s || '')
.toLowerCase()
.replace(/straße|strasse/g, 'str')
.replace(/[^a-zäöüß0-9]/g, '');
}
// Adressen mit mehreren kommagetrennten Hausnummern (z.B. "Straße 173,173a")
// in einzelne vollständige Varianten zerlegen ("Straße 173" / "Straße 173a"),
// damit ein Treffer gegen nur eine der beiden Hausnummern trotzdem zählt
function expandAddressVariants(addr) {
const m = (addr || '').match(/^(.*?)(\d.*)$/);
if (!m) return [addr];
const streetPart = m[1].trim();
const numbers = m[2].split(',').map(s => s.trim()).filter(Boolean);
if (numbers.length <= 1) return [addr];
return numbers.map(n => `${streetPart} ${n}`);
}
function addressesMatch(a, b) {
for (const va of expandAddressVariants(a)) {
for (const vb of expandAddressVariants(b)) {
const na = normalizeAddress(va), nb = normalizeAddress(vb);
if (na && nb && (na.includes(nb) || nb.includes(na))) return true;
}
}
return false;
}
// Die gewünschten Filialen. Zwei Wege, wie eine Filiale hier eingetragen wird:
// - leafletId bekannt (Prospekt-Link bestätigt): wird direkt aufgelöst.
// - nur addressMatch bekannt: wird automatisch unter allen AKTUELL laufenden
// Prospekten dieser Kette in der Umgebung gesucht — die closestStore-Adresse
// wird gegen die angegebene Zieladresse abgeglichen, kein Prospekt-Link nötig.
const LOCAL_STORE_SEED_LEAFLETS = [
{ retailer: 'Netto Marken-Discount', leafletId: 5653691 }, // Im Bonnefeld 19
{ retailer: 'Netto Getränke-Discount', leafletId: 5627267 }, // Im Bonnefeld 21
{ retailer: 'trinkgut', leafletId: 5634593 }, // Keniastraße 39
{ retailer: 'REWE', leafletId: 5616164 }, // Fischerstr. 110-112
{ retailer: 'Kaufland', leafletId: 5628827 }, // Auf der Höhe 20
{ retailer: 'Penny', leafletId: 5638286 }, // Sittardsberger Allee 10
{ retailer: 'E center', leafletId: 5637005 }, // Düsseldorfer Landstr. 361
];
const LOCAL_STORE_SEED_BY_ADDRESS = [
{ retailer: 'Lidl', matchRetailerKey: 'lidl', addressMatch: 'Mündelheimer Str. 184' },
{ retailer: 'ALDI Süd', matchRetailerKey: 'aldi', addressMatch: 'Mündelheimer Straße 173' },
{ retailer: 'HORNBACH', matchRetailerKey: 'hornbach', addressMatch: 'Düsseldorfer Straße 400' },
];
function isKnownLocalStore(storeId) {
if (storeId == null) return true; // Store konnte nicht ermittelt werden -> lieber behalten als faelschlich verstecken
return !!db.prepare('SELECT 1 FROM koepi_local_stores WHERE store_id=?').get(storeId);
}
// Explizit unerwünschte Marken/Franchise-Label, unabhängig von der Filiale —
// zusätzliches Sicherheitsnetz, greift aber durch die ID-Whitelist ohnehin kaum
// noch (REWE Dortmund taucht dort schlicht nicht auf)
const EXCLUDED_RETAILER_NAMES = ['rewe dortmund'];
function isExcludedRetailer(retailer) {
const n = (retailer || '').toLowerCase();
return EXCLUDED_RETAILER_NAMES.some(x => n.includes(x));
}
// ── Angebote von marktguru ────────────────────────────────────────────────────
// Primär über die öffentlich bekannte marktguru-JSON-API (schnell, kein Browser
// nötig, kein Cloudflare-Risiko). Schlägt das fehl (z.B. weil die Keys mal
// ungültig werden), fällt der Code automatisch auf das alte Puppeteer/HTML-
// Scraping zurück, damit KöPi nicht komplett ausfällt.
const MARKTGURU_API_URL = 'https://api.marktguru.de/api/v1/offers/search';
const MARKTGURU_CLIENT_KEY = 'WU/RH+PMGDi+gkZer3WbMelt6zcYHSTytNB7VpTia90=';
const MARKTGURU_API_KEY = '8Kk+pmbf7TgJ9nVj2cXeA7P5zBGv8iuutVVMRfOfvNE=';
const MG_ZIP = '47259';
const MG_QUERY = 'könig pilsener';
function formatIsoDate(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = n => String(n).padStart(2, '0');
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.`;
}
// Duisburg 47259 Zentrum — für den closestStore-Abgleich im leaflets-Detail-Call.
// Innerhalb der zipCode-Filterung spielt der genaue Punkt kaum eine Rolle.
const MG_LAT = '51.3543213';
const MG_LON = '6.7151937';
const mgHeaders = {
'x-clientkey': MARKTGURU_CLIENT_KEY,
'x-apikey': MARKTGURU_API_KEY,
'Accept': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
};
// Holt für eine PLZ alle laufenden Prospekt-Kampagnen (leafletFlights) und baut
// eine Zuordnung leafletFlightId -> mainLeafletId, die wir brauchen, um darüber
// später das leaflet-Detail (inkl. closestStore) abzurufen.
async function fetchLeafletFlightToLeafletMap() {
const url = `https://api.marktguru.de/api/v1/leafletflights?as=mobile&limit=100&zipCode=${MG_ZIP}`;
const res = await fetch(url, { headers: mgHeaders });
if (!res.ok) throw new Error(`leafletflights HTTP ${res.status}`);
const json = await res.json();
const map = new Map();
for (const f of (json.results || [])) {
if (f.id && f.mainLeafletId) map.set(f.id, f.mainLeafletId);
}
console.log(`🍺 KöPi: ${map.size} leafletFlight→Leaflet Zuordnungen geladen (von ${json.totalResults} gesamt)`);
return map;
}
// Holt für ein einzelnes Leaflet den kompletten Datensatz von marktguru:
// closestStore (id/address/distanceInMeters — Grundlage der Filial-Whitelist),
// name (enthält oft "(KW29 Do-Sa)" o.ä.) und children (Liste aller Angebote
// mit ihrem pageIndex, für den seiten-genauen Direktlink).
async function fetchLeafletDetail(leafletId, cache) {
if (cache.has(leafletId)) return cache.get(leafletId);
const empty = { id: leafletId, name: '', closestStore: { id: null, address: '', distanceMeters: null }, children: [] };
try {
const url = `https://api.marktguru.de/api/v1/leaflets/${leafletId}?as=mobiledetailed&latitude=${MG_LAT}&longitude=${MG_LON}&zipCode=${MG_ZIP}`;
const res = await fetch(url, { headers: mgHeaders });
if (!res.ok) { cache.set(leafletId, empty); return empty; }
const json = await res.json();
const store = json.closestStore;
const result = {
id: json.id ?? leafletId,
name: json.name || '',
closestStore: store
? { id: store.id ?? null, address: store.address || '', distanceMeters: store.distanceInMeters ?? null }
: { id: null, address: '', distanceMeters: null },
children: json.children || [],
};
console.log(`🍺 KöPi: Leaflet ${leafletId} ("${result.name}") → closestStore: ${store ? `id=${store.id} "${store.address}" (${Math.round(store.distanceInMeters||0)}m entfernt)` : 'keine'}`);
cache.set(leafletId, result);
return result;
} catch (e) {
console.log(`🍺 KöPi: Leaflet-Detail-Abruf für ${leafletId} fehlgeschlagen: ${e.message}`);
cache.set(leafletId, empty);
return empty;
}
}
// Extrahiert eine "(KW29 Do-Sa)"-artige Angabe aus dem Leaflet-Namen. Manche
// Ketten (z.B. ALDI) schreiben die KW-Angabe ohne umschließende Klammern
// ("... KW32 ...") — die wird dann selbst eingeklammert, damit die Anzeige
// überall einheitlich aussieht.
function extractWeekInfo(name) {
// Fängt nur "KW29" bzw. "KW 29" plus optional einen kurzen Wochentag-Bereich
// wie "Do-Sa" direkt danach ein — ignoriert bewusst alles andere drumherum
// (z.B. technischen Datensatz-Text wie "HHZ Dataset 2"), egal ob das Ganze
// in Klammern steht oder nicht. Klammert das Ergebnis immer selbst neu ein,
// damit die Anzeige überall einheitlich aussieht.
const m = (name || '').match(/KW\s*\d+(?:\s*[A-Za-zÄÖÜäöüß]{2,3}-[A-Za-zÄÖÜäöüß]{2,3})?/i);
return m ? `(${m[0].replace(/\s+/g, ' ').trim()})` : '';
}
// Entfernt bekannten Stör-/Metatext aus dem angezeigten Prospekt-Titel:
// "(weekly)"-Zusätze und jede KW-Klammer (die steht ja schon separat als
// eigene weekInfo daneben, muss also nicht nochmal im Titel stehen — und dort
// hängt bei manchen Ketten zusätzlich technischer Datensatz-Text mit dran).
const TITLE_NOISE_PATTERNS = [/\(weekly\)/gi, /\([^)]*KW[^)]*\)/gi];
function cleanTitle(name) {
let t = name || '';
for (const p of TITLE_NOISE_PATTERNS) t = t.replace(p, '');
return t.replace(/\s{2,}/g, ' ').trim();
}
// Fallback, falls eine Kette (z.B. REWE) gar keine KW-Angabe im Namen mitliefert:
// Kalenderwoche selbst aus dem Gültig-ab-Datum berechnen
function getISOWeekNumber(dateStr) {
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return null;
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + 4 - (d.getDay() || 7));
const yearStart = new Date(d.getFullYear(), 0, 1);
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
}
function weekInfoWithFallback(name, validFromIso) {
const found = extractWeekInfo(name);
if (found) return found;
const week = validFromIso ? getISOWeekNumber(validFromIso) : null;
return week ? `(KW${week})` : '';
}
// Löst die feste Liste gewünschter Filialen (LOCAL_STORE_SEED_LEAFLETS) einmalig
// zu echten Store-IDs auf und speichert sie in der DB als Whitelist. Wird beim
// ersten Scrape automatisch ausgeführt (falls die Tabelle leer ist) und kann
// über den Admin-Button jederzeit erneut angestoßen werden, falls sich mal ein
// Prospekt-Link ändert oder eine Filiale ergänzt werden soll.
async function resolveLocalStores() {
const cache = new Map();
let resolved = 0;
// 1) Direkt bekannte Leaflet-IDs (per Prospekt-Link bestätigt)
for (const seed of LOCAL_STORE_SEED_LEAFLETS) {
const detail = await fetchLeafletDetail(seed.leafletId, cache);
const store = detail.closestStore;
if (store.id != null) {
db.prepare(`
INSERT OR REPLACE INTO koepi_local_stores (store_id, retailer, address, resolved_at)
VALUES (?, ?, ?, datetime('now','localtime'))
`).run(store.id, seed.retailer, store.address);
console.log(`🍺 KöPi: Filiale aufgelöst — ${seed.retailer} → Store-ID ${store.id} ("${store.address}")`);
resolved++;
} else {
console.log(`🍺 KöPi: Filiale NICHT auflösbar — ${seed.retailer} (Leaflet ${seed.leafletId}) — evtl. Prospekt abgelaufen, Link erneuern`);
}
}
// 2) Nur Adresse bekannt — unter allen aktuell laufenden Prospekten dieser
// Kette in der Umgebung suchen, closestStore-Adresse gegen Zieladresse prüfen
if (LOCAL_STORE_SEED_BY_ADDRESS.length) {
try {
const flightsUrl = `https://api.marktguru.de/api/v1/leafletflights?as=mobile&limit=100&zipCode=${MG_ZIP}`;
const flightsRes = await fetch(flightsUrl, { headers: mgHeaders });
const flightsJson = flightsRes.ok ? await flightsRes.json() : { results: [] };
for (const target of LOCAL_STORE_SEED_BY_ADDRESS) {
const candidates = (flightsJson.results || []).filter(f =>
(f.advertiser?.name || '').toLowerCase().includes(target.matchRetailerKey)
);
console.log(`🍺 KöPi: Suche "${target.retailer}" per Adresse — ${candidates.length} laufende Prospekt(e) dieser Kette gefunden`);
let found = null;
for (const c of candidates) {
if (!c.mainLeafletId) continue;
const detail = await fetchLeafletDetail(c.mainLeafletId, cache);
const store = detail.closestStore;
if (store.id != null && addressesMatch(store.address, target.addressMatch)) {
found = store;
break;
}
}
if (found) {
db.prepare(`
INSERT OR REPLACE INTO koepi_local_stores (store_id, retailer, address, resolved_at)
VALUES (?, ?, ?, datetime('now','localtime'))
`).run(found.id, target.retailer, found.address);
console.log(`🍺 KöPi: Filiale per Adresse gefunden — ${target.retailer} → Store-ID ${found.id} ("${found.address}")`);
resolved++;
} else {
console.log(`🍺 KöPi: Filiale per Adresse NICHT gefunden — ${target.retailer} (gesucht: "${target.addressMatch}") — evtl. gerade kein laufender Prospekt dieser Kette in der Gegend`);
}
}
} catch (e) {
console.log('🍺 KöPi: Adress-basierte Filial-Suche fehlgeschlagen:', e.message);
}
}
return resolved;
}
async function fetchOffersViaApi() {
const url = `${MARKTGURU_API_URL}?as=web&limit=24&offset=0&q=${encodeURIComponent(MG_QUERY)}&zipCode=${MG_ZIP}`;
const res = await fetch(url, { headers: mgHeaders });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`API HTTP ${res.status}: ${text.slice(0, 200)}`);
}
const json = await res.json();
console.log('🍺 KöPi API: totalResults=', json.totalResults, '| results.length=', json.results?.length);
const rawOffers = json.results || [];
// Echte Filial-ID + Adresse pro Angebot auflösen (leafletFlightId -> mainLeafletId -> closestStore)
let flightToLeaflet = new Map();
try {
flightToLeaflet = await fetchLeafletFlightToLeafletMap();
} catch (e) {
console.log('🍺 KöPi: leafletflights-Abruf fehlgeschlagen, Filial-Filter bleibt diesmal wirkungslos:', e.message);
}
const leafletCache = new Map();
const detailByOfferId = new Map();
for (const r of rawOffers) {
const leafletId = flightToLeaflet.get(r.leafletFlightId);
if (leafletId) {
const detail = await fetchLeafletDetail(leafletId, leafletCache);
detailByOfferId.set(r.id, detail);
}
}
return rawOffers.map(r => {
const advertiser = r.advertisers?.[0] || {};
const priceNum = typeof r.price === 'number' ? r.price : parseFloat(r.price);
const oldPriceNum = typeof r.oldPrice === 'number' ? r.oldPrice : parseFloat(r.oldPrice);
const detail = detailByOfferId.get(r.id);
const storeInfo = detail?.closestStore || { id: null, address: '', distanceMeters: null };
const validity0 = r.validityDates?.[0] || {};
const weekInfo = weekInfoWithFallback(detail?.name, validity0.from);
const baseDateRange = validity0.from && validity0.to
? `${formatIsoDate(validity0.from)} - ${formatIsoDate(validity0.to)}`
: formatIsoDate(validity0.to);
// Seiten-genauer Direktlink zum Angebot innerhalb des Prospekts — wird bei
// jedem Scrape frisch aus den aktuellen API-Daten ermittelt (children[].id
// == "offers/{id}"), funktioniert also automatisch weiter bei neuen
// Prospekten, ohne dass irgendwas hartkodiert werden muss.
const child = detail?.children?.find(c => c.id === `offers/${r.id}`);
const leafletUrl = detail
? `https://www.marktguru.de/leaflets/${detail.id}${child ? `/page/${child.pageIndex}` : ''}`
: null;
const knownStore = storeInfo.id != null
? db.prepare('SELECT retailer FROM koepi_local_stores WHERE store_id=?').get(storeInfo.id)
: null;
const retailerLabel = knownStore?.retailer || advertiser.name || '';
return {
name: r.description || r.title || advertiser.name || '',
brand: r.brand?.name || '',
price: Number.isFinite(priceNum) ? `${priceNum.toFixed(2).replace('.', ',')}` : (r.price || ''),
oldPrice: Number.isFinite(oldPriceNum) ? `${oldPriceNum.toFixed(2).replace('.', ',')}` : (r.oldPrice || ''),
retailer: retailerLabel,
dateRange: weekInfo ? `${baseDateRange} ${weekInfo}` : baseDateRange,
weekInfo,
validity: '',
description: r.description || '',
badge: '',
// Adresse kommt jetzt direkt von marktguru (closestStore.address), nicht mehr fest hinterlegt
address: storeInfo.address || '',
realAddress: storeInfo.address || '',
storeId: storeInfo.id,
storeDistanceMeters: storeInfo.distanceMeters,
leafletFlightId: r.leafletFlightId || null,
offerId: r.id || null,
leafletUrl,
image: r.image?.url || r.images?.[0]?.url || null,
};
});
}
async function fetchOffersViaPuppeteer() {
let puppeteer;
try { puppeteer = require('puppeteer-core'); }
catch(e) { throw new Error('puppeteer-core nicht installiert'); }
const browser = await puppeteer.launch({
executablePath: '/usr/bin/chromium-browser',
args: ['--no-sandbox','--disable-setuid-sandbox','--disable-dev-shm-usage','--disable-gpu','--single-process'],
headless: true,
});
try {
const page = await browser.newPage();
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
await page.setViewport({ width:1280, height:800 });
await page.setCookie({ name:'mg_user-settings', value:MG_SETTINGS, domain:'.marktguru.de', path:'/' });
await page.goto('https://www.marktguru.de/search/k%C3%B6nig%20pilsener?zipCode=47259', {
waitUntil: 'networkidle2', timeout: 30000,
});
// Seite nach unten scrollen damit lazy-loaded Bilder geladen werden
await page.evaluate(async () => {
await new Promise(resolve => {
let totalHeight = 0;
const distance = 300;
const timer = setInterval(() => {
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= document.body.scrollHeight) {
clearInterval(timer);
resolve();
}
}, 200);
});
});
await new Promise(r => setTimeout(r, 3000));
const offers = await page.evaluate(() => {
// Exakte Selektoren basierend auf marktguru HTML-Struktur
const cards = document.querySelectorAll('li.offer-list-item');
return Array.from(cards).map(card => {
const name = card.querySelector('h3')?.textContent?.trim() || '';
const brand = card.querySelector('dd.brand a')?.textContent?.trim() || '';
const price = card.querySelector('span.price')?.textContent?.trim() || '';
const oldPrice = card.querySelector('.price-bubble .old-price, .crossed')?.textContent?.trim() || '';
const retailer = card.querySelector('dd.retailer-name a')?.textContent?.trim() || '';
const dateRange= card.querySelector('dd.valid')?.textContent?.trim() || '';
const daysLeft = card.querySelector('dd.time-left span')?.textContent?.trim() || '';
const validity = daysLeft ? `Noch ${daysLeft} Tag${daysLeft==='1'?'':'e'}` :
card.querySelector('dt.time-left')?.textContent?.includes('Brandneu') ? 'Neu' : '';
const descEl = card.querySelector('.info div, .info');
const description = descEl?.textContent?.trim() || '';
const img = card.querySelector('img.offer-list-item-img');
const badge = card.querySelector('.badge, .discount-badge')?.textContent?.trim() || '';
const imgSrc = img?.src?.replace('/medium.webp', '/large.webp') || img?.src || null;
const allDds = Array.from(card.querySelectorAll('dd'));
const retailerIdx = allDds.findIndex(d => d.classList.contains('retailer-name'));
let realAddress = '';
if (retailerIdx !== -1 && allDds[retailerIdx + 1]) {
const next = allDds[retailerIdx + 1];
const isOtherField = next.classList.contains('valid') || next.classList.contains('time-left');
if (!isOtherField) realAddress = next.textContent?.trim() || '';
}
return { name, brand, price, oldPrice, retailer, dateRange, validity, description, badge, address: '', realAddress, image: imgSrc };
});
});
return offers;
} finally {
await browser.close();
}
}
// Prüft, ob "COUNT x VOL" (z.B. "20 x 0,5") in der Beschreibung vorkommt —
// robust gegen Leerzeichen um das "x", Komma/Punkt als Dezimaltrennzeichen,
// und verhindert Fehltreffer wie "0,5" innerhalb von "0,55"
function hasQty(desc, count, vol) {
const volPattern = vol.replace(',', '[,.]');
const re = new RegExp(`${count}\\s*x\\s*${volPattern}(?!\\d)`, 'i');
return re.test(desc);
}
// Bild pro Angebot exakt nach Mengenangabe wählen (siehe Vorgabe):
// 20x0,5 -> kasten_050 | 20x0,33 -> kasten_steini | 24x0,33 -> kasten_033
// 11x0,5 -> kasten_11er | 6x0,33 -> traeger | 24x0,5 -> palette_050
// 20x0,5 UND 24x0,33 gleichzeitig -> kasten_033_05 | sonst -> koepi_platzhalter
function pickOfferImage(description) {
const d = (description || '').toLowerCase();
const q2005 = hasQty(d, 20, '0,5');
const q2433 = hasQty(d, 24, '0,33');
if (q2005 && q2433) return '/koepi/kasten_033_05.png';
if (hasQty(d, 20, '0,33')) return '/koepi/kasten_steini.png';
if (q2433) return '/koepi/kasten_033.png';
if (hasQty(d, 11, '0,5')) return '/koepi/kasten_11er.png';
if (hasQty(d, 6, '0,33')) return '/koepi/traeger.png';
if (hasQty(d, 24, '0,5')) return '/koepi/palette_050.png';
if (q2005) return '/koepi/kasten_050.png';
return '/koepi/koepi_platzhalter.png';
}
// Gemeinsame Weiterverarbeitung, egal ob die Rohdaten über die API oder per
// Puppeteer/HTML-Scraping ermittelt wurden
function processOffers(offers) {
// Lokale Bilder auswählen basierend auf Beschreibung
for (const offer of offers) {
offer.imageLocal = pickOfferImage(offer.description);
offer.image = null; // CDN nicht mehr nötig
}
// Filtern auf Ziel-Händler + nur König Pilsener
const filtered = offers.filter(o => {
const isKP = (o.name + ' ' + o.brand + ' ' + o.description).toLowerCase();
const hasKP = isKP.includes('pilsen') || isKP.includes('pilsener');
return hasKP && isTargetRetailer(o.retailer);
});
const excludedNow = filtered.filter(o => isExcludedRetailer(o.retailer));
if (excludedNow.length) {
console.log('🍺 KöPi: Angebote per Namens-Filter ausgeschlossen:', excludedNow.map(o => o.retailer).join(', '));
}
console.log('🍺 KöPi Filial-Whitelist-Diagnose:', filtered.map(o =>
`${o.retailer} | Store-ID=${o.storeId ?? 'unbekannt'} | ${o.realAddress||'keine Adresse'} | bekannt=${isKnownLocalStore(o.storeId)}`
).join('\n '));
const localOnly = filtered
.filter(o => !isExcludedRetailer(o.retailer))
.filter(o => isKnownLocalStore(o.storeId));
for (const o of localOnly) {
o.retailerDisplay = o.retailer;
}
// Deduplizieren: gleicher normalisierter Händler + gleicher Preis + gleiche
// Filiale → ein Eintrag
const merged = new Map();
for (const o of localOnly) {
const normalizedRetailer = o.retailer?.toLowerCase().replace('e center','edeka') || '';
const key = `${normalizedRetailer}|${o.price}|${o.storeId ?? ''}`;
if (merged.has(key)) {
const existing = merged.get(key);
if (o.description && (!existing.description || o.description.length < existing.description.length)) {
existing.description = o.description;
}
} else {
merged.set(key, { ...o });
}
}
return [...merged.values()];
}
async function scrapeMarktguru(forceRefresh = false) {
const cacheKey = 'koepi:mg';
if (!forceRefresh) {
const cached = cacheGet(cacheKey);
if (cached) return cached;
}
// Filial-Whitelist beim allerersten Lauf automatisch auflösen (Tabelle leer)
const knownCount = db.prepare('SELECT COUNT(*) AS n FROM koepi_local_stores').get().n;
if (knownCount === 0) {
console.log('🍺 KöPi: Filial-Whitelist ist leer, löse sie jetzt einmalig auf...');
try { await resolveLocalStores(); }
catch (e) { console.error('🍺 KöPi: Auflösen der Filial-Whitelist fehlgeschlagen:', e.message); }
}
let offers, source;
try {
offers = await fetchOffersViaApi();
source = 'marktguru.de (API)';
console.log(`🍺 KöPi: ${offers.length} Rohtreffer über die marktguru-API`);
} catch (apiErr) {
console.error('🍺 KöPi: API-Weg fehlgeschlagen, falle zurück auf Puppeteer:', apiErr.message);
try {
offers = await fetchOffersViaPuppeteer();
source = 'marktguru.de (Puppeteer-Fallback)';
console.log(`🍺 KöPi: ${offers.length} Rohtreffer per Puppeteer (Fallback)`);
} catch (ppErr) {
console.error('🍺 KöPi: auch Puppeteer-Fallback fehlgeschlagen:', ppErr.message);
return { offers: [], error: `API: ${apiErr.message} | Puppeteer: ${ppErr.message}`, scrapedAt: new Date().toISOString() };
}
}
const unique = processOffers(offers);
const result = { offers: unique, total: unique.length, scrapedAt: new Date().toISOString(), source };
cacheSet(cacheKey, result);
return result;
}
// ── Prospekte von marktguru (dieselbe Filial-Whitelist wie bei den Angeboten) ──
async function scrapeProspekte() {
const cacheKey = 'koepi:prospekte';
const cached = cacheGet(cacheKey);
if (cached) return cached;
const knownCount = db.prepare('SELECT COUNT(*) AS n FROM koepi_local_stores').get().n;
if (knownCount === 0) {
console.log('🍺 KöPi Prospekte: Filial-Whitelist ist leer, löse sie jetzt einmalig auf...');
try { await resolveLocalStores(); }
catch (e) { console.error('🍺 KöPi Prospekte: Auflösen der Filial-Whitelist fehlgeschlagen:', e.message); }
}
const flightsUrl = `https://api.marktguru.de/api/v1/leafletflights?as=mobile&limit=100&zipCode=${MG_ZIP}`;
const flightsRes = await fetch(flightsUrl, { headers: mgHeaders });
if (!flightsRes.ok) throw new Error(`leafletflights HTTP ${flightsRes.status}`);
const flightsJson = await flightsRes.json();
console.log(`🍺 KöPi Prospekte: ${flightsJson.results?.length} von ${flightsJson.totalResults} Prospekt-Kampagnen geladen`);
// Nur Kampagnen der gewünschten Ketten überhaupt im Detail auflösen (spart API-Calls)
const candidates = (flightsJson.results || []).filter(f => isTargetRetailer(f.advertiser?.name));
const leafletCache = new Map();
const all = [];
for (const f of candidates) {
if (!f.mainLeafletId) continue;
const detail = await fetchLeafletDetail(f.mainLeafletId, leafletCache);
const store = detail.closestStore;
const weekInfo = weekInfoWithFallback(detail.name, f.validFrom);
// Store-spezifische Bezeichnung aus der Whitelist bevorzugen (z.B. "Netto
// Getränke-Discount") statt dem generischen advertiser.name von marktguru
// (liefert für diese Filiale fälschlich nur "Netto Marken-Discount")
const knownStore = store.id != null
? db.prepare('SELECT retailer FROM koepi_local_stores WHERE store_id=?').get(store.id)
: null;
all.push({
id: f.id,
publisher: knownStore?.retailer || f.advertiser?.name || '',
title: f.advertiser?.name || cleanTitle(detail.name) || '',
rawName: detail.name || '', // nur intern, für den Ausschluss-Filter unten
weekInfo,
street: store.address || '',
zip: MG_ZIP,
city: 'Duisburg',
storeId: store.id,
image: null,
validFrom: f.validFrom || null,
validTo: f.validTo || null,
pageCount: f.pageCount || null,
url: `https://www.marktguru.de/leaflets/${f.mainLeafletId}`,
isTarget: isKnownLocalStore(store.id) && store.id != null,
badges: [],
});
}
// Bier-Icon: mit den aktuellen König-Pilsener-Angeboten abgleichen, welche
// Filialen gerade wirklich König Pilsener im Angebot haben
try {
const offersResult = await scrapeMarktguru(false);
const storeIdsWithKoepi = new Set((offersResult.offers || []).map(o => o.storeId).filter(id => id != null));
for (const b of all) b.hasKoenigPilsener = b.storeId != null && storeIdsWithKoepi.has(b.storeId);
} catch (e) {
console.log('🍺 KöPi Prospekte: Abgleich mit aktuellen Angeboten fehlgeschlagen (ignoriert):', e.message);
for (const b of all) b.hasKoenigPilsener = false;
}
// Unerwünschte Prospekt-Typen ausblenden (Sondermagazine, Reise-Prospekte etc.)
// WICHTIG: gegen rawName (den echten Leaflet-Namen) prüfen, nicht gegen
// title/publisher — die zeigen nur noch den sauberen Kettennamen und würden
// "Sondermagazin"/"Bestellmagazin" im echten Namen sonst nie mehr finden.
const EXCLUDED_TITLE_KEYWORDS = ['sondermagazin', 'reisen', 'bestellmagazin'];
const withoutSondermagazin = all.filter(b => {
const t = (b.rawName || '').toLowerCase();
return !EXCLUDED_TITLE_KEYWORDS.some(kw => t.includes(kw));
}).map(({ rawName, ...rest }) => rest); // rawName war nur intern nötig
const targeted = withoutSondermagazin.filter(b => b.isTarget);
// Chronologisch in "aktuell laufend" und "kommend" trennen
const todayIso = new Date().toISOString().slice(0, 10);
const isCurrent = b => {
const from = (b.validFrom || '').slice(0, 10);
const to = (b.validTo || '').slice(0, 10);
if (!from) return true; // ohne Datum lieber anzeigen als verstecken
if (from > todayIso) return false; // startet erst noch
if (to && to < todayIso) return false; // schon abgelaufen
return true;
};
const byValidFrom = (a, b) => (a.validFrom || '').localeCompare(b.validFrom || '');
const current = targeted.filter(isCurrent).sort(byValidFrom);
const future = targeted.filter(b => !isCurrent(b)).sort(byValidFrom);
const result = {
current,
future,
scrapedAt: new Date().toISOString(),
};
console.log(`🍺 KöPi Prospekte: ${current.length} aktuell laufend, ${future.length} kommend, ${current.filter(b=>b.hasKoenigPilsener).length} davon aktuell mit König Pilsener im Angebot`);
cacheSet(cacheKey, result);
return result;
}
// ── Bild-Proxy (für evtl. externe Bilder) ───────────────────────────────────
router.get('/img', authenticate, (req, res) => {
const url = req.query.url;
if (!url || !url.startsWith('https://')) return res.status(400).end();
try {
const parsed = new URL(url);
const allowed = ['content-media.bonial.biz','publisher-media.bonial.biz'];
if (!allowed.some(h => parsed.hostname === h)) return res.status(403).end();
const r = https.get(url, { headers: {'User-Agent':'Mozilla/5.0'} }, imgRes => {
res.setHeader('Content-Type', imgRes.headers['content-type'] || 'image/jpeg');
res.setHeader('Cache-Control', 'public, max-age=3600');
imgRes.pipe(res);
});
r.on('error', () => res.status(502).end());
} catch { res.status(400).end(); }
});
// ── Tages-Cron: neue/geänderte Angebote per Pushover melden ─────────────────
const KOEPI_SETTINGS_KEY = 'koepi_last_offers';
// Vergleichbare, stabile Signatur eines Angebots (Reihenfolge-unabhängig).
// Bewusst NUR dateRange (echtes Datum), NICHT validity ("Noch X Tage") — der
// Countdown-Text ändert sich täglich von allein und würde sonst jeden Tag
// eine Pushover-Benachrichtigung auslösen, auch wenn sich am Angebot nichts
// geändert hat.
function offerSignature(offers) {
return offers
.map(o => `${(o.retailer || '').toLowerCase().trim()}|${(o.price || '').trim()}|${(o.dateRange || '').trim()}`)
.sort()
.join('\n');
}
function formatOfferMessage(offers) {
if (!offers.length) return 'Aktuell keine König Pilsener Angebote gefunden.';
return offers
.map(o => `${o.retailer || '?'}: ${o.price || '?'} (${o.dateRange || 'Gültigkeit unbekannt'})`)
.join('\n');
}
// onlyUserId gesetzt: nur an diesen einen Nutzer schicken (z.B. Admin-Testklick),
// sonst an alle Nutzer mit hinterlegtem Pushover (echter Tages-Cron)
async function sendKoepiPushover(offers, onlyUserId = null) {
const recipients = onlyUserId
? db.prepare(`
SELECT user_id, user_key, app_token FROM pushover_settings
WHERE user_id = ? AND user_key IS NOT NULL AND app_token IS NOT NULL
`).all(onlyUserId)
: db.prepare(`
SELECT user_id, user_key, app_token FROM pushover_settings
WHERE user_key IS NOT NULL AND app_token IS NOT NULL
`).all();
const message = formatOfferMessage(offers);
const title = '🍺 KöPi Angebote';
for (const r of recipients) {
try {
await fetch('https://api.pushover.net/1/messages.json', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
token: r.app_token,
user: r.user_key,
title,
message,
priority: 0,
}),
});
logPush({ userId: r.user_id, title, message, priority: 0, source: 'koepi' });
} catch (e) {
console.error('KöPi Pushover Fehler:', e.message);
logPush({ userId: r.user_id, title, message, priority: 0, source: 'koepi', success: false });
}
}
}
// Reiner Anzeige-Refresh für den HA-Button "KöPi Check jetzt" — scraped frisch
// und aktualisiert nur die MQTT-Entitäten. Sendet NIE Pushover und rührt den
// in admin_settings gespeicherten Vergleichsstand nicht an (der bleibt exklusiv
// dem 06:00-Cron vorbehalten, damit dessen Diff-Erkennung korrekt bleibt).
async function refreshOffersOnly() {
try {
const { offers } = await scrapeMarktguru(true);
const currentSig = offerSignature(offers);
const lastSig = db.prepare('SELECT value FROM admin_settings WHERE key=?').get(KOEPI_SETTINGS_KEY)?.value || '';
const changed = currentSig !== lastSig; // nur zur Anzeige im "geändert"-Sensor, löst nichts aus
mqttClient.publishKoepiState({ offers, changed });
console.log(`🍺 KöPi-Refresh (HA-Button): ${offers.length} Angebot(e) angezeigt, keine Pushover.`);
return { changed, sent: false, offerCount: offers.length };
} catch (e) {
console.error('KöPi Refresh Fehler:', e.message);
return { error: e.message };
}
}
// Wird täglich um 06:00 vom Scheduler in index.js aufgerufen (ohne onlyUserId
// → geht an alle). forceNotify=true (manueller Admin-Test): Pushover wird auch
// ohne Änderung verschickt; onlyUserId gesetzt: nur an diesen einen Nutzer.
// Löst die Filial-Whitelist automatisch neu auf, wenn die letzte Auflösung
// mehr als 6 Tage her ist — läuft als Teil des täglichen 06:00-Checks, damit
// ein einmalig falscher/veralteter Treffer sich von selbst korrigiert, statt
// dauerhaft stehen zu bleiben.
const RESOLVE_MAX_AGE_DAYS = 6;
async function resolveLocalStoresIfStale() {
const row = db.prepare('SELECT MIN(resolved_at) AS oldest FROM koepi_local_stores').get();
const oldest = row?.oldest;
const staleOrMissing = !oldest ||
(Date.now() - new Date(oldest.replace(' ', 'T')).getTime()) > RESOLVE_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
if (!staleOrMissing) return;
console.log(`🍺 KöPi: Filial-Whitelist ist älter als ${RESOLVE_MAX_AGE_DAYS} Tage (oder unvollständig) — löse automatisch neu auf...`);
try { await resolveLocalStores(); }
catch (e) { console.error('🍺 KöPi: automatische Neu-Auflösung fehlgeschlagen:', e.message); }
}
async function runDailyCheck(forceNotify = false, onlyUserId = null) {
try {
await resolveLocalStoresIfStale();
const { offers } = await scrapeMarktguru(true);
const currentSig = offerSignature(offers);
const lastSig = db.prepare('SELECT value FROM admin_settings WHERE key=?').get(KOEPI_SETTINGS_KEY)?.value || '';
const changed = currentSig !== lastSig;
if (!changed && !forceNotify) {
console.log('🍺 KöPi-Check: keine Änderungen an den Angeboten.');
mqttClient.publishKoepiState({ offers, changed: false });
return { changed: false, sent: false, offerCount: offers.length };
}
await sendKoepiPushover(offers, onlyUserId);
db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)')
.run(KOEPI_SETTINGS_KEY, currentSig);
console.log(`🍺 KöPi-Check: ${offers.length} Angebot(e)${changed ? ' geändert' : ' (Test, unverändert)'} Pushover versendet${onlyUserId ? ` (nur an User ${onlyUserId})` : ''}.`);
mqttClient.publishKoepiState({ offers, changed });
return { changed, sent: true, offerCount: offers.length };
} catch (e) {
console.error('KöPi Tages-Check Fehler:', e.message);
return { error: e.message };
}
}
// ── Routes ────────────────────────────────────────────────────────────────────
router.get('/offers', authenticate, async (req, res) => {
try { res.json(await scrapeMarktguru()); }
catch(e) { console.error('KöPi offers:', e.message); res.status(500).json({ error: e.message }); }
});
router.get('/prospekte', authenticate, async (req, res) => {
try { res.json(await scrapeProspekte()); }
catch(e) {
console.error('🍺 KöPi Prospekte-Fehler:', e.message);
res.status(500).json({ error: e.message });
}
});
router.post('/clear-cache', authenticate, (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
['koepi:mg','koepi:prospekte'].forEach(k => delete cache[k]);
res.json({ ok: true });
});
// Manueller Test des Tages-Checks (Admin) — sendet Pushover auch ohne echte
// Änderung, aber NUR an den Admin selbst (nicht an alle Nutzer)
// GET /local-stores aktuelle Filial-Whitelist anzeigen (Admin)
router.get('/local-stores', authenticate, (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
const stores = db.prepare('SELECT * FROM koepi_local_stores ORDER BY retailer').all();
res.json({ stores, seedCount: LOCAL_STORE_SEED_LEAFLETS.length + LOCAL_STORE_SEED_BY_ADDRESS.length });
});
// POST /local-stores/resolve Filial-Whitelist neu auflösen (Admin, z.B. wenn
// sich ein Prospekt-Link geändert hat oder eine Filiale ergänzt wurde)
router.post('/local-stores/resolve', authenticate, async (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
try {
const resolved = await resolveLocalStores();
const stores = db.prepare('SELECT * FROM koepi_local_stores ORDER BY retailer').all();
res.json({ ok: true, resolved, total: LOCAL_STORE_SEED_LEAFLETS.length + LOCAL_STORE_SEED_BY_ADDRESS.length, stores });
} catch(e) { res.status(500).json({ error: e.message }); }
});
router.post('/run-daily-check', authenticate, async (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
try {
const result = await runDailyCheck(true, req.user.id);
if (result.error) return res.status(500).json({ error: result.error });
res.json(result);
} catch(e) { res.status(500).json({ error: e.message }); }
});
// ── Öffentliche Teilen-Links (kein Login nötig) ─────────────────────────────
// Sicherheitsprinzip: die öffentlichen Routen unten liefern AUSSCHLIESSLICH
// bereits gecachte Daten aus (kein forceRefresh, kein Puppeteer-Trigger, kein
// Zugriff auf irgendeine Admin-Funktion) und sind zusätzlich pro IP
// ratenbegrenzt — ein gefundener/erratener Link kann also weder einen echten
// marktguru-Abruf erzwingen noch den Server sonst irgendwie belasten.
//
// Mehrere benannte Links gleichzeitig möglich (z.B. einer pro Person), damit
// in den Logs nachvollziehbar ist, WER welchen Link benutzt hat.
function getShareLinkByToken(token) {
return db.prepare('SELECT id, name FROM koepi_share_links WHERE token=?').get(token) || null;
}
// Einfache In-Memory-Ratenbegrenzung pro IP (30 Anfragen/Minute) — bewusst
// simpel gehalten, reicht für dieses Nutzungsszenario, kein Redis o.ä. nötig
const publicRateLimits = new Map();
function publicRateLimit(req, res, next) {
const ip = req.ip || 'unknown';
const now = Date.now();
const windowMs = 60000, maxReq = 30;
const arr = (publicRateLimits.get(ip) || []).filter(t => now - t < windowMs);
if (arr.length >= maxReq) return res.status(429).json({ error: 'Zu viele Anfragen, bitte kurz warten.' });
arr.push(now);
publicRateLimits.set(ip, arr);
next();
}
// Gelegentliches Aufräumen alter IP-Einträge, damit die Map nicht unbegrenzt wächst
setInterval(() => {
const now = Date.now();
for (const [ip, arr] of publicRateLimits) {
const fresh = arr.filter(t => now - t < 60000);
if (fresh.length) publicRateLimits.set(ip, fresh); else publicRateLimits.delete(ip);
}
}, 5 * 60 * 1000);
// GET /share-links alle benannten Links auflisten (Admin)
router.get('/share-links', authenticate, (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
const links = db.prepare('SELECT id, token, name, created_at FROM koepi_share_links ORDER BY created_at DESC').all()
.map(l => ({ ...l, url: `${req.protocol}://${req.get('host')}/kp/${l.token}` }));
res.json({ links });
});
// POST /share-links neuen benannten Link erstellen (Admin)
router.post('/share-links', authenticate, (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
const name = (req.body?.name || '').trim();
if (!name) return res.status(400).json({ error: 'Name fehlt' });
const token = crypto.randomBytes(20).toString('hex');
db.prepare(`INSERT INTO koepi_share_links (token, name, created_at) VALUES (?, ?, datetime('now','localtime'))`).run(token, name);
const link = db.prepare('SELECT id, token, name, created_at FROM koepi_share_links WHERE token=?').get(token);
res.json({ ...link, url: `${req.protocol}://${req.get('host')}/kp/${token}` });
});
// POST /share-links/:id/reset Token dieses Links neu erzeugen (Name bleibt,
// alter Link wird ungültig)
router.post('/share-links/:id/reset', authenticate, (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
const existing = db.prepare('SELECT * FROM koepi_share_links WHERE id=?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
const token = crypto.randomBytes(20).toString('hex');
db.prepare('UPDATE koepi_share_links SET token=? WHERE id=?').run(token, req.params.id);
res.json({ id: existing.id, token, name: existing.name, url: `${req.protocol}://${req.get('host')}/kp/${token}` });
});
// DELETE /share-links/:id Link löschen (Admin)
router.delete('/share-links/:id', authenticate, (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
db.prepare('DELETE FROM koepi_share_links WHERE id=?').run(req.params.id);
res.json({ ok: true });
});
// GET /public/:token/manifest.json eigenes PWA-Manifest für die öffentliche
// Prospekt-Seite: eigener Name ("Dickens Prospekte") und eigener start_url,
// damit die installierte App direkt wieder auf der öffentlichen Seite landet
// statt auf dem normalen Login der Haupt-App.
router.get('/public/:token/manifest.json', (req, res) => {
const link = getShareLinkByToken(req.params.token);
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
res.setHeader('Content-Type', 'application/manifest+json');
res.json({
name: 'Dickens Prospekte',
short_name: 'Prospekte',
description: 'Wöchentliche Prospekte',
start_url: `/kp/${req.params.token}`,
scope: `/kp/${req.params.token}`,
display: 'standalone',
orientation: 'portrait',
background_color: '#0f1117',
theme_color: '#0f1117',
icons: [
{ src: '/favicon.svg', type: 'image/svg+xml', sizes: 'any', purpose: 'any maskable' },
{ src: '/icon-192.png', type: 'image/png', sizes: '192x192' },
{ src: '/icon-512.png', type: 'image/png', sizes: '512x512' },
],
});
});
// GET /public/:token/offers öffentlich, nur Cache, kein Login
router.get('/public/:token/offers', publicRateLimit, async (req, res) => {
const link = getShareLinkByToken(req.params.token);
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
try {
const result = await scrapeMarktguru(false); // niemals forceRefresh über die öffentliche Route
res.json(result);
} catch (e) { res.status(500).json({ error: 'Angebote gerade nicht verfügbar' }); }
});
// GET /public/:token/prospekte öffentlich, nur Cache, kein Login
router.get('/public/:token/prospekte', publicRateLimit, async (req, res) => {
const link = getShareLinkByToken(req.params.token);
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
logPublicAccess({ linkType: 'koepi_share', path: `/kp/${req.params.token}`, ip: req.ip, userAgent: req.headers['user-agent'], linkName: link.name });
try {
const result = await scrapeProspekte();
res.json(result);
} catch (e) { res.status(500).json({ error: 'Prospekte gerade nicht verfügbar' }); }
});
module.exports = router;
module.exports.runDailyCheck = runDailyCheck;
module.exports.refreshOffersOnly = refreshOffersOnly;
module.exports.resolveLocalStores = resolveLocalStores;

View File

@@ -1,212 +0,0 @@
const express = require('express');
const db = require('../../db');
const { authenticate } = require('../../middleware/auth');
const router = express.Router();
// ── Ordner ────────────────────────────────────────────────────────────────────
router.get('/folders', authenticate, (req, res) => {
res.json(db.prepare('SELECT * FROM link_list_folders WHERE user_id=? ORDER BY sort_order,id').all(req.user.id));
});
router.post('/folders', authenticate, (req, res) => {
const uid = req.user.id;
const { name, icon='📁' } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' });
const max = db.prepare('SELECT MAX(sort_order) m FROM link_list_folders WHERE user_id=?').get(uid);
const r = db.prepare(`INSERT INTO link_list_folders (user_id,name,icon,sort_order,created_at) VALUES (?,?,?,?,datetime('now','localtime'))`)
.run(uid, name.trim(), icon, (max?.m ?? -1) + 1);
res.json(db.prepare('SELECT * FROM link_list_folders WHERE id=?').get(r.lastInsertRowid));
});
router.put('/folders/:id', authenticate, (req, res) => {
const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!f) return res.status(404).json({ error: 'Nicht gefunden' });
const { name=f.name, icon=f.icon, in_quickaccess } = req.body;
const qa = in_quickaccess !== undefined ? (in_quickaccess ? 1 : 0) : f.in_quickaccess;
db.prepare('UPDATE link_list_folders SET name=?, icon=?, in_quickaccess=? WHERE id=?').run(name, icon, qa, f.id);
res.json(db.prepare('SELECT * FROM link_list_folders WHERE id=?').get(f.id));
});
router.delete('/folders/:id', authenticate, (req, res) => {
const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!f) return res.status(404).json({ error: 'Nicht gefunden' });
// Links aus Ordner herauslösen
db.prepare('UPDATE link_list SET folder_id=NULL WHERE folder_id=? AND user_id=?').run(f.id, req.user.id);
db.prepare('DELETE FROM link_list_folders WHERE id=?').run(f.id);
res.json({ ok: true });
});
// Ordner-Inhalt abrufen (für Folder-Modal im Dashboard)
router.get('/folders/:id/links', authenticate, (req, res) => {
const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!f) return res.status(404).json({ error: 'Nicht gefunden' });
const links = db.prepare('SELECT * FROM link_list WHERE folder_id=? AND user_id=? ORDER BY sort_order,id').all(f.id, req.user.id);
res.json({ folder: f, links });
});
// ── Sortierung ────────────────────────────────────────────────────────────────
router.put('/sort', authenticate, (req, res) => {
const uid = req.user.id;
const { links=[], folders=[] } = req.body;
const upLink = db.prepare('UPDATE link_list SET sort_order=? WHERE id=? AND user_id=?');
const upFolder = db.prepare('UPDATE link_list_folders SET sort_order=? WHERE id=? AND user_id=?');
db.transaction(() => {
links.forEach((id, i) => upLink.run(i, id, uid));
folders.forEach((id, i) => upFolder.run(i, id, uid));
})();
res.json({ ok: true });
});
// ── Eigene Links ──────────────────────────────────────────────────────────────
router.get('/', authenticate, (req, res) => {
const uid = req.user.id;
const own = db.prepare(`
SELECT l.*, 0 as is_shared, NULL as owner_name,
(SELECT COUNT(*) FROM link_list_shares WHERE link_id=l.id) as share_count
FROM link_list l WHERE l.user_id=? ORDER BY l.folder_id IS NULL DESC, l.folder_id, l.sort_order, l.id
`).all(uid);
const folders = db.prepare('SELECT * FROM link_list_folders WHERE user_id=? ORDER BY sort_order,id').all(uid);
const shared = db.prepare(`
SELECT l.*, 1 as is_shared, u.username as owner_name, 0 as share_count
FROM link_list l
JOIN link_list_shares s ON s.link_id=l.id
JOIN users u ON u.id=l.user_id
WHERE s.shared_with=?
ORDER BY l.created_at DESC
`).all(uid);
const sharedByMe = db.prepare(`
SELECT l.*, 0 as is_shared, u.username as shared_with_name
FROM link_list l
JOIN link_list_shares s ON s.link_id=l.id
JOIN users u ON u.id=s.shared_with
WHERE l.user_id=?
ORDER BY l.title ASC
`).all(uid);
// Ordner die mit mir geteilt wurden (inkl. deren Links)
const sharedFolders = db.prepare(`
SELECT f.*, u.username as owner_name
FROM link_list_folders f
JOIN link_folder_shares s ON s.folder_id=f.id
JOIN users u ON u.id=f.user_id
WHERE s.shared_with=?
ORDER BY f.name ASC
`).all(uid);
const sharedFolderLinks = sharedFolders.map(folder => ({
...folder,
links: db.prepare('SELECT * FROM link_list WHERE folder_id=? ORDER BY sort_order,id').all(folder.id)
}));
// Ordner die ich geteilt habe
const sharedFoldersByMe = db.prepare(`
SELECT f.*, u.username as shared_with_name
FROM link_list_folders f
JOIN link_folder_shares s ON s.folder_id=f.id
JOIN users u ON u.id=s.shared_with
WHERE f.user_id=?
ORDER BY f.name ASC
`).all(uid);
res.json({ own, folders, shared, sharedByMe, sharedFolders: sharedFolderLinks, sharedFoldersByMe });
});
router.post('/', authenticate, (req, res) => {
const uid = req.user.id;
const { title, url, icon = '🔗', description = '', folder_id = null } = req.body;
if (!title?.trim() || !url?.trim()) return res.status(400).json({ error: 'Titel und URL erforderlich' });
const max = db.prepare('SELECT MAX(sort_order) m FROM link_list WHERE user_id=?').get(uid);
const r = db.prepare(`
INSERT INTO link_list (user_id, title, url, icon, description, folder_id, sort_order, created_at)
VALUES (?,?,?,?,?,?,?,datetime('now','localtime'))
`).run(uid, title.trim(), url.trim(), icon, description.trim(), folder_id || null, (max?.m ?? -1) + 1);
res.json(db.prepare('SELECT * FROM link_list WHERE id=?').get(r.lastInsertRowid));
});
router.put('/:id', authenticate, (req, res) => {
const link = db.prepare('SELECT * FROM link_list WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
const { title, url, icon, description, folder_id, in_quickaccess } = req.body;
db.prepare('UPDATE link_list SET title=?, url=?, icon=?, description=?, folder_id=?, in_quickaccess=? WHERE id=?')
.run(
title ?? link.title,
url ?? link.url,
icon ?? link.icon,
description ?? link.description,
folder_id !== undefined ? (folder_id || null) : link.folder_id,
in_quickaccess !== undefined ? (in_quickaccess ? 1 : 0) : link.in_quickaccess,
link.id
);
res.json(db.prepare('SELECT * FROM link_list WHERE id=?').get(link.id));
});
router.delete('/:id', authenticate, (req, res) => {
const r = db.prepare('DELETE FROM link_list 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({ ok: true });
});
// ── Shares ────────────────────────────────────────────────────────────────────
router.get('/:id/shares', authenticate, (req, res) => {
const link = db.prepare('SELECT * FROM link_list WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
res.json(db.prepare(`
SELECT u.id, u.username, s.created_at as shared_at
FROM link_list_shares s JOIN users u ON u.id=s.shared_with
WHERE s.link_id=?
`).all(link.id));
});
router.post('/:id/share', authenticate, (req, res) => {
const link = db.prepare('SELECT * FROM link_list WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
db.prepare(`INSERT OR IGNORE INTO link_list_shares (link_id, shared_by, shared_with, created_at) VALUES (?,?,?,datetime('now','localtime'))`)
.run(link.id, req.user.id, target.id);
res.json({ ok: true });
});
router.delete('/:id/share/:userId', authenticate, (req, res) => {
const link = db.prepare('SELECT * FROM link_list WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
db.prepare('DELETE FROM link_list_shares WHERE link_id=? AND shared_with=?').run(link.id, req.params.userId);
res.json({ ok: true });
});
// ── Ordner-Shares ─────────────────────────────────────────────────────────────
router.get('/folders/:id/shares', authenticate, (req, res) => {
const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!f) return res.status(404).json({ error: 'Nicht gefunden' });
const shares = db.prepare(`
SELECT u.id, u.username, s.created_at as shared_at
FROM link_folder_shares s JOIN users u ON u.id=s.shared_with
WHERE s.folder_id=?
`).all(f.id);
res.json(shares);
});
router.post('/folders/:id/share', authenticate, (req, res) => {
const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!f) return res.status(404).json({ error: 'Nicht gefunden' });
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
db.prepare(`INSERT OR IGNORE INTO link_folder_shares (folder_id, shared_by, shared_with, created_at) VALUES (?,?,?,datetime('now','localtime'))`)
.run(f.id, req.user.id, target.id);
res.json({ ok: true });
});
router.delete('/folders/:id/share/:userId', authenticate, (req, res) => {
const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!f) return res.status(404).json({ error: 'Nicht gefunden' });
db.prepare('DELETE FROM link_folder_shares WHERE folder_id=? AND shared_with=?').run(f.id, req.params.userId);
res.json({ ok: true });
});
module.exports = router;

File diff suppressed because it is too large Load Diff

View File

@@ -1,216 +0,0 @@
const express = require('express');
const db = require('../../db');
const { logPush } = require('../../pushLog');
const { authenticate } = require('../../middleware/auth');
const router = express.Router();
// ── Pushover Helper ───────────────────────────────────────────────────────────
async function sendPushover(userKey, appToken, title, message, opts = {}, userId = null) {
let priority = 0;
try {
const params = { token: appToken, user: userKey, title, message };
if (opts.retry && opts.expire) {
params.priority = 2;
priority = 2;
params.retry = opts.retry;
params.expire = opts.expire;
}
await fetch('https://api.pushover.net/1/messages.json', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params),
});
logPush({ userId, title, message, priority, source: 'nachrichten' });
} catch {
logPush({ userId, title, message, priority, source: 'nachrichten', success: false });
}
}
// ── Public Keys ───────────────────────────────────────────────────────────────
router.get('/keys/:userId', authenticate, (req, res) => {
const row = db.prepare('SELECT public_key FROM user_keys WHERE user_id = ?').get(req.params.userId);
res.json({ public_key: row?.public_key || null });
});
router.post('/keys', authenticate, (req, res) => {
const { public_key } = req.body;
if (!public_key) return res.status(400).json({ error: 'Kein Schlüssel' });
db.prepare(`
INSERT INTO user_keys (user_id, public_key, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET public_key=excluded.public_key, updated_at=CURRENT_TIMESTAMP
`).run(req.user.id, public_key);
res.json({ ok: true });
});
// ── Users ─────────────────────────────────────────────────────────────────────
// Presence: User ist gerade aktiv im Chat
router.post('/presence', authenticate, (req, res) => {
const { active } = req.body;
if (active) {
db.prepare("UPDATE users SET chat_active_at=datetime('now','localtime') WHERE id=?").run(req.user.id);
} else {
db.prepare("UPDATE users SET chat_active_at=NULL WHERE id=?").run(req.user.id);
}
res.json({ ok: true });
});
router.get('/users', authenticate, (req, res) => {
const me = req.user.id;
const isAdmin = req.user.role === 'admin';
// Admins sehen alle. Normale User sehen:
// - keine hidden User
// - wenn sie selbst hidden sind: nur Admins
const meUser = db.prepare('SELECT hidden FROM users WHERE id=?').get(me);
const isMeHidden = !!meUser?.hidden;
let whereExtra = '';
if (!isAdmin && isMeHidden) {
// Ich bin hidden → sehe nur Admins
whereExtra = "AND u.role = 'admin'";
} else if (!isAdmin) {
// Normaler User → sieht keine hidden User
whereExtra = "AND (u.hidden = 0 OR u.hidden IS NULL)";
}
// Admin → kein Filter
const users = db.prepare(`
SELECT u.id, u.username, uk.public_key,
(SELECT COUNT(*) FROM messages
WHERE sender_id = u.id AND recipient_id = ? AND read_by_recipient = 0) AS unread,
(SELECT MAX(created_at) FROM messages
WHERE (sender_id = u.id AND recipient_id = ?)
OR (sender_id = ? AND recipient_id = u.id)) AS last_message_at
FROM users u
LEFT JOIN user_keys uk ON uk.user_id = u.id
WHERE u.id != ? ${whereExtra}
ORDER BY last_message_at DESC, u.username ASC
`).all(me, me, me, me);
res.json(users);
});
// ── Unread Count ──────────────────────────────────────────────────────────────
router.get('/unread', authenticate, (req, res) => {
const row = db.prepare(
'SELECT COUNT(*) AS count FROM messages WHERE recipient_id = ? AND read_by_recipient = 0'
).get(req.user.id);
res.json({ count: row.count });
});
// ── Messages ──────────────────────────────────────────────────────────────────
router.get('/messages/:userId', authenticate, (req, res) => {
const me = req.user.id;
const other = parseInt(req.params.userId);
db.prepare(`
UPDATE messages SET read_by_recipient = 1
WHERE recipient_id = ? AND sender_id = ? AND read_by_recipient = 0
`).run(me, other);
const msgs = db.prepare(`
SELECT id, sender_id, encrypted_content, iv, created_at,
(sender_id = ?) AS is_mine,
read_by_recipient
FROM messages
WHERE (sender_id = ? AND recipient_id = ?)
OR (recipient_id = ? AND sender_id = ?)
ORDER BY created_at ASC
`).all(me, me, other, me, other);
res.json(msgs);
});
router.post('/messages/:userId', authenticate, async (req, res) => {
const me = req.user.id;
const recipId = parseInt(req.params.userId);
const { encrypted_content, iv } = req.body;
if (!encrypted_content || !iv) return res.status(400).json({ error: 'Fehlende Felder' });
const recipient = db.prepare('SELECT id FROM users WHERE id = ?').get(recipId);
if (!recipient) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
const result = db.prepare(
"INSERT INTO messages (sender_id, recipient_id, encrypted_content, iv, created_at) VALUES (?, ?, ?, ?, datetime('now', 'localtime'))"
).run(me, recipId, encrypted_content, iv);
const senderName = db.prepare('SELECT username FROM users WHERE id = ?').get(me)?.username || 'Jemand';
const pushover = db.prepare('SELECT * FROM pushover_settings WHERE user_id = ?').get(recipId);
if (pushover) {
// Kein Push wenn Empfänger gerade im Chat ist (aktiv in letzten 45 Sekunden)
const presence = db.prepare(`
SELECT chat_active_at FROM users WHERE id=? AND chat_active_at > datetime('now','localtime','-45 seconds')
`).get(recipId);
if (!presence) {
await sendPushover(pushover.user_key, pushover.app_token, 'DickenDock', `Neue Nachricht von ${senderName}`,
{ retry: pushover.retry, expire: pushover.expire }, recipId);
}
}
res.json({ id: result.lastInsertRowid });
});
// DELETE einzelne Nachricht hard delete, beide Seiten dürfen löschen
router.delete('/messages/:messageId', authenticate, (req, res) => {
const me = req.user.id;
const mid = parseInt(req.params.messageId);
db.prepare(
'DELETE FROM messages WHERE id = ? AND (sender_id = ? OR recipient_id = ?)'
).run(mid, me, me);
res.json({ ok: true });
});
// DELETE gesamte Konversation hard delete
router.delete('/conversation/:userId', authenticate, (req, res) => {
const me = req.user.id;
const other = parseInt(req.params.userId);
db.prepare(`
DELETE FROM messages
WHERE (sender_id = ? AND recipient_id = ?) OR (sender_id = ? AND recipient_id = ?)
`).run(me, other, other, me);
res.json({ ok: true });
});
// DELETE alle eigenen Nachrichten (für Schlüssel-Reset)
router.delete('/my-messages', authenticate, (req, res) => {
db.prepare('DELETE FROM messages WHERE sender_id = ? OR recipient_id = ?')
.run(req.user.id, req.user.id);
res.json({ ok: true });
});
// ── Pushover Settings ─────────────────────────────────────────────────────────
router.get('/pushover', authenticate, (req, res) => {
const row = db.prepare('SELECT user_key, app_token, retry, expire FROM pushover_settings WHERE user_id = ?').get(req.user.id);
res.json(row || { user_key: '', app_token: '', retry: null, expire: null });
});
router.post('/pushover', authenticate, (req, res) => {
const { user_key, app_token, retry, expire } = req.body;
if (!user_key || !app_token) return res.status(400).json({ error: 'Beide Felder erforderlich' });
db.prepare(`
INSERT INTO pushover_settings (user_id, user_key, app_token, retry, expire)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET user_key=excluded.user_key, app_token=excluded.app_token,
retry=excluded.retry, expire=excluded.expire
`).run(req.user.id, user_key, app_token, retry || null, expire || null);
res.json({ ok: true });
});
router.delete('/pushover', authenticate, (req, res) => {
db.prepare('DELETE FROM pushover_settings WHERE user_id = ?').run(req.user.id);
res.json({ ok: true });
});
router.post('/pushover/test', authenticate, async (req, res) => {
const row = db.prepare('SELECT * FROM pushover_settings WHERE user_id = ?').get(req.user.id);
if (!row) return res.status(400).json({ error: 'Keine Pushover-Einstellungen gespeichert' });
const useEmergency = req.body?.emergency === true && row.retry && row.expire;
const msg = useEmergency
? `🚨 Emergency-Test! Wiederholt alle ${row.retry}s für max. ${row.expire}s. Bitte in Pushover quittieren.`
: 'Pushover-Verbindung erfolgreich! 🎉';
await sendPushover(row.user_key, row.app_token, 'DickenDock', msg,
useEmergency ? { retry: row.retry, expire: row.expire } : {}, req.user.id);
res.json({ ok: true });
});
module.exports = router;

View File

@@ -1,5 +0,0 @@
const express = require('express');
const router = express.Router();
// Paywall-Killer: gesamter Flow läuft im Browser des Users.
// Kein aktiver Backend-Endpoint nötig.
module.exports = router;

View File

@@ -1,41 +0,0 @@
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 qr_codes WHERE user_id=? ORDER BY created_at DESC').all(req.user.id));
});
router.post('/', authenticate, (req, res) => {
const uid = req.user.id;
const { label='', url, size=256, fg_color='#000000', bg_color='#ffffff', margin=4,
dot_style='square', corner_style='square', caption='', caption_pos='bottom',
caption_color='#000000', caption_size=14 } = req.body;
if (!url?.trim()) return res.status(400).json({ error: 'URL erforderlich' });
const r = db.prepare(`
INSERT INTO qr_codes (user_id,label,url,size,fg_color,bg_color,margin,dot_style,corner_style,caption,caption_pos,caption_color,caption_size,created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now','localtime'))
`).run(uid, label.trim(), url.trim(), size, fg_color, bg_color, margin, dot_style, corner_style, caption.trim(), caption_pos, caption_color, caption_size);
res.json(db.prepare('SELECT * FROM qr_codes WHERE id=?').get(r.lastInsertRowid));
});
router.put('/:id', authenticate, (req, res) => {
const qr = db.prepare('SELECT * FROM qr_codes WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!qr) return res.status(404).json({ error: 'Nicht gefunden' });
const fields = ['label','url','size','fg_color','bg_color','margin','dot_style','corner_style','caption','caption_pos','caption_color','caption_size'];
const updates = {};
fields.forEach(f => { if (req.body[f] !== undefined) updates[f] = req.body[f]; });
if (!Object.keys(updates).length) return res.json(qr);
const sets = Object.keys(updates).map(f=>`${f}=?`).join(',');
db.prepare(`UPDATE qr_codes SET ${sets} WHERE id=?`).run(...Object.values(updates), qr.id);
res.json(db.prepare('SELECT * FROM qr_codes WHERE id=?').get(qr.id));
});
router.delete('/:id', authenticate, (req, res) => {
const r = db.prepare('DELETE FROM qr_codes 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({ ok: true });
});
module.exports = router;

View File

@@ -1,609 +0,0 @@
const express = require('express');
const db = require('../../db');
const { logPush } = require('../../pushLog');
const { authenticate } = require('../../middleware/auth');
const router = express.Router();
// ── DB-Migration ──────────────────────────────────────────────────────────────
(function migrate() {
db.exec(`
CREATE TABLE IF NOT EXISTS schocken_games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
players TEXT NOT NULL, -- JSON: [{id, username, order}]
status TEXT NOT NULL DEFAULT 'lobby',
-- status: lobby | half1 | half2 | endkampf | finished
phase_status TEXT NOT NULL DEFAULT 'playing',
-- phase_status: playing | finished
stock INTEGER NOT NULL DEFAULT 15, -- Scheiben auf dem Stock
player_chips TEXT NOT NULL DEFAULT '{}', -- JSON: {userId: chipCount}
has_16th INTEGER, -- userId der die 16. Scheibe hat
loser_h1 INTEGER, -- userId Verlierer Hälfte 1
loser_h2 INTEGER, -- userId Verlierer Hälfte 2
current_round TEXT, -- JSON: aktueller Rundenstand
round_number INTEGER NOT NULL DEFAULT 1,
beginner_id INTEGER, -- wer fängt die Runde an
current_player_idx INTEGER NOT NULL DEFAULT 0,
max_rolls INTEGER, -- vom Beginner bestimmt
first_round INTEGER NOT NULL DEFAULT 1, -- 1 = erste Runde der Hälfte
drink_losses TEXT NOT NULL DEFAULT '{}', -- {userId: count}
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
`);
})();
// ── Würfellogik ───────────────────────────────────────────────────────────────
function rollDice(count = 3) {
return Array.from({ length: count }, () => Math.floor(Math.random() * 6) + 1);
}
// Würfel klassifizieren
function classifyDice(dice) {
const sorted = [...dice].sort((a,b) => a-b);
const [a,b,c] = sorted;
// Schock aus: 1-1-1
if (a===1 && b===1 && c===1) return { type:'schock_aus', scheiben: 0, label:'Schock aus! 💀' };
// Julchen: 4-2-1 (unsortiert erkannt)
if (dice.includes(1) && dice.includes(2) && dice.includes(4)) return { type:'julchen', scheiben:7, label:'Julchen! 🎉' };
// General: 3x gleich (außer 3x1)
if (a===b && b===c) return { type:'general', scheiben:3, label:`General ${a}er! 🎲`, value: a };
// Schock: 2x Eins + andere
if (a===1 && b===1) {
const schockVal = c;
const label = schockVal === 2 ? 'Schock doof 😅' : `Schock ${schockVal}!`;
return { type:'schock', scheiben: schockVal, label, value: schockVal };
}
// Straße: aufeinanderfolgende Zahlen (nur wenn alle 3 auf einmal gewürfelt)
if (c-a===2 && b-a===1) return { type:'strasse', scheiben:2, label:`Straße ${a}-${b}-${c}!`, value: a };
// Normale Zahl
// Normale Zahlen: absteigend sortiert als dreistellige Zahl (z.B. 6-2-1 = 621, 5-5-4 = 554)
const desc = [...sorted].reverse(); // absteigend
const normalValue = desc[0]*100 + desc[1]*10 + desc[2];
return { type:'normal', scheiben:1, label:`${desc[0]}-${desc[1]}-${desc[2]}`, value: normalValue };
}
// Wertungsvergleich: wer hat schlechtere Würfel (bekommt Scheiben)
// Gibt -1 wenn a schlechter, 1 wenn b schlechter, 0 bei echtem Gleichstand
const TYPE_ORDER = ['normal','strasse','general','schock','julchen','schock_aus'];
function compareResults(a, b) {
const ai = TYPE_ORDER.indexOf(a.result.type);
const bi = TYPE_ORDER.indexOf(b.result.type);
if (ai !== bi) return ai < bi ? -1 : 1; // niedrigerer Typ = schlechter
// Gleicher Typ — Detailvergleich
if (a.result.type === 'normal') {
// Niedrigere Augensumme = schlechter
if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1;
return 0;
}
if (a.result.type === 'strasse') {
if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1;
return 0;
}
if (a.result.type === 'schock') {
if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1;
return 0;
}
if (a.result.type === 'general') {
if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1;
return 0;
}
return 0;
}
function sendPush(userId, title, message) {
const cfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId);
if (!cfg?.app_token || !cfg?.user_key) return;
fetch('https://api.pushover.net/1/messages.json', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ token:cfg.app_token, user:cfg.user_key, title, message, priority:0 }),
}).catch(() => {});
logPush({ userId, title, message, priority: 0, source: 'schocken' });
}
const uid = req => {
// Testmodus: Admin kann als anderen User agieren
const testAs = req.headers['x-schocken-test-as'];
if (testAs && req.user?.role === 'admin') {
const testId = parseInt(testAs);
if (!isNaN(testId)) return testId;
}
return req.user.id;
};
// ── Alle Spiele des Users ─────────────────────────────────────────────────────
router.get('/', authenticate, (req, res) => {
const me = uid(req);
const games = db.prepare(`
SELECT * FROM schocken_games
WHERE players LIKE ? ORDER BY updated_at DESC LIMIT 20
`).all(`%"id":${me}%`);
res.json(games.map(g => ({
...g,
players: JSON.parse(g.players),
player_chips: JSON.parse(g.player_chips),
drink_losses: JSON.parse(g.drink_losses),
current_round: g.current_round ? JSON.parse(g.current_round) : null,
})));
});
// ── Users für Einladen ────────────────────────────────────────────────────────
router.get('/users', authenticate, (req, res) => {
const me = uid(req);
const isAdmin = req.user?.role === 'admin';
const users = isAdmin
? db.prepare('SELECT id, username FROM users WHERE id != ? ORDER BY username').all(me)
: db.prepare('SELECT id, username FROM users WHERE id != ? AND hidden != 1 ORDER BY username').all(me);
res.json(users);
});
// ── Einzelnes Spiel ───────────────────────────────────────────────────────────
router.get('/:id', authenticate, (req, res) => {
const me = uid(req);
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id);
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
const players = JSON.parse(game.players);
if (!players.find(p => p.id === me)) return res.status(403).json({ error: 'Kein Zugriff' });
res.json({
...game,
players,
player_chips: JSON.parse(game.player_chips),
drink_losses: JSON.parse(game.drink_losses),
current_round: game.current_round ? JSON.parse(game.current_round) : null,
});
});
// ── Neues Spiel erstellen ─────────────────────────────────────────────────────
router.post('/', authenticate, (req, res) => {
const me = uid(req);
const { player_ids = [] } = req.body;
if (player_ids.length < 1) return res.status(400).json({ error: 'Mindestens 1 weiterer Spieler' });
const allIds = [me, ...player_ids.filter(id => id !== me)];
const userRows = db.prepare(`SELECT id, username FROM users WHERE id IN (${allIds.map(()=>'?').join(',')}) ORDER BY username`).all(...allIds);
// Reihenfolge: Ersteller zuerst, dann die anderen
const ordered = [
userRows.find(u => u.id === me),
...allIds.slice(1).map(id => userRows.find(u => u.id === id)).filter(Boolean),
];
const players = ordered.map((u, i) => ({ id: u.id, username: u.username, order: i }));
const playerChips = Object.fromEntries(players.map(p => [p.id, 0]));
const drinkLosses = Object.fromEntries(players.map(p => [p.id, 0]));
const result = db.prepare(`
INSERT INTO schocken_games (players, stock, player_chips, drink_losses, beginner_id, status)
VALUES (?, 15, ?, ?, ?, 'lobby')
`).run(JSON.stringify(players), JSON.stringify(playerChips), JSON.stringify(drinkLosses), me);
// Pushover an andere Spieler
const myName = ordered[0].username;
for (const p of players.slice(1)) {
sendPush(p.id, '🎲 Schocken', `${myName} lädt dich zu einer Runde Schocken ein!`);
}
res.json({ id: result.lastInsertRowid });
});
// ── Spiel starten (Lobby → Hälfte 1) ─────────────────────────────────────────
router.post('/:id/start', authenticate, (req, res) => {
const me = uid(req);
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id);
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
const players = JSON.parse(game.players);
if (players[0].id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann starten' });
if (game.status !== 'lobby') return res.status(400).json({ error: 'Spiel läuft schon' });
db.prepare(`UPDATE schocken_games SET status='half1', first_round=1, beginner_id=?,
current_player_idx=0, updated_at=datetime('now','localtime') WHERE id=?`).run(me, game.id);
for (const p of players.slice(1)) {
sendPush(p.id, '🎲 Schocken', 'Das Spiel beginnt! Du bist dran.');
}
res.json(getGame(game.id, me));
});
// ── Würfeln ───────────────────────────────────────────────────────────────────
router.post('/:id/roll', authenticate, (req, res) => {
const me = uid(req);
const { keep_dice = [], flip_sixes = false, go_dark = false } = req.body;
// keep_dice: array of indices (0-2) der Würfel die stehen bleiben
// flip_sixes: zwei Sechsen zu Eins umdrehen
// go_dark: dunkel legen nach diesem Wurf
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id);
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
const players = JSON.parse(game.players);
const activePlayers = getActivePlayers(game);
const currentPlayer = activePlayers[game.current_player_idx % activePlayers.length];
if (!currentPlayer || currentPlayer.id !== me)
return res.status(400).json({ error: 'Nicht dein Zug' });
let round = game.current_round ? JSON.parse(game.current_round) : initRound(game, activePlayers);
const myState = round.playerStates[me];
if (myState.done) return res.status(400).json({ error: 'Du hast schon gewürfelt' });
if (myState.dark) return res.status(400).json({ error: 'Du bist dunkel' });
// Flip sixes: nur wenn noch Würfe übrig und nicht letzter Wurf
if (flip_sixes) {
const sixes = myState.dice.reduce((acc, v, i) => v === 6 ? [...acc, i] : acc, []);
if (sixes.length >= 2) {
myState.dice[sixes[0]] = 1;
myState.dice[sixes[1]] = 1;
// Kostet keinen Wurf, braucht aber noch einen
}
}
// Würfeln
const rollCount = myState.roll_count;
const isFirstRound = !!game.first_round;
// Neue Würfel für nicht-stehen-gelassene
if (!isFirstRound || rollCount === 0) {
const newDice = rollDice(3 - keep_dice.length);
let newIdx = 0;
for (let i = 0; i < 3; i++) {
if (!keep_dice.includes(i)) myState.dice[i] = newDice[newIdx++];
}
}
myState.roll_count++;
// Straße nur gültig wenn im ersten Wurf (keine stehen gelassenen Würfel)
const classified = classifyDice(myState.dice);
if (classified.type === 'strasse' && (keep_dice.length > 0 || myState.roll_count > 1)) {
// Straße ungültig — als normal werten
classified.type = 'normal';
classified.scheiben = 1;
classified.label = 'Normal (Straße ungültig)';
}
// Max rolls durch Beginner bestimmt
const maxRolls = round.max_rolls || 3;
const isDark = go_dark || myState.roll_count >= maxRolls || isFirstRound;
const isDone = isDark || myState.roll_count >= maxRolls || isFirstRound;
myState.dark = go_dark || (myState.roll_count >= maxRolls && !isDone) || isFirstRound;
myState.done = isDone;
myState.result = isDone && !go_dark ? classified : null; // bei dunkel: result erst am Ende
// Wenn Beginner (erster Spieler): max_rolls setzen
if (currentPlayer.id === round.beginner_id && round.max_rolls === null) {
round.max_rolls = myState.roll_count;
}
// Nächsten Spieler
const nextIdx = (game.current_player_idx + 1) % activePlayers.length;
const allDone = activePlayers.every(p => round.playerStates[p.id]?.done);
round.playerStates[me] = myState;
if (allDone) {
// Alle dunkel aufdecken
for (const p of activePlayers) {
const ps = round.playerStates[p.id];
if (!ps.result) {
ps.result = classifyDice(ps.dice);
// Straße bei dunkel-legen auch ungültig wenn keep_dice vorhanden (nicht trackbar nachträglich)
}
}
round.phase = 'reveal';
}
db.prepare(`UPDATE schocken_games SET current_round=?, current_player_idx=?,
updated_at=datetime('now','localtime') WHERE id=?`)
.run(JSON.stringify(round), allDone ? game.current_player_idx : nextIdx, game.id);
// Pushover an nächsten Spieler
if (!allDone) {
const next = activePlayers[nextIdx];
if (next && next.id !== me) {
sendPush(next.id, '🎲 Schocken', `${currentPlayer.username} hat gewürfelt — du bist dran!`);
}
}
res.json(getGame(game.id, me));
});
// ── Runde auswerten ───────────────────────────────────────────────────────────
router.post('/:id/evaluate', authenticate, (req, res) => {
const me = uid(req);
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id);
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
const players = JSON.parse(game.players);
const activePlayers = getActivePlayers(game);
if (players[0].id !== me && activePlayers[0]?.id !== me)
return res.status(403).json({ error: 'Kein Zugriff' });
const round = JSON.parse(game.current_round);
if (round.phase !== 'reveal') return res.status(400).json({ error: 'Noch nicht alle fertig' });
const playerChips = JSON.parse(game.player_chips);
let stock = game.stock;
// Schock aus prüfen
const schockAus = activePlayers.find(p => round.playerStates[p.id]?.result?.type === 'schock_aus');
if (schockAus) {
return handleSchockAus(game, round, activePlayers, res, me);
}
// Bestes Ergebnis finden (höchster Typ)
const results = activePlayers.map(p => ({
player: p,
state: round.playerStates[p.id],
result: round.playerStates[p.id]?.result,
rolls: round.playerStates[p.id]?.roll_count || 1,
})).filter(r => r.result);
// Schlechtestes Ergebnis = bekommt Scheiben
results.sort((a, b) => {
const cmp = compareResults(a, b);
if (cmp !== 0) return cmp; // -1 wenn a schlechter
// Gleicher Typ: mehr Würfe = schlechter (vorne in der Liste = Index 0 = Verlierer)
if (a.rolls !== b.rolls) return a.rolls - b.rolls; // mehr Würfe = schlechter (vorne)
// Nachleger: später in Reihenfolge = schlechter (vorne)
return activePlayers.indexOf(a.player) - activePlayers.indexOf(b.player);
});
const loser = results[0]; // schlechtestes Ergebnis (Index 0 nach sort)
const winner = results[results.length - 1]; // bestes Ergebnis
// Scheiben die verteilt werden = Wert des BESTEN Ergebnisses der Runde
const scheiben = winner.result.scheiben;
// Scheiben verteilen
let fromStock = 0;
let newStock = stock;
if (stock >= scheiben) {
fromStock = scheiben;
newStock = stock - scheiben;
} else {
fromStock = stock;
newStock = 0;
}
const fromOthers = scheiben - fromStock;
playerChips[loser.player.id] = (playerChips[loser.player.id] || 0) + scheiben;
// Wenn vom Stock nicht genug: Rest von aktivsten Spielern nehmen (noch nicht implementiert vereinfacht)
if (fromOthers > 0) {
// Scheiben von anderen Spielern umverteilen (von denen mit meisten Scheiben)
const donors = activePlayers
.filter(p => p.id !== loser.player.id && playerChips[p.id] > 0)
.sort((a,b) => playerChips[b.id] - playerChips[a.id]);
let remaining = fromOthers;
for (const donor of donors) {
if (remaining <= 0) break;
const take = Math.min(playerChips[donor.id], remaining);
playerChips[donor.id] -= take;
remaining -= take;
}
}
// Hälfte/Endkampf vorbei? Prüfen ob Stock leer und nur einer Scheiben hat
const playersWithChips = activePlayers.filter(p => playerChips[p.id] > 0);
const phaseOver = newStock === 0 && playersWithChips.length <= 1;
let newStatus = game.status;
let newPhaseStatus = 'playing';
let loserH1 = game.loser_h1;
let loserH2 = game.loser_h2;
let has16th = game.has_16th;
let eventMsg = null;
if (phaseOver) {
const phaseLoser = playersWithChips[0] || loser.player;
if (game.status === 'half1') {
// Verlierer H1 bestimmt
loserH1 = phaseLoser.id;
has16th = phaseLoser.id;
newStatus = 'half2';
// Chips reset, Stock reset
Object.keys(playerChips).forEach(k => { playerChips[k] = 0; });
newStock = 15;
eventMsg = `${phaseLoser.username} hat die erste Hälfte verloren. Die erste Hälfte kostet nur Nerven! 😅`;
// Pushover
for (const p of players) {
sendPush(p.id, '🎲 Schocken', eventMsg);
}
} else if (game.status === 'half2') {
loserH2 = phaseLoser.id;
if (loserH1 === loserH2) {
// Doppelfeige!
newStatus = 'finished';
newPhaseStatus = 'finished';
const dl = JSON.parse(game.drink_losses);
dl[phaseLoser.id] = (dl[phaseLoser.id] || 0) + 1;
eventMsg = `${phaseLoser.username} ist DOPPELFEIGE! 🪶 Die Runde ist vorbei.`;
db.prepare(`UPDATE schocken_games SET status=?, phase_status=?, loser_h2=?, has_16th=?,
player_chips=?, stock=?, drink_losses=?, current_round=NULL,
updated_at=datetime('now','localtime') WHERE id=?`)
.run(newStatus, newPhaseStatus, loserH2, has16th, JSON.stringify(playerChips),
newStock, dl, game.id);
for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg);
return res.json(getGame(game.id, me));
}
// Endkampf
newStatus = 'endkampf';
Object.keys(playerChips).forEach(k => { playerChips[k] = 0; });
newStock = 15;
eventMsg = `${phaseLoser.username} verliert die zweite Hälfte! Endkampf: ${players.find(p=>p.id===loserH1)?.username} vs ${phaseLoser.username}`;
for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg);
} else if (game.status === 'endkampf') {
newStatus = 'finished';
newPhaseStatus = 'finished';
const dl = JSON.parse(game.drink_losses);
dl[phaseLoser.id] = (dl[phaseLoser.id] || 0) + 1;
eventMsg = `${phaseLoser.username} hat den Endkampf verloren und muss die nächste Runde zahlen! 🍺`;
db.prepare(`UPDATE schocken_games SET status=?, phase_status=?, loser_h2=?,
player_chips=?, stock=?, drink_losses=?, current_round=NULL,
updated_at=datetime('now','localtime') WHERE id=?`)
.run(newStatus, newPhaseStatus, loserH2, JSON.stringify(playerChips), newStock, JSON.stringify(dl), game.id);
for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg);
return res.json(getGame(game.id, me));
}
}
// Nächste Runde vorbereiten
const nextBeginner = loser.player;
db.prepare(`UPDATE schocken_games SET player_chips=?, stock=?, status=?, loser_h1=?, loser_h2=?,
has_16th=?, beginner_id=?, current_round=NULL, current_player_idx=0, first_round=0,
updated_at=datetime('now','localtime') WHERE id=?`)
.run(JSON.stringify(playerChips), newStock, newStatus, loserH1, loserH2,
has16th, nextBeginner.id, game.id);
// Pushover an Verlierer der Runde (er fängt an)
sendPush(loser.player.id, '🎲 Schocken', `Du hast die Runde verloren und fängst die nächste an! (${scheiben} Scheibe${scheiben!==1?'n':''})`);
res.json({ ...getGame(game.id, me), round_result: { loser: loser.player, scheiben, eventMsg } });
});
// ── Becher umdrehen (Runde bereit signalisieren) ──────────────────────────────
router.post('/:id/ready', authenticate, (req, res) => {
const me = uid(req);
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id);
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
const players = JSON.parse(game.players);
const playerChips = JSON.parse(game.player_chips);
const activePlayers = getActivePlayers(game);
// Spieler der eigentlich raus wäre aber trotzdem ready drückt → wird wieder aktiv (Falle!)
const isActuallyActive = activePlayers.find(p => p.id === me);
const isOut = !isActuallyActive && playerChips[me] === 0 && game.stock === 0;
let round = game.current_round ? JSON.parse(game.current_round) : null;
if (!round) {
round = initRound(game, isOut ? [...activePlayers, players.find(p=>p.id===me)] : activePlayers);
}
round.ready = round.ready || {};
round.ready[me] = true;
// Wenn isOut und drückt trotzdem: wird als "gefangen" markiert
if (isOut) {
round.trapped = round.trapped || [];
if (!round.trapped.includes(me)) round.trapped.push(me);
}
db.prepare(`UPDATE schocken_games SET current_round=?, updated_at=datetime('now','localtime') WHERE id=?`)
.run(JSON.stringify(round), game.id);
res.json(getGame(game.id, me));
});
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
function getActivePlayers(game) {
const players = JSON.parse(game.players);
const playerChips = JSON.parse(game.player_chips);
const stock = game.stock;
if (game.status === 'endkampf') {
return players.filter(p => p.id === game.loser_h1 || p.id === game.loser_h2);
}
// Wenn Stock noch voll oder erste Runde: alle mitspielen
if (stock > 0) return players;
// Stock leer: nur Spieler mit Scheiben
return players.filter(p => playerChips[p.id] > 0);
}
function initRound(game, activePlayers) {
const playerStates = {};
for (const p of activePlayers) {
playerStates[p.id] = {
dice: [null, null, null],
roll_count: 0,
done: false,
dark: false,
result: null,
};
}
return {
phase: 'rolling',
beginner_id: game.beginner_id,
max_rolls: game.first_round ? 1 : null,
ready: {},
trapped: [],
playerStates,
};
}
function handleSchockAus(game, round, activePlayers, res, me) {
const players = JSON.parse(game.players);
const playerChips = JSON.parse(game.player_chips);
// Alle Scheiben (Stock + Spieler) → schlechtester Spieler
const allChips = Object.values(playerChips).reduce((s,v) => s+v, 0) + game.stock;
// Wer hat die wenigsten Punkte (außer dem der Schock aus hat)?
const schockAusPlayer = activePlayers.find(p => round.playerStates[p.id]?.result?.type === 'schock_aus');
const others = activePlayers.filter(p => p.id !== schockAusPlayer?.id);
// Vereinfacht: Schock aus Spieler hat gewonnen, alle Scheiben gehen an den schlechtesten der anderen
// (Schock aus steht über allem)
const newChips = Object.fromEntries(Object.keys(playerChips).map(k => [k, 0]));
// Verlierer = wer die wenigsten Punkte hat unter den anderen
// Bei nur einem anderen: der verliert
const phaseLoser = others.length > 0 ? others[others.length - 1] : schockAusPlayer;
newChips[phaseLoser.id] = allChips;
let newStatus = game.status;
let loserH1 = game.loser_h1;
let has16th = game.has_16th;
if (game.status === 'half1') {
loserH1 = phaseLoser.id;
has16th = phaseLoser.id;
newStatus = 'half2';
Object.keys(newChips).forEach(k => { newChips[k] = 0; });
const eventMsg = `SCHOCK AUS! ${phaseLoser.username} verliert die erste Hälfte! Die erste Hälfte kostet nur Nerven! 😅`;
for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg);
db.prepare(`UPDATE schocken_games SET player_chips=?, stock=15, status=?, loser_h1=?, has_16th=?,
beginner_id=?, current_round=NULL, current_player_idx=0, first_round=1,
updated_at=datetime('now','localtime') WHERE id=?`)
.run(JSON.stringify(newChips), newStatus, loserH1, has16th, phaseLoser.id, game.id);
}
res.json({ ...getGame(game.id, me), schock_aus: true, phase_loser: phaseLoser });
}
function getGame(id, requesterId) {
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(id);
if (!game) return null;
return {
...game,
players: JSON.parse(game.players),
player_chips: JSON.parse(game.player_chips),
drink_losses: JSON.parse(game.drink_losses),
current_round: game.current_round ? JSON.parse(game.current_round) : null,
};
}
// ── Spiel abbrechen (Admin only) — wird komplett gelöscht ───────────────────
router.post('/:id/cancel', authenticate, (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Nur Admin' });
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id);
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
const players = JSON.parse(game.players);
db.prepare('DELETE FROM schocken_games WHERE id=?').run(game.id);
for (const p of players) {
sendPush(p.id, '🎲 Schocken', 'Das Spiel wurde vom Admin abgebrochen.');
}
res.json({ ok: true });
});
module.exports = router;

View File

@@ -1,142 +0,0 @@
const express = require('express');
const db = require('../../db');
const { authenticate } = require('../../middleware/auth');
const router = express.Router();
const uid = req => req.user.id;
const getTags = id => db.prepare('SELECT tag FROM snippet_tags WHERE snippet_id=?').all(id).map(r=>r.tag);
const enrich = (s) => ({ ...s, tags: getTags(s.id) });
// ── Alle Snippets (eigene + geteilt) ────────────────────────────────────────
router.get('/', authenticate, (req, res) => {
const me = uid(req);
const own = db.prepare(`
SELECT s.*, u.username as owner
FROM snippets s JOIN users u ON u.id=s.user_id
WHERE s.user_id=? ORDER BY s.updated_at DESC, s.created_at DESC
`).all(me).map(enrich);
const shared = db.prepare(`
SELECT s.*, u.username as owner, 1 as is_shared
FROM snippets s
JOIN snippet_shares sh ON sh.snippet_id=s.id
JOIN users u ON u.id=s.user_id
WHERE sh.shared_with=?
ORDER BY s.updated_at DESC
`).all(me).map(enrich);
const sharedByMe = db.prepare(`
SELECT s.*, u.username as shared_with_name
FROM snippets s
JOIN snippet_shares sh ON sh.snippet_id=s.id
JOIN users u ON u.id=sh.shared_with
WHERE s.user_id=?
ORDER BY s.title ASC
`).all(me).map(enrich);
res.json({ own, shared, sharedByMe });
});
// ── Einzelnes Snippet ────────────────────────────────────────────────────────
router.get('/:id', authenticate, (req, res) => {
const s = db.prepare('SELECT s.*, u.username as owner FROM snippets s JOIN users u ON u.id=s.user_id WHERE s.id=?').get(req.params.id);
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
const isOwner = s.user_id === uid(req);
const isShared = db.prepare('SELECT id FROM snippet_shares WHERE snippet_id=? AND shared_with=?').get(s.id, uid(req));
if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' });
res.json(enrich(s));
});
// ── History ──────────────────────────────────────────────────────────────────
router.get('/:id/history', authenticate, (req, res) => {
const s = db.prepare('SELECT * FROM snippets WHERE id=?').get(req.params.id);
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
const isOwner = s.user_id === uid(req);
const isShared = db.prepare('SELECT id FROM snippet_shares WHERE snippet_id=? AND shared_with=?').get(s.id, uid(req));
if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' });
res.json(db.prepare('SELECT * FROM snippet_history WHERE snippet_id=? ORDER BY saved_at DESC').all(s.id));
});
// ── Erstellen ────────────────────────────────────────────────────────────────
router.post('/', authenticate, (req, res) => {
const { title, code='', language='text', description='', tags=[] } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'Titel erforderlich' });
const r = db.prepare(`
INSERT INTO snippets (user_id, title, code, language, description, created_at, updated_at)
VALUES (?,?,?,?,?,datetime('now','localtime'),datetime('now','localtime'))
`).run(uid(req), title.trim(), code, language, description);
const id = r.lastInsertRowid;
const insertTag = db.prepare('INSERT OR IGNORE INTO snippet_tags (snippet_id, tag) VALUES (?,?)');
db.transaction(()=>{ tags.forEach(t => insertTag.run(id, t)); })();
res.json(enrich(db.prepare('SELECT s.*, u.username as owner FROM snippets s JOIN users u ON u.id=s.user_id WHERE s.id=?').get(id)));
});
// ── Aktualisieren ────────────────────────────────────────────────────────────
router.put('/:id', authenticate, (req, res) => {
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
const { title, code, language, description, tags } = req.body;
// History-Eintrag wenn Code geändert
if (code !== undefined && code !== s.code) {
db.prepare(`INSERT INTO snippet_history (snippet_id, code, language, saved_at) VALUES (?,?,?,datetime('now','localtime'))`)
.run(s.id, s.code, s.language);
}
db.prepare(`UPDATE snippets SET title=?,code=?,language=?,description=?,updated_at=datetime('now','localtime') WHERE id=?`)
.run(title??s.title, code??s.code, language??s.language, description??s.description, s.id);
if (Array.isArray(tags)) {
db.prepare('DELETE FROM snippet_tags WHERE snippet_id=?').run(s.id);
const ins = db.prepare('INSERT OR IGNORE INTO snippet_tags (snippet_id, tag) VALUES (?,?)');
db.transaction(()=>{ tags.forEach(t => ins.run(s.id, t)); })();
}
res.json(enrich(db.prepare('SELECT s.*, u.username as owner FROM snippets s JOIN users u ON u.id=s.user_id WHERE s.id=?').get(s.id)));
});
// ── Löschen ──────────────────────────────────────────────────────────────────
router.delete('/:id/history/:hid', authenticate, (req, res) => {
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
if (!s) return res.status(403).json({ error: 'Kein Zugriff' });
db.prepare('DELETE FROM snippet_history WHERE id=? AND snippet_id=?').run(req.params.hid, s.id);
res.json({ ok: true });
});
router.delete('/:id', authenticate, (req, res) => {
const r = db.prepare('DELETE FROM snippets WHERE id=? AND user_id=?').run(req.params.id, uid(req));
if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
res.json({ ok: true });
});
// ── Shares ───────────────────────────────────────────────────────────────────
router.get('/:id/shares', authenticate, (req, res) => {
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
res.json(db.prepare(`
SELECT u.id, u.username, sh.created_at as shared_at
FROM snippet_shares sh JOIN users u ON u.id=sh.shared_with WHERE sh.snippet_id=?
`).all(s.id));
});
router.post('/:id/share', authenticate, (req, res) => {
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
if (target.id === uid(req)) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
db.prepare(`INSERT OR IGNORE INTO snippet_shares (snippet_id,shared_by,shared_with,created_at) VALUES (?,?,?,datetime('now','localtime'))`)
.run(s.id, uid(req), target.id);
res.json({ ok: true });
});
router.delete('/:id/share/:userId', authenticate, (req, res) => {
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
db.prepare('DELETE FROM snippet_shares WHERE snippet_id=? AND shared_with=?').run(s.id, req.params.userId);
res.json({ ok: true });
});
module.exports = router;

View File

@@ -1,266 +0,0 @@
const express = require('express');
const router = express.Router();
const db = require('../../db');
const { authenticate } = require('../../middleware/auth');
const uid = req => req.user.id;
// ── Ausgaben CRUD ──────────────────────────────────────────────────────────
router.get('/expenses', authenticate, (req, res) => {
const { from, to } = req.query;
let q = 'SELECT * FROM expenses WHERE user_id=?';
const p = [uid(req)];
if (from) { q += ' AND date>=?'; p.push(from); }
if (to) { q += ' AND date<=?'; p.push(to); }
res.json(db.prepare(q + ' ORDER BY date DESC, id DESC').all(...p));
});
router.post('/expenses', authenticate, (req, res) => {
const { date, category, description, amount } = req.body;
if (!date || !description || !amount) return res.status(400).json({ error: 'Fehlende Felder' });
const r = db.prepare('INSERT INTO expenses (user_id,date,category,description,amount) VALUES (?,?,?,?,?)')
.run(uid(req), date, category||'Sonstiges', description, parseFloat(amount));
res.json(db.prepare('SELECT * FROM expenses WHERE id=?').get(r.lastInsertRowid));
});
router.delete('/expenses/:id', authenticate, (req, res) => {
const e = db.prepare('SELECT id FROM expenses WHERE id=? AND user_id=?').get(req.params.id, uid(req));
if (!e) return res.status(404).json({ error: 'Nicht gefunden' });
db.prepare('DELETE FROM expenses WHERE id=?').run(e.id);
res.json({ ok: true });
});
// ── Revenue eines Auftrags ────────────────────────────────────────────────
function getOrderRevenue(order) {
if (order.custom_price != null && order.custom_price > 0) return parseFloat(order.custom_price);
const r = db.prepare(
'SELECT COALESCE(SUM(custom_price * stueckzahl), 0) as s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL'
).get(order.id);
return r?.s || 0;
}
function getOrderBaseCost(orderId) {
const r = db.prepare(`
SELECT COALESCE(SUM(COALESCE(c.preis_freundschaft, oi.preis_freundschaft, 0) * oi.stueckzahl), 0) as s
FROM order_items oi
LEFT JOIN calculations c ON c.id = oi.calculation_id
WHERE oi.order_id = ?
`).get(orderId);
return r?.s || 0;
}
function round2(n) { return Math.round((n||0) * 100) / 100; }
// ── Statistik-Übersicht ────────────────────────────────────────────────────
router.get('/overview', authenticate, (req, res) => {
const { from, to } = req.query;
const u = uid(req);
// Datums-Vergleich: ISO-String slice(0,10) = YYYY-MM-DD
const inRange = (dateStr) => {
if (!dateStr) return !from && !to; // kein Datum + kein Filter = ja; kein Datum + Filter = nein
const d = dateStr.slice(0, 10);
if (from && d < from) return false;
if (to && d > to) return false;
return true;
};
// Alle Aufträge
const allOrders = db.prepare('SELECT * FROM orders WHERE user_id=? ORDER BY created_at DESC').all(u);
// Zeitraum-Filterung:
// - Bezahlte Aufträge: Datum = bezahlt_am (wann wurde Geld erhalten)
// - Offene Aufträge (noch nicht bezahlt): Datum = created_at (wann erstellt)
const filteredOrders = allOrders.filter(o => {
if (!from && !to) return true; // kein Filter → alle
if (o.bezahlt) {
// Bezahlt → nach bezahlt_am wenn vorhanden, sonst created_at
// Wenn bezahlt_am NULL ist (alte Aufträge vor dem Feature): created_at nutzen
const d = o.bezahlt_am || o.created_at;
return inRange(d);
} else {
return inRange(o.created_at);
}
});
const paidOrders = filteredOrders.filter(o => !!o.bezahlt);
const openOrders = filteredOrders.filter(o => !o.bezahlt && o.status !== 'warteliste');
// KPI-Werte für den gewählten Zeitraum
const totalRevenue = paidOrders.reduce((s,o) => s + getOrderRevenue(o), 0);
const openRevenue = openOrders.reduce((s,o) => s + getOrderRevenue(o), 0);
const totalBaseCost = paidOrders.reduce((s,o) => s + getOrderBaseCost(o.id), 0);
const totalProfit = totalRevenue - totalBaseCost;
// Ausgaben im Zeitraum
let eq = 'SELECT * FROM expenses WHERE user_id=?';
const ep = [u];
if (from) { eq += ' AND date>=?'; ep.push(from); }
if (to) { eq += ' AND date<=?'; ep.push(to); }
const expenses = db.prepare(eq + ' ORDER BY date DESC').all(...ep);
const totalExpenses = expenses.reduce((s,e) => s + e.amount, 0);
const netProfit = totalProfit - totalExpenses;
// Status-Verteilung im Zeitraum
const byStatus = { warteliste:0, in_arbeit:0, fertig:0, bezahlt:0, abgeschlossen:0 };
for (const o of filteredOrders) {
if (!!o.bezahlt && !!o.abgeholt) byStatus.abgeschlossen++;
else if (!!o.bezahlt) byStatus.bezahlt++;
else if (o.status in byStatus) byStatus[o.status]++;
}
// Ausgaben nach Kategorie (Zeitraum)
const byCategory = {};
for (const e of expenses) {
byCategory[e.category] = (byCategory[e.category] || 0) + e.amount;
}
// ── Diagramm-Daten: immer nach dem relevanten Datum gruppiert ────────────
// Für Diagramme: alle Aufträge (ungefiltert) in Monatsbuckets einordnen
// damit das Balkendiagramm den vollen Kontext zeigt (letzte 12 Monate)
// ABER: wenn ein konkreter Zeitraum gewählt ist, zeigen wir nur diesen Zeitraum
// aufgeteilt in Tages/Wochen/Monats-Buckets je nach Länge
// Granularität bestimmen
let granularity = 'month'; // Standard
if (from && to) {
const days = (new Date(to) - new Date(from)) / 86400000;
if (days <= 14) granularity = 'day';
else if (days <= 31) granularity = 'week';
// > 31 Tage → month (Monatsauswahl, Jahresauswahl, Gesamt)
}
function getBucketKey(dateStr) {
if (!dateStr) return null;
const d = dateStr.slice(0, 10); // YYYY-MM-DD, immer lokal
if (granularity === 'day') return d;
if (granularity === 'week') {
// Montag der Woche rein mit String-Arithmetik (kein UTC-Bug)
const [y, m, dd] = d.split('-').map(Number);
const dt = new Date(y, m - 1, dd); // lokaler Konstruktor
const day = dt.getDay() || 7; // 1=Mo … 7=So
dt.setDate(dt.getDate() - day + 1);
const py = dt.getFullYear();
const pm = String(dt.getMonth() + 1).padStart(2, '0');
const pd = String(dt.getDate()).padStart(2, '0');
return `${py}-${pm}-${pd}`;
}
return d.slice(0, 7); // YYYY-MM
}
// Buckets aus gefilterten Aufträgen (damit der Zeitraum stimmt)
const buckets = {};
// Auch alle Aufträge für Diagramm wenn kein Filter (zeige letzte 12 Monate)
const diagramOrders = (from || to) ? filteredOrders : allOrders;
for (const o of diagramOrders) {
const dateStr = o.bezahlt ? (o.bezahlt_am || o.created_at) : o.created_at;
const key = getBucketKey(dateStr);
if (!key) continue;
if (!buckets[key]) buckets[key] = { month:key, revenue:0, expenses:0, orders:0 };
if (o.bezahlt) {
buckets[key].revenue += getOrderRevenue(o);
buckets[key].base_cost = (buckets[key].base_cost||0) + getOrderBaseCost(o.id);
}
buckets[key].orders++;
}
// Ausgaben in Buckets (gefiltert)
const diagramExpenses = (from || to) ? expenses : db.prepare('SELECT * FROM expenses WHERE user_id=?').all(u);
for (const e of diagramExpenses) {
const key = getBucketKey(e.date);
if (!key) continue;
if (!buckets[key]) buckets[key] = { month:key, revenue:0, expenses:0, orders:0 };
buckets[key].expenses += e.amount;
}
// Leere Buckets auffüllen lokaler Date-Konstruktor um UTC-Bug zu vermeiden
if (from || to) {
const parseLocal = s => { const [y,m,d] = s.split('-').map(Number); return new Date(y, m-1, d); };
const fmtLocal = dt => {
const y = dt.getFullYear(), m = String(dt.getMonth()+1).padStart(2,'0'), d = String(dt.getDate()).padStart(2,'0');
return `${y}-${m}-${d}`;
};
const firstBucket = Object.keys(buckets).sort()[0];
const startD = parseLocal(from || firstBucket || to);
const endD = parseLocal(to || fmtLocal(new Date()));
const cur = new Date(startD);
while (cur <= endD) {
const key = getBucketKey(fmtLocal(cur));
if (key && !buckets[key]) buckets[key] = { month:key, revenue:0, base_cost:0, expenses:0, orders:0 };
if (granularity === 'day') cur.setDate(cur.getDate() + 1);
else if (granularity === 'week') cur.setDate(cur.getDate() + 7);
else cur.setMonth(cur.getMonth() + 1);
}
}
// Sortiert + ohne Filter auf letzte 12 Monate begrenzen
let monthlyData = Object.values(buckets)
.sort((a,b) => a.month.localeCompare(b.month))
.map(m => ({ ...m, revenue: round2(m.revenue), base_cost: round2(m.base_cost||0), expenses: round2(m.expenses) }));
if (!from && !to) monthlyData = monthlyData.slice(-12);
// Kumulierter Nettogewinn (pro Bucket)
let cum = 0;
const cumulativeData = monthlyData.map(m => {
cum += (m.revenue||0) - (m.base_cost||0) - (m.expenses||0);
return { month: m.month, value: round2(cum) };
});
// Aufträge-Tabelle mit korrekten Werten
const recentOrders = filteredOrders.slice(0, 50).map(o => ({
id: o.id, name: o.name, status: o.status,
bezahlt: o.bezahlt, abgeholt: o.abgeholt,
created_at: o.created_at, bezahlt_am: o.bezahlt_am,
revenue: round2(getOrderRevenue(o)),
base_cost: round2(getOrderBaseCost(o.id)),
}));
const paidOrdersOut = paidOrders.slice(0,50).map(o => ({
id: o.id, name: o.name, status: o.status,
bezahlt: o.bezahlt, abgeholt: o.abgeholt,
created_at: o.created_at, bezahlt_am: o.bezahlt_am,
revenue: round2(getOrderRevenue(o)),
base_cost: round2(getOrderBaseCost(o.id)),
}));
res.json({
totalRevenue: round2(totalRevenue),
openRevenue: round2(openRevenue),
totalBaseCost: round2(totalBaseCost),
totalProfit: round2(totalProfit),
totalExpenses: round2(totalExpenses),
netProfit: round2(netProfit),
totalOrders: filteredOrders.length,
granularity,
byStatus, byCategory, monthlyData, cumulativeData,
expenses: expenses.slice(0, 100),
recentOrders,
paidOrders: paidOrdersOut,
});
});
// Bestellungen nach created_at (für "Bestellungen diesen Monat" Tab)
router.get('/month-orders', authenticate, (req, res) => {
const { from, to } = req.query;
const u = uid(req);
let q = 'SELECT * FROM orders WHERE user_id=?';
const p = [u];
if (from) { q += ' AND DATE(created_at)>=?'; p.push(from); }
if (to) { q += ' AND DATE(created_at)<=?'; p.push(to); }
q += ' ORDER BY created_at DESC';
const orders = db.prepare(q).all(...p);
res.json(orders.map(o => ({
id: o.id, name: o.name, status: o.status,
bezahlt: o.bezahlt, abgeholt: o.abgeholt,
created_at: o.created_at, bezahlt_am: o.bezahlt_am,
revenue: round2(getOrderRevenue(o)),
base_cost: round2(getOrderBaseCost(o.id)),
})));
});
module.exports = router;

View File

@@ -1,257 +0,0 @@
const express = require('express');
const db = require('../../db');
const { logPush } = require('../../pushLog');
const { authenticate, requireAdmin } = require('../../middleware/auth');
const router = express.Router();
// ── DB-Migration ──────────────────────────────────────────────────────────────
(function migrate() {
db.exec(`
CREATE TABLE IF NOT EXISTS whiteboards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL,
title TEXT NOT NULL DEFAULT 'Neues Whiteboard',
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS whiteboard_data (
whiteboard_id INTEGER PRIMARY KEY REFERENCES whiteboards(id) ON DELETE CASCADE,
elements TEXT NOT NULL DEFAULT '[]',
viewport TEXT NOT NULL DEFAULT '{"x":0,"y":0,"zoom":1}'
);
CREATE TABLE IF NOT EXISTS whiteboard_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
whiteboard_id INTEGER NOT NULL REFERENCES whiteboards(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'edit',
UNIQUE(whiteboard_id, user_id)
);
CREATE TABLE IF NOT EXISTS whiteboard_unread (
whiteboard_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
reason TEXT NOT NULL DEFAULT 'updated',
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
PRIMARY KEY (whiteboard_id, user_id)
);
`);
})();
const uid = req => req.user.id;
function getAccessUsers(whiteboardId, exceptUserId) {
const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(whiteboardId);
if (!wb) return [];
const users = new Set();
if (wb.owner_id !== exceptUserId) users.add(wb.owner_id);
const perms = db.prepare('SELECT user_id FROM whiteboard_permissions WHERE whiteboard_id=?').all(whiteboardId);
for (const p of perms) if (p.user_id !== exceptUserId) users.add(p.user_id);
return [...users];
}
function markUnread(whiteboardId, userIds, reason = 'updated') {
const upsert = db.prepare(`
INSERT INTO whiteboard_unread (whiteboard_id, user_id, reason, created_at)
VALUES (?, ?, ?, datetime('now','localtime'))
ON CONFLICT(whiteboard_id, user_id) DO UPDATE SET reason=excluded.reason, created_at=excluded.created_at
`);
for (const u of userIds) upsert.run(whiteboardId, u, reason);
}
function markRead(whiteboardId, userId) {
db.prepare('DELETE FROM whiteboard_unread WHERE whiteboard_id=? AND user_id=?').run(whiteboardId, userId);
}
// Hilfsfunktion: Hat User Zugriff? Gibt 'owner'|'edit'|'view'|null zurück
function access(whiteboardId, userId) {
const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(whiteboardId);
if (!wb) return null;
if (wb.owner_id === userId) return 'owner';
const perm = db.prepare('SELECT role FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?').get(whiteboardId, userId);
return perm?.role || null;
}
// ── Unread count ──────────────────────────────────────────────────────────────
router.get('/unread', authenticate, (req, res) => {
const me = uid(req);
const { count } = db.prepare('SELECT COUNT(*) as count FROM whiteboard_unread WHERE user_id=?').get(me);
res.json({ count });
});
// ── Unread IDs (welche Whiteboards konkret) ───────────────────────────────────
router.get('/unread-ids', authenticate, (req, res) => {
const me = uid(req);
const rows = db.prepare('SELECT whiteboard_id FROM whiteboard_unread WHERE user_id=?').all(me);
res.json({ ids: rows.map(r => r.whiteboard_id) });
});
// ── Liste ─────────────────────────────────────────────────────────────────────
router.get('/', authenticate, (req, res) => {
const me = uid(req);
// Eigene + geteilte Whiteboards
const own = db.prepare(`
SELECT w.*, 'owner' as role, u.username as owner_name
FROM whiteboards w JOIN users u ON u.id=w.owner_id
WHERE w.owner_id=? ORDER BY w.updated_at DESC
`).all(me);
const shared = db.prepare(`
SELECT w.*, p.role, u.username as owner_name
FROM whiteboards w
JOIN whiteboard_permissions p ON p.whiteboard_id=w.id AND p.user_id=?
JOIN users u ON u.id=w.owner_id
ORDER BY w.updated_at DESC
`).all(me);
res.json({ whiteboards: [...own, ...shared] });
});
// ── Erstellen ─────────────────────────────────────────────────────────────────
router.post('/', authenticate, (req, res) => {
const { title = 'Neues Whiteboard' } = req.body;
const me = uid(req);
const r = db.prepare("INSERT INTO whiteboards (owner_id, title) VALUES (?,?)").run(me, title.trim() || 'Neues Whiteboard');
db.prepare("INSERT INTO whiteboard_data (whiteboard_id) VALUES (?)").run(r.lastInsertRowid);
res.json(db.prepare('SELECT * FROM whiteboards WHERE id=?').get(r.lastInsertRowid));
});
// ── Umbenennen ────────────────────────────────────────────────────────────────
router.patch('/:id/title', authenticate, (req, res) => {
const { title } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'Titel fehlt' });
const me = uid(req);
const role = access(req.params.id, me);
if (!role || role === 'view') return res.status(403).json({ error: 'Kein Zugriff' });
db.prepare("UPDATE whiteboards SET title=?, updated_at=datetime('now','localtime') WHERE id=?").run(title.trim(), req.params.id);
res.json({ ok: true });
});
// ── Löschen ───────────────────────────────────────────────────────────────────
router.delete('/:id', authenticate, (req, res) => {
const me = uid(req);
const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id);
if (!wb) return res.status(404).json({ error: 'Nicht gefunden' });
if (wb.owner_id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann löschen' });
db.prepare('DELETE FROM whiteboards WHERE id=?').run(wb.id);
res.json({ ok: true });
});
// ── Canvas laden ──────────────────────────────────────────────────────────────
router.get('/:id/data', authenticate, (req, res) => {
const me = uid(req);
const role = access(req.params.id, me);
if (!role) return res.status(403).json({ error: 'Kein Zugriff' });
const data = db.prepare('SELECT * FROM whiteboard_data WHERE whiteboard_id=?').get(req.params.id);
const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id);
const perms = db.prepare(`
SELECT p.*, u.username FROM whiteboard_permissions p
JOIN users u ON u.id=p.user_id WHERE p.whiteboard_id=?
`).all(req.params.id);
const owner = db.prepare('SELECT username FROM users WHERE id=?').get(wb.owner_id);
markRead(req.params.id, me);
res.json({ ...data, role, title: wb.title, owner: owner.username, permissions: perms });
});
// ── Canvas speichern ──────────────────────────────────────────────────────────
// Feste Route vor :id-Routen
router.post('/:id/save', authenticate, async (req, res) => {
const me = uid(req);
const role = access(req.params.id, me);
if (!role || role === 'view') return res.status(403).json({ error: 'Kein Schreibzugriff' });
const { elements, viewport } = req.body;
db.prepare(`
UPDATE whiteboard_data SET elements=?, viewport=? WHERE whiteboard_id=?
`).run(JSON.stringify(elements || []), JSON.stringify(viewport || {x:0,y:0,zoom:1}), req.params.id);
db.prepare("UPDATE whiteboards SET updated_at=datetime('now','localtime') WHERE id=?").run(req.params.id);
res.json({ ok: true });
// Pushover an alle anderen mit Zugriff (fire & forget)
try {
const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id);
const saver = db.prepare('SELECT username FROM users WHERE id=?').get(me);
const message = `${saver?.username || 'Jemand'} hat das Whiteboard "${wb?.title || ''}" gespeichert.`;
// Alle User mit Zugriff: Owner + alle Permissions — außer dem Speichernden selbst
const notify = getAccessUsers(req.params.id, me);
markUnread(req.params.id, notify, 'updated');
for (const userId of notify) {
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId);
if (!poCfg?.app_token || !poCfg?.user_key) continue;
const title = '🖊 Whiteboard aktualisiert';
fetch('https://api.pushover.net/1/messages.json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: poCfg.app_token,
user: poCfg.user_key,
title,
message,
priority: 0,
}),
}).catch(() => {});
logPush({ userId, title, message, priority: 0, source: 'whiteboard-save' });
}
} catch {}
});
// ── Berechtigungen: alle User für Share-Modal ─────────────────────────────────
router.get('/users-list', authenticate, (req, res) => {
const me = uid(req);
const users = db.prepare('SELECT id, username FROM users WHERE id!=? ORDER BY username').all(me);
res.json({ users });
});
// ── Berechtigungen setzen ─────────────────────────────────────────────────────
router.put('/:id/permissions', authenticate, async (req, res) => {
const me = uid(req);
const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id);
if (!wb) return res.status(404).json({ error: 'Nicht gefunden' });
if (wb.owner_id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann Berechtigungen vergeben' });
const { permissions } = req.body; // [{user_id, role: 'edit'|'view'|null}]
if (!Array.isArray(permissions)) return res.status(400).json({ error: 'permissions fehlt' });
// Vorherigen Stand merken um neue User zu erkennen
const before = db.prepare('SELECT user_id FROM whiteboard_permissions WHERE whiteboard_id=?').all(req.params.id).map(r => r.user_id);
const upsert = db.prepare("INSERT INTO whiteboard_permissions (whiteboard_id,user_id,role) VALUES (?,?,?) ON CONFLICT(whiteboard_id,user_id) DO UPDATE SET role=excluded.role");
const remove = db.prepare("DELETE FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?");
db.transaction(() => {
for (const p of permissions) {
if (!p.user_id) continue;
if (p.role === null || p.role === 'none') remove.run(req.params.id, p.user_id);
else upsert.run(req.params.id, p.user_id, p.role);
}
})();
const owner = db.prepare('SELECT username FROM users WHERE id=?').get(me);
const newlyAdded = permissions.filter(p => p.role && p.role !== 'none' && !before.includes(p.user_id));
if (newlyAdded.length) markUnread(req.params.id, newlyAdded.map(p => p.user_id), 'shared');
for (const p of newlyAdded) {
try {
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(p.user_id);
if (!poCfg?.app_token || !poCfg?.user_key) continue;
const roleLabel = p.role === 'edit' ? 'bearbeiten' : 'ansehen';
const title = '🖊 Whiteboard geteilt';
const message = `${owner.username} hat das Whiteboard "${wb.title}" mit dir geteilt (${roleLabel}).`;
fetch('https://api.pushover.net/1/messages.json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: poCfg.app_token,
user: poCfg.user_key,
title,
message,
priority: 0,
}),
}).catch(() => {});
logPush({ userId: p.user_id, title, message, priority: 0, source: 'whiteboard-share' });
} catch {}
}
const updated = db.prepare(`
SELECT p.*, u.username FROM whiteboard_permissions p
JOIN users u ON u.id=p.user_id WHERE p.whiteboard_id=?
`).all(req.params.id);
res.json({ permissions: updated });
});
module.exports = router;

View File

@@ -1,238 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Datei hochladen DickenDock</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#0d0d0f;color:#fff;font-family:'Courier New',monospace;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}
.card{background:#1a1a1e;border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:28px;width:100%;max-width:440px}
h1{font-size:18px;margin-bottom:6px;color:#4ecdc4}
.sub{color:rgba(255,255,255,0.4);font-size:12px;margin-bottom:20px}
label{display:block;color:rgba(255,255,255,0.5);font-size:10px;letter-spacing:2px;margin-bottom:4px;margin-top:12px}
input[type=password],input[type=file]{width:100%;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.12);border-radius:8px;padding:10px 12px;color:#fff;font-family:inherit;font-size:14px;outline:none;transition:border 0.2s}
input[type=password]:focus{border-color:#4ecdc4}
input[type=file]{cursor:pointer;padding:8px}
input[type=file]::file-selector-button{background:#4ecdc4;border:none;border-radius:6px;color:#0d0d0f;cursor:pointer;font-family:inherit;font-weight:700;padding:4px 12px;margin-right:10px}
.btn{width:100%;background:#4ecdc4;border:none;border-radius:8px;color:#0d0d0f;cursor:pointer;font-family:inherit;font-size:14px;font-weight:700;padding:12px;margin-top:12px;transition:opacity 0.2s}
.btn:hover:not(:disabled){opacity:0.85}
.btn:disabled{opacity:0.4;cursor:default}
.info{background:rgba(78,205,196,0.08);border:1px solid rgba(78,205,196,0.2);border-radius:8px;padding:10px 12px;font-size:12px;color:rgba(255,255,255,0.6);margin-bottom:16px;margin-top:4px}
.msg{border-radius:8px;padding:10px 12px;font-size:13px;margin-top:10px}
.msg.ok{background:rgba(78,205,196,0.12);border:1px solid rgba(78,205,196,0.3);color:#4ecdc4}
.msg.err{background:rgba(255,107,157,0.12);border:1px solid rgba(255,107,157,0.3);color:#ff6b9d}
.msg.warn{background:rgba(255,230,109,0.12);border:1px solid rgba(255,230,109,0.3);color:#ffe66d}
.hidden{display:none}
.done-list{margin-top:10px;font-size:12px;color:rgba(255,255,255,0.5)}
.done-list li{padding:2px 0;list-style:none}
.done-list li::before{content:'✓ ';color:#4ecdc4}
#progress{height:4px;background:rgba(78,205,196,0.15);border-radius:2px;margin-top:10px;overflow:hidden}
#progress-bar{height:100%;background:#4ecdc4;width:0;transition:width 0.15s}
#progress-label{font-size:11px;color:rgba(255,255,255,0.4);margin-top:4px;font-family:monospace;min-height:14px}
.attempts{color:#ffe66d;font-size:11px;margin-top:6px;font-family:monospace}
#drop-zone{border:2px dashed rgba(255,255,255,0.15);border-radius:10px;padding:18px 12px;text-align:center;
color:rgba(255,255,255,0.45);font-size:12px;cursor:pointer;transition:all 0.15s;margin-bottom:4px}
#drop-zone.drag{border-color:#4ecdc4;background:rgba(78,205,196,0.08);color:#4ecdc4}
#file-input{display:none}
#file-label{display:block;font-size:12px;color:rgba(255,255,255,0.55);margin-top:6px;word-break:break-all}
</style>
</head>
<body>
<div class="card">
<h1>📤 Datei hochladen</h1>
<p class="sub" id="sub">Dateien senden</p>
<div id="pw-section">
<div class="info" id="share-info">⏳ Link wird geprüft…</div>
<label>PASSWORT</label>
<input type="password" id="pw" placeholder="Passwort eingeben" autocomplete="off" onkeydown="if(event.key==='Enter')verify()"/>
<button class="btn" id="pw-btn" onclick="verify()">Zugang bestätigen</button>
<div id="pw-msg" class="hidden"></div>
<div id="attempts-info" class="attempts hidden"></div>
</div>
<div id="upload-section" class="hidden">
<div class="info" id="upload-info"></div>
<label>DATEI(EN) AUSWÄHLEN</label>
<div id="drop-zone">📁 Dateien hierher ziehen oder klicken</div>
<div id="file-label"></div>
<input type="file" id="file-input" multiple/>
<div id="progress"><div id="progress-bar"></div></div>
<div id="progress-label"></div>
<button class="btn" id="upload-btn" onclick="uploadFiles()">⬆ Hochladen</button>
<div id="upload-msg" class="hidden"></div>
<ul class="done-list" id="done-list"></ul>
</div>
</div>
<script>
const token = location.pathname.split('/u/')[1] || '';
let password = '';
let maxMb = 10;
async function init() {
if (!token) return showFatal('Ungültiger Link-Aufbau');
try {
const r = await fetch('/api/upload-shares/public/' + encodeURIComponent(token));
const d = await r.json();
if (!r.ok) return showFatal(d.error || 'Link ungültig');
maxMb = d.max_size_mb;
const exp = new Date(d.expires_at).toLocaleString('de-DE',{timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
document.getElementById('share-info').textContent = '🔐 Gültig bis ' + exp + ' · max ' + d.max_size_mb + ' MB pro Datei';
} catch(e) {
showFatal('Verbindung fehlgeschlagen: ' + e.message);
}
}
function showFatal(msg) {
document.getElementById('pw-section').innerHTML = '<div class="msg err">' + msg + '</div>';
}
async function verify() {
const pw = document.getElementById('pw').value;
if (!pw) { document.getElementById('pw').focus(); return; }
const btn = document.getElementById('pw-btn');
btn.disabled = true;
btn.textContent = '…';
const msg = document.getElementById('pw-msg');
try {
const r = await fetch('/api/upload-shares/public/' + encodeURIComponent(token) + '/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw })
});
const d = await r.json();
if (!r.ok) {
msg.className = 'msg err';
msg.textContent = d.error;
msg.classList.remove('hidden');
if (d.locked) {
showFatal(d.error);
return;
}
if (d.attempts_left !== undefined) {
const ai = document.getElementById('attempts-info');
ai.textContent = '⚠ Noch ' + d.attempts_left + ' Versuch' + (d.attempts_left===1?'':'e') + ' übrig';
ai.classList.remove('hidden');
}
btn.disabled = false;
btn.textContent = 'Zugang bestätigen';
return;
}
password = pw;
maxMb = d.max_size_mb;
document.getElementById('pw-section').classList.add('hidden');
const us = document.getElementById('upload-section');
us.classList.remove('hidden');
const exp = new Date(d.expires_at).toLocaleString('de-DE',{timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
document.getElementById('upload-info').textContent = '✓ Zugang bestätigt · Gültig bis ' + exp + ' · max ' + d.max_size_mb + ' MB pro Datei';
document.getElementById('sub').textContent = 'Bereit zum Hochladen';
} catch(e) {
msg.className = 'msg err';
msg.textContent = 'Verbindung fehlgeschlagen: ' + e.message;
msg.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Zugang bestätigen';
}
}
// ── Drag & Drop ──────────────────────────────────────────────────────────────
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag');
if (e.dataTransfer.files.length) {
fileInput.files = e.dataTransfer.files;
updateFileLabel();
}
});
fileInput.addEventListener('change', updateFileLabel);
function updateFileLabel() {
const files = fileInput.files;
const label = document.getElementById('file-label');
if (!files.length) { label.textContent = ''; return; }
label.textContent = files.length === 1
? files[0].name
: files.length + ' Dateien ausgewählt';
}
// ── Upload mit echtem Byte-Fortschritt (XMLHttpRequest statt fetch,
// da fetch() keine Upload-Progress-Events liefert) ─────────────────────────
function uploadOne(file) {
return new Promise((resolve, reject) => {
const fd = new FormData();
fd.append('file', file);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload-shares/public/' + encodeURIComponent(token) + '/upload');
xhr.setRequestHeader('x-upload-password', password);
xhr.upload.onprogress = e => {
if (e.lengthComputable) setProgress(e.loaded / e.total * 100, file.name);
};
xhr.onload = () => {
let d = {};
try { d = JSON.parse(xhr.responseText); } catch {}
if (xhr.status >= 200 && xhr.status < 300) resolve(d);
else reject(new Error(d.error || 'Upload fehlgeschlagen'));
};
xhr.onerror = () => reject(new Error('Verbindung fehlgeschlagen'));
xhr.send(fd);
});
}
function setProgress(percent, label) {
document.getElementById('progress-bar').style.width = percent + '%';
document.getElementById('progress-label').textContent = label ? Math.round(percent) + '% · ' + label : '';
}
async function uploadFiles() {
const files = document.getElementById('file-input').files;
if (!files.length) { document.getElementById('file-input').click(); return; }
const btn = document.getElementById('upload-btn');
btn.disabled = true;
const msg = document.getElementById('upload-msg');
const list = document.getElementById('done-list');
msg.classList.add('hidden');
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.size > maxMb * 1024 * 1024) {
msg.className = 'msg warn';
msg.textContent = '"' + file.name + '" ist zu groß (max ' + maxMb + ' MB)';
msg.classList.remove('hidden');
continue;
}
setProgress(0, file.name);
try {
await uploadOne(file);
setProgress(100, file.name);
const li = document.createElement('li');
li.textContent = file.name;
list.appendChild(li);
} catch(e) {
msg.className = 'msg err';
msg.textContent = file.name + ': ' + e.message;
msg.classList.remove('hidden');
}
}
setTimeout(() => setProgress(0, ''), 1200);
if (list.children.length > 0) {
msg.className = 'msg ok';
msg.textContent = list.children.length + ' Datei' + (list.children.length !== 1 ? 'en' : '') + ' erfolgreich hochgeladen';
msg.classList.remove('hidden');
}
btn.disabled = false;
document.getElementById('file-input').value = '';
updateFileLabel();
}
init();
</script>
</body>
</html>

View File

@@ -1,16 +0,0 @@
#!/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"

View File

@@ -1,17 +0,0 @@
services:
dickendock:
build:
context: "http://192.168.1.26:3000/Dicken/dickendock.git#main"
dockerfile: Dockerfile
container_name: dickendock
restart: unless-stopped
env_file:
- .env
environment:
- GITEA_URL=http://192.168.1.26:3000
- GITEA_REPO=Dicken/dickendock
- TZ=Europe/Berlin
volumes:
- ./data:/data
ports:
- "8080:4000"

View File

@@ -1,12 +0,0 @@
# Kopiere diese Datei zu .env und passe die Werte an
# .env wird NICHT ins Git eingecheckt
JWT_SECRET=ersetze-mich-mit-einem-langen-zufaelligen-string
# Home Assistant / Mosquitto MQTT (optional — ohne MQTT_HOST bleibt die
# Integration deaktiviert, der Rest der App läuft unverändert)
MQTT_HOST=192.168.1.222
MQTT_PORT=1883
MQTT_USERNAME=dickendock
MQTT_PASSWORD=ersetze-mich
MQTT_BASE_TOPIC=dickendock

View File

@@ -1,106 +0,0 @@
// Generates icon-192.png and icon-512.png without external dependencies
// Uses a minimal PNG encoder written in pure JS
import { writeFileSync } from 'fs';
import { deflateSync } from 'zlib';
function createPNG(size) {
const r = Math.round(size * 0.22);
// Draw pixels
const pixels = new Uint8Array(size * size * 4);
const setPixel = (x, y, R, G, B, A = 255) => {
if (x < 0 || x >= size || y < 0 || y >= size) return;
const i = (y * size + x) * 4;
pixels[i] = R; pixels[i+1] = G; pixels[i+2] = B; pixels[i+3] = A;
};
// Gradient background with rounded corners
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
// Rounded corner check
const inCornerTL = x < r && y < r && (x-r)**2 + (y-r)**2 > r**2;
const inCornerTR = x >= size-r && y < r && (x-(size-r))**2 + (y-r)**2 > r**2;
const inCornerBL = x < r && y >= size-r && (x-r)**2 + (y-(size-r))**2 > r**2;
const inCornerBR = x >= size-r && y >= size-r && (x-(size-r))**2 + (y-(size-r))**2 > r**2;
if (inCornerTL || inCornerTR || inCornerBL || inCornerBR) continue;
// Linear gradient: top-left #4ecdc4 → bottom-right #ff6b9d
const t = (x + y) / (size * 2);
const R = Math.round(0x4e + (0xff - 0x4e) * t);
const G = Math.round(0xcd + (0x6b - 0xcd) * t);
const B = Math.round(0xc4 + (0x9d - 0xc4) * t);
setPixel(x, y, R, G, B);
}
}
// Draw ⚒ as a simple hammer shape (dark color on gradient)
const cx = Math.floor(size / 2);
const cy = Math.floor(size / 2);
const s = Math.floor(size * 0.18);
// Hammer head (rectangle)
for (let dy = -s; dy <= 0; dy++) {
for (let dx = -s; dx <= s; dx++) {
setPixel(cx + dx, cy + dy, 13, 13, 15);
}
}
// Handle
for (let dy = 0; dy <= s * 2; dy++) {
for (let dx = -Math.floor(s*0.25); dx <= Math.floor(s*0.25); dx++) {
setPixel(cx + dx, cy + dy, 13, 13, 15);
}
}
// Encode as PNG
const width = size, height = size;
const signature = Buffer.from([137,80,78,71,13,10,26,10]);
// IHDR
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; ihdr[9] = 2; // 8-bit RGB... wait we need RGBA
// Use RGBA (color type 6)
ihdr[9] = 6;
const ihdrChunk = makeChunk('IHDR', ihdr);
// IDAT - raw image data with filter bytes
const raw = Buffer.alloc(height * (1 + width * 4));
for (let y = 0; y < height; y++) {
raw[y * (1 + width * 4)] = 0; // filter type None
for (let x = 0; x < width; x++) {
const si = (y * width + x) * 4;
const di = y * (1 + width * 4) + 1 + x * 4;
raw[di] = pixels[si];
raw[di+1] = pixels[si+1];
raw[di+2] = pixels[si+2];
raw[di+3] = pixels[si+3] || (pixels[si] || pixels[si+1] || pixels[si+2] ? 255 : 0);
}
}
const compressed = deflateSync(raw);
const idatChunk = makeChunk('IDAT', compressed);
const iendChunk = makeChunk('IEND', Buffer.alloc(0));
return Buffer.concat([signature, ihdrChunk, idatChunk, iendChunk]);
}
function makeChunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
const typeB = Buffer.from(type);
const crc = crc32(Buffer.concat([typeB, data]));
const crcB = Buffer.alloc(4); crcB.writeUInt32BE(crc >>> 0);
return Buffer.concat([len, typeB, data, crcB]);
}
function crc32(buf) {
let crc = 0xFFFFFFFF;
for (const b of buf) {
crc ^= b;
for (let i = 0; i < 8; i++) crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0);
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
writeFileSync('public/icon-192.png', createPNG(192));
writeFileSync('public/icon-512.png', createPNG(512));
console.log('Icons generated.');

View File

@@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<meta charset="UTF-8" />
<meta name="theme-color" content="#0d0d0f"><style>html,body{background:#0d0d0f;margin:0}</style>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="#0d0d0f" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Dicken Dock" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>Dicken Dock</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -1,19 +0,0 @@
{
"name": "dickendock-frontend",
"version": "1.0.0",
"scripts": {
"prebuild": "node generate-icons.mjs",
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"qrcode": "^1.5.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"recharts": "^2.12.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.0.0",
"vite": "^5.0.0"
}
}

View File

@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#4ecdc4"/>
<stop offset="100%" stop-color="#ff6b9d"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="7" fill="url(#g)"/>
<text x="16" y="22" text-anchor="middle" font-size="18" fill="#0d0d0f"></text>
</svg>

Before

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="80" fill="#0d0d0f"/>
<rect x="40" y="40" width="432" height="432" rx="60" fill="#111114"/>
<text x="256" y="200" text-anchor="middle" font-family="monospace" font-weight="bold" font-size="140" fill="#4ecdc4">DD</text>
<text x="256" y="340" text-anchor="middle" font-family="monospace" font-size="52" fill="rgba(255,255,255,0.35)">DOCK</text>
<rect x="100" y="370" width="312" height="3" rx="2" fill="#4ecdc4" opacity="0.4"/>
</svg>

Before

Width:  |  Height:  |  Size: 540 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -1,28 +0,0 @@
{
"name": "Dicken Dock",
"short_name": "DickenDock",
"description": "Dein 3D-Druck Verwaltungstool",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#0d0d0f",
"theme_color": "#4ecdc4",
"icons": [
{
"src": "/favicon.svg",
"type": "image/svg+xml",
"sizes": "any",
"purpose": "any maskable"
},
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/icon-512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}

View File

@@ -1,55 +0,0 @@
// Service Worker Version wird zur Build-Zeit injiziert via Dockerfile
// Dadurch erkennt der Browser bei jedem Deploy einen neuen SW → Cache-Purge
const VERSION = '__BUILD_VERSION__'; // wird im Dockerfile ersetzt
const CACHE_NAME = 'dickendock-' + VERSION;
self.addEventListener('install', () => {
self.skipWaiting(); // neuen SW sofort übernehmen
});
self.addEventListener('activate', e => {
e.waitUntil((async () => {
// alle alten Caches löschen
const keys = await caches.keys();
await Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)));
await self.clients.claim();
// allen offenen Clients sagen: neue Version aktiv → reload
const clients = await self.clients.matchAll({ type: 'window' });
clients.forEach(c => c.postMessage({ type: 'SW_ACTIVATED', version: VERSION }));
})());
});
self.addEventListener('fetch', e => {
const url = new URL(e.request.url);
// Cross-Origin-Requests nicht abfangen
if (url.origin !== self.location.origin) return;
// API immer direkt ans Netz
if (url.pathname.startsWith('/api/')) {
e.respondWith(fetch(e.request));
return;
}
// sw.js selbst nie cachen
if (url.pathname === '/sw.js') {
e.respondWith(fetch(e.request, { cache: 'no-store' }));
return;
}
// HTML-Navigation: IMMER frisch vom Netzwerk
if (e.request.mode === 'navigate') {
e.respondWith(
fetch(e.request, { cache: 'reload' })
.catch(() => caches.match('/index.html'))
);
return;
}
// Same-Origin Assets: durchreichen
e.respondWith(fetch(e.request).catch(() => caches.match(e.request)));
});
self.addEventListener('message', e => {
if (e.data === 'SKIP_WAITING') self.skipWaiting();
});

View File

@@ -1,10 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: { proxy: { '/api': 'http://localhost:4000' } },
define: {
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
},
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,401 +0,0 @@
// ── Kalender Widget mit iCal-Unterstützung ────────────────────────────────────
import { useState, useEffect, useMemo } from 'react';
import { api, S } from './lib.js';
// iCal-Parser unterstützt gefaltete Zeilen, TZID, VALUE=DATE
function parseIcal(raw) {
// 1. Gefaltete Zeilen (RFC 5545) zusammenführen
const text = raw.replace(/\r\n[ \t]/g, '').replace(/\r/g, '');
// 2. Alle Properties als Map extrahieren
function getProps(block) {
// VALARM-Blöcke entfernen (haben eigene SUMMARY/DESCRIPTION die nicht zum Termin gehören)
const stripped = block.replace(/BEGIN:VALARM[\s\S]*?END:VALARM/g, '');
const props = {};
for (const line of stripped.split('\n')) {
const col = line.indexOf(':');
if (col < 0) continue;
const namepart = line.slice(0, col).toUpperCase();
const val = line.slice(col + 1).trim();
// Basisname ohne Parameter
const name = namepart.split(';')[0];
// TZID-Parameter extrahieren
const tzidM = namepart.match(/TZID=([^;:]+)/);
props[name] = { val, raw: namepart, tzid: tzidM?.[1] || null };
}
return props;
}
// 3. Datum parsen Ortszeit wenn TZID gesetzt, UTC wenn Z-Suffix
function parseDate(prop, isEnd) {
if (!prop) return null;
const s = prop.val;
if (!s) return null;
const allDay = s.length === 8 || prop.raw.includes('VALUE=DATE');
if (allDay) {
// DTEND bei all-day ist exklusiv → einen Tag abziehen
const d = new Date(+s.slice(0,4), +s.slice(4,6)-1, +s.slice(6,8));
if (isEnd) d.setDate(d.getDate() - 1);
return { date: d, allDay: true };
}
const utc = s.endsWith('Z');
const y=+s.slice(0,4), mo=+s.slice(4,6)-1, d=+s.slice(6,8);
const h=+s.slice(9,11)||0, mi=+s.slice(11,13)||0, sec=+s.slice(13,15)||0;
const date = utc
? new Date(Date.UTC(y,mo,d,h,mi,sec))
: new Date(y,mo,d,h,mi,sec); // Ortszeit des Browsers (beste Annäherung)
return { date, allDay: false };
}
const events = [];
const blocks = text.split('BEGIN:VEVENT');
for (let i = 1; i < blocks.length; i++) {
const end = blocks[i].indexOf('END:VEVENT');
const block = end >= 0 ? blocks[i].slice(0, end) : blocks[i];
const p = getProps(block);
const startObj = parseDate(p['DTSTART']);
const endObj = parseDate(p['DTEND'], true) || startObj;
if (!startObj || isNaN(startObj.date)) continue;
events.push({
summary: (p['SUMMARY']?.val || '(kein Titel)').replace(/\\n/g,'\n').replace(/\\,/g,','),
location: (p['LOCATION']?.val || '').replace(/\\n/g,'\n').replace(/\\,/g,','),
dtstart: startObj.date,
dtend: endObj.date,
allDay: startObj.allDay,
});
}
return events;
}
function sameDay(a, b) {
return a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate();
}
function inRange(event, day) {
const d = new Date(day); d.setHours(0,0,0,0);
const s = new Date(event.dtstart); s.setHours(0,0,0,0);
const e = new Date(event.dtend); e.setHours(0,0,0,0);
return d >= s && d <= e;
}
const WEEKDAYS = ['Mo','Di','Mi','Do','Fr','Sa','So'];
const MONTHS = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
export default function CalendarWidget() {
const [isOpen, setIsOpen] = useState(false);
const [today] = useState(new Date());
const [cur, setCur] = useState({ y: today.getFullYear(), m: today.getMonth() });
const [events, setEvents] = useState([]);
const [feeds, setFeeds] = useState([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState(new Date()); // default: heute
const [errors, setErrors] = useState([]);
// Load all feeds
const loadFeeds = async (feedList) => {
if (!feedList.length) { setEvents([]); setDebug(['Keine Feeds konfiguriert']); return; }
setLoading(true);
setErrors([]);
const all = [];
const errs = [];
for (const f of feedList) {
try {
const res = await fetch(`/api/calendar/fetch?url=${encodeURIComponent(f.url)}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('sk_token')}` }
});
if (res.ok) {
const text = await res.text();
const evs = parseIcal(text).map(e => ({ ...e, color: f.color, feedName: f.name }));
all.push(...evs);
} else {
const d = await res.json().catch(()=>({error:'Unbekannter Fehler'}));
errs.push(`${f.name}: ${d.error}`);
}
} catch(e) { errs.push(`${f.name}: ${e.message}`); }
}
setEvents(all);
setErrors(errs);
setLoading(false);
};
const refresh = () => {
api('/calendar/feeds').then(f=>{ setFeeds(f); loadFeeds(f); }).catch(()=>{});
// Kalender-Cache für Suche mitsynchronisieren
api('/search/sync-calendar', { method:'POST', body:{ force:true } }).catch(()=>{});
};
// Load on mount so next event shows without opening
useEffect(() => { refresh(); }, []);
// Reload when opened
useEffect(() => { if (isOpen) refresh(); }, [isOpen]);
// Nächster oder aktueller Termin (für zugeklappte Ansicht)
const nextEvent = useMemo(() => {
const now = new Date();
const upcoming = events
.filter(e => e.dtend >= now)
.sort((a,b) => a.dtstart - b.dtstart);
return upcoming[0] || null;
}, [events]);
// Calendar grid
const days = useMemo(() => {
const first = new Date(cur.y, cur.m, 1);
const dow = (first.getDay() + 6) % 7; // 0=Mo
const total = new Date(cur.y, cur.m+1, 0).getDate();
const grid = [];
for (let i = 0; i < dow; i++) grid.push(null);
for (let d = 1; d <= total; d++) grid.push(new Date(cur.y, cur.m, d));
return grid;
}, [cur]);
const eventsOnDay = (day) => day ? events.filter(e => inRange(e, day)) : [];
const selectedEvents = selected ? events.filter(e => inRange(e, selected)) : [];
const prevMonth = () => setCur(p => p.m === 0 ? { y:p.y-1, m:11 } : { y:p.y, m:p.m-1 });
const nextMonth = () => setCur(p => p.m === 11 ? { y:p.y+1, m:0 } : { y:p.y, m:p.m+1 });
return (
<div style={{ ...S.card, marginBottom:10 }}>
{/* Header */}
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center' }}>
<button onClick={()=>setIsOpen(v=>!v)} style={{ flex:1, background:'transparent', border:'none',
display:'flex', justifyContent:'space-between', alignItems:'center', cursor:'pointer', padding:0, minWidth:0 }}>
<div style={{ display:'flex', alignItems:'center', gap:10, flex:1, minWidth:0 }}>
<div style={{...S.head, marginBottom:0, flexShrink:0}}>KALENDER</div>
{!isOpen && nextEvent && (
<div style={{ display:'flex', alignItems:'center', gap:6, flex:1, minWidth:0, overflow:'hidden' }}>
<span style={{ width:6, height:6, borderRadius:'50%', background:nextEvent.color||'#4ecdc4',
flexShrink:0, display:'inline-block' }}/>
<span style={{ color:'rgba(255,255,255,0.6)', fontFamily:'monospace', fontSize:10,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
{nextEvent.summary}
</span>
<span style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
{nextEvent.allDay
? nextEvent.dtstart.toLocaleDateString('de-DE',{day:'numeric',month:'short'})
: nextEvent.dtstart.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})
}
</span>
</div>
)}
</div>
<span style={{ color:'rgba(255,255,255,0.4)', fontSize:11, display:'inline-block', flexShrink:0,
transform:isOpen?'rotate(90deg)':'rotate(0)', transition:'transform 0.2s' }}></span>
</button>
{isOpen && feeds.length > 0 && (
<button onClick={refresh} disabled={loading}
style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.4)',
cursor:'pointer', fontSize:13, padding:'0 0 0 8px', marginLeft:8 }}
title="Neu laden">
{loading ? '⏳' : '↻'}
</button>
)}
</div>
{isOpen && (
<div style={{ marginTop:12 }}>
{/* Fehler */}
{errors.length > 0 && (
<div style={{ marginBottom:10 }}>
{errors.map((e,i) => (
<div key={i} style={{ color:'#ff6b9d', fontFamily:'monospace', fontSize:10,
background:'rgba(255,107,157,0.08)', borderRadius:6, padding:'4px 8px', marginBottom:3 }}>
{e}
</div>
))}
</div>
)}
{/* Monat Nav */}
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:10 }}>
<button onClick={prevMonth} style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.5)',
cursor:'pointer', fontSize:18, padding:'0 6px' }}></button>
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
<span style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:13, fontWeight:700 }}>
{MONTHS[cur.m]} {cur.y}
</span>
{(!selected || !sameDay(selected, today) || cur.m !== today.getMonth() || cur.y !== today.getFullYear()) && (
<button onClick={() => { setCur({ y:today.getFullYear(), m:today.getMonth() }); setSelected(new Date()); }}
style={{ background:'rgba(78,205,196,0.15)', border:'1px solid rgba(78,205,196,0.3)',
borderRadius:6, color:'#4ecdc4', fontFamily:'monospace', fontSize:9,
cursor:'pointer', padding:'2px 8px' }}>Heute</button>
)}
</div>
<button onClick={nextMonth} style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.5)',
cursor:'pointer', fontSize:18, padding:'0 6px' }}></button>
</div>
<div style={{ display: window.innerWidth>=768 ? 'flex' : 'block', gap:16, alignItems:'flex-start' }}>
{/* Kalender-Grid */}
<div style={{ flex:'0 0 auto', width: window.innerWidth>=768 ? 'min(300px,50%)' : '100%' }}>
{/* Wochentage */}
<div style={{ display:'grid', gridTemplateColumns:'repeat(7,1fr)', gap:2, marginBottom:4 }}>
{WEEKDAYS.map(d => (
<div key={d} style={{ textAlign:'center', color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:9 }}>{d}</div>
))}
</div>
{/* Tage */}
<div style={{ display:'grid', gridTemplateColumns:'repeat(7,1fr)', gap:2 }}>
{days.map((day, i) => {
const evs = eventsOnDay(day);
const isToday = day && sameDay(day, today);
const isSel = day && selected && sameDay(day, selected);
return (
<button key={i} onClick={()=>day&&setSelected(isSel?null:day)} style={{
aspectRatio:'1', borderRadius:6, border:'none', cursor:day?'pointer':'default',
background: isSel ? 'rgba(78,205,196,0.25)' : isToday ? 'rgba(78,205,196,0.1)' : 'transparent',
outline: isToday ? '1px solid rgba(78,205,196,0.4)' : 'none',
position:'relative', padding:0, display:'flex', flexDirection:'column',
alignItems:'center', justifyContent:'center',
}}>
{day && <>
<span style={{ color: isSel?'#4ecdc4':isToday?'#4ecdc4':'rgba(255,255,255,0.75)',
fontFamily:'monospace', fontSize:11, fontWeight:isToday?700:400 }}>
{day.getDate()}
</span>
{evs.length > 0 && (
<div style={{ display:'flex', gap:1, flexWrap:'wrap', justifyContent:'center', marginTop:1 }}>
{evs.slice(0,3).map((e,j) => (
<span key={j} style={{ width:4, height:4, borderRadius:'50%',
background: e.color || '#4ecdc4', display:'inline-block' }}/>
))}
</div>
)}
</>}
</button>
);
})}
</div>
</div>{/* Ende Kalender-Grid */}
{/* Termine */}
<div style={{ flex:1, minWidth:0, width:'100%', marginTop: window.innerWidth>=768 ? 0 : 10 }}>
{/* Ausgewählter Tag Events */}
{selected && (
<div style={{ marginTop:10, borderTop:'1px solid rgba(255,255,255,0.07)', paddingTop:10 }}>
<div style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, marginBottom:6 }}>
{selected.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long'})}
</div>
{selectedEvents.length === 0
? <div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:11 }}>Keine Termine</div>
: selectedEvents.map((e,i) => (
<div key={i} style={{ display:'flex', gap:8, padding:'6px 0',
borderBottom:'1px solid rgba(255,255,255,0.05)' }}>
<span style={{ width:3, borderRadius:2, background:e.color||'#4ecdc4', flexShrink:0 }}/>
<div>
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:12 }}>{e.summary}</div>
{e.location && <div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10 }}>{e.location}</div>}
{!e.allDay && <div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10 }}>
{e.dtstart.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}
{' '}{e.dtend.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}
</div>}
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:9 }}>{e.feedName}</div>
</div>
</div>
))
}
</div>
)}
</div>{/* Ende Termine */}
</div>{/* Ende Flex-Layout */}
{feeds.length === 0 && (
<div style={{ marginTop:10, color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:11, textAlign:'center' }}>
Noch keine Kalender abonniert. Füge Kalender in den Einstellungen hinzu.
</div>
)}
</div>
)}
</div>
);
}
// ── iCal-Einstellungen (für Einstellungen-Seite) ──────────────────────────────
export function CalendarSettings({ toast }) {
const [feeds, setFeeds] = useState([]);
const [form, setForm] = useState({ name:'', url:'', color:'#4ecdc4' });
const [testing, setTesting] = useState(false);
useEffect(() => { api('/calendar/feeds').then(setFeeds).catch(()=>{}); }, []);
const add = async () => {
if (!form.name.trim() || !form.url.trim()) { toast('Name und URL erforderlich', 'error'); return; }
setTesting(true);
try {
const res = await fetch(`/api/calendar/fetch?url=${encodeURIComponent(form.url)}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('sk_token')}` }
});
if (!res.ok) { const d=await res.json(); toast(d.error||'Feed-Fehler','error'); return; }
const newFeed = await api('/calendar/feeds', { body: { name:form.name.trim(), url:form.url.trim(), color:form.color } });
setFeeds(p => [...p, newFeed]);
setForm({ name:'', url:'', color:'#4ecdc4' });
toast('Kalender hinzugefügt ✓');
} catch(e) { toast(e.message,'error'); } finally { setTesting(false); }
};
const remove = async (id) => {
try { await api(`/calendar/feeds/${id}`, {method:'DELETE'}); setFeeds(p=>p.filter(f=>f.id!==id)); toast('Entfernt'); }
catch(e) { toast(e.message,'error'); }
};
const COLORS = ['#4ecdc4','#ff6b9d','#ffe66d','#6bcb77','#a78bfa','#60a5fa','#fb923c'];
return (
<div>
<div style={{ marginBottom:12 }}>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>NAME</label>
<input value={form.name} onChange={e=>setForm(f=>({...f,name:e.target.value}))}
placeholder="z.B. Google Kalender" style={{ ...S.inp, fontSize:15, marginBottom:8 }}/>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>ICAL URL</label>
<input value={form.url} onChange={e=>setForm(f=>({...f,url:e.target.value}))}
placeholder="https://calendar.google.com/calendar/ical/…"
style={{ ...S.inp, fontSize:13, marginBottom:8 }}
autoCapitalize="none" autoCorrect="off"/>
<label style={{ ...S.head, display:'block', marginBottom:6 }}>FARBE</label>
<div style={{ display:'flex', gap:6, marginBottom:12 }}>
{COLORS.map(col => (
<button key={col} onClick={()=>setForm(f=>({...f,color:col}))} style={{
width:28, height:28, borderRadius:'50%', background:col, border:'none', cursor:'pointer',
outline: form.color===col ? '3px solid #fff' : '2px solid transparent',
outlineOffset:2,
}}/>
))}
</div>
<button onClick={add} disabled={testing} style={{ ...S.btn('#4ecdc4'), width:'100%', textAlign:'center', padding:'10px 0' }}>
{testing ? '⏳ Teste Feed…' : '+ Kalender hinzufügen'}
</button>
</div>
{feeds.length > 0 && (
<div>
<div style={{ ...S.head, marginBottom:6 }}>ABONNIERTE KALENDER</div>
{feeds.map(f => (
<div key={f.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 0',
borderBottom:'1px solid rgba(255,255,255,0.05)' }}>
<span style={{ width:12, height:12, borderRadius:'50%', background:f.color, flexShrink:0 }}/>
<div style={{ flex:1, minWidth:0 }}>
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:12 }}>{f.name}</div>
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:9,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{f.url}</div>
</div>
<button onClick={()=>remove(f.id)} style={{ background:'transparent', border:'none',
color:'rgba(255,107,157,0.7)', cursor:'pointer', fontSize:16, padding:'0 4px' }}></button>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,53 +0,0 @@
// ── Globaler Bestätigungs-Dialog ─────────────────────────────────────────────
// Verwendung: const { confirm, ConfirmDialog } = useConfirm();
// await confirm('Wirklich löschen?') && deleteFn();
import { useState, useCallback } from 'react';
export function useConfirm() {
const [state, setState] = useState({ open:false, msg:'', resolve:null });
const confirm = useCallback((msg='Wirklich fortfahren?') => {
return new Promise(resolve => {
setState({ open:true, msg, resolve });
});
}, []);
const handle = yes => {
state.resolve?.(yes);
setState({ open:false, msg:'', resolve:null });
};
const ConfirmDialog = () => !state.open ? null : (
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.75)',
zIndex:9000, display:'flex',
alignItems: window.innerWidth>=768 ? 'center' : 'flex-end',
justifyContent:'center', padding: window.innerWidth>=768 ? 24 : 0 }}
onClick={() => handle(false)}>
<div style={{ background:'#1a1a1e',
borderRadius: window.innerWidth>=768 ? 16 : '16px 16px 0 0',
width:'100%', maxWidth:500, padding:'20px 20px 32px',
border:'1px solid rgba(255,255,255,0.15)',
boxShadow: window.innerWidth>=768 ? '0 24px 80px rgba(0,0,0,0.6)' : 'none' }}
onClick={e => e.stopPropagation()}>
{window.innerWidth < 768 && <div style={{ width:36, height:4, background:'rgba(255,255,255,0.15)',
borderRadius:2, margin:'0 auto 18px' }}/>}
<p style={{ color:'#fff', fontFamily:'monospace', fontSize:14,
textAlign:'center', marginBottom:20, lineHeight:1.6 }}>{state.msg}</p>
<div style={{ display:'flex', gap:10 }}>
<button onClick={() => handle(false)} style={{
flex:1, padding:'12px 0', background:'rgba(255,255,255,0.06)',
border:'1px solid rgba(255,255,255,0.1)', borderRadius:10,
color:'rgba(255,255,255,0.7)', fontFamily:'monospace', fontSize:13, cursor:'pointer',
}}>Abbrechen</button>
<button onClick={() => handle(true)} style={{
flex:1, padding:'12px 0', background:'rgba(255,107,157,0.15)',
border:'1px solid rgba(255,107,157,0.4)', borderRadius:10,
color:'#ff6b9d', fontFamily:'monospace', fontSize:13, cursor:'pointer', fontWeight:700,
}}>Ja, löschen</button>
</div>
</div>
</div>
);
return { confirm, ConfirmDialog };
}

View File

@@ -1,216 +0,0 @@
/**
* DickenDock E2E-Verschlüsselung
*
* Algorithmen:
* X25519 → Schlüsselaustausch (WebCrypto; Chrome 113+/Firefox 130+/Safari 17.4+)
* AES-256-GCM → Nachrichtenverschlüsselung
* PBKDF2-SHA256 → Export-Passwortverschlüsselung (200k Iterationen)
* ↳ statt Argon2id: kein WebCrypto-Support ohne WASM-Lib → keine externen Abhängigkeiten
*
* Private Key: ausschließlich in IndexedDB, niemals an den Server übertragen.
*/
const IDB_NAME = 'dd_e2e_v1';
const IDB_STORE = 'keys';
const KEY_ID = 'identity';
// ── IndexedDB helpers ─────────────────────────────────────────────────────────
function _openDB() {
return new Promise((res, rej) => {
const r = indexedDB.open(IDB_NAME, 1);
r.onupgradeneeded = e => e.target.result.createObjectStore(IDB_STORE);
r.onsuccess = e => res(e.target.result);
r.onerror = () => rej(r.error);
});
}
async function _idbGet(k) {
const db = await _openDB();
return new Promise((res, rej) => {
const r = db.transaction(IDB_STORE, 'readonly').objectStore(IDB_STORE).get(k);
r.onsuccess = () => res(r.result ?? null);
r.onerror = () => rej(r.error);
});
}
async function _idbSet(k, v) {
const db = await _openDB();
return new Promise((res, rej) => {
const tx = db.transaction(IDB_STORE, 'readwrite');
tx.objectStore(IDB_STORE).put(v, k);
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
});
}
async function _idbDel(k) {
const db = await _openDB();
return new Promise((res, rej) => {
const tx = db.transaction(IDB_STORE, 'readwrite');
tx.objectStore(IDB_STORE).delete(k);
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
});
}
// ── Base64 helpers ────────────────────────────────────────────────────────────
const b64 = u8 => btoa(String.fromCharCode(...u8));
const ub64 = s => Uint8Array.from(atob(s), c => c.charCodeAt(0));
// ── Internal ──────────────────────────────────────────────────────────────────
async function _importJwkPair(pub, priv) {
const publicKey = await crypto.subtle.importKey('jwk', pub, { name:'X25519' }, true, []);
const privateKey = await crypto.subtle.importKey('jwk', priv, { name:'X25519' }, true, ['deriveKey']);
return { publicKey, privateKey };
}
async function _pbkdf2Key(password, salt) {
const raw = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']
);
return crypto.subtle.deriveKey(
{ name:'PBKDF2', salt, iterations:200_000, hash:'SHA-256' },
raw,
{ name:'AES-GCM', length:256 }, false, ['encrypt','decrypt']
);
}
// ── Keypair Management ────────────────────────────────────────────────────────
/**
* Lädt Keypair aus IndexedDB.
* Migriert altes localStorage-Format (P-256) → löscht es, erzeugt neues X25519-Keypair.
* Erstellt neues Keypair falls keins vorhanden.
*/
export async function getOrCreateKeyPair() {
// Migration: altes P-256-Keypair aus localStorage entfernen
if (localStorage.getItem('dd_msg_keypair')) {
localStorage.removeItem('dd_msg_keypair');
// Kein Import möglich (Algorithmuswechsel P-256→X25519), neues Keypair wird unten erzeugt
}
const stored = await _idbGet(KEY_ID);
if (stored) return stored;
// Neues X25519-Keypair erzeugen
const kp = await crypto.subtle.generateKey({ name:'X25519' }, true, ['deriveKey']);
await _idbSet(KEY_ID, kp);
return kp;
}
/** Neues Keypair erzeugen und in IndexedDB speichern (überschreibt vorhandenes). */
export async function generateNewKeyPair() {
const kp = await crypto.subtle.generateKey({ name:'X25519' }, true, ['deriveKey']);
await _idbSet(KEY_ID, kp);
return kp;
}
/** Keypair aus IndexedDB löschen. */
export async function clearStoredKeyPair() {
await _idbDel(KEY_ID);
}
/** Public Key als JWK exportieren (für Server-Registrierung). */
export async function getPublicKeyJwk(kp) {
return crypto.subtle.exportKey('jwk', kp.publicKey);
}
// ── Fingerprint ───────────────────────────────────────────────────────────────
/**
* SHA-256-Hash des Public Key, angezeigt als Kurzformat: ABCD-EF12-3456-CDEF
* Kann manuell verglichen werden um Man-in-the-Middle zu erkennen.
*/
export async function getFingerprint(kp) {
const jwk = await getPublicKeyJwk(kp);
const enc = new TextEncoder().encode(JSON.stringify(jwk));
const hash = await crypto.subtle.digest('SHA-256', enc);
const hex = [...new Uint8Array(hash)]
.map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase();
return `${hex.slice(0,4)}-${hex.slice(4,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}`;
}
// ── Export / Import ───────────────────────────────────────────────────────────
/**
* Keypair verschlüsselt als JSON-String exportieren.
* Verschlüsselung: PBKDF2-SHA256 (200k) → AES-256-GCM
*/
export async function exportEncrypted(kp, password) {
const pub = await crypto.subtle.exportKey('jwk', kp.publicKey);
const priv = await crypto.subtle.exportKey('jwk', kp.privateKey);
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt(
{ name:'AES-GCM', iv },
await _pbkdf2Key(password, salt),
new TextEncoder().encode(JSON.stringify({ pub, priv }))
);
return JSON.stringify({
v: 1,
alg: 'X25519+PBKDF2-SHA256-200k+AES-256-GCM',
salt: b64(salt),
iv: b64(iv),
data: b64(new Uint8Array(enc)),
});
}
/**
* Verschlüsseltes Keypair importieren, entschlüsseln und in IndexedDB speichern.
* Wirft bei falschem Passwort oder korrupter Datei einen Fehler.
*/
export async function importEncrypted(jsonStr, password) {
const obj = JSON.parse(jsonStr);
if (obj.v !== 1) throw new Error('Unbekanntes Export-Format');
let dec;
try {
dec = await crypto.subtle.decrypt(
{ name:'AES-GCM', iv: ub64(obj.iv) },
await _pbkdf2Key(password, ub64(obj.salt)),
ub64(obj.data)
);
} catch {
throw new Error('Entschlüsselung fehlgeschlagen falsches Passwort?');
}
const { pub, priv } = JSON.parse(new TextDecoder().decode(dec));
const kp = await _importJwkPair(pub, priv);
await _idbSet(KEY_ID, kp);
return kp;
}
// ── Message Encryption ────────────────────────────────────────────────────────
/**
* Gemeinsamen AES-256-GCM-Schlüssel aus eigenem Private Key + Public Key des Partners ableiten.
* Beide Seiten erhalten denselben Schlüssel (X25519-Eigenschaft).
*/
export async function deriveSharedKey(myPrivateKey, theirPubJwk) {
const theirKey = await crypto.subtle.importKey('jwk', theirPubJwk, { name:'X25519' }, false, []);
return crypto.subtle.deriveKey(
{ name:'X25519', public: theirKey },
myPrivateKey,
{ name:'AES-GCM', length:256 }, false, ['encrypt','decrypt']
);
}
/** Nachricht mit AES-256-GCM verschlüsseln. */
export async function encryptMsg(sharedKey, text) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt(
{ name:'AES-GCM', iv }, sharedKey, new TextEncoder().encode(text)
);
return {
encrypted_content: b64(new Uint8Array(enc)),
iv: b64(iv),
};
}
/** Nachricht entschlüsseln. Wirft bei falschem Schlüssel/korrupten Daten. */
export async function decryptMsg(sharedKey, ecB64, ivB64) {
const dec = await crypto.subtle.decrypt(
{ name:'AES-GCM', iv: ub64(ivB64) },
sharedKey,
ub64(ecB64)
);
return new TextDecoder().decode(dec);
}

View File

@@ -1,219 +0,0 @@
// ── Icon Library stroke-basierte SVG Icons ──────────────────────────────────
// Verwendung: <Icon name="home" size={20} color="#4ecdc4" />
// Oder direkt: <HomeIcon size={20} color="#fff" />
const base = (path, size, color, sw = 1.75) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke={color} strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{path}
</svg>
);
export const HomeIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M3 9.5L12 3l9 6.5V20a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9.5z"/>
<path d="M9 21V12h6v9"/>
</>, size, color, sw);
export const AppsIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</>, size, color, sw);
export const CalculatorIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="4" y="2" width="16" height="20" rx="2"/>
<line x1="8" y1="7" x2="16" y2="7"/>
<line x1="8" y1="12" x2="10" y2="12"/>
<line x1="14" y1="12" x2="16" y2="12"/>
<line x1="8" y1="17" x2="10" y2="17"/>
<line x1="14" y1="17" x2="16" y2="17"/>
</>, size, color, sw);
export const ArchiveIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="2" y="3" width="20" height="4" rx="1"/>
<path d="M4 7v13a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V7"/>
<line x1="9" y1="12" x2="15" y2="12"/>
</>, size, color, sw);
export const AdminIcon = ({size=20,color='currentColor',sw}) => base(<>
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</>, size, color, sw);
export const MoreIcon = ({size=20,color='currentColor',sw}) => base(<>
<circle cx="5" cy="12" r="1.2" fill={color}/>
<circle cx="12" cy="12" r="1.2" fill={color}/>
<circle cx="19" cy="12" r="1.2" fill={color}/>
</>, size, color, sw);
export const UserIcon = ({size=20,color='currentColor',sw}) => base(<>
<circle cx="12" cy="8" r="4"/>
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
</>, size, color, sw);
export const LogOutIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</>, size, color, sw);
export const SearchIcon = ({size=20,color='currentColor',sw}) => base(<>
<circle cx="11" cy="11" r="7"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</>, size, color, sw);
export const PlusIcon = ({size=20,color='currentColor',sw}) => base(<>
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</>, size, color, sw);
export const XIcon = ({size=20,color='currentColor',sw}) => base(<>
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</>, size, color, sw);
export const CheckIcon = ({size=20,color='currentColor',sw}) => base(<>
<polyline points="20 6 9 17 4 12"/>
</>, size, color, sw);
export const EditIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</>, size, color, sw);
export const TrashIcon = ({size=20,color='currentColor',sw}) => base(<>
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</>, size, color, sw);
export const DownloadIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</>, size, color, sw);
export const UploadIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</>, size, color, sw);
export const UpdateIcon = ({size=20,color='currentColor',sw}) => base(<>
<circle cx="12" cy="12" r="9"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</>, size, color, sw);
export const ChevronIcon = ({size=20,color='currentColor',sw,dir='right'}) => base(<>
{dir==='right' && <polyline points="9 18 15 12 9 6"/>}
{dir==='down' && <polyline points="6 9 12 15 18 9"/>}
{dir==='left' && <polyline points="15 18 9 12 15 6"/>}
{dir==='up' && <polyline points="18 15 12 9 6 15"/>}
</>, size, color, sw);
export const CameraIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</>, size, color, sw);
export const KeyIcon = ({size=20,color='currentColor',sw}) => base(<>
<circle cx="8" cy="12" r="5"/>
<path d="M13 12h8M17 10v4"/>
</>, size, color, sw);
export const DatabaseIcon = ({size=20,color='currentColor',sw}) => base(<>
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M3 5v4c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/>
<path d="M3 9v4c0 1.66 4.03 3 9 3s9-1.34 9-3V9"/>
<path d="M3 13v4c0 1.66 4.03 3 9 3s9-1.34 9-3v-4"/>
</>, size, color, sw);
export const ZapIcon = ({size=20,color='currentColor',sw}) => base(<>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
</>, size, color, sw);
export const MessageIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinejoin="round" strokeLinecap="round"/>
</>, size, color, sw);
export const LinkIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</>, size, color, sw);
export const CodeIcon = ({size=20,color='currentColor',sw}) => base(<>
<polyline points="16 18 22 12 16 6" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<polyline points="8 6 2 12 8 18" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</>, size, color, sw);
export const SkizzeIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="3" y="3" width="18" height="18" rx="2" stroke={color} strokeWidth={sw||1.5} fill="none"/>
<path d="M3 9h18M9 21V9" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round"/>
<path d="M13 13h5M13 17h3" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round"/>
</>, size, color, sw);
export const WrenchIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</>, size, color, sw);
export const PaywallIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="3" y="11" width="18" height="11" rx="2" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M7 11V7a5 5 0 0 1 9.9-1" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="8" y1="16" x2="10" y2="16" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
<line x1="14" y1="16" x2="16" y2="16" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
<line x1="11" y1="14" x2="13" y2="18" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
</>, size, color, sw);
export const FilmIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="2" y="2" width="20" height="20" rx="2.18" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="7" y1="2" x2="7" y2="22" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
<line x1="17" y1="2" x2="17" y2="22" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
<line x1="2" y1="12" x2="22" y2="12" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
<line x1="2" y1="7" x2="7" y2="7" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
<line x1="2" y1="17" x2="7" y2="17" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
<line x1="17" y1="17" x2="22" y2="17" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
<line x1="17" y1="7" x2="22" y2="7" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
</>, size, color, sw);
export const ChartIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="3" y="12" width="4" height="8" rx="1" fill={color}/>
<rect x="9" y="7" width="4" height="13" rx="1" fill={color}/>
<rect x="15" y="3" width="4" height="17" rx="1" fill={color}/>
</>, size, color, sw);
export const KanbanIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="3" y="3" width="5" height="13" rx="1.5" stroke={color} strokeWidth={sw||1.5} fill="none"/>
<rect x="10" y="3" width="5" height="8" rx="1.5" stroke={color} strokeWidth={sw||1.5} fill="none"/>
<rect x="17" y="3" width="4" height="5" rx="1.5" stroke={color} strokeWidth={sw||1.5} fill="none"/>
</>, size, color, sw);
export const WhiteboardIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="3" y="3" width="18" height="14" rx="2" stroke={color} strokeWidth={sw||1.5} fill="none"/>
<path d="M7 13 L10 9 L13 11 L16 7" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="3" y1="19" x2="21" y2="19" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
</>, size, color, sw);
export const KoepiIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M5 3h14l-2 10H7L5 3z" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinejoin="round"/>
<path d="M7 13c0 3 2 5 5 5s5-2 5-5" stroke={color} strokeWidth={sw||1.5} fill="none"/>
<path d="M19 7h2v4h-2" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinejoin="round"/>
</>, size, color, sw);
export const SchockenIcon = ({size=20,color='currentColor',sw}) => base(<>
<circle cx="12" cy="12" r="9" stroke={color} strokeWidth={sw||1.5} fill="none"/>
<circle cx="8.5" cy="9.5" r="1.2" fill={color}/>
<circle cx="15.5" cy="9.5" r="1.2" fill={color}/>
<circle cx="8.5" cy="14.5" r="1.2" fill={color}/>
<circle cx="15.5" cy="14.5" r="1.2" fill={color}/>
<circle cx="12" cy="12" r="1.2" fill={color}/>
</>, size, color, sw);
export const GeoIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="3" y="3" width="18" height="18" rx="2" stroke={color} strokeWidth={sw||1.5} fill="none"/>
<rect x="3" y="3" width="8" height="8" rx="1" fill={color} opacity="0.7"/>
<rect x="13" y="13" width="8" height="8" rx="1" fill={color} opacity="0.4"/>
<rect x="11" y="3" width="10" height="5" rx="1" stroke={color} strokeWidth={sw||1.2} fill="none"/>
<rect x="3" y="11" width="5" height="10" rx="1" stroke={color} strokeWidth={sw||1.2} fill="none"/>
</>, size, color, sw);
export const ScaleIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M12 3v18" stroke={color} strokeWidth={sw||1.5}/>
<path d="M7 21h10" stroke={color} strokeWidth={sw||1.5}/>
<path d="M5 7h14" stroke={color} strokeWidth={sw||1.5}/>
<path d="M12 3l3 4" stroke={color} strokeWidth={sw||1.5}/>
<path d="M2 12l3-5 3 5a3 3 0 0 1-6 0z" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinejoin="round"/>
<path d="M16 12l3-5 3 5a3 3 0 0 1-6 0z" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinejoin="round"/>
</>, size, color, sw);

View File

@@ -1,80 +0,0 @@
// ── 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;
};
// ── Upload mit echtem Byte-Fortschritt ──────────────────────────────────────
// fetch() liefert keinerlei Upload-Progress-Events, daher hier bewusst auf
// XMLHttpRequest aufgebaut. Nur für Datei-Uploads gedacht (FormData-Body).
// onProgress(percent: 0-100) wird während des Uploads laufend aufgerufen.
export const apiUpload = (path, formData, { onProgress } = {}) => {
const token = localStorage.getItem('sk_token');
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', `/api${path}`);
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.upload.onprogress = e => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
let data = {};
try { data = JSON.parse(xhr.responseText); } catch { /* leere/ungültige Antwort */ }
if (xhr.status >= 200 && xhr.status < 300) resolve(data);
else reject(new Error(data.error || 'Fehler'));
};
xhr.onerror = () => reject(new Error('Verbindung fehlgeschlagen'));
xhr.send(formData);
});
};
// ── 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.6)', fontSize: 10,
fontFamily: 'monospace', letterSpacing: 2, marginBottom: 14,
},
// Untergeordneter/Hilfstext (Beschreibungen, Hinweise)
sub: {
color: 'rgba(255,255,255,0.4)', fontSize: 11,
fontFamily: 'monospace', lineHeight: 1.5,
},
};

View File

@@ -1,101 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
// ── Auto-Reload bei neuem Build ───────────────────────────────────────────────
// __BUILD_TIME__ wird von Vite zur Build-Zeit aus version.txt gebacken.
// /api/build-time liefert denselben Wert vom Server.
// Wenn sie abweichen → neues Build wurde deployed → Reload.
// Kein localStorage, keine Race-Condition.
const MY_BUILD_TIME = __BUILD_TIME__;
window.__newBuildAvailable = false;
function isUserTyping() {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
}
function hasUnsavedInput() {
const fields = document.querySelectorAll('input, textarea');
for (const f of fields) {
const isText = f.tagName === 'TEXTAREA' ||
['text','search','url','email','number','password','tel'].includes(f.type);
if (isText && f.value && f.value.trim() !== '') return true;
}
return false;
}
async function getBuildTime() {
try {
const r = await fetch('/api/build-time', { cache: 'no-store' });
const { buildTime } = await r.json();
return buildTime;
} catch { return null; }
}
// Prüft ob Server-Build != Bundle-Build → Flag setzen
// reloadNow: sofort laden wenn kein Input aktiv (für App-Fokus)
async function checkBuild({ reloadNow = false } = {}) {
const serverBt = await getBuildTime();
if (!serverBt) return;
if (serverBt !== MY_BUILD_TIME) {
window.__newBuildAvailable = true;
if (reloadNow && !isUserTyping() && !hasUnsavedInput()) {
window.location.reload();
}
}
}
// Sicherer Reload bei Seitenwechsel (aufgerufen aus App.jsx setActive)
window.__safeReloadIfNewBuild = function () {
if (!window.__newBuildAvailable) return false;
if (isUserTyping() || hasUnsavedInput()) return false;
window.location.reload();
return true;
};
// Beim Start prüfen nach 3s sofort reloaden wenn neue Version (App hat Zeit zu rendern)
checkBuild().then(() => {
if (window.__newBuildAvailable && !isUserTyping() && !hasUnsavedInput()) {
setTimeout(() => {
if (!isUserTyping() && !hasUnsavedInput()) window.location.reload();
}, 3000);
}
});
// Im Hintergrund alle 60s
setInterval(() => checkBuild(), 60_000);
// App kommt in den Vordergrund → prüfen und bei Bedarf sofort laden
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
checkBuild({ reloadNow: true });
}
});
// ── Service Worker ────────────────────────────────────────────────────────────
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (e) => {
// SW hat neue Version aktiviert → sofort reloaden
if (e.data?.type === 'SW_ACTIVATED') {
window.__newBuildAvailable = true;
if (!isUserTyping() && !hasUnsavedInput()) window.location.reload();
}
});
navigator.serviceWorker.register('/sw.js').then(reg => {
setInterval(() => reg.update().catch(()=>{}), 60_000);
if (reg.waiting) reg.waiting.postMessage('SKIP_WAITING');
reg.addEventListener('updatefound', () => {
const nw = reg.installing;
nw?.addEventListener('statechange', () => {
if (nw.state === 'installed') nw.postMessage('SKIP_WAITING');
});
});
}).catch(()=>{});
}
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode><App /></React.StrictMode>
)

View File

@@ -1,178 +0,0 @@
import Kalkulator3D, { SavedList } from './tools/kalkulator3d.jsx';
import Bestellungen from './tools/bestellungen.jsx';
import Statistik from './tools/statistik.jsx';
import Dateien from './tools/dateien.jsx';
import Nachrichten from './tools/nachrichten.jsx';
import Linkliste from './tools/linkliste.jsx';
import CodeSchnipsel from './tools/codeschnipsel.jsx';
import Skizze from './tools/skizze.jsx';
import DevTools from './tools/devtools.jsx';
import Media from './tools/media.jsx';
import PaywallKiller from './tools/paywallkiller.jsx';
import Kanban from './tools/kanban.jsx';
import Whiteboard from './tools/whiteboard.jsx';
import Gebietseroberung from './tools/gebietseroberung.jsx';
import Schocken from './tools/schocken.jsx';
import Koepi from './tools/koepi.jsx';
import Wettrechner from './tools/wettrechner.jsx';
import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon, CodeIcon, SkizzeIcon, WrenchIcon, FilmIcon, PaywallIcon, ChartIcon, KanbanIcon, WhiteboardIcon, GeoIcon, SchockenIcon, KoepiIcon, ScaleIcon } from './icons.jsx';
export const TOOLS = [
{
id: 'kalkulator3d',
Icon: CalculatorIcon,
label: 'Kostenrechner',
navLabel: 'Kosten',
group: '3D-Druck',
component: Kalkulator3D,
},
{
id: 'kalkulator3d-saved',
Icon: ArchiveIcon,
label: 'Archiv',
navLabel: 'Archiv',
group: '3D-Druck',
component: SavedList,
},
{
id: 'bestellungen',
Icon: DatabaseIcon,
label: 'Bestellungen',
navLabel: 'Aufträge',
group: '3D-Druck',
component: Bestellungen,
},
{
id: 'statistik3d',
Icon: ChartIcon,
label: 'Statistik',
navLabel: 'Statistik',
group: '3D-Druck',
component: Statistik,
},
{
id: 'kanban',
Icon: KanbanIcon,
label: 'Kanban',
navLabel: 'Kanban',
group: 'Werkzeuge',
component: Kanban,
},
{
id: 'whiteboard',
Icon: WhiteboardIcon,
label: 'Whiteboard',
navLabel: 'Whiteboard',
group: 'Werkzeuge',
component: Whiteboard,
},
{
id: 'gebietseroberung',
Icon: GeoIcon,
label: 'Hex Wars',
navLabel: 'Hex Wars',
group: 'Freizeit',
component: Gebietseroberung,
},
{
id: 'schocken',
Icon: SchockenIcon,
label: 'Schocken',
navLabel: 'Schocken',
group: 'Freizeit',
component: Schocken,
},
{
id: 'koepi',
Icon: KoepiIcon,
label: 'KöPi',
navLabel: 'KöPi',
group: 'Freizeit',
component: Koepi,
},
{
id: 'wettrechner',
Icon: ScaleIcon,
label: 'Wettrechner',
navLabel: 'Wetten',
group: 'Freizeit',
component: Wettrechner,
},
{
id: 'dateien',
Icon: DatabaseIcon,
label: 'Dateien',
navLabel: 'Dateien',
group: 'Werkzeuge',
component: Dateien,
},
{
id: 'nachrichten',
Icon: MessageIcon,
label: 'Nachrichten',
navLabel: 'Nachrichten',
group: 'Werkzeuge',
component: Nachrichten,
},
{
id: 'linkliste',
Icon: LinkIcon,
label: 'Linkliste',
navLabel: 'Linkliste',
group: 'Werkzeuge',
component: Linkliste,
},
{
id: 'codeschnipsel',
Icon: CodeIcon,
label: 'Code-Schnipsel',
navLabel: 'Code',
group: 'Werkzeuge',
component: CodeSchnipsel,
},
{
id: 'skizze',
Icon: SkizzeIcon,
label: 'CAD-Skizzen 🚧',
navLabel: 'CAD-Skizzen 🚧',
group: 'Werkzeuge',
component: Skizze,
},
{
id: 'devtools',
Icon: WrenchIcon,
label: 'Dev-Tools',
navLabel: 'Dev-Tools',
group: 'Werkzeuge',
component: DevTools,
},
{
id: 'paywallkiller',
Icon: PaywallIcon,
label: 'Paywall-Killer',
navLabel: 'Paywall',
group: 'Werkzeuge',
component: PaywallKiller,
},
{
id: 'media',
Icon: FilmIcon,
label: 'Media',
navLabel: 'Media',
group: 'Freizeit',
component: Media,
},
// Neues Tool: { id:'...', Icon:..., label:'...', navLabel:'...', group:'...', component:... },
];
export function getGroupedTools(isAdmin = false, hiddenTools = []) {
const groups = {};
for (const t of TOOLS) {
if (t.adminOnly && !isAdmin) continue;
if (hiddenTools.includes(t.id)) continue;
const g = t.group || 'Allgemein';
if (!groups[g]) groups[g] = [];
groups[g].push(t);
}
return Object.entries(groups);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,701 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { api, S } from '../lib.js';
// ── Spracherkennung ───────────────────────────────────────────────────────────
const LANG_PATTERNS = [
{ lang:'javascript', label:'JavaScript', patterns:[/\b(const|let|var|function|=>|require|import|export|console\.log)\b/] },
{ lang:'typescript', label:'TypeScript', patterns:[/\b(interface|type\s+\w+\s*=|:\s*(string|number|boolean|void)|<T>)\b/] },
{ lang:'python', label:'Python', patterns:[/\b(def |import |from .* import|elif |print\(|self\.|__init__)\b/] },
{ lang:'jsx', label:'JSX/React', patterns:[/<[A-Z][a-zA-Z]+|useState|useEffect|return\s*\([\s\S]*<|className=/] },
{ lang:'html', label:'HTML', patterns:[/<html|<body|<div|<span|<head|<!DOCTYPE/i] },
{ lang:'css', label:'CSS', patterns:[/\{[\s\S]*:\s*[^;]+;\s*\}|@media|@keyframes|\.[\w-]+\s*\{/] },
{ lang:'sql', label:'SQL', patterns:[/\b(SELECT|INSERT|UPDATE|DELETE|FROM|WHERE|JOIN|CREATE TABLE)\b/i] },
{ lang:'bash', label:'Bash/Shell', patterns:[/^#!/m, /\b(echo|grep|sed|awk|chmod|sudo|apt|curl|wget)\b/] },
{ lang:'json', label:'JSON', patterns:[/^\s*[\[{][\s\S]*[\]}]\s*$/, /"\w+":\s*[\w"[\]{]/] },
{ lang:'rust', label:'Rust', patterns:[/\b(fn |let mut|impl |pub fn|use std|println!)\b/] },
{ lang:'go', label:'Go', patterns:[/\b(func |package |import |fmt\.|var |:=)\b/] },
{ lang:'java', label:'Java', patterns:[/\b(public class|private |protected |void |System\.out)\b/] },
{ lang:'php', label:'PHP', patterns:[/<\?php|\$\w+\s*=|echo |function \w+|\$this->/] },
{ lang:'csharp', label:'C#', patterns:[/\b(using System|namespace |class \w+|public void|Console\.Write)\b/] },
{ lang:'cpp', label:'C/C++', patterns:[/#include|std::|cout <<|int main\(|nullptr/] },
{ lang:'yaml', label:'YAML', patterns:[/^[\w-]+:\s*$/m, /^\s+-\s+\w/m] },
{ lang:'dockerfile', label:'Dockerfile', patterns:[/^FROM |^RUN |^CMD |^EXPOSE |^COPY /m] },
];
const LANG_COLORS = {
javascript:'#f7df1e', typescript:'#3178c6', python:'#3572a5', jsx:'#61dafb',
html:'#e34c26', css:'#563d7c', sql:'#e38c00', bash:'#4EAA25',
json:'#4ecdc4', rust:'#dea584', go:'#00add8', java:'#b07219',
php:'#4f5d95', csharp:'#178600', cpp:'#f34b7d', yaml:'#cb171e',
dockerfile:'#2496ed', text:'rgba(255,255,255,0.3)',
};
function detectLanguage(code) {
if (!code?.trim()) return 'text';
for (const { lang, patterns } of LANG_PATTERNS) {
if (patterns.some(p => p.test(code))) return lang;
}
return 'text';
}
function getLangLabel(lang) {
return LANG_PATTERNS.find(l=>l.lang===lang)?.label || lang.toUpperCase();
}
function fmtDt(s) {
if (!s) return '';
const d = new Date(s.replace(' ','T'));
return isNaN(d) ? '' : d.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
}
// ── Share Modal ───────────────────────────────────────────────────────────────
function SnippetShareModal({ snippet, onClose, toast }) {
const [username, setUsername] = useState('');
const [shares, setShares] = useState([]);
const load = () => api(`/tools/snippets/${snippet.id}/shares`).then(setShares).catch(()=>{});
useEffect(()=>{ load(); },[]);
const share = async () => {
if (!username.trim()) return;
try { await api(`/tools/snippets/${snippet.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); }
catch(e){ toast(e.message,'error'); }
};
const unshare = async uid => {
try { await api(`/tools/snippets/${snippet.id}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); }
catch(e){ toast(e.message,'error'); }
};
const isMob = window.innerWidth < 768;
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:7000,
display:'flex',alignItems:isMob?'flex-end':'center',justifyContent:'center',padding:isMob?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:isMob?'16px 16px 0 0':14,
width:'100%',maxWidth:400,padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
{isMob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:12}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700,
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',flex:1}}>🤝 {snippet.title}</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18,marginLeft:8}}></button>
</div>
<p style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10,marginBottom:12,lineHeight:1.6}}>
Empfänger können lesen, aber nicht bearbeiten.
</p>
<div style={{display:'flex',gap:8,marginBottom:14}}>
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1}}/>
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
</div>
{shares.length>0 ? shares.map(s=>(
<div key={s.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
padding:'7px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:13}}>{s.username}</span>
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d',true)}></button>
</div>
)) : <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Noch nicht geteilt.</div>}
</div>
</div>
);
}
// ── Simple Diff ───────────────────────────────────────────────────────────────
function DiffView({ oldCode, newCode }) {
if (!oldCode) return (
<pre style={{margin:0,padding:'12px 14px',color:'#d0d0d0',fontFamily:"'Courier New',monospace",
fontSize:11,lineHeight:1.6,whiteSpace:'pre-wrap',wordBreak:'break-all',
background:'rgba(0,0,0,0.35)'}}>
{newCode}
</pre>
);
const oldLines = oldCode.split('\n');
const newLines = newCode.split('\n');
const maxLen = Math.max(oldLines.length, newLines.length);
const rows = [];
for (let i = 0; i < maxLen; i++) {
const o = oldLines[i];
const n = newLines[i];
if (o === n) {
rows.push({ type:'same', text: n ?? '' });
} else {
if (o !== undefined) rows.push({ type:'del', text: o });
if (n !== undefined) rows.push({ type:'add', text: n });
}
}
return (
<div style={{background:'rgba(0,0,0,0.4)',border:'1px solid rgba(255,255,255,0.08)',
borderRadius:8,overflow:'auto',maxHeight:320,fontFamily:"'Courier New',monospace",fontSize:11,lineHeight:1.6}}>
{rows.map((r,i) => (
<div key={i} style={{
padding:'0 12px',whiteSpace:'pre-wrap',wordBreak:'break-all',
background: r.type==='add'?'rgba(78,205,196,0.12)':r.type==='del'?'rgba(255,107,157,0.12)':'transparent',
borderLeft:`2px solid ${r.type==='add'?'#4ecdc4':r.type==='del'?'#ff6b9d':'transparent'}`,
}}>
<span style={{color: r.type==='add'?'#4ecdc4':r.type==='del'?'rgba(255,107,157,0.7)':'rgba(255,255,255,0.2)',
marginRight:6,userSelect:'none',fontSize:10}}>
{r.type==='add'?'+':r.type==='del'?'':' '}
</span>
<span style={{color: r.type==='same'?'#d0d0d0':'#fff'}}>{r.text}</span>
</div>
))}
</div>
);
}
function HistoryModal({ snippet, onClose, onRestore, toast }) {
const [history, setHistory] = useState([]);
const [selectedIdx, setSelectedIdx] = useState(0); // 0 = Aktuell (current)
const [showDiff, setShowDiff] = useState(true);
const isMob = window.innerWidth < 768;
const load = () => api(`/tools/snippets/${snippet.id}/history`).then(setHistory).catch(()=>{});
useEffect(()=>{ load(); },[]);
// Entries: [current, ...history] where index 0 = current
const entries = [
{ id:'current', code: snippet.code, language: snippet.language,
saved_at: snippet.updated_at, label:'Aktuell', isCurrent: true },
...history.map((h,i) => ({ ...h, label:`v${history.length - i}`, isCurrent: false })),
];
// Check if current code matches a history entry (= was restored)
const restoredFromIdx = history.findIndex(h => h.code === snippet.code);
const restoredFromLabel = restoredFromIdx >= 0 ? `v${history.length - restoredFromIdx}` : null;
const selected = entries[selectedIdx];
const prevEntry = entries[selectedIdx + 1];
const oldCode = prevEntry?.code ?? null;
const delHistory = async (hid) => {
if (!window.confirm('Diese Version wirklich löschen?')) return;
try {
await api(`/tools/snippets/${snippet.id}/history/${hid}`, { method:'DELETE' });
await load();
setSelectedIdx(0);
toast('Version gelöscht');
} catch(e) { toast(e.message,'error'); }
};
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.85)',zIndex:7000,
display:'flex',alignItems:isMob?'flex-end':'center',justifyContent:'center',padding:isMob?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:isMob?'16px 16px 0 0':14,
width:'100%',maxWidth:700,border:'1px solid rgba(255,255,255,0.12)',
display:'flex',flexDirection:'column',maxHeight:isMob?'88vh':'82vh',overflow:'hidden'}}>
{/* Header */}
<div style={{padding:'14px 20px',borderBottom:'1px solid rgba(255,255,255,0.08)',
display:'flex',justifyContent:'space-between',alignItems:'center',flexShrink:0,gap:10}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
📜 Versionshistorie · {snippet.title}
</div>
<div style={{display:'flex',gap:6,alignItems:'center'}}>
<button onClick={()=>setShowDiff(v=>!v)} style={{
background:showDiff?'rgba(78,205,196,0.12)':'rgba(255,255,255,0.05)',
border:`1px solid ${showDiff?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.1)'}`,
borderRadius:6,color:showDiff?'#4ecdc4':'rgba(255,255,255,0.4)',
cursor:'pointer',fontSize:9,fontFamily:'monospace',padding:'3px 8px'}}>
{showDiff?'± Diff':'📄 Code'}
</button>
<button onClick={onClose} style={{background:'transparent',border:'none',
color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}></button>
</div>
</div>
{entries.length <= 1 ? (
<div style={{padding:32,textAlign:'center',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>
Keine älteren Versionen vorhanden.
</div>
) : (
<div style={{display:'flex',flex:1,overflow:'hidden'}}>
{/* Versions-Liste */}
<div style={{width:155,borderRight:'1px solid rgba(255,255,255,0.07)',overflowY:'auto',flexShrink:0}}>
{entries.map((e,i) => (
<div key={e.id} style={{
display:'flex',alignItems:'center',
background: i===selectedIdx?'rgba(78,205,196,0.08)':'transparent',
borderLeft:`2px solid ${i===selectedIdx?'#4ecdc4':'transparent'}`,
borderBottom:'1px solid rgba(255,255,255,0.04)',
}}>
<button onClick={()=>setSelectedIdx(i)} style={{
flex:1,padding:'10px 10px',background:'transparent',
border:'none',cursor:'pointer',textAlign:'left',
}}>
<div style={{color: e.isCurrent?'#4ecdc4':'rgba(255,255,255,0.65)',
fontFamily:'monospace',fontSize:10,fontWeight: e.isCurrent?700:400}}>
{e.label}
{e.isCurrent&&<span style={{color:'rgba(78,205,196,0.5)',fontSize:8,marginLeft:4}}></span>}
{e.isCurrent && restoredFromLabel && (
<div style={{color:'rgba(255,230,109,0.7)',fontSize:8,fontWeight:400,marginTop:2}}>
aus {restoredFromLabel}
</div>
)}
</div>
<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:8,marginTop:2}}>
{fmtDt(e.saved_at)}
</div>
</button>
{/* Löschen nur für Altversionen, nicht Aktuell */}
{!e.isCurrent && !snippet.is_shared && (
<button onClick={()=>delHistory(e.id)}
title="Version löschen"
style={{background:'transparent',border:'none',cursor:'pointer',
padding:'0 8px',color:'rgba(255,107,157,0.35)',fontSize:12,
flexShrink:0,lineHeight:1}}></button>
)}
</div>
))}
</div>
{/* Detail */}
<div style={{flex:1,overflow:'auto',padding:14,display:'flex',flexDirection:'column',gap:10}}>
{selected && (
<>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',flexWrap:'wrap',gap:6}}>
<div style={{display:'flex',gap:6,alignItems:'center',flexWrap:'wrap'}}>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10}}>
{getLangLabel(selected.language)} · {fmtDt(selected.saved_at)}
</span>
{showDiff && oldCode===null && (
<span style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:9}}>älteste Version kein Diff verfügbar</span>
)}
{showDiff && oldCode!==null && prevEntry && (
<span style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:9}}>
Änderungen gegenüber {prevEntry.label}
</span>
)}
</div>
{/* Wiederherstellen nur für Altversionen die vom aktuellen abweichen */}
{!selected.isCurrent && !snippet.is_shared && selected.code !== snippet.code && (
<button onClick={()=>onRestore(selected)} style={{
background:'rgba(255,230,109,0.1)',border:'1px solid rgba(255,230,109,0.25)',
borderRadius:6,color:'#ffe66d',cursor:'pointer',fontSize:10,padding:'4px 10px'}}>
Wiederherstellen
</button>
)}
</div>
{showDiff && oldCode!==null
? <DiffView oldCode={oldCode} newCode={selected.code}/>
: <pre style={{margin:0,padding:'12px 14px',color:'#d0d0d0',
fontFamily:"'Courier New',monospace",fontSize:11,lineHeight:1.6,
background:'rgba(0,0,0,0.35)',border:'1px solid rgba(255,255,255,0.08)',
borderRadius:8,overflowX:'auto',whiteSpace:'pre-wrap',wordBreak:'break-all',
maxHeight:380,overflowY:'auto'}}>
{selected.code}
</pre>
}
</>
)}
</div>
</div>
)}
</div>
</div>
);
}
// ── Snippet Card ──────────────────────────────────────────────────────────────
function SnippetCard({ snippet, onEdit, onDelete, onShare, onHistory, toast }) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const langColor = LANG_COLORS[snippet.language] || LANG_COLORS.text;
const copy = async () => {
try { await navigator.clipboard.writeText(snippet.code); setCopied(true); setTimeout(()=>setCopied(false),1500); }
catch { toast('Kopieren fehlgeschlagen','error'); }
};
return (
<div style={{...S.card,marginBottom:10,padding:0,overflow:'hidden'}}>
{/* Header */}
<div onClick={()=>setExpanded(v=>!v)} style={{padding:'12px 14px',cursor:'pointer',
display:'flex',alignItems:'center',gap:10}}>
<div style={{width:8,height:8,borderRadius:'50%',background:langColor,flexShrink:0,
boxShadow:`0 0 6px ${langColor}80`}}/>
<div style={{flex:1,minWidth:0}}>
<div style={{display:'flex',alignItems:'center',gap:6,flexWrap:'wrap'}}>
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700,
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{snippet.title}</span>
{snippet.is_shared && (
<span style={{fontSize:9,fontFamily:'monospace',color:'rgba(78,205,196,0.6)',
background:'rgba(78,205,196,0.08)',border:'1px solid rgba(78,205,196,0.2)',
borderRadius:4,padding:'1px 5px',flexShrink:0}}>von {snippet.owner}</span>
)}
</div>
{snippet.description && (
<div style={{color:'rgba(255,255,255,0.38)',fontFamily:'monospace',fontSize:10,
marginTop:2,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>
{snippet.description}
</div>
)}
<div style={{display:'flex',alignItems:'center',gap:6,marginTop:2,flexWrap:'wrap'}}>
<span style={{fontSize:9,fontFamily:'monospace',color:langColor,opacity:0.8}}>
{getLangLabel(snippet.language)}
</span>
{snippet.tags?.map(t=>(
<span key={t} style={{fontSize:8,fontFamily:'monospace',
color:'rgba(255,255,255,0.4)',background:'rgba(255,255,255,0.05)',
border:'1px solid rgba(255,255,255,0.08)',borderRadius:4,padding:'1px 5px'}}>
{t}
</span>
))}
</div>
</div>
<span style={{color:'rgba(255,255,255,0.3)',fontSize:10,
transform:expanded?'rotate(90deg)':'rotate(0)',transition:'transform 0.2s',flexShrink:0}}></span>
</div>
{/* Expanded */}
{expanded && (
<div style={{borderTop:'1px solid rgba(255,255,255,0.06)'}}>
{snippet.description && (
<div style={{padding:'8px 14px',color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:11,
borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
{snippet.description}
</div>
)}
{/* Code */}
<div style={{position:'relative'}}>
<pre style={{margin:0,padding:'12px 14px',paddingRight:48,
color:'#d0d0d0',fontFamily:"'Courier New',Courier,monospace",
fontSize:12,lineHeight:1.65,overflowX:'auto',
background:'rgba(0,0,0,0.35)',whiteSpace:'pre',maxHeight:320,overflowY:'auto'}}>
{snippet.code}
</pre>
<button onClick={copy} title="Kopieren" style={{
position:'absolute',top:8,right:8,
background:'rgba(255,255,255,0.07)',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:6,color: copied?'#4ecdc4':'rgba(255,255,255,0.5)',
cursor:'pointer',fontSize:13,padding:'4px 8px',transition:'color 0.2s',
}}>{copied?'✓':'⎘'}</button>
</div>
{/* Meta + Aktionen */}
<div style={{padding:'10px 14px',display:'flex',alignItems:'center',
justifyContent:'space-between',gap:8,flexWrap:'wrap',
borderTop:'1px solid rgba(255,255,255,0.05)'}}>
<div style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:9,lineHeight:1.7}}>
<div>erstellt: {fmtDt(snippet.created_at)}</div>
{snippet.updated_at !== snippet.created_at && (
<div>bearbeitet: {fmtDt(snippet.updated_at)}</div>
)}
</div>
<div style={{display:'flex',gap:5,flexWrap:'wrap'}}>
<button onClick={()=>onHistory(snippet)}
style={{background:'rgba(255,255,255,0.07)',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:6,color:'rgba(255,255,255,0.5)',cursor:'pointer',
fontSize:10,padding:'4px 8px'}}>📜</button>
{!snippet.is_shared && (
<>
<button onClick={()=>onShare(snippet)}
style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 8px'}}>🤝</button>
<button onClick={()=>onEdit(snippet)}
style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'4px 8px'}}></button>
<button onClick={()=>onDelete(snippet.id)}
style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 8px'}}></button>
</>
)}
</div>
</div>
</div>
)}
</div>
);
}
// ── Snippet Form ──────────────────────────────────────────────────────────────
function SnippetForm({ initial, onSave, onCancel, toast, existingTags=[] }) {
const ALL_LANGS = ['text',...LANG_PATTERNS.map(l=>l.lang)];
const [title, setTitle] = useState(initial?.title || '');
const [code, setCode] = useState(initial?.code || '');
const [lang, setLang] = useState(initial?.language || 'text');
const [desc, setDesc] = useState(initial?.description || '');
const [tags, setTags] = useState(initial?.tags?.filter(t=>t!==initial?.language).join(', ') || '');
const [busy, setBusy] = useState(false);
const [autoLang,setAutoLang]= useState(!initial?.id);
useEffect(()=>{
if (!autoLang) return;
const detected = detectLanguage(code);
setLang(detected);
},[code, autoLang]);
// Nur Komma als Trennzeichen
const parseTags = () => {
const langTag = lang !== 'text' ? [lang] : [];
const manual = tags.split(',').map(t=>t.trim().toLowerCase()).filter(Boolean);
return [...new Set([...langTag, ...manual])];
};
const addSuggestedTag = tag => {
const cur = tags.split(',').map(t=>t.trim()).filter(Boolean);
if (cur.includes(tag)) return;
setTags(cur.length ? cur.join(', ') + ', ' + tag : tag);
};
// Tags die noch nicht eingetragen sind als Vorschläge
const currentParsed = parseTags();
const suggestions = existingTags.filter(t => t !== lang && !currentParsed.includes(t));
const save = async () => {
if (!title.trim()) { toast('Titel erforderlich','error'); return; }
setBusy(true);
try {
await onSave({ title:title.trim(), code, language:lang, description:desc.trim(), tags:parseTags() });
} catch(e) { toast(e.message,'error'); }
setBusy(false);
};
return (
<div style={{...S.card,marginBottom:14}}>
<div style={{...S.head,marginBottom:10}}>{initial?.id ? 'BEARBEITEN' : 'NEUER SCHNIPSEL'}</div>
<input value={title} onChange={e=>setTitle(e.target.value)} placeholder="Titel *"
style={{...S.inp,marginBottom:8}}/>
<input value={desc} onChange={e=>setDesc(e.target.value)} placeholder="Beschreibung (optional)"
style={{...S.inp,marginBottom:8,fontSize:12}}/>
{/* Code */}
<textarea value={code} onChange={e=>setCode(e.target.value)} placeholder="Code einfügen…"
rows={8} style={{...S.inp,resize:'vertical',fontFamily:"'Courier New',monospace",
fontSize:12,lineHeight:1.6,marginBottom:8,width:'100%',boxSizing:'border-box'}}/>
{/* Sprache */}
<div style={{display:'flex',gap:8,marginBottom:8,alignItems:'center'}}>
<select value={lang} onChange={e=>{setLang(e.target.value);setAutoLang(false);}}
style={{...S.inp,flex:1,fontSize:12}}>
{ALL_LANGS.map(l=>(
<option key={l} value={l} style={{background:'#1a1a1e'}}>
{getLangLabel(l)}
</option>
))}
</select>
<label style={{display:'flex',alignItems:'center',gap:5,cursor:'pointer',flexShrink:0}}>
<input type="checkbox" checked={autoLang} onChange={e=>setAutoLang(e.target.checked)}
style={{accentColor:'#4ecdc4'}}/>
<span style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:10}}>Auto</span>
</label>
{lang!=='text'&&<div style={{width:10,height:10,borderRadius:'50%',
background:LANG_COLORS[lang]||'#fff',flexShrink:0}}/>}
</div>
{/* Tags */}
<div style={{marginBottom:12}}>
<label style={{...S.head,display:'block',marginBottom:4,fontSize:9}}>WEITERE TAGS (kommagetrennt)</label>
<input value={tags} onChange={e=>setTags(e.target.value)} placeholder="z.B. api, auth, utils"
autoCapitalize="none" style={{...S.inp,fontSize:12}}/>
{/* Vorhandene Tags als Vorschläge */}
{suggestions.length>0 && (
<div style={{marginTop:6}}>
<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:8,marginBottom:4}}>VORHANDENE TAGS HINZUFÜGEN:</div>
<div style={{display:'flex',gap:4,flexWrap:'wrap'}}>
{suggestions.map(t=>(
<button key={t} onClick={()=>addSuggestedTag(t)} style={{
fontSize:9,fontFamily:'monospace',padding:'2px 8px',borderRadius:12,cursor:'pointer',
background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.1)',
color:'rgba(255,255,255,0.4)',
}}>+ {t}</button>
))}
</div>
</div>
)}
{parseTags().length>0&&(
<div style={{display:'flex',gap:4,flexWrap:'wrap',marginTop:6}}>
{parseTags().map(t=>(
<span key={t} style={{fontSize:9,fontFamily:'monospace',
color:t===lang?LANG_COLORS[lang]:'rgba(255,255,255,0.5)',
background:'rgba(255,255,255,0.06)',borderRadius:4,padding:'2px 7px',
border:`1px solid ${t===lang?(LANG_COLORS[lang]+'40'):'rgba(255,255,255,0.1)'}`}}>
{t}
</span>
))}
</div>
)}
</div>
<div style={{display:'flex',gap:8}}>
<button onClick={save} disabled={busy}
style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0',opacity:busy?0.5:1}}>
{busy?'…':initial?.id?'✓ Aktualisieren':'↓ Speichern'}
</button>
<button onClick={onCancel} style={{...S.btn('#ff6b9d',true),padding:'0 16px'}}>Abbrechen</button>
</div>
</div>
);
}
// ── Main Component ────────────────────────────────────────────────────────────
export default function CodeSchnipsel({ toast, mobile }) {
const [own, setOwn] = useState([]);
const [shared, setShared] = useState([]);
const [sharedByMe,setSharedByMe]= useState([]);
const [tab, setTab] = useState('own');
const [search, setSearch] = useState('');
const [tagFilters,setTagFilters]= useState([]); // multi-select array
const [showForm, setShowForm] = useState(false);
const [editItem, setEditItem] = useState(null);
const [shareItem, setShareItem] = useState(null);
const [histItem, setHistItem] = useState(null);
const load = useCallback(()=>{
api('/tools/snippets').then(d=>{ setOwn(d.own||[]); setShared(d.shared||[]); setSharedByMe(d.sharedByMe||[]); }).catch(()=>{});
},[]);
useEffect(()=>{ load(); },[load]);
// Only tags that are currently in use
const allItems = [...own, ...shared];
const allTags = [...new Set(allItems.flatMap(s=>s.tags||[]))].sort();
const toggleTag = t => setTagFilters(p => p.includes(t) ? p.filter(x=>x!==t) : [...p, t]);
const filterItems = items => {
let res = items;
if (tagFilters.length) res = res.filter(s => tagFilters.every(t=>s.tags?.includes(t)));
if (search.trim()) {
const q = search.toLowerCase();
res = res.filter(s =>
s.title.toLowerCase().includes(q) ||
s.code.toLowerCase().includes(q) ||
s.description?.toLowerCase().includes(q) ||
s.tags?.some(t=>t.includes(q)) ||
s.language.includes(q)
);
}
return res;
};
const curList = tab==='own' ? own : tab==='shared' ? shared : sharedByMe;
const items = filterItems(curList);
const handleSave = async form => {
if (editItem?.id) {
const r = await api(`/tools/snippets/${editItem.id}`,{method:'PUT',body:form});
setOwn(p=>p.map(s=>s.id===r.id?r:s)); setEditItem(null);
} else {
const r = await api('/tools/snippets',{body:form});
setOwn(p=>[r,...p]); setShowForm(false);
}
toast('Gespeichert ✓');
};
const handleDelete = async id => {
if (!window.confirm('Schnipsel und alle seine Versionen wirklich löschen?')) return;
await api(`/tools/snippets/${id}`,{method:'DELETE'});
setOwn(p=>p.filter(s=>s.id!==id)); toast('Gelöscht');
};
const handleRestore = async (histEntry) => {
if (!histItem) return;
const r = await api(`/tools/snippets/${histItem.id}`,{method:'PUT',body:{
code: histEntry.code, language: histEntry.language
}});
setOwn(p=>p.map(s=>s.id===r.id?r:s));
setHistItem(null); toast('Version wiederhergestellt ✓');
};
return (
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:860}}>
{shareItem && <SnippetShareModal snippet={shareItem} onClose={()=>{setShareItem(null);load();}} toast={toast}/>}
{histItem && <HistoryModal snippet={histItem} onClose={()=>setHistItem(null)} onRestore={handleRestore} toast={toast}/>}
{/* Header */}
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:14}}>
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?17:22,margin:0}}>Code-Schnipsel</h1>
<div style={{display:'flex',gap:8}}>
<button onClick={load} title="Neu laden"
style={{background:'transparent',border:'1px solid rgba(255,255,255,0.1)',borderRadius:8,
color:'rgba(255,255,255,0.4)',cursor:'pointer',padding:'6px 10px',fontSize:14,fontFamily:'monospace'}}></button>
{!showForm && !editItem && tab==='own' && (
<button onClick={()=>setShowForm(true)}
style={{...S.btn('#4ecdc4'),padding:'7px 16px',fontSize:12}}>+ Neu</button>
)}
</div>
</div>
{/* Form */}
{(showForm || editItem) && (
<SnippetForm
initial={editItem}
onSave={handleSave}
onCancel={()=>{ setShowForm(false); setEditItem(null); }}
toast={toast}
existingTags={allTags}
/>
)}
{/* Tabs */}
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
{[['own','Meine',own.length],['shared','Geteilt mit mir',shared.length],['sharedByMe','Geteilt von mir',sharedByMe.length]].map(([k,l,cnt])=>(
<button key={k} onClick={()=>{setTab(k);setSearch('');setTagFilters([]);}} style={{
padding:'6px 14px',borderRadius:20,fontFamily:'monospace',fontSize:11,cursor:'pointer',
background:tab===k?'#4ecdc4':'rgba(255,255,255,0.05)',
color:tab===k?'#0d0d0f':'rgba(255,255,255,0.55)',
border:tab===k?'none':'1px solid rgba(255,255,255,0.1)',
fontWeight:tab===k?700:400,
}}>
{l}{cnt>0&&<span style={{marginLeft:4,opacity:0.7}}>({cnt})</span>}
</button>
))}
</div>
{/* Suche */}
<div style={{position:'relative',marginBottom:10}}>
<span style={{position:'absolute',left:12,top:'50%',transform:'translateY(-50%)',
color:'rgba(255,255,255,0.4)',pointerEvents:'none'}}></span>
<input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Suchen (Titel, Code, Tags…)"
style={{...S.inp,paddingLeft:34}}/>
{search && <button onClick={()=>setSearch('')} style={{position:'absolute',right:10,top:'50%',
transform:'translateY(-50%)',background:'transparent',border:'none',
color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:16}}></button>}
</div>
{/* Tag-Filter (Mehrfachauswahl) */}
{allTags.length>0 && (
<div style={{display:'flex',gap:5,flexWrap:'wrap',marginBottom:14,alignItems:'center'}}>
<button onClick={()=>setTagFilters([])} style={{
fontSize:9,fontFamily:'monospace',padding:'3px 9px',borderRadius:20,cursor:'pointer',
background:!tagFilters.length?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.04)',
border:`1px solid ${!tagFilters.length?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.08)'}`,
color:!tagFilters.length?'#4ecdc4':'rgba(255,255,255,0.4)',
}}>Alle</button>
{allTags.map(t=>{
const active = tagFilters.includes(t);
const c = LANG_COLORS[t] || '#4ecdc4';
return (
<button key={t} onClick={()=>toggleTag(t)} style={{
fontSize:9,fontFamily:'monospace',padding:'3px 9px',borderRadius:20,cursor:'pointer',
background:active?`${c}20`:'rgba(255,255,255,0.04)',
border:`1px solid ${active?`${c}50`:'rgba(255,255,255,0.08)'}`,
color:active?c:'rgba(255,255,255,0.45)',
}}>{active?'✓ ':''}{t}</button>
);
})}
{tagFilters.length>0 && (
<span style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,marginLeft:2}}>
({tagFilters.length} aktiv)
</span>
)}
</div>
)}
{/* Liste */}
{items.length===0 ? (
<div style={{...S.card,textAlign:'center',padding:40}}>
<div style={{fontSize:28,marginBottom:10}}>{'</>'}</div>
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:12}}>
{search||tagFilters.length ? 'Keine Treffer.' : tab==='own' ? 'Noch keine Schnipsel füge deinen ersten hinzu.' : 'Noch nichts geteilt.'}
</div>
</div>
) : items.map(s=>(
<SnippetCard
key={s.id} snippet={s} toast={toast}
onEdit={setEditItem}
onDelete={handleDelete}
onShare={setShareItem}
onHistory={setHistItem}
/>
))}
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,643 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { api, S } from '../lib.js';
const GRID = 20;
const MY_COLOR = '#4ecdc4';
const OPP_COLOR = '#ff6b9d';
const DIRS8 = [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]];
// Terrain-Konstanten (müssen mit Backend übereinstimmen)
const T_NORMAL = 0;
const T_GOLD = 1;
const T_MINE = 2;
const T_ROCK = 3;
const T_FOG = -1; // maskiert vom Server
function getMyId() {
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).id || null; } catch { return null; }
}
function getMyRole() {
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).role || null; } catch { return null; }
}
// ── Spielerklärung ────────────────────────────────────────────────────────────
function HelpModal({ onClose }) {
return (
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.85)', zIndex:1000, display:'flex', flexDirection:'column' }}
onClick={onClose}>
<div style={{ ...S.card, maxWidth:460, width:'100%', margin:'16px auto', display:'flex', flexDirection:'column',
maxHeight:'calc(100dvh - 32px)', overflow:'hidden' }}
onClick={e => e.stopPropagation()}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center',
paddingBottom:14, marginBottom:14, borderBottom:'1px solid rgba(255,255,255,0.08)', flexShrink:0 }}>
<span style={{ color:'#fff', fontFamily:'Space Mono,monospace', fontSize:14, fontWeight:700, letterSpacing:1 }}> SPIELREGELN</span>
<button onClick={onClose} style={S.btn('#ff6b9d', true)}> Schließen</button>
</div>
<div style={{ overflowY:'auto', paddingBottom:'calc(56px + env(safe-area-inset-bottom, 0px))' }}>
<div style={{ color:'rgba(255,255,255,0.75)', fontSize:13, lineHeight:1.9, fontFamily:'monospace' }}>
<p style={{ marginTop:0 }}>
<strong style={{ color:MY_COLOR }}>Ziel:</strong> Sammle mehr <strong>Punkte</strong> als dein Gegner durch clevere Expansion und das Meiden von Fallen.
</p>
<p><strong style={{ color:'#fff' }}>Ablauf:</strong><br/>
Beide starten in gegenüberliegenden Ecken. Du hast einen <strong style={{ color:MY_COLOR }}>Kopf</strong> dein letzter Zug, markiert mit einem leuchtenden Rahmen. Du kannst <strong>nur an den Kopf anbauen</strong> (alle 8 Richtungen). Dein Gebiet bleibt erhalten, aber du kannst nicht mehr von alten Feldern aus expandieren. Tippe ein Feld an, bestätige fertig.
</p>
<div style={{ background:'rgba(255,255,255,0.04)', borderRadius:8, padding:'12px 14px', marginBottom:12, border:'1px solid rgba(255,255,255,0.08)' }}>
<div style={{ marginBottom:8, color:'#fff', fontSize:12, letterSpacing:1 }}>FELDER & PUNKTE</div>
<div style={{ display:'grid', gridTemplateColumns:'28px 1fr', gap:'6px 10px', alignItems:'center' }}>
<span style={{ fontSize:16, textAlign:'center' }}></span><span><strong style={{ color:'rgba(255,255,255,0.9)' }}>Normales Feld</strong> +1 Punkt</span>
<span style={{ fontSize:16, textAlign:'center' }}></span><span><strong style={{ color:'#ffe66d' }}>Goldfeld</strong> +3 Punkte, sehr wertvoll!</span>
<span style={{ fontSize:16, textAlign:'center' }}>💣</span><span><strong style={{ color:'#ff6b9d' }}>Sprengmine</strong> Betreten zündet eine Explosion: alle 8 umliegenden Felder werden zu Felsen (auch Gold!). Besetzte Felder gehen verloren. Liegt eine weitere Mine im Umkreis, explodiert auch diese (Kettenreaktion!). Du verlierst deinen nächsten Zug.</span>
<span style={{ fontSize:16, textAlign:'center' }}>🪨</span><span><strong style={{ color:'rgba(255,255,255,0.4)' }}>Felsen</strong> unbesetzbar, blockiert Wege</span>
</div>
</div>
<p>
<strong style={{ color:'#fff' }}>🌫 Nebel:</strong><br/>
Du siehst nur dein Gebiet und die direkt angrenzenden Felder. Goldfelder und Minen bleiben verborgen bis du nahe genug herankommst also aufgepasst!
</p>
<p>
<strong style={{ color:'#ffe66d' }}>🔭 Kundschaften:</strong><br/>
Statt ein Feld zu besetzen kannst du einmal pro Zug <strong>kundschaften</strong>: tippe auf den Button <strong style={{ color:'#ffe66d' }}>🔭 Kundschaften</strong>, dann wähle einen Mittelpunkt auf dem Spielfeld ein <strong>3×3 Bereich</strong> wird nur für dich aufgedeckt. Dein Gegner sieht nicht was du entdeckt hast. Kostet deinen Zug, gibt dir aber wertvolle Information über Minen und Gold.
</p>
<p>
<strong style={{ color:'#fff' }}>Spielende:</strong><br/>
Das Spiel endet wenn niemand mehr ziehen kann oder wenn jemand <strong>doppelt so viele Punkte</strong> hat wie der Gegner (Dominanzsieg 👑). Wer mehr Punkte hat, gewinnt.
</p>
<div style={{ background:'rgba(255,255,255,0.04)', borderRadius:8, padding:'10px 14px', border:'1px solid rgba(255,255,255,0.08)' }}>
📱 <em>Auf dem Handy: Pinch zum Zoomen, Feld antippen und bestätigen.</em>
</div>
</div>
</div>
</div>
</div>
);
}
// ── Neues Spiel Modal ─────────────────────────────────────────────────────────
function NewGameModal({ onClose, onCreated, toast }) {
const [users, setUsers] = useState([]);
const [oppId, setOppId] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => { api('/tools/gebietseroberung/users').then(setUsers).catch(() => {}); }, []);
const create = async () => {
if (!oppId) return;
setLoading(true);
try {
const res = await api('/tools/gebietseroberung', { body: { opponent_id: Number(oppId) } });
toast('Spiel erstellt! Dein Gegner wurde benachrichtigt ⬡');
onCreated(res.id);
} catch(e) {
toast(e.message || 'Fehler beim Erstellen', 'error');
} finally { setLoading(false); }
};
return (
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.85)', display:'flex', alignItems:'center', justifyContent:'center', zIndex:1000, padding:20 }}>
<div style={{ ...S.card, maxWidth:360, width:'100%' }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:18 }}>
<span style={{ color:'#fff', fontFamily:'Space Mono,monospace', fontSize:13, fontWeight:700, letterSpacing:1 }}>NEUES SPIEL</span>
<button onClick={onClose} style={S.btn('#666666', true)}></button>
</div>
<div style={{ ...S.head, marginBottom:6 }}>GEGNER WÄHLEN</div>
<select value={oppId} onChange={e => setOppId(e.target.value)} style={{ ...S.inp, marginBottom:18 }}>
<option value=''>-- Benutzer wählen --</option>
{users.map(u => <option key={u.id} value={u.id}>{u.username}</option>)}
</select>
<div style={{ display:'flex', gap:8 }}>
<button onClick={onClose} style={{ ...S.btn('#666666'), flex:1 }}>Abbrechen</button>
<button onClick={create} disabled={!oppId || loading} style={{ ...S.btn('#4ecdc4'), flex:1 }}>
{loading ? 'Erstelle…' : 'Spiel starten'}
</button>
</div>
</div>
</div>
);
}
// ── Topliste ──────────────────────────────────────────────────────────────────
function Leaderboard({ reloadKey }) {
const [rows, setRows] = useState(null);
useEffect(() => { api('/tools/gebietseroberung/leaderboard').then(setRows).catch(() => setRows([])); }, [reloadKey]);
if (!rows) return null;
const medals = ['🥇','🥈','🥉'];
return (
<div style={{ marginTop:28 }}>
<div style={{ ...S.head, marginBottom:10 }}>TOPLISTE SIEGE</div>
{rows.length === 0
? <div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:12, textAlign:'center', padding:'16px 0' }}>Noch keine abgeschlossenen Spiele.</div>
: rows.map((r,i) => (
<div key={r.username} style={{ display:'flex', alignItems:'center', gap:12, padding:'10px 14px', marginBottom:6, background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.07)', borderRadius:8 }}>
<span style={{ fontSize:16, width:24, textAlign:'center', flexShrink:0 }}>{medals[i] || `${i+1}.`}</span>
<span style={{ flex:1, color:'#fff', fontFamily:'monospace', fontSize:13 }}>{r.username}</span>
<span style={{ color:MY_COLOR, fontFamily:'Space Mono,monospace', fontSize:16, fontWeight:700 }}>{r.wins}</span>
<span style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10 }}>Siege</span>
</div>
))
}
</div>
);
}
// ── Spielfeld ─────────────────────────────────────────────────────────────────
function GameBoard({ game, myId, onMove, onScout, toast }) {
const [pending, setPending] = useState(null);
const [moving, setMoving] = useState(false);
const [scoutMode, setScoutMode] = useState(false); // Scout-Modus aktiv
const [scoutPend, setScoutPend] = useState(null); // gewählter Scout-Mittelpunkt
const [mineAlert, setMineAlert] = useState(null); // letztes Mine-Event
const grid = JSON.parse(game.grid);
const terrain = JSON.parse(game.terrain || '[]');
const hasTerrain = terrain.length > 0;
const isMyTurn = game.current_turn === myId;
const opponent = game.owner_id === myId ? game.opponent_id : game.owner_id;
const myColor = game.owner_id === myId ? MY_COLOR : OPP_COLOR;
const oppColor = game.owner_id === myId ? OPP_COLOR : MY_COLOR;
const oppName = game.owner_id === myId ? game.opp_name : game.owner_name;
// Mine-Event anzeigen wenn frisch
const [blastHighlight, setBlastHighlight] = useState(new Set());
const [alertShownAt, setAlertShownAt] = useState(null); // move_count beim Anzeigen
useEffect(() => {
if (!game.last_event) {
setMineAlert(null); // Event wurde vom Server geclearet (nächster Zug)
return;
}
try {
const ev = JSON.parse(game.last_event);
if (ev.type === 'mine') {
setMineAlert(ev);
setAlertShownAt(game.move_count);
if (ev.blastCells?.length) {
const keys = new Set(ev.blastCells.map(([r,c]) => `${r}-${c}`));
setBlastHighlight(keys);
setTimeout(() => setBlastHighlight(new Set()), 2000);
}
}
} catch {}
}, [game.last_event, game.move_count]);
// Schlangen-Köpfe aus Server-Response
const myHeadStr = game.myHead;
const myHead = myHeadStr ? myHeadStr.split(',').map(Number) : null;
const [myHR, myHC] = myHead ?? [null, null];
const oppHeadStr = game.oppHead;
const oppHead = oppHeadStr ? oppHeadStr.split(',').map(Number) : null;
const [oppHR, oppHC] = oppHead ?? [null, null];
const isAdjacentToHead = (r, c) => {
if (myHR === null) return false;
return Math.abs(r - myHR) <= 1 && Math.abs(c - myHC) <= 1 && !(r === myHR && c === myHC);
};
const canClick = (r, c) => {
if (scoutMode) return false; // im Scout-Modus kein normaler Zug
if (!isMyTurn || game.status !== 'active') return false;
if (grid[r][c] !== 0) return false;
if (hasTerrain && terrain[r][c] === T_ROCK) return false;
if (myHead) return isAdjacentToHead(r, c);
return DIRS8.some(([dr,dc]) => {
const nr=r+dr, nc=c+dc;
return nr>=0&&nr<GRID&&nc>=0&&nc<GRID&&grid[nr][nc]===myId;
});
};
const handleCellClick = (r, c) => {
if (!canClick(r, c) || moving) return;
if (pending?.row === r && pending?.col === c) { setPending(null); return; }
setPending({ row:r, col:c });
};
const confirmMove = async () => {
if (!pending || moving) return;
setMoving(true);
try {
await onMove(pending.row, pending.col);
setPending(null);
} catch(e) {
toast?.(e.message || 'Fehler', 'error');
} finally { setMoving(false); }
};
// Scores kommen vom Server (auf ungemasktem Grid berechnet — korrekt für beide Spieler)
const myScore = game.myScore ?? 0;
const oppScore = game.oppScore ?? 0;
const myCount = grid.flat().filter(v => v === myId).length;
const oppCount = grid.flat().filter(v => v === opponent).length;
const fogCount = grid.flat().filter(v => v === 0 && hasTerrain).length;
let banner = null;
if (game.status === 'finished') {
const isDominance = myScore >= oppScore * 2 || oppScore >= myScore * 2;
if (!game.winner_id) banner = { text:'Unentschieden! 🤝', color:'#ffe66d' };
else if (game.winner_id === myId) banner = { text: isDominance ? 'Dominanzsieg! 👑🎉' : 'Du hast gewonnen! 🎉', color:MY_COLOR };
else banner = { text: isDominance ? 'Dominanzniederlage 💀' : 'Du hast verloren.', color:OPP_COLOR };
}
const [cellSize, setCellSize] = useState(14);
useEffect(() => {
const calc = () => {
const vw = Math.min(window.innerWidth, 600);
const vh = window.visualViewport?.height ?? window.innerHeight;
// Overhead: Header(36) + Scoreboard(50) + Status(30) + Legende(26) + margins(28) = ~170px
const availH = vh - 170;
const byW = Math.floor((vw - 32) / GRID);
const byH = Math.floor(availH / GRID);
setCellSize(Math.max(10, Math.min(byW, byH)));
};
calc();
window.visualViewport?.addEventListener('resize', calc);
window.addEventListener('resize', calc);
return () => {
window.visualViewport?.removeEventListener('resize', calc);
window.removeEventListener('resize', calc);
};
}, []);
// Zell-Rendering
const handleScoutClick = (r, c) => {
if (!scoutPend) return;
setScoutPend({ row:r, col:c, confirmed: true });
};
const confirmScout = async () => {
if (!scoutPend || moving) return;
setMoving(true);
try {
await onScout(scoutPend.row, scoutPend.col);
setScoutMode(false);
setScoutPend(null);
} catch(e) {
toast?.(e.message || 'Fehler', 'error');
} finally { setMoving(false); }
};
const renderCell = (r, c) => {
const cell = grid[r][c];
const t = hasTerrain ? terrain[r][c] : T_NORMAL;
const clic = canClick(r, c);
const isPend = pending?.row === r && pending?.col === c;
const isBlast = blastHighlight.has(`${r}-${c}`);
const isHead = myHR === r && myHC === c;
const isOppHead = game.status === 'finished' && oppHR === r && oppHC === c;
// Scout-Preview: 3x3 um Maus-Position im Scout-Modus
const isScoutPreview = scoutPend &&
Math.abs(r - scoutPend.row) <= 1 && Math.abs(c - scoutPend.col) <= 1;
const isFog = t === T_FOG;
const isRock = t === T_ROCK;
const isGold = t === T_GOLD;
const isMine = t === T_MINE;
let bg = 'rgba(255,255,255,0.05)';
let content = null;
let border = '1px solid transparent';
if (isFog || (cell === 0 && !clic && hasTerrain && t === T_FOG)) {
bg = 'rgba(0,0,0,0.4)'; // dunkler Nebel
} else if (isRock) {
bg = 'rgba(255,255,255,0.12)';
content = cellSize > 16 ? '🪨' : null;
} else if (cell === myId) {
bg = isGold ? `#ffe66d88` : isMine ? `#ff6b9d44` : `${myColor}55`;
if (isGold) content = cellSize > 16 ? '⭐' : null;
if (isMine) content = cellSize > 16 ? '💣' : null;
} else if (cell === opponent) {
bg = isGold ? `#ffe66d55` : isMine ? `#ff6b9d33` : `${oppColor}55`;
} else {
// neutral & sichtbar
if (isGold) { bg = 'rgba(255,230,109,0.18)'; content = cellSize > 16 ? '⭐' : null; }
else if (isMine) { bg = 'rgba(255,107,157,0.15)'; content = cellSize > 16 ? '💣' : null; }
else if (clic) bg = `${myColor}22`;
}
if (isBlast) { bg = '#ff440066'; border = '2px solid #ff4400'; }
if (isOppHead) { border = `2px solid ${oppColor}`; }
if (isScoutPreview && !isHead) { bg = `rgba(255,230,109,0.2)`; border = `1px solid rgba(255,230,109,0.5)`; }
if (isHead && !isPend) { border = `2px solid ${myColor}`; }
if (isPend) { bg = myColor; border = `2px solid ${myColor}`; }
else if (clic && !isPend && !isBlast && !isHead) border = `1px solid ${myColor}44`;
return (
<div
key={`${r}-${c}`}
onClick={() => scoutMode ? setScoutPend({row:r,col:c}) : handleCellClick(r, c)}
onMouseEnter={() => scoutMode ? setScoutPend({row:r,col:c}) : setHover(`${r}-${c}`)}
onMouseLeave={() => { if(!scoutMode) setHover(null); }}
style={{
width:cellSize, height:cellSize, background:bg,
borderRadius:1, cursor:clic?'pointer':'default',
transition:'background 0.08s', border, boxSizing:'border-box',
display:'flex', alignItems:'center', justifyContent:'center',
fontSize: cellSize > 18 ? 10 : 7, lineHeight:1, userSelect:'none',
}}
>{content}</div>
);
};
return (
<div>
{/* Scoreboard */}
<div style={{ display:'flex', gap:8, alignItems:'center', marginBottom:6 }}>
<div style={{ ...S.card, padding:'5px 8px', flex:1 }}>
<div style={{ color:myColor, fontFamily:'monospace', fontSize:9, letterSpacing:1, marginBottom:2 }}>DU</div>
<div style={{ color:'#fff', fontFamily:'Space Mono,monospace', fontSize:16, fontWeight:700, lineHeight:1 }}>{myScore} <span style={{ fontSize:9, color:'rgba(255,255,255,0.3)' }}>Pkt</span></div>
</div>
<div style={{ textAlign:'center', color:'rgba(255,255,255,0.2)', fontFamily:'monospace', fontSize:9 }}>🌫{fogCount}</div>
<div style={{ ...S.card, padding:'5px 8px', flex:1, textAlign:'right' }}>
<div style={{ color:oppColor, fontFamily:'monospace', fontSize:9, letterSpacing:1, marginBottom:2 }}>{oppName?.toUpperCase()}</div>
<div style={{ color:'#fff', fontFamily:'Space Mono,monospace', fontSize:16, fontWeight:700, lineHeight:1 }}><span style={{ fontSize:9, color:'rgba(255,255,255,0.3)' }}>Pkt </span>{oppScore}</div>
</div>
</div>
{/* Mine-Alert */}
{mineAlert && (
<div style={{
background:'rgba(255,107,157,0.15)', border:'1px solid rgba(255,107,157,0.4)',
borderRadius:8, padding:'10px 14px', marginBottom:12,
display:'flex', alignItems:'center', gap:10,
}}>
<span style={{ fontSize:20 }}>💣</span>
<span style={{ color:'#ff6b9d', fontFamily:'monospace', fontSize:12, flex:1 }}>
{mineAlert.player === myId
? `💥 Mine! ${mineAlert.destroyedOwn ? mineAlert.destroyedOwn + ' deiner Felder zerstört — ' : ''}Zug verloren.`
: `💥 ${oppName} trat auf eine Mine! ${mineAlert.destroyedOpp ? mineAlert.destroyedOpp + ' deiner Felder zerstört!' : 'Kein Schaden.'} 😈`}
</span>
<button onClick={() => setMineAlert(null)} style={{ ...S.btn('#666666', true), padding:'2px 8px' }}></button>
</div>
)}
{/* Gewinner-Banner */}
{banner && (
<div style={{
background:`${banner.color}18`, border:`1px solid ${banner.color}44`,
borderRadius:10, padding:'12px 16px', marginBottom:14,
color:banner.color, fontFamily:'Space Mono,monospace', fontSize:15,
textAlign:'center', fontWeight:700, letterSpacing:1,
}}>{banner.text}</div>
)}
{/* Zug-Status */}
{game.status === 'active' && (
<div style={{ marginBottom:6 }}>
{!isMyTurn && !pending && (
<div style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:11, textAlign:'center', padding:'4px 0' }}>
{oppName} ist dran
</div>
)}
{isMyTurn && !pending && !scoutMode && (
<div style={{ display:'flex', alignItems:'center', gap:6 }}>
<div style={{ color:myColor, fontFamily:'monospace', fontSize:11, flex:1, textAlign:'center', padding:'4px 0' }}>
Baue vom Kopf aus Vorsicht vor Minen! 💣
</div>
<button onClick={() => { setScoutMode(true); setPending(null); }}
style={{ ...S.btn('#ffe66d', true), fontSize:10, flexShrink:0 }} title="3x3 Bereich aufdecken (kostet Zug)">
🔭 Kundschaften
</button>
</div>
)}
{isMyTurn && scoutMode && !scoutPend && (
<div style={{ display:'flex', alignItems:'center', gap:8, background:'rgba(255,230,109,0.1)', border:'1px solid rgba(255,230,109,0.4)', borderRadius:8, padding:'7px 10px' }}>
<span style={{ color:'#ffe66d', fontFamily:'monospace', fontSize:11, flex:1 }}>
🔭 Klicke einen Mittelpunkt zum Aufdecken (3×3)
</span>
<button onClick={() => { setScoutMode(false); setScoutPend(null); }} style={S.btn('#666666', true)}> Abbrechen</button>
</div>
)}
{isMyTurn && scoutMode && scoutPend && (
<div style={{ display:'flex', alignItems:'center', gap:8, background:'rgba(255,230,109,0.1)', border:'1px solid rgba(255,230,109,0.44)', borderRadius:8, padding:'7px 10px' }}>
<span style={{ color:'#ffe66d', fontFamily:'monospace', fontSize:11, flex:1 }}>
🔭 R{scoutPend.row+1} S{scoutPend.col+1} aufdecken kostet einen Zug
</span>
<button onClick={() => setScoutPend(null)} style={S.btn('#666666', true)}></button>
<button onClick={confirmScout} disabled={moving} style={S.btn('#ffe66d', true)}>{moving ? '…' : '✓ Aufdecken'}</button>
</div>
)}
{pending && !scoutMode && (
<div style={{ display:'flex', alignItems:'center', gap:8, background:`${myColor}12`, border:`1px solid ${myColor}44`, borderRadius:8, padding:'7px 10px' }}>
<span style={{ color:myColor, fontFamily:'monospace', fontSize:11, flex:1 }}>
R{pending.row+1} S{pending.col+1} bestätigen?
</span>
<button onClick={() => setPending(null)} style={S.btn('#666666', true)}></button>
<button onClick={confirmMove} disabled={moving} style={S.btn(MY_COLOR, true)}>{moving ? '…' : '✓ Ja'}</button>
</div>
)}
</div>
)}
{/* Legende */}
<div style={{ display:'flex', gap:6, marginBottom:4, flexWrap:'wrap' }}>
{[['⭐','Gold +3','#ffe66d'],['💣','Mine 💥','#ff6b9d'],['🪨','Felsen','rgba(255,255,255,0.35)'],['🌫️','Nebel','rgba(255,255,255,0.25)']].map(([icon,label,color]) => (
<div key={label} style={{ display:'flex', alignItems:'center', gap:4 }}>
<span style={{ fontSize:12 }}>{icon}</span>
<span style={{ color, fontFamily:'monospace', fontSize:10 }}>{label}</span>
</div>
))}
</div>
{/* Grid */}
<div style={{ overflowX:'auto', overflowY:'auto', WebkitOverflowScrolling:'touch', touchAction:'pinch-zoom', borderRadius:8, width:'fit-content' }}>
<div style={{ display:'grid', gridTemplateColumns:`repeat(${GRID}, ${cellSize}px)`, gridTemplateRows:`repeat(${GRID}, ${cellSize}px)`, gap:1, background:'rgba(255,255,255,0.04)', borderRadius:8, padding:3 }}>
{grid.map((row, r) => row.map((_, c) => renderCell(r, c)))}
</div>
</div>
</div>
);
}
// ── Spielzeile ────────────────────────────────────────────────────────────────
function GameRow({ g, myId, isAdmin, onSelect, onDelete }) {
const isMyTurn = g.status === 'active' && g.current_turn === myId;
const opp = g.owner_id === myId ? g.opp_name : g.owner_name;
const finished = g.status === 'finished' && (g.move_count || 0) >= 2;
let result = null;
if (g.status === 'finished') {
if (!g.winner_id) result = { label:'Unentschieden', color:'#ffe66d' };
else if (g.winner_id === myId) result = { label:'Gewonnen 🎉', color:MY_COLOR };
else result = { label:'Verloren', color:OPP_COLOR };
}
return (
<div style={{ ...S.card, marginBottom:8, display:'flex', alignItems:'center', gap:10, border: isMyTurn ? '1px solid rgba(78,205,196,0.35)' : '1px solid rgba(255,255,255,0.07)', padding:'12px 14px' }}>
<button onClick={() => onSelect(g.id)} style={{ flex:1, background:'none', border:'none', cursor:'pointer', textAlign:'left', padding:0 }}>
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13, marginBottom:4 }}>
{isMyTurn && <span style={{ color:MY_COLOR, marginRight:6 }}></span>}
vs <strong>{opp}</strong>
</div>
<div style={{ color:'rgba(255,255,255,0.35)', fontSize:11, fontFamily:'monospace' }}>
{g.move_count || 0} Züge gespielt
</div>
</button>
{result
? <span style={{ color:result.color, fontSize:11, fontFamily:'monospace', flexShrink:0 }}>{result.label}</span>
: <span style={{ color: isMyTurn ? MY_COLOR : 'rgba(255,255,255,0.3)', fontSize:11, fontFamily:'monospace', flexShrink:0 }}>
{isMyTurn ? 'Du bist dran' : 'Gegner dran'}
</span>
}
{isAdmin && finished && (
<button onClick={e => { e.stopPropagation(); onDelete(g.id); }} style={{ ...S.btn('#ff6b9d', true), flexShrink:0 }} title="Löschen">🗑</button>
)}
</div>
);
}
// ── Spielliste ────────────────────────────────────────────────────────────────
function GameList({ games, myId, isAdmin, onSelect, onNew, onDelete, reloadKey }) {
const active = games.filter(g => g.status === 'active');
const finished = games.filter(g => g.status === 'finished' && (g.move_count || 0) >= 2);
return (
<div>
<button onClick={onNew} style={{ ...S.btn('#4ecdc4'), width:'100%', marginBottom:20, padding:'10px 14px', fontSize:13 }}>
+ Neues Spiel starten
</button>
{active.length > 0 && <>
<div style={{ ...S.head, marginBottom:10 }}>LAUFENDE SPIELE ({active.length})</div>
{active.map(g => <GameRow key={g.id} g={g} myId={myId} isAdmin={isAdmin} onSelect={onSelect} onDelete={onDelete} />)}
</>}
{finished.length > 0 && <>
<div style={{ ...S.head, marginTop:20, marginBottom:10 }}>BEENDETE SPIELE</div>
{finished.map(g => <GameRow key={g.id} g={g} myId={myId} isAdmin={isAdmin} onSelect={onSelect} onDelete={onDelete} finished />)}
</>}
{games.length === 0 && (
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:13, textAlign:'center', paddingTop:40 }}>
Noch keine Spiele starte eines!
</div>
)}
<Leaderboard reloadKey={reloadKey} />
</div>
);
}
// ── Hauptkomponente ───────────────────────────────────────────────────────────
export default function HexWars({ toast }) {
const [games, setGames] = useState([]);
const [activeId, setActiveId] = useState(null);
const [game, setGame] = useState(null);
const [showNew, setShowNew] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [loading, setLoading] = useState(true);
const [reloadKey, setReloadKey] = useState(0);
const myId = getMyId();
const isAdmin = getMyRole() === 'admin';
const loadGames = useCallback(async () => {
try { setGames(await api('/tools/gebietseroberung')); }
catch { toast?.('Fehler beim Laden', 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { loadGames(); }, [loadGames]);
useEffect(() => {
if (!activeId) { setGame(null); return; }
api(`/tools/gebietseroberung/${activeId}`).then(g => {
setGame(g);
// Beendetes Spiel als gesehen markieren → Badge verschwindet
if (g.status === 'finished') {
api(`/tools/gebietseroberung/${activeId}/seen`, { body: {} }).catch(() => {});
}
}).catch(() => {});
}, [activeId]);
// Polling wenn Gegner dran
useEffect(() => {
if (!activeId || !game || !myId) return;
if (game.status !== 'active' || game.current_turn === myId) return;
const iv = setInterval(async () => {
const g = await api(`/tools/gebietseroberung/${activeId}`).catch(() => null);
if (!g) return;
setGame(g);
if (g.current_turn === myId) {
toast?.('Du bist dran! ⬡');
// games-Liste sofort aktualisieren
setGames(prev => prev.map(p => p.id === g.id
? { ...p, current_turn: g.current_turn, move_count: g.move_count }
: p
));
}
}, 10000);
return () => clearInterval(iv);
}, [activeId, game, myId]);
const handleMove = async (row, col) => {
const updated = await api(`/tools/gebietseroberung/${activeId}/move`, { body: { row, col } });
setGame(updated);
if (updated.status === 'finished') {
api(`/tools/gebietseroberung/${activeId}/seen`, { body: {} }).catch(() => {});
}
// Direkt den games-State patchen damit die Liste sofort stimmt (kein Warten auf loadGames)
setGames(prev => prev.map(g => g.id === updated.id
? { ...g, current_turn: updated.current_turn, move_count: updated.move_count, status: updated.status, winner_id: updated.winner_id }
: g
));
};
const handleScout = async (row, col) => {
const updated = await api(`/tools/gebietseroberung/${activeId}/scout`, { body: { row, col } });
setGame(updated);
setGames(prev => prev.map(g => g.id === updated.id
? { ...g, current_turn: updated.current_turn, move_count: updated.move_count }
: g
));
};
const handleResign = async () => {
if (!window.confirm('Wirklich aufgeben?')) return;
await api(`/tools/gebietseroberung/${activeId}/resign`, { body: {} });
const updated = await api(`/tools/gebietseroberung/${activeId}`);
setGame(updated);
setGames(prev => prev.map(g => g.id === updated.id ? { ...g, ...updated } : g));
toast('Du hast aufgegeben.');
};
const handleDelete = async (id) => {
if (!window.confirm('Spiel wirklich löschen?')) return;
try {
await api(`/tools/gebietseroberung/${id}`, { method: 'DELETE' });
setGames(prev => prev.filter(g => g.id !== id));
setReloadKey(k => k + 1);
toast('Spiel gelöscht.');
} catch(e) { toast?.(e.message || 'Fehler', 'error'); }
};
if (loading) return <div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', padding:40, textAlign:'center' }}>Lade</div>;
return (
<div style={{ maxWidth:600, margin:'0 auto' }}>
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:8 }}>
{activeId && (
<button onClick={() => { setActiveId(null); setGame(null); loadGames(); }} style={S.btn('#666666', true)}> Zurück</button>
)}
<h2 style={{ margin:0, fontSize:13, fontFamily:'monospace', color:'rgba(255,255,255,0.55)', letterSpacing:2, fontWeight:400, flexShrink:0 }}>
HEX WARS
</h2>
<div style={{ flex:1 }} />
<button onClick={() => setShowHelp(true)} style={S.btn('#ffe66d', true)}>? Regeln</button>
{activeId && game?.status === 'active' && (
<button onClick={handleResign} style={S.btn('#ff6b9d', true)}></button>
)}
</div>
{!activeId
? <GameList games={games} myId={myId} isAdmin={isAdmin} onSelect={setActiveId} onNew={() => setShowNew(true)} onDelete={handleDelete} reloadKey={reloadKey} />
: game && myId
? <GameBoard game={game} myId={myId} onMove={handleMove} onScout={handleScout} toast={toast} />
: <div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', textAlign:'center', paddingTop:40 }}>Lade Spiel</div>
}
{showNew && <NewGameModal onClose={() => setShowNew(false)} toast={toast} onCreated={id => { setShowNew(false); setActiveId(id); loadGames(); }} />}
{showHelp && <HelpModal onClose={() => setShowHelp(false)} />}
</div>
);
}

View File

@@ -1,844 +0,0 @@
import { useConfirm } from '../confirm.jsx';
import { useState, useMemo, useEffect, useRef } from 'react';
import { api, S } from '../lib.js';
const MODEL_EXTENSIONS = ['.stl','.3mf','.f3d','.step','.stp','.obj','.iges','.igs','.fcstd','.amf','.scad'];
const DEFAULTS = {
materialpreis_pro_rolle: 10, // €/Rolle (1 kg = 1000 g → 0.01 €/g)
stromverbrauch_kw: 0.15,
strompreis_pro_kwh: 0.38, druckerpreis: 550,
gesamtdruckstunden: 5000, verschleiss_pro_stunde: 0.25,
};
const LS_CUSTOM = 'dd_kalk3d_custom_defaults';
const getEffectiveDefaults = () => {
try { const s = localStorage.getItem(LS_CUSTOM); if (s) return { ...DEFAULTS, ...JSON.parse(s) }; } catch {}
return { ...DEFAULTS };
};
const getFarbFaktor = n => Math.round((1.0 + Math.max(0, n - 1) * 0.1) * 100) / 100;
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' },
];
// ── Bild komprimieren (client-seitig, max 800px, JPEG 0.75) ──────────────────
function compressImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
const MAX = 800;
let w = img.width, h = img.height;
if (w > MAX || h > MAX) {
if (w > h) { h = Math.round(h * MAX / w); w = MAX; }
else { w = Math.round(w * MAX / h); h = MAX; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', 0.75));
};
img.onerror = reject;
img.src = e.target.result;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// ── Stepper ───────────────────────────────────────────────────────────────────
function Stepper({ label, value, onChange, step, unit, min = 0, hint }) {
const [raw, setRaw] = useState(String(value));
// Sync wenn value sich von außen ändert (z.B. beim Bearbeiten eines Archiv-Eintrags)
useEffect(() => { setRaw(String(value)); }, [value]);
const dec = () => { const v = Math.max(min, Math.round((value - step) * 10000) / 10000); onChange(v); setRaw(String(v)); };
const inc = () => { const v = Math.round((value + step) * 10000) / 10000; onChange(v); setRaw(String(v)); };
return (
<div>
<div style={{ color:'rgba(255,255,255,0.4)', fontSize:10, fontFamily:'monospace', marginBottom:5, letterSpacing:1 }}>
{label.toUpperCase()}{unit ? ` (${unit})` : ''}
</div>
<div style={{ display:'flex', alignItems:'center', background:'rgba(255,255,255,0.05)', borderRadius:8, border:'1px solid rgba(255,255,255,0.1)', overflow:'hidden' }}>
<button onClick={dec} style={{ padding:'10px 12px', background:'transparent', border:'none', color:'rgba(255,255,255,0.6)', fontSize:20, cursor:'pointer', flexShrink:0, lineHeight:1 }}></button>
<input
type="number" value={raw} step={step}
onChange={e => {
setRaw(e.target.value);
const parsed = parseFloat(e.target.value);
if (!isNaN(parsed)) onChange(Math.max(min, parsed));
}}
onBlur={() => {
const parsed = parseFloat(raw);
const safe = isNaN(parsed) ? min : Math.max(min, parsed);
onChange(safe); setRaw(String(safe));
}}
style={{ flex:1, background:'transparent', border:'none', color:'#fff', fontSize:15, fontFamily:"'Space Mono',monospace", textAlign:'center', outline:'none', minWidth:0, padding:'10px 4px' }}
/>
<button onClick={inc} style={{ padding:'10px 12px', background:'transparent', border:'none', color:'rgba(255,255,255,0.6)', fontSize:20, cursor:'pointer', flexShrink:0, lineHeight:1 }}>+</button>
</div>
{hint && <div style={{ color:'rgba(255,255,255,0.5)', fontSize:10, fontFamily:'monospace', marginTop:3 }}>{hint}</div>}
</div>
);
}
function SmallInput({ label, value, onChange, step = 0.01 }) {
const [raw, setRaw] = useState(String(value));
useEffect(() => { setRaw(String(value)); }, [value]);
return (
<div>
<div style={{ color:'rgba(255,255,255,0.6)', fontSize:9, fontFamily:'monospace', marginBottom:3 }}>{label}</div>
<input
type="number" value={raw} step={step}
onChange={e => {
setRaw(e.target.value);
const parsed = parseFloat(e.target.value);
if (!isNaN(parsed)) onChange(parsed);
}}
onBlur={() => {
const parsed = parseFloat(raw);
if (!isNaN(parsed)) { onChange(parsed); setRaw(String(parsed)); }
else setRaw(String(value));
}}
style={{ ...S.inp, padding:'7px 8px', fontSize:13 }}
/>
</div>
);
}
// ── Bild-Modal ────────────────────────────────────────────────────────────────
function ImageModal({ src, onClose }) {
return (
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.9)', zIndex:9000,
display:'flex', alignItems:'center', justifyContent:'center', padding:20 }}
onClick={onClose}>
<img src={src} alt="Vorschau"
style={{ maxWidth:'100%', maxHeight:'90vh', borderRadius:10, objectFit:'contain' }} />
</div>
);
}
// ── Kostenrechner ─────────────────────────────────────────────────────────────
export default function Kalkulator3D({ toast, editData, onEditDone, mobile, setActive }) {
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_rolle: editData.materialpreis_pro_gramm != null ? Math.round(editData.materialpreis_pro_gramm * 1000 * 100) / 100 : DEFAULTS.materialpreis_pro_rolle,
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,
} : { ...getEffectiveDefaults() });
const [showAdv, setShowAdv] = useState(false);
const [name, setName] = useState(editData?.name ?? '');
const [bemerkung, setBemerkung] = useState(editData?.bemerkung ?? '');
const [image, setImage] = useState(editData?.image ?? null);
const [saving, setSaving] = useState(false);
const [modelFiles, setModelFiles] = useState([]); // neue Dateien zum Hochladen
const [existingFiles, setExistingFiles] = useState([]); // bereits gespeicherte Dateien
const [modelFolderId, setModelFolderId] = useState(null);
const fileRef = useRef(null);
const modelFileRef = useRef(null);
const sv = (k, v) => setVars(p => ({ ...p, [k]: v }));
// Im Bearbeitungsmodus: existierende Dateien laden (ohne Ordner zu erstellen)
useEffect(() => {
if (!editData?.id || !editData?.name) return;
(async () => {
try {
const base = await api(`/tools/dateien/find-folder?name=3D-Modelle`);
if (!base) return; // Basisordner existiert noch nicht → keine Dateien
const sub = await api(`/tools/dateien/find-folder?name=${encodeURIComponent(editData.name.trim())}&parent_id=${base.id}`);
if (!sub) return; // Unterordner existiert nicht → keine Dateien
setModelFolderId(sub.id);
const data = await api(`/tools/dateien?folder_id=${sub.id}`);
setExistingFiles(data.own || []);
} catch {}
})();
}, [editData?.id]);
const calc = useMemo(() => {
const f = getFarbFaktor(farben);
const matProGramm = vars.materialpreis_pro_rolle / 1000;
const mat = gramm * matProGramm * 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 handleImagePick = async e => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) { toast('Nur Bilddateien erlaubt (JPG, PNG, …)', 'error'); e.target.value = ''; return; }
if (file.size > 20 * 1024 * 1024) { toast(`Bild zu groß (${(file.size/1024/1024).toFixed(1)} MB) max. 20 MB`, 'error'); e.target.value = ''; return; }
try {
const compressed = await compressImage(file);
setImage(compressed);
} catch { toast('Bild konnte nicht verarbeitet werden ist die Datei beschädigt?', 'error'); }
e.target.value = '';
};
const save = async () => {
if (!name.trim()) { toast('Bitte einen Namen eingeben', 'error'); return; }
setSaving(true);
try {
const payload = { name, gramm, stunden, farben,
materialpreis_pro_gramm: vars.materialpreis_pro_rolle / 1000,
stromverbrauch_kw: vars.stromverbrauch_kw,
strompreis_pro_kwh: vars.strompreis_pro_kwh,
druckerpreis: vars.druckerpreis,
gesamtdruckstunden: vars.gesamtdruckstunden,
verschleiss_pro_stunde: vars.verschleiss_pro_stunde,
preis_freundschaft: calc.grund * 1.0,
preis_normal: calc.grund * 1.5,
preis_auftrag: calc.grund * 2.5,
image, bemerkung };
if (editData?.id) {
await api(`/tools/kalkulator3d/${editData.id}`, { method:'PUT', body:payload });
// Neue Dateien hochladen falls vorhanden
if (modelFiles.length > 0) {
let folderId = modelFolderId;
if (!folderId) {
// Ordner erst jetzt anlegen wenn wirklich Dateien da sind
const base = await api('/tools/dateien/ensure-folder', { body: { name: '3D-Modelle' } });
const sub = await api('/tools/dateien/ensure-folder', { body: { name: editData.name.trim(), parent_id: base.id } });
folderId = sub.id;
setModelFolderId(folderId);
}
const form = new FormData();
form.append('folder_id', folderId);
for (const f of modelFiles) form.append('files', f);
await fetch('/api/tools/dateien/upload-3d', {
method:'POST',
headers:{ Authorization:`Bearer ${localStorage.getItem('sk_token')}` },
body: form,
});
setModelFiles([]);
const data = await api(`/tools/dateien?folder_id=${folderId}`);
setExistingFiles(data.own || []);
}
toast('Aktualisiert ✓'); onEditDone?.();
} else {
await api('/tools/kalkulator3d', { body:payload });
// 3D-Dateien hochladen wenn vorhanden
if (modelFiles.length > 0) {
try {
// 1. "3D-Modelle" Basisordner sicherstellen
const base = await api('/tools/dateien/ensure-folder', { body: { name: '3D-Modelle' } });
// 2. Unterordner mit Modellnamen anlegen
const sub = await api('/tools/dateien/ensure-folder', { body: { name: name.trim(), parent_id: base.id } });
// 3. Dateien hochladen
const form = new FormData();
form.append('folder_id', sub.id);
for (const f of modelFiles) form.append('files', f);
await fetch('/api/tools/dateien/upload-3d', {
method: 'POST',
headers: { Authorization: `Bearer ${localStorage.getItem('sk_token')}` },
body: form,
});
toast(`Gespeichert ✓ · ${modelFiles.length} Datei(en) in 3D-Modelle/${name.trim()} abgelegt`);
} catch {
toast('Gespeichert ✓ (Datei-Upload fehlgeschlagen)', 'error');
}
} else {
toast('Gespeichert ✓');
}
setName(''); setBemerkung(''); setImage(null); setModelFiles([]);
setActive?.('kalkulator3d-saved');
}
} catch(e) { toast(e.message, 'error'); } finally { setSaving(false); }
};
const pad = mobile ? '14px 14px 90px' : '36px 44px';
return (
<div style={{ padding:pad, maxWidth:860 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', marginBottom:20 }}>
<div>
{editData && <div style={{ color:'#ffe66d', fontSize:10, fontFamily:'monospace', marginBottom:4 }}> {editData.name}</div>}
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:mobile?18:22, margin:0 }}>Kostenrechner</h1>
</div>
<div style={{ display:'flex', gap:8 }}>
{editData && <button onClick={onEditDone} style={S.btn('#ff6b9d', true)}> Abbrechen</button>}
{!editData && <button onClick={() => window.location.reload()} title="Neu laden"
style={{ background:'transparent', border:'1px solid rgba(255,255,255,0.1)', borderRadius:8,
color:'rgba(255,255,255,0.4)', cursor:'pointer', padding:'6px 10px', fontSize:14, fontFamily:'monospace' }}></button>}
</div>
</div>
{/* Eingaben */}
<div style={{ ...S.card, marginBottom:10 }}>
<div style={{ display:'flex', flexDirection:'column', gap:10, marginBottom:10 }}>
<Stepper label="Gewicht" value={gramm} onChange={setGramm} step={1} unit="g" min={0} />
<Stepper label="Druckdauer" value={stunden} onChange={setStunden} step={0.5} unit="h" min={0} />
<Stepper label="Anzahl Farben" value={farben}
onChange={v => setFarben(Math.max(1, Math.round(v)))} step={1} min={1}
hint={`Farbfaktor: ×${getFarbFaktor(farben).toFixed(2)}`} />
</div>
<button onClick={() => setShowAdv(v => !v)} style={{
width:'100%', marginTop:4, background:'rgba(255,255,255,0.02)',
border:'1px solid rgba(255,255,255,0.06)', borderRadius:7,
padding:'9px 12px', color:'rgba(255,255,255,0.6)', 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 !== getEffectiveDefaults()[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:12 }}>
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:10 }}>
<SmallInput label="Material €/Rolle" value={vars.materialpreis_pro_rolle} onChange={v=>sv('materialpreis_pro_rolle',v)} step={0.5}/>
<SmallInput label="Strom kW" value={vars.stromverbrauch_kw} onChange={v=>sv('stromverbrauch_kw',v)} step={0.01}/>
<SmallInput label="Strom €/kWh" value={vars.strompreis_pro_kwh} onChange={v=>sv('strompreis_pro_kwh',v)} step={0.01}/>
<SmallInput label="Drucker €" value={vars.druckerpreis} onChange={v=>sv('druckerpreis',v)} step={10}/>
<SmallInput label="Lebensdauer h" value={vars.gesamtdruckstunden} onChange={v=>sv('gesamtdruckstunden',v)} step={100}/>
<SmallInput label="Verschleiß €/h" value={vars.verschleiss_pro_stunde} onChange={v=>sv('verschleiss_pro_stunde',v)} step={0.01}/>
</div>
<div style={{ display:'flex', gap:8, marginTop:10 }}>
<button onClick={() => {
localStorage.setItem(LS_CUSTOM, JSON.stringify(vars));
toast('Als persönlicher Standard gespeichert ✓');
}} style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center' }}>
Als Standard speichern
</button>
<button onClick={() => {
localStorage.removeItem(LS_CUSTOM);
setVars({ ...DEFAULTS });
toast('Auf Werkseinstellungen zurückgesetzt');
}} style={{ ...S.btn('#ff6b9d', true) }} title="Werkseinstellungen wiederherstellen">
</button>
</div>
</div>
)}
</div>
{/* Ergebnis */}
<div style={{ ...S.card, marginBottom:10 }}>
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:0 }}>
{[['Material',calc.mat,'#a78bfa'],['Strom',calc.str,'#60a5fa'],['Verschleiß',calc.ver,'#f59e0b'],['Grundkosten',calc.grund,'#4ecdc4']].map(([l,v,c],i) => (
<div key={l} style={{
padding:'10px 14px',
borderBottom: i < 2 ? '1px solid rgba(255,255,255,0.05)' : 'none',
borderRight: i % 2 === 0 ? '1px solid rgba(255,255,255,0.05)' : 'none',
}}>
<div style={{ color:'rgba(255,255,255,0.6)', fontSize:9, fontFamily:'monospace', marginBottom:2 }}>{l.toUpperCase()}</div>
<div style={{ color:c, fontSize:i===3?18:14, fontFamily:"'Space Mono',monospace", fontWeight:i===3?700:400 }}>
{v.toFixed(2)}
</div>
</div>
))}
</div>
</div>
{/* Preise */}
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:8, marginBottom:12 }}>
{STUFEN.map((s, i) => (
<div key={s.key} style={{
background:`${s.c}10`, border:`1px solid ${s.c}30`, borderRadius:10,
padding:'12px 10px', textAlign:'center', transform:i===1?'scale(1.04)':'scale(1)',
}}>
<div style={{ fontSize:20, marginBottom:3 }}>{s.emoji}</div>
<div style={{ color:'rgba(255,255,255,0.6)', fontSize:9, fontFamily:'monospace', marginBottom:4 }}>{s.label}</div>
<div style={{ color:s.c, fontSize:mobile?16:20, fontFamily:"'Space Mono',monospace", fontWeight:700 }}>
{(calc.grund * s.mult).toFixed(2)}
</div>
<div style={{ color:'rgba(255,255,255,0.5)', fontSize:8, fontFamily:'monospace', marginTop:2 }}>×{s.mult}</div>
</div>
))}
</div>
{/* Speichern */}
<div style={S.card}>
<div style={{ ...S.head, marginBottom:10 }}>BERECHNUNG SPEICHERN</div>
{/* Name */}
<div style={{ marginBottom:10 }}>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>NAME</label>
<input value={name} onChange={e => setName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && save()}
placeholder='z.B. "Halterung Müller"'
style={{ ...S.inp, fontSize:15 }} />
</div>
{/* Bemerkung */}
<div style={{ marginBottom:10 }}>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>BEMERKUNG</label>
<textarea value={bemerkung} onChange={e => setBemerkung(e.target.value)}
placeholder="Material, Infill, Notizen…"
rows={3}
style={{ ...S.inp, resize:'vertical', lineHeight:1.6, fontSize:14, padding:'9px 12px' }} />
</div>
{/* Bild */}
<div style={{ marginBottom:14 }}>
<label style={{ ...S.head, display:'block', marginBottom:6 }}>BILD (optional)</label>
<input ref={fileRef} type="file" accept="image/*" onChange={handleImagePick}
style={{ display:'none' }} />
{image ? (
<div style={{ position:'relative', display:'inline-block' }}>
<img src={image} alt="Vorschau"
style={{ width:'100%', maxWidth:240, height:140, objectFit:'cover', borderRadius:8,
border:'1px solid rgba(255,255,255,0.1)', display:'block' }} />
<button onClick={() => setImage(null)} style={{
position:'absolute', top:6, right:6,
width:24, height:24, borderRadius:'50%',
background:'rgba(0,0,0,0.7)', border:'none',
color:'#fff', fontSize:13, cursor:'pointer', lineHeight:1,
}}></button>
</div>
) : (
<button onClick={() => fileRef.current?.click()} style={{
width:'100%', padding:'20px', background:'rgba(255,255,255,0.03)',
border:'1px dashed rgba(255,255,255,0.15)', borderRadius:8,
color:'rgba(255,255,255,0.6)', cursor:'pointer', fontFamily:'monospace', fontSize:12,
}}>📷 Bild hinzufügen</button>
)}
</div>
{/* 3D-Modelldateien beim Neuanlegen */}
{!editData?.id && (
<div style={{ marginBottom:14 }}>
<label style={{ ...S.head, display:'block', marginBottom:6 }}>
3D-DATEIEN (optional) werden in 3D-Modelle/{name.trim()||'…'} abgelegt
</label>
<input ref={modelFileRef} type="file" multiple
accept={MODEL_EXTENSIONS.join(',')}
onChange={e => setModelFiles(Array.from(e.target.files||[]))}
style={{ display:'none' }} />
{modelFiles.length > 0 ? (
<div style={{ background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.1)',
borderRadius:8, padding:10 }}>
{modelFiles.map((f,i) => (
<div key={i} style={{ display:'flex', alignItems:'center', justifyContent:'space-between',
padding:'4px 0', borderBottom: i<modelFiles.length-1 ? '1px solid rgba(255,255,255,0.05)' : 'none' }}>
<span style={{ color:'rgba(255,255,255,0.7)', fontFamily:'monospace', fontSize:11,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', maxWidth:'80%' }}>
🖨 {f.name}
</span>
<span style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
{(f.size/1024/1024).toFixed(1)} MB
</span>
</div>
))}
<div style={{ display:'flex', gap:6, marginTop:8 }}>
<button onClick={() => modelFileRef.current?.click()}
style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center', fontSize:11 }}>
+ Weitere hinzufügen
</button>
<button onClick={() => setModelFiles([])}
style={{ ...S.btn('#ff6b9d', true), fontSize:11 }}></button>
</div>
</div>
) : (
<button onClick={() => modelFileRef.current?.click()} style={{
width:'100%', padding:'16px', background:'rgba(255,255,255,0.03)',
border:'1px dashed rgba(255,255,255,0.15)', borderRadius:8,
color:'rgba(255,255,255,0.6)', cursor:'pointer', fontFamily:'monospace', fontSize:12,
}}>🖨 3D-Dateien anhängen (STL, 3MF, F3D, STEP )</button>
)}
</div>
)}
{/* 3D-Modelldateien beim Bearbeiten */}
{editData?.id && (
<div style={{ marginBottom:14 }}>
<label style={{ ...S.head, display:'block', marginBottom:6 }}>
3D-DATEIEN 3D-Modelle/{editData.name}
</label>
<input ref={modelFileRef} type="file" multiple
accept={MODEL_EXTENSIONS.join(',')}
onChange={e => setModelFiles(Array.from(e.target.files||[]))}
style={{ display:'none' }} />
{/* Existierende Dateien */}
{existingFiles.length > 0 && (
<div style={{ background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.08)',
borderRadius:8, padding:10, marginBottom:8 }}>
{existingFiles.map((f,i) => (
<div key={f.id} style={{ display:'flex', alignItems:'center', gap:8,
padding:'5px 0', borderBottom: i<existingFiles.length-1 ? '1px solid rgba(255,255,255,0.05)' : 'none' }}>
<span style={{ fontSize:16, flexShrink:0 }}>🖨</span>
<span style={{ color:'rgba(255,255,255,0.7)', fontFamily:'monospace', fontSize:11,
flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
{f.originalname}
</span>
<span style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
{(f.size/1024/1024).toFixed(1)} MB
</span>
<button onClick={async () => {
try {
await api(`/tools/dateien/${f.id}`, { method:'DELETE' });
const remaining = existingFiles.filter(x => x.id !== f.id);
setExistingFiles(remaining);
// Ordner löschen wenn er jetzt leer ist
if (remaining.length === 0 && modelFolderId) {
await api(`/tools/dateien/folders/${modelFolderId}`, { method:'DELETE' });
setModelFolderId(null);
}
} catch(e) { toast(e.message, 'error'); }
}} style={{ background:'transparent', border:'none', cursor:'pointer',
color:'rgba(255,107,157,0.5)', fontSize:14, padding:'0 2px', flexShrink:0, lineHeight:1 }}></button>
</div>
))}
</div>
)}
{/* Neue Dateien zum Hochladen */}
{modelFiles.length > 0 && (
<div style={{ background:'rgba(78,205,196,0.04)', border:'1px solid rgba(78,205,196,0.15)',
borderRadius:8, padding:10, marginBottom:8 }}>
<div style={{ ...S.head, marginBottom:6 }}>NEU HINZUFÜGEN</div>
{modelFiles.map((f,i) => (
<div key={i} style={{ display:'flex', justifyContent:'space-between',
padding:'3px 0', borderBottom: i<modelFiles.length-1 ? '1px solid rgba(255,255,255,0.05)' : 'none' }}>
<span style={{ color:'rgba(255,255,255,0.6)', fontFamily:'monospace', fontSize:11,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', maxWidth:'80%' }}>
{f.name}
</span>
<span style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
{(f.size/1024/1024).toFixed(1)} MB
</span>
</div>
))}
</div>
)}
<div style={{ display:'flex', gap:6 }}>
<button onClick={() => modelFileRef.current?.click()}
style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center', fontSize:11, padding:'9px 0' }}>
+ Dateien hinzufügen
</button>
{modelFiles.length > 0 && (
<button onClick={() => setModelFiles([])}
style={{ ...S.btn('#ff6b9d', true), fontSize:11 }}> Abbrechen</button>
)}
</div>
</div>
)}
<button onClick={save} disabled={saving} style={{
width:'100%', background:'linear-gradient(135deg,#4ecdc4,#45b7d1)', border:'none',
borderRadius:8, padding:'12px 0', color:'#0d0d0f',
fontFamily:"'Space Mono',monospace", fontWeight:700,
fontSize:13, cursor:saving?'default':'pointer', opacity:saving?0.7:1,
}}>{saving ? '…' : editData?.id ? '✓ Aktualisieren' : '↓ Speichern'}</button>
</div>
</div>
);
}
// ── Archiv ──────────────────────────────────────────────────────────────
// ── Datei-Downloads für ein Archiv-Modell ────────────────────────────────────
function ModelFiles({ calcId, toast }) {
const [files, setFiles] = useState(null); // null = noch nicht geladen
const [open, setOpen] = useState(false);
const load = async () => {
if (files !== null) return;
try { setFiles(await api(`/tools/kalkulator3d/${calcId}/files`)); }
catch { setFiles([]); }
};
const download = async f => {
try {
const res = await fetch(`/api/tools/kalkulator3d/${calcId}/files/${f.id}/download`, {
headers:{ Authorization:`Bearer ${localStorage.getItem('sk_token')}` }
});
if (!res.ok) { toast('Download fehlgeschlagen','error'); return; }
const blob = await res.blob();
const a = Object.assign(document.createElement('a'),{ href:URL.createObjectURL(blob), download:f.originalname });
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(()=>URL.revokeObjectURL(a.href),1000);
} catch(e){ toast(e.message,'error'); }
};
const toggle = () => { if (!open) load(); setOpen(v=>!v); };
return (
<div>
<button onClick={toggle} style={{ ...S.btn('#4ecdc4', true), width:'100%', textAlign:'left',
display:'flex', alignItems:'center', gap:6, fontSize:11 }}>
<span>🖨</span>
<span style={{flex:1}}>3D-Dateien {files!==null&&files.length>0?`(${files.length})`:''}</span>
<span style={{transform:open?'rotate(90deg)':'rotate(0)',transition:'transform 0.2s',fontSize:10}}></span>
</button>
{open && (
<div style={{ marginTop:6, background:'rgba(255,255,255,0.02)', border:'1px solid rgba(255,255,255,0.07)',
borderRadius:8, overflow:'hidden' }}>
{files === null ? (
<div style={{ padding:'10px 14px', color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:11 }}>Lädt</div>
) : files.length === 0 ? (
<div style={{ padding:'10px 14px', color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:11 }}>Keine Dateien vorhanden</div>
) : files.map((f,i) => (
<div key={f.id} style={{ display:'flex', alignItems:'center', gap:8, padding:'8px 12px',
borderBottom: i<files.length-1 ? '1px solid rgba(255,255,255,0.05)' : 'none' }}>
<span style={{ color:'rgba(255,255,255,0.6)', fontFamily:'monospace', fontSize:11,
flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
{f.originalname}
</span>
<span style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
{(f.size/1024/1024).toFixed(1)} MB
</span>
<button onClick={()=>download(f)} style={{ ...S.btn('#4ecdc4'), padding:'4px 10px', fontSize:10, flexShrink:0 }}>
</button>
</div>
))}
</div>
)}
</div>
);
}
// ── Share Modal ───────────────────────────────────────────────────────────────
function CalcShareModal({ item, onClose, toast }) {
const [username, setUsername] = useState('');
const [shares, setShares] = useState([]);
const load = () => api(`/tools/kalkulator3d/${item.id}/shares`).then(setShares).catch(()=>{});
useEffect(()=>{ load(); },[]);
const share = async () => {
if (!username.trim()) return;
try { await api(`/tools/kalkulator3d/${item.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); }
catch(e) { toast(e.message,'error'); }
};
const unshare = async uid => {
try { await api(`/tools/kalkulator3d/${item.id}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); toast('Freigabe entfernt'); }
catch(e) { toast(e.message,'error'); }
};
const isMobile = window.innerWidth<768;
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:6000,
display:'flex',alignItems:isMobile?'flex-end':'center',justifyContent:'center',padding:isMobile?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:isMobile?'16px 16px 0 0':16,
width:'100%',maxWidth:420,padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
{isMobile&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
🖨 {item.name}
</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18,padding:'0 4px'}}></button>
</div>
<p style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:12,lineHeight:1.6}}>
Empfänger können das Modell nur ansehen, nicht bearbeiten oder löschen.
</p>
<div style={{display:'flex',gap:8,marginBottom:14}}>
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1,fontSize:15}}/>
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
</div>
{shares.length>0 ? (
<div>
<div style={{...S.head,marginBottom:6}}>GETEILT MIT</div>
{shares.map(s=>(
<div key={s.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<div>
<div style={{color:'rgba(255,255,255,0.8)',fontFamily:'monospace',fontSize:13}}>{s.username}</div>
{s.shared_at && <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10}}>seit {new Date(s.shared_at.replace(' ','T')).toLocaleDateString('de-DE')}</div>}
</div>
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d',true)}></button>
</div>
))}
</div>
) : (
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Noch mit niemandem geteilt.</div>
)}
</div>
</div>
);
}
export function SavedList({ toast, mobile }) {
const { confirm, ConfirmDialog } = useConfirm();
const [own, setOwn] = useState([]);
const [shared, setShared] = useState([]);
const [sharedByMe, setSharedByMe] = useState([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState('own');
const [editData, setEditData] = useState(null);
const [search, setSearch] = useState('');
const [lightbox, setLightbox] = useState(null);
const [shareItem, setShareItem]= useState(null);
const load = () => {
setLoading(true);
api('/tools/kalkulator3d')
.then(d => { setOwn(d.own||[]); setShared(d.shared||[]); setSharedByMe(d.sharedByMe||[]); })
.catch(e => toast(e.message,'error'))
.finally(()=>setLoading(false));
};
useEffect(()=>{ load(); },[]);
const del = async id => {
try { await api(`/tools/kalkulator3d/${id}`,{method:'DELETE'}); setOwn(p=>p.filter(i=>i.id!==id)); toast('Gelöscht'); }
catch(e) { toast(e.message,'error'); }
};
const items = tab==='own' ? own : tab==='shared' ? shared : sharedByMe;
const filtered = search.trim()
? items.filter(i => i.name.toLowerCase().includes(search.toLowerCase()) || (i.bemerkung&&i.bemerkung.toLowerCase().includes(search.toLowerCase())))
: items;
if (editData) return (
<Kalkulator3D toast={toast} mobile={mobile} editData={editData} onEditDone={() => { setEditData(null); load(); }} />
);
return (
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px', maxWidth:900 }}>
{lightbox && <ImageModal src={lightbox} onClose={() => setLightbox(null)} />}
{shareItem && <CalcShareModal item={shareItem} onClose={()=>{setShareItem(null);load();}} toast={toast}/>}
<ConfirmDialog/>
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:14 }}>
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:mobile?18:22, margin:0 }}>Archiv</h1>
<button onClick={load} title="Neu laden"
style={{ background:'transparent', border:'1px solid rgba(255,255,255,0.1)', borderRadius:8,
color:'rgba(255,255,255,0.4)', cursor:'pointer', padding:'6px 10px', fontSize:14, fontFamily:'monospace' }}></button>
</div>
{/* Tabs */}
<div style={{ display:'flex', gap:6, marginBottom:14, flexWrap:'wrap' }}>
{[['own','Meine',own.length],['shared','Geteilt mit mir',shared.length],['sharedByMe','Geteilt von mir',sharedByMe.length]].map(([k,l,cnt])=>(
<button key={k} onClick={()=>{setTab(k);setSearch('');}} style={{
padding:'6px 14px', borderRadius:20, fontFamily:'monospace', fontSize:11, cursor:'pointer',
background: tab===k ? '#4ecdc4' : 'rgba(255,255,255,0.05)',
color: tab===k ? '#0d0d0f' : 'rgba(255,255,255,0.55)',
border: tab===k ? 'none' : '1px solid rgba(255,255,255,0.1)',
fontWeight: tab===k ? 700 : 400,
}}>
{l}{cnt>0&&<span style={{marginLeft:5,opacity:0.7}}>({cnt})</span>}
</button>
))}
</div>
{/* Suche */}
<div style={{ position:'relative', marginBottom:12 }}>
<span style={{ position:'absolute', left:12, top:'50%', transform:'translateY(-50%)',
color:'rgba(255,255,255,0.6)', fontSize:14, pointerEvents:'none' }}></span>
<input value={search} onChange={e => setSearch(e.target.value)}
placeholder="Name oder Bemerkung suchen…"
style={{ ...S.inp, paddingLeft:34, fontSize:15 }} />
{search && (
<button onClick={() => setSearch('')} style={{
position:'absolute', right:10, top:'50%', transform:'translateY(-50%)',
background:'transparent', border:'none', color:'rgba(255,255,255,0.6)',
cursor:'pointer', fontSize:16,
}}></button>
)}
</div>
<p style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, marginBottom:14 }}>
{filtered.length} von {items.length} Einträgen{search ? ` für „${search}"` : ''}
</p>
{loading && <div style={{ color:'rgba(255,255,255,0.6)', fontFamily:'monospace' }}>Lädt</div>}
{!loading && items.length === 0 && (
<div style={{ ...S.card, textAlign:'center', padding:40 }}>
<div style={{ fontSize:28, marginBottom:10 }}></div>
<div style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:12 }}>Noch keine Berechnungen.</div>
</div>
)}
{!loading && search && filtered.length === 0 && (
<div style={{ ...S.card, textAlign:'center', padding:24 }}>
<div style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:12 }}>Keine Treffer.</div>
</div>
)}
{filtered.map(item => (
<div key={item.id} style={{ ...S.card, marginBottom:10, padding:'14px' }}>
<div style={{ display:'flex', gap:12 }}>
{/* Bild Thumbnail */}
{item.image && (
<img src={item.image} alt={item.name}
onClick={() => setLightbox(item.image)}
style={{ width:72, height:72, objectFit:'cover', borderRadius:8, flexShrink:0,
border:'1px solid rgba(255,255,255,0.1)', cursor:'pointer' }} />
)}
<div style={{ flex:1, minWidth:0 }}>
{/* Name + Datum */}
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', marginBottom:4 }}>
<div style={{ display:'flex', alignItems:'center', gap:6 }}>
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13, fontWeight:700 }}>{item.name}</div>
{item.has_files && (
<span title="3D-Dateien vorhanden"
style={{ background:'rgba(78,205,196,0.12)', border:'1px solid rgba(78,205,196,0.25)',
borderRadius:5, padding:'1px 5px', color:'#4ecdc4', fontFamily:'monospace', fontSize:9,
letterSpacing:0.5, flexShrink:0 }}>🖨 Dateien</span>
)}
</div>
<div style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:10, flexShrink:0, marginLeft:8 }}>
{new Date(item.created_at).toLocaleDateString('de-DE')}
</div>
</div>
{/* Infos */}
<div style={{ color:'rgba(255,255,255,0.6)', fontFamily:'monospace', fontSize:10, marginBottom:6 }}>
{item.gramm}g · {item.stunden}h · {item.farben} Farbe{item.farben > 1 ? 'n' : ''}
</div>
{/* Bemerkung */}
{item.bemerkung && (
<div style={{ color:'rgba(255,255,255,0.45)', fontFamily:'monospace', fontSize:11,
lineHeight:1.5, marginBottom:8, wordBreak:'break-word' }}>
{item.bemerkung}
</div>
)}
{/* Preise */}
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:6, marginBottom:10 }}>
{[['💖',item.preis_freundschaft,'#ff6b9d'],['🤝',item.preis_normal,'#4ecdc4'],['💼',item.preis_auftrag,'#ffe66d']].map(([e,v,c]) => (
<div key={e} style={{ background:`${c}10`, border:`1px solid ${c}25`, borderRadius:7, padding:'6px 4px', textAlign:'center' }}>
<div style={{ fontSize:12 }}>{e}</div>
<div style={{ color:c, fontFamily:"'Space Mono',monospace", fontSize:12, fontWeight:700 }}>{v.toFixed(2)}</div>
</div>
))}
</div>
</div>
</div>
{/* Aktionen */}
<div style={{ display:'flex', gap:6, marginTop:10, flexDirection:'column' }}>
{/* Datei-Downloads */}
{item.has_files && (
<ModelFiles calcId={item.id} toast={toast}/>
)}
<div style={{ display:'flex', gap:6 }}>
{!item.is_shared && tab !== 'sharedByMe' ? (
<>
<button onClick={() => setEditData(item)} style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center' }}> Bearbeiten</button>
<button onClick={() => setShareItem(item)} style={{ ...S.btn('#ffe66d', true), padding:'0 12px' }} title="Teilen">🤝</button>
<button onClick={() => del(item.id)} style={{ ...S.btn('#ff6b9d', true), padding:'0 12px' }} title="Löschen"></button>
</>
) : tab === 'sharedByMe' ? (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10, padding:'4px 0' }}>
{item.shared_with_name}
</div>
) : (
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:10, padding:'6px 0' }}>
von {item.owner_name} · nur Ansicht
</div>
)}
</div>
</div>
</div>
))}
</div>
);
}

View File

@@ -1,513 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { api, S } from '../lib.js';
// ── Konstanten ────────────────────────────────────────────────────────────────
const PRIORITIES = [
{ id: 'none', label: 'Keine', color: '#666666' },
{ id: 'low', label: 'Niedrig', color: '#4ecdc4' },
{ id: 'medium', label: 'Mittel', color: '#ffe66d' },
{ id: 'high', label: 'Hoch', color: '#ff6b9d' },
];
const COL_COLORS = ['#4ecdc4','#60a5fa','#a78bfa','#ff6b9d','#ffe66d','#fb923c','#4ade80','#f472b6','#94a3b8'];
const fmtCardDate = s => {
if (!s) return '';
const d = new Date(String(s).replace(' ', 'T'));
if (isNaN(d)) return '';
return d.toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' })
+ ' ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' });
};
const prioOf = id => PRIORITIES.find(p => p.id === id) || PRIORITIES[0];
// ── Karten-Modal ──────────────────────────────────────────────────────────────
function CardModal({ card, columnId, columns, onSave, onClose, toast }) {
const [title, setTitle] = useState(card?.title || '');
const [desc, setDesc] = useState(card?.description || '');
const [prio, setPrio] = useState(card?.priority || 'none');
const [moveToCol, setMoveToCol] = useState(card?.column_id || columnId);
const [newColName, setNewColName] = useState('');
const [showNewCol, setShowNewCol] = useState(false);
const [saving, setSaving] = useState(false);
const titleRef = useRef(null);
const newColRef = useRef(null);
const isMob = window.innerWidth < 768;
useEffect(() => { titleRef.current?.focus(); }, []);
useEffect(() => { if (showNewCol) newColRef.current?.focus(); }, [showNewCol]);
const save = async () => {
if (!title.trim()) return;
setSaving(true);
try {
// Neue Spalte anlegen wenn gewünscht
let targetCol = moveToCol;
if (showNewCol && newColName.trim()) {
const nc = await api('/tools/kanban/columns', { body: { title: newColName.trim() } });
targetCol = nc.id;
}
if (card) {
await api(`/tools/kanban/cards/${card.id}`, {
method: 'PATCH',
body: { title, description: desc, priority: prio },
});
// Spalte gewechselt?
if (targetCol && targetCol !== card.column_id) {
await api(`/tools/kanban/cards/${card.id}/move`, { body: { column_id: targetCol } });
}
} else {
await api('/tools/kanban/cards', { body: { column_id: targetCol || columnId, title, description: desc, priority: prio } });
}
onSave();
} catch (e) { toast(e.message, 'error'); }
finally { setSaving(false); }
};
const onKey = e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) save();
if (e.key === 'Escape') onClose();
};
return (
<div onClick={e => e.target === e.currentTarget && onClose()}
style={{ position:'fixed',inset:0,background:'rgba(0,0,0,0.7)',zIndex:1000,
display:'flex',
alignItems:isMob?'flex-end':'center',
justifyContent:'center',
padding:isMob?0:20,
// Auf Mobile: unten Platz für die fixe Bottom-Nav (56px + safe-area) lassen
paddingBottom: isMob ? 'calc(56px + env(safe-area-inset-bottom, 0px))' : 20,
}}>
<div style={{ background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:isMob?'16px 16px 0 0':14,
width:'100%',maxWidth:460,
display:'flex',flexDirection:'column',
maxHeight: isMob ? '80vh' : '90vh',
}}>
{/* Drag-Handle + Header immer sichtbar, nicht scrollend */}
<div style={{ padding:'18px 20px 0', flexShrink:0 }}>
{isMob && <div style={{ width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px' }}/>}
<div style={{ display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:16 }}>
<div style={{ ...S.head, marginBottom:0 }}>{card ? 'KARTE BEARBEITEN' : 'NEUE KARTE'}</div>
<button onClick={onClose} style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18 }}></button>
</div>
</div>
{/* Scrollbarer Inhalt */}
<div style={{ overflowY:'auto',padding:'0 20px',flex:1,scrollbarWidth:'thin',scrollbarColor:'rgba(255,255,255,0.08) transparent' }}>
<input ref={titleRef} value={title} onChange={e=>setTitle(e.target.value)} onKeyDown={onKey}
placeholder="Titel" style={{ ...S.inp, marginBottom:10 }} />
<textarea value={desc} onChange={e=>setDesc(e.target.value)} onKeyDown={onKey}
placeholder="Beschreibung (optional)" rows={3}
style={{ ...S.inp, resize:'vertical', marginBottom:14 }} />
{/* Priorität */}
<div style={{ marginBottom:14 }}>
<div style={{ ...S.head, marginBottom:8 }}>PRIORITÄT</div>
<div style={{ display:'flex', gap:6, flexWrap:'wrap' }}>
{PRIORITIES.map(p => (
<button key={p.id} onClick={()=>setPrio(p.id)} style={{
...S.btn(p.color, true),
background: prio===p.id ? `${p.color}28` : `${p.color}0e`,
borderColor: prio===p.id ? p.color : `${p.color}35`,
fontWeight: prio===p.id ? 700 : 400,
}}>{p.label}</button>
))}
</div>
</div>
{/* Verschieben nach */}
<div style={{ marginBottom:14 }}>
<div style={{ ...S.head, marginBottom:8 }}>{card ? 'VERSCHIEBEN NACH' : 'IN SPALTE'}</div>
{!showNewCol ? (
<div style={{ display:'flex', gap:6, flexWrap:'wrap' }}>
{columns.map(c => (
<button key={c.id} onClick={()=>setMoveToCol(c.id)} style={{
...S.btn(c.color || '#4ecdc4', true),
background: moveToCol===c.id ? `${c.color||'#4ecdc4'}28` : `${c.color||'#4ecdc4'}0e`,
borderColor: moveToCol===c.id ? (c.color||'#4ecdc4') : `${c.color||'#4ecdc4'}35`,
fontWeight: moveToCol===c.id ? 700 : 400,
}}>{c.title}</button>
))}
<button onClick={()=>setShowNewCol(true)} style={S.btn('#888888', true)}>+ Neue Spalte</button>
</div>
) : (
<div style={{ display:'flex', gap:6 }}>
<input ref={newColRef} value={newColName} onChange={e=>setNewColName(e.target.value)}
onKeyDown={e=>{ if(e.key==='Escape'){setShowNewCol(false);setNewColName('');} }}
placeholder="Name der neuen Spalte"
style={{ ...S.inp, flex:1, fontSize:12 }} />
<button onClick={()=>{setShowNewCol(false);setNewColName('');}} style={S.btn('#888888',true)}></button>
</div>
)}
</div>
</div>
{/* Aktionsbuttons immer sichtbar am unteren Rand, nicht scrollend */}
<div style={{ padding:'14px 20px 24px', flexShrink:0, borderTop:'1px solid rgba(255,255,255,0.07)',
display:'flex', gap:8, justifyContent:'flex-end' }}>
<button onClick={onClose} style={S.btn('#888888', true)}>Abbrechen</button>
<button onClick={save} disabled={!title.trim()||saving} style={S.btn('#4ecdc4', true)}>
{saving ? '…' : card ? 'Speichern' : 'Erstellen'}
</button>
</div>
</div>
</div>
);
}
// ── Spalten-Header ────────────────────────────────────────────────────────────
function ColumnHeader({ col, onUpdate, onDelete, toast }) {
const [editTitle, setEditTitle] = useState(false);
const [titleVal, setTitleVal] = useState(col.title);
const [showColor, setShowColor] = useState(false);
const inputRef = useRef(null);
useEffect(() => { if (editTitle) inputRef.current?.focus(); }, [editTitle]);
const commitTitle = async () => {
setEditTitle(false);
if (titleVal.trim() && titleVal.trim() !== col.title) {
try { await onUpdate(col.id, { title: titleVal.trim() }); }
catch (e) { toast(e.message, 'error'); setTitleVal(col.title); }
} else { setTitleVal(col.title); }
};
const setColor = async (color) => {
setShowColor(false);
try { await onUpdate(col.id, { color }); }
catch (e) { toast(e.message, 'error'); }
};
return (
<div style={{ marginBottom:12 }}>
{/* Farbstreifen oben */}
<div style={{ height:3,borderRadius:'2px 2px 0 0',background:col.color||'#4ecdc4',margin:'-12px -12px 10px',marginTop:-12 }}/>
<div style={{ display:'flex', alignItems:'center', gap:6 }}>
{/* Farbpicker-Button */}
<div style={{ position:'relative' }}>
<button onClick={()=>setShowColor(v=>!v)} title="Farbe ändern"
style={{ width:16,height:16,borderRadius:'50%',background:col.color||'#4ecdc4',
border:'2px solid rgba(255,255,255,0.2)',cursor:'pointer',flexShrink:0,padding:0 }}/>
{showColor && (
<div style={{ position:'absolute',top:22,left:0,zIndex:200,
background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:10,padding:8,display:'flex',flexWrap:'wrap',gap:6,width:130 }}>
{COL_COLORS.map(c => (
<button key={c} onClick={()=>setColor(c)} style={{
width:22,height:22,borderRadius:'50%',background:c,border:
(col.color||'#4ecdc4')===c?'2px solid #fff':'2px solid transparent',
cursor:'pointer',padding:0,transition:'border 0.1s',
}}/>
))}
</div>
)}
</div>
{/* Titel */}
{editTitle ? (
<input ref={inputRef} value={titleVal} onChange={e=>setTitleVal(e.target.value)}
onBlur={commitTitle}
onKeyDown={e=>{ if(e.key==='Enter') commitTitle(); if(e.key==='Escape'){setTitleVal(col.title);setEditTitle(false);} }}
style={{ ...S.inp, fontSize:12,fontWeight:700,padding:'3px 7px',flex:1 }} />
) : (
<span onDoubleClick={()=>setEditTitle(true)} title="Doppelklick zum Umbenennen"
style={{ flex:1,fontSize:12,fontWeight:700,fontFamily:'monospace',color:'#fff',
cursor:'pointer',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap' }}>
{col.title}
</span>
)}
<span style={{ color:'rgba(255,255,255,0.3)',fontSize:11,fontFamily:'monospace',flexShrink:0 }}>
{col.cards.length}
</span>
<button onClick={()=>onDelete(col.id)} title="Spalte löschen"
style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',
cursor:'pointer',fontSize:13,padding:'0 2px',lineHeight:1,flexShrink:0 }}></button>
</div>
</div>
);
}
// ── Einzelne Karte ─────────────────────────────────────────────────────────────
function KanbanCard({ card, col, allCols, onEdit, onDelete, onMove, isDragging, dragHandlers }) {
const prio = prioOf(card.priority);
const colIdx = allCols.findIndex(c => c.id === col.id);
const cardIdx = col.cards.findIndex(c => c.id === card.id);
const isFirst = cardIdx === 0;
const isLast = cardIdx === col.cards.length - 1;
const isFirstCol = colIdx === 0;
const isLastCol = colIdx === allCols.length - 1;
const arrowBtn = (label, disabled, onClick) => (
<button onClick={disabled ? undefined : onClick} disabled={disabled}
style={{ background:'transparent',border:'none',padding:'1px 4px',lineHeight:1,fontSize:13,
color: disabled ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.4)',
cursor: disabled ? 'default' : 'pointer' }}>
{label}
</button>
);
return (
<div {...dragHandlers} style={{
background: isDragging ? 'rgba(78,205,196,0.06)' : 'rgba(255,255,255,0.04)',
border: `1px solid ${isDragging ? '#4ecdc4' : 'rgba(255,255,255,0.08)'}`,
borderLeft: `3px solid ${prio.color}`,
borderRadius:8, padding:'10px 10px 8px', marginBottom:6,
cursor:'grab', opacity: isDragging ? 0.45 : 1,
transition:'border-color 0.12s, opacity 0.12s', userSelect:'none',
}}>
{/* Titel-Zeile */}
<div style={{ display:'flex',alignItems:'flex-start',gap:6,marginBottom:card.description?6:8 }}>
<span style={{ flex:1,fontSize:13,fontFamily:'monospace',color:'#fff',wordBreak:'break-word',lineHeight:1.4 }}>
{card.title}
</span>
<div style={{ display:'flex',gap:3,flexShrink:0 }}>
<button onClick={()=>onEdit(card)} title="Bearbeiten"
style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.35)',
cursor:'pointer',fontSize:12,padding:'1px 3px',lineHeight:1 }}></button>
<button onClick={()=>onDelete(card.id)} title="Löschen"
style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',
cursor:'pointer',fontSize:12,padding:'1px 3px',lineHeight:1 }}></button>
</div>
</div>
{/* Beschreibung */}
{card.description && (
<div style={{ fontSize:11,color:'rgba(255,255,255,0.4)',fontFamily:'monospace',
lineHeight:1.4,whiteSpace:'pre-wrap',wordBreak:'break-word',marginBottom:8 }}>
{card.description}
</div>
)}
{/* Priorität + Pfeile */}
<div style={{ display:'flex',alignItems:'center',justifyContent:'space-between',gap:4 }}>
{prio.id !== 'none' ? (
<span style={{ fontSize:9,fontFamily:'monospace',letterSpacing:1,
color:prio.color, background:`${prio.color}15`,
border:`1px solid ${prio.color}30`, borderRadius:4, padding:'1px 5px' }}>
{prio.label.toUpperCase()}
</span>
) : <span/>}
<div style={{ display:'flex',gap:0,flexShrink:0 }}>
{arrowBtn('↑', isFirst, ()=>onMove(card.id, col.id, cardIdx-1))}
{arrowBtn('↓', isLast, ()=>onMove(card.id, col.id, cardIdx+1))}
{arrowBtn('←', isFirstCol, ()=>onMove(card.id, allCols[colIdx-1]?.id))}
{arrowBtn('→', isLastCol, ()=>onMove(card.id, allCols[colIdx+1]?.id))}
</div>
</div>
{/* Erstellungsdatum */}
{card.created_at && (
<div style={{ fontSize:9,fontFamily:'monospace',color:'rgba(255,255,255,0.2)',
marginTop:6, textAlign:'right', letterSpacing:0.5 }}>
{fmtCardDate(card.created_at)}
</div>
)}
</div>
);
}
// ── Hauptkomponente ───────────────────────────────────────────────────────────
export default function Kanban({ toast, mobile }) {
const [columns, setColumns] = useState([]);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState(null); // null | { card?, columnId }
const [newColInput, setNewColInput] = useState('');
const [addingCol, setAddingCol] = useState(false);
const [dragCardId, setDragCardId] = useState(null);
const [dragOverCol, setDragOverCol] = useState(null);
const [dragOverPos, setDragOverPos] = useState(null);
const newColRef = useRef(null);
const load = useCallback(() => {
setLoading(true);
api('/tools/kanban/board')
.then(d => setColumns(d.columns || []))
.catch(() => toast('Fehler beim Laden', 'error'))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => { if (addingCol) newColRef.current?.focus(); }, [addingCol]);
// ── Spalten-Aktionen ────────────────────────────────────────────────────────
const addColumn = async () => {
if (!newColInput.trim()) return;
try {
await api('/tools/kanban/columns', { body: { title: newColInput.trim() } });
setNewColInput(''); setAddingCol(false); load();
} catch (e) { toast(e.message, 'error'); }
};
const updateColumn = async (id, patch) => {
await api(`/tools/kanban/columns/${id}`, { method:'PATCH', body: patch });
load();
};
const deleteColumn = async (id) => {
const col = columns.find(c => c.id === id);
const msg = col?.cards?.length
? `Spalte "${col.title}" und alle ${col.cards.length} Karte(n) löschen?`
: `Spalte "${col?.title}" löschen?`;
if (!confirm(msg)) return;
try { await api(`/tools/kanban/columns/${id}`, { method:'DELETE' }); load(); }
catch (e) { toast(e.message, 'error'); }
};
// ── Karten-Aktionen ─────────────────────────────────────────────────────────
const deleteCard = async (id) => {
if (!confirm('Karte löschen?')) return;
try { await api(`/tools/kanban/cards/${id}`, { method:'DELETE' }); load(); }
catch (e) { toast(e.message, 'error'); }
};
const moveCard = async (cardId, colIdOrPos, posOrUndef) => {
// Aufruf-Varianten:
// onMove(id, col.id, newPos) → Position innerhalb Spalte
// onMove(id, targetColId) → Spalte wechseln, ans Ende
try {
if (posOrUndef !== undefined) {
await api(`/tools/kanban/cards/${cardId}/move`, { body:{ column_id:colIdOrPos, position:posOrUndef } });
} else {
await api(`/tools/kanban/cards/${cardId}/move`, { body:{ column_id:colIdOrPos } });
}
load();
} catch (e) { toast(e.message, 'error'); }
};
// ── Drag & Drop ─────────────────────────────────────────────────────────────
const onDragStart = (e, cardId) => {
setDragCardId(cardId);
e.dataTransfer.effectAllowed = 'move';
};
const onDragEnd = () => { setDragCardId(null); setDragOverCol(null); setDragOverPos(null); };
const onDragOverCard = (e, colId, pos) => {
e.preventDefault(); e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
setDragOverCol(colId); setDragOverPos(pos);
};
const onDragOverColBg = (e, colId) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const col = columns.find(c => c.id === colId);
setDragOverCol(colId); setDragOverPos(col ? col.cards.length : 0);
};
const onDrop = async (e, colId, pos) => {
e.preventDefault();
if (!dragCardId) return;
const targetPos = pos !== undefined ? pos : dragOverPos;
setDragCardId(null); setDragOverCol(null); setDragOverPos(null);
try {
await api(`/tools/kanban/cards/${dragCardId}/move`, { body:{ column_id:colId, position:targetPos } });
load();
} catch (err) { toast(err.message, 'error'); }
};
// ── Render ────────────────────────────────────────────────────────────────────
if (loading) return (
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px' }}>
<div style={{ color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:12 }}>Lädt</div>
</div>
);
return (
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px', height:'100%', boxSizing:'border-box', display:'flex', flexDirection:'column' }}>
{/* ── Header ─────────────────────────────────────────────────────────── */}
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:24, flexWrap:'wrap' }}>
<h2 style={{ margin:0, fontSize:15, fontFamily:'monospace', color:'rgba(255,255,255,0.55)', letterSpacing:2, fontWeight:400 }}>
KANBAN BOARD
</h2>
<div style={{ flex:1 }}/>
{addingCol ? (
<div style={{ display:'flex', gap:6 }}>
<input ref={newColRef} value={newColInput} onChange={e=>setNewColInput(e.target.value)}
onKeyDown={e=>{ if(e.key==='Enter') addColumn(); if(e.key==='Escape'){setAddingCol(false);setNewColInput('');} }}
placeholder="Spaltenname" style={{ ...S.inp, width: mobile?140:180, fontSize:12 }} />
<button onClick={addColumn} style={S.btn('#4ecdc4', true)}>Hinzufügen</button>
<button onClick={()=>{setAddingCol(false);setNewColInput('');}} style={S.btn('#888888', true)}></button>
</div>
) : (
<button onClick={()=>setAddingCol(true)} style={S.btn('#4ecdc4', true)}>+ Spalte</button>
)}
</div>
{/* ── Board ──────────────────────────────────────────────────────────── */}
{columns.length === 0 ? (
<div style={{ ...S.card, textAlign:'center', padding:40 }}>
<div style={{ color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:12 }}>
Noch keine Spalten. Erstelle eine Spalte um loszulegen.
</div>
</div>
) : (
<div style={{ display:'flex', gap:14, overflowX:'auto', flex:1,
paddingBottom:12, scrollbarWidth:'thin', scrollbarColor:'rgba(255,255,255,0.08) transparent' }}>
{columns.map(col => (
<div key={col.id}
onDragOver={e=>onDragOverColBg(e, col.id)}
onDrop={e=>onDrop(e, col.id, col.cards.length)}
style={{
...S.card,
minWidth: mobile ? 260 : 280,
maxWidth: 320,
width: mobile ? 260 : 280,
flexShrink:0,
display:'flex', flexDirection:'column',
padding:12, overflow:'hidden',
border:`1px solid ${dragOverCol===col.id && dragCardId ? (col.color||'#4ecdc4')+'66' : 'rgba(255,255,255,0.07)'}`,
transition:'border-color 0.15s',
}}>
<ColumnHeader col={col} onUpdate={updateColumn} onDelete={deleteColumn} toast={toast}/>
{/* Karten-Liste */}
<div style={{ flex:1, overflowY:'auto', scrollbarWidth:'thin', scrollbarColor:'rgba(255,255,255,0.08) transparent' }}>
{col.cards.map((card, cardIdx) => (
<div key={card.id}
onDragOver={e=>onDragOverCard(e, col.id, cardIdx)}
onDrop={e=>{ e.stopPropagation(); onDrop(e, col.id, cardIdx); }}>
{dragCardId && dragOverCol===col.id && dragOverPos===cardIdx && (
<div style={{ height:3,background:col.color||'#4ecdc4',borderRadius:2,marginBottom:4,opacity:0.7 }}/>
)}
<KanbanCard
card={card} col={col} allCols={columns}
onEdit={card=>setModal({ card, columnId:col.id })}
onDelete={deleteCard}
onMove={moveCard}
isDragging={dragCardId===card.id}
dragHandlers={{ draggable:true, onDragStart:e=>onDragStart(e,card.id), onDragEnd }}
/>
</div>
))}
{dragCardId && dragOverCol===col.id && dragOverPos===col.cards.length && (
<div style={{ height:3,background:col.color||'#4ecdc4',borderRadius:2,marginTop:2,opacity:0.7 }}/>
)}
</div>
{/* Karte hinzufügen */}
<button onClick={()=>setModal({ card:null, columnId:col.id })}
style={{ ...S.btn(col.color||'#4ecdc4', true), width:'100%', marginTop:10, textAlign:'center' }}>
+ Karte
</button>
</div>
))}
</div>
)}
{/* ── Modal ──────────────────────────────────────────────────────────── */}
{modal && (
<CardModal
card={modal.card}
columnId={modal.columnId}
columns={columns}
onSave={()=>{ setModal(null); load(); }}
onClose={()=>setModal(null)}
toast={toast}
/>
)}
</div>
);
}

View File

@@ -1,468 +0,0 @@
import { useState, useEffect } from 'react';
import { api, S } from '../lib.js';
const GOLD = '#f59e0b';
function getMyRole() {
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).role; } catch { return null; }
}
function publisherColor(name) {
if (!name) return '#555';
const n = name.toLowerCase();
if (n.includes('rewe')) return '#e2001a';
if (n.includes('edeka') || n.includes('e center')) return '#e8a800';
if (n.includes('netto')) return '#0057a8';
if (n.includes('trinkgut')) return '#e87722';
if (n.includes('penny')) return '#e2001a';
if (n.includes('kaufland')) return '#e2001a';
return '#888';
}
// Logo-Bild je Händler — Reihenfolge wichtig: "Netto Getränke-Discount" muss
// VOR dem generischen "netto"-Check geprüft werden. Kein Eintrag (z.B.
// Kaufland) -> Aufrufer fällt auf den farbigen Text-Badge zurück.
function retailerLogo(name) {
if (!name) return null;
const n = name.toLowerCase();
if (n.includes('netto') && n.includes('getränke')) return '/koepi/netto_getraenkemarkt.png';
if (n.includes('netto')) return '/koepi/netto.png';
if (n.includes('rewe dortmund')) return null;
if (n.includes('rewe')) return '/koepi/rewe.png';
if (n.includes('edeka') || n.includes('e center')) return '/koepi/edeka.png';
if (n.includes('trinkgut')) return '/koepi/trinkgut.png';
if (n.includes('penny')) return '/koepi/penny.png';
if (n.includes('lidl')) return '/koepi/lidl.png';
if (n.includes('aldi')) return '/koepi/aldi.png';
if (n.includes('hornbach')) return '/koepi/hornbach.png';
if (n.includes('kaufland')) return '/koepi/kaufland.png';
return null;
}
function fmtDate(str) {
if (!str) return null;
try { return new Date(str).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' }); }
catch { return str; }
}
// Prospekte mit Bier-Icon (König Pilsener im Angebot) immer nach oben,
// ansonsten Reihenfolge vom Backend (chronologisch) beibehalten
function sortBeerFirst(arr) {
return [...arr].sort((a, b) => (b.hasKoenigPilsener?1:0) - (a.hasKoenigPilsener?1:0));
}
// ISO-Kalenderwoche aus einem Datum berechnen
function getISOWeek(dateStr) {
const d = new Date(dateStr);
d.setHours(0,0,0,0);
d.setDate(d.getDate() + 4 - (d.getDay() || 7));
const yearStart = new Date(d.getFullYear(), 0, 1);
return { week: Math.ceil((((d - yearStart) / 86400000) + 1) / 7), year: d.getFullYear() };
}
// Kommende Prospekte nach Kalenderwoche gruppieren, Gruppen chronologisch sortiert
function groupByWeek(arr) {
const groups = new Map();
for (const b of arr) {
if (!b.validFrom) { // kein Datum -> eigene Sammelgruppe ganz unten
const key = 'unbekannt';
if (!groups.has(key)) groups.set(key, { label: 'Ohne Datum', sortKey: Infinity, items: [] });
groups.get(key).items.push(b);
continue;
}
const { week, year } = getISOWeek(b.validFrom);
const key = `${year}-${week}`;
if (!groups.has(key)) groups.set(key, { label: `KW${week}`, sortKey: year*100+week, items: [] });
groups.get(key).items.push(b);
}
return [...groups.values()].sort((a,b) => a.sortKey - b.sortKey);
}
// ── Angebots-Karte (marktguru) ────────────────────────────────────────────────
function OfferCard({ offer }) {
const color = publisherColor(offer.retailer);
const logo = retailerLogo(offer.retailer);
return (
<div style={{
...S.card, padding:'12px 14px',
border:'1px solid rgba(255,255,255,0.1)',
display:'flex', flexDirection:'column', gap:8,
}}>
{/* Bild oben */}
{offer.imageLocal && (
<div style={{ margin:'-12px -14px 8px -14px', borderRadius:'8px 8px 0 0', background:'transparent', height:130, display:'flex', alignItems:'center', justifyContent:'center' }}>
<img src={offer.imageLocal + '?v=4'} alt="König Pilsener"
style={{ maxWidth:'90%', maxHeight:'130px', width:'auto', height:'auto', display:'block', objectFit:'contain' }}
/>
</div>
)}
{/* Händler + Adresse */}
<div style={{ display:'flex', alignItems:'center', gap:6, flexWrap:'wrap' }}>
{logo ? (
<img src={logo} alt={offer.retailer} style={{ height:20, maxWidth:100, objectFit:'contain', borderRadius:3 }}/>
) : (
<span style={{
background:`${color}22`, border:`1px solid ${color}55`,
borderRadius:4, padding:'2px 7px',
color, fontFamily:'monospace', fontSize:10, fontWeight:700,
}}>{offer.retailer}</span>
)}
{offer.oldPrice && <span style={{ background:'#e2001a', color:'#fff', borderRadius:4, padding:'2px 6px', fontSize:9, fontFamily:'monospace', fontWeight:700 }}>-29%</span>}
</div>
{offer.address && (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9 }}>
📍 {offer.address}
</div>
)}
{/* Name + Beschreibung */}
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13, fontWeight:600 }}>König Pilsener</div>
{offer.description && (
<div style={{ color:'rgba(255,255,255,0.45)', fontFamily:'monospace', fontSize:10, lineHeight:1.4 }}>
{offer.description}
</div>
)}
{/* Preis */}
<div style={{ display:'flex', alignItems:'baseline', gap:8 }}>
<span style={{ color:GOLD, fontFamily:'Space Mono,monospace', fontSize:22, fontWeight:700 }}>
{offer.price}
</span>
{offer.oldPrice && (
<span style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:12, textDecoration:'line-through' }}>
{offer.oldPrice}
</span>
)}
</div>
{/* Gültigkeit */}
<div style={{ borderTop:'1px solid rgba(255,255,255,0.06)', paddingTop:6, display:'flex', gap:12, flexWrap:'wrap', alignItems:'center' }}>
{offer.dateRange && (
<span style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10 }}>📅 {offer.dateRange}</span>
)}
{offer.leafletUrl && (
<a href={offer.leafletUrl} target="_blank" rel="noopener noreferrer"
style={{ color:'#4ecdc4', fontFamily:'monospace', fontSize:10, textDecoration:'none', marginLeft:'auto' }}>
🔗 Im Prospekt ansehen
</a>
)}
</div>
</div>
);
}
// ── Prospekt-Karte (marktguru) ────────────────────────────────────────────────
function ProspektCard({ b }) {
const color = publisherColor(b.publisher);
const logo = retailerLogo(b.publisher);
return (
<a href={b.url} target="_blank" rel="noopener noreferrer" style={{ textDecoration:'none' }}>
<div style={{
...S.card, padding:0, overflow:'hidden', cursor:'pointer',
border: '1px solid rgba(255,255,255,0.08)',
}}>
{b.image && (
<img src={`/api/tools/koepi/img?url=${encodeURIComponent(b.image)}`}
alt={b.title} style={{ width:'100%', height:110, objectFit:'cover', display:'block' }}
onError={e => { e.target.style.display='none'; }}
/>
)}
<div style={{ padding:'10px 12px' }}>
<div style={{ display:'flex', alignItems:'center', gap:6, marginBottom:4, flexWrap:'wrap' }}>
{logo ? (
<img src={logo} alt={b.publisher} style={{ height:20, maxWidth:110, objectFit:'contain', borderRadius:3 }}/>
) : (
<div style={{ background:`${color}22`, border:`1px solid ${color}44`,
borderRadius:4, padding:'2px 8px', color, fontFamily:'monospace', fontSize:10, fontWeight:700 }}>
{b.publisher}
</div>
)}
{b.hasKoenigPilsener && (
<span title="König Pilsener gerade im Angebot" style={{ fontSize:13 }}>🍺</span>
)}
</div>
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10, marginBottom:2 }}>
📍 {b.street}
</div>
{b.title && (
<div style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, marginTop:2 }}>
{b.title}
</div>
)}
{(b.validFrom || b.validTo || b.weekInfo) && (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9, marginTop:4 }}>
{b.validFrom && `Ab ${fmtDate(b.validFrom)}`}{b.validFrom&&b.validTo&&' · '}{b.validTo&&`Bis ${fmtDate(b.validTo)}`}
{b.weekInfo && ` ${b.weekInfo}`}
</div>
)}
</div>
</div>
</a>
);
}
// ── Hauptkomponente ───────────────────────────────────────────────────────────
export default function Koepi({ toast }) {
const [tab, setTab] = useState('angebote');
const [offers, setOffers] = useState(null);
const [prospekte, setProspekte] = useState(null);
const [loading, setLoading] = useState(false);
const isAdmin = getMyRole() === 'admin';
const loadOffers = async (force=false) => {
if (offers && !force) return;
setLoading(true);
try { setOffers(await api('/tools/koepi/offers')); }
catch(e) { toast?.(e.message||'Fehler','error'); }
finally { setLoading(false); }
};
const loadProspekte = async (force=false) => {
if (prospekte && !force) return;
setLoading(true);
try { setProspekte(await api('/tools/koepi/prospekte')); }
catch(e) { toast?.(e.message||'Fehler','error'); }
finally { setLoading(false); }
};
useEffect(() => {
if (tab === 'angebote') loadOffers();
else loadProspekte();
}, [tab]);
const clearCache = async () => {
try {
await api('/tools/koepi/clear-cache', { body:{} });
setOffers(null); setProspekte(null);
toast('Cache geleert');
setTimeout(() => { if (tab==='angebote') loadOffers(true); else loadProspekte(true); }, 100);
} catch(e) { toast?.(e.message,'error'); }
};
const [cronRunning, setCronRunning] = useState(false);
const testDailyCheck = async () => {
setCronRunning(true);
try {
const r = await api('/tools/koepi/run-daily-check', { method:'POST', body:{} });
toast(`🔔 Test: ${r.offerCount} Angebot(e) gefunden, Pushover ${r.sent ? 'versendet' : 'nicht versendet'}${r.changed ? ' (Änderung erkannt)' : ' (unverändert)'}`);
} catch(e) { toast?.(e.message||'Fehler','error'); }
finally { setCronRunning(false); }
};
const [resolvingStores, setResolvingStores] = useState(false);
const resolveStores = async () => {
setResolvingStores(true);
try {
const r = await api('/tools/koepi/local-stores/resolve', { method:'POST', body:{} });
toast(`🏪 Filial-Liste: ${r.resolved} von ${r.total} aufgelöst`);
setOffers(null); setProspekte(null);
setTimeout(() => { if (tab==='angebote') loadOffers(true); else loadProspekte(true); }, 100);
} catch(e) { toast?.(e.message||'Fehler','error'); }
finally { setResolvingStores(false); }
};
const [shareLinks, setShareLinks] = useState([]);
const [shareOpen, setShareOpen] = useState(false);
const [shareBusy, setShareBusy] = useState(false);
const [newLinkName, setNewLinkName] = useState('');
const loadShareLinks = () => {
api('/tools/koepi/share-links').then(r => setShareLinks(r.links || [])).catch(()=>{});
};
useEffect(() => { if (isAdmin) loadShareLinks(); }, [isAdmin]);
const createShareLink = async () => {
const name = newLinkName.trim();
if (!name) { toast?.('Bitte einen Namen eingeben','error'); return; }
setShareBusy(true);
try {
await api('/tools/koepi/share-links', { method:'POST', body:{ name } });
setNewLinkName('');
toast(`🔗 Link für "${name}" erstellt`);
loadShareLinks();
} catch(e) { toast?.(e.message||'Fehler','error'); }
finally { setShareBusy(false); }
};
const resetShareLink = async (id, name) => {
if (!window.confirm(`Link für "${name}" wirklich zurücksetzen? Der alte Link funktioniert danach nicht mehr.`)) return;
setShareBusy(true);
try {
await api(`/tools/koepi/share-links/${id}/reset`, { method:'POST', body:{} });
toast(`🔄 Link für "${name}" zurückgesetzt`);
loadShareLinks();
} catch(e) { toast?.(e.message||'Fehler','error'); }
finally { setShareBusy(false); }
};
const deleteShareLink = async (id, name) => {
if (!window.confirm(`Link für "${name}" wirklich löschen?`)) return;
setShareBusy(true);
try {
await api(`/tools/koepi/share-links/${id}`, { method:'DELETE' });
toast(`🗑 Link für "${name}" gelöscht`);
loadShareLinks();
} catch(e) { toast?.(e.message||'Fehler','error'); }
finally { setShareBusy(false); }
};
const copyLink = async (url) => {
try { await navigator.clipboard.writeText(url); toast('Link kopiert'); return; } catch {}
try {
const ta = document.createElement('textarea');
ta.value = url; ta.style.position = 'fixed'; ta.style.left = '-9999px';
document.body.appendChild(ta); ta.focus(); ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
toast?.(ok ? 'Link kopiert' : 'Kopieren nicht möglich', ok ? 'success' : 'error');
} catch { toast?.('Kopieren nicht möglich','error'); }
};
return (
<div style={{ maxWidth:700, margin:'0 auto', padding:'0 14px' }}>
{/* Header */}
<div style={{ marginBottom:20 }}>
<h2 style={{ margin:'0 0 10px 0', fontSize:15, fontFamily:'monospace', color:'rgba(255,255,255,0.55)', letterSpacing:2, fontWeight:400 }}>
🍺 KÖPI König Pilsener
</h2>
{isAdmin && (
<div style={{ display:'flex', gap:8, overflowX:'auto', scrollbarWidth:'none',
WebkitOverflowScrolling:'touch', paddingBottom:2 }}>
<button onClick={testDailyCheck} disabled={cronRunning} style={{ ...S.btn('#f59e0b',true), fontSize:10, opacity:cronRunning?0.5:1, flexShrink:0, whiteSpace:'nowrap' }}>
{cronRunning ? '⏳ läuft…' : '🔔 Cron testen'}
</button>
<button onClick={resolveStores} disabled={resolvingStores} style={{ ...S.btn('#4ecdc4',true), fontSize:10, opacity:resolvingStores?0.5:1, flexShrink:0, whiteSpace:'nowrap' }}>
{resolvingStores ? '⏳ läuft…' : '🏪 Filial-Liste neu auflösen'}
</button>
<button onClick={clearCache} style={{ ...S.btn('#666666',true), fontSize:10, flexShrink:0, whiteSpace:'nowrap' }}>🗑 Cache leeren</button>
<button onClick={()=>setShareOpen(v=>!v)} style={{ ...S.btn(shareLinks.length?'#ffe66d':'#888888',true), fontSize:10, flexShrink:0, whiteSpace:'nowrap' }}>
🔗 Teilen{shareLinks.length ? ` (${shareLinks.length})` : ''}
</button>
</div>
)}
{isAdmin && shareOpen && (
<div style={{ ...S.card, marginTop:10 }}>
<div style={{ ...S.sub, marginBottom:10 }}>
Öffentliche, schreibgeschützte Links ohne Login zeigen zuerst die Prospekte, man kann dort
auch zu den Angeboten wechseln. Keine Admin-Funktionen sichtbar/erreichbar über diese Links.
Du kannst mehrere Links gleichzeitig anlegen (z.B. einen pro Person) in den Logs siehst du
dann, wer welchen Link benutzt hat.
</div>
{shareLinks.length > 0 && (
<div style={{ display:'flex', flexDirection:'column', gap:8, marginBottom:14 }}>
{shareLinks.map(l => (
<div key={l.id} style={{ border:'1px solid rgba(255,255,255,0.08)', borderRadius:8, padding:'8px 10px' }}>
<div style={{ color:'#ffe66d', fontFamily:"'Space Mono',monospace", fontSize:12, fontWeight:700, marginBottom:4 }}>
{l.name}
</div>
<div style={{ display:'flex', gap:8, marginBottom:6, flexWrap:'wrap' }}>
<input readOnly value={l.url} onClick={e=>e.target.select()} style={{ ...S.inp, flex:1, minWidth:180, fontSize:10 }}/>
<button onClick={()=>copyLink(l.url)} style={{ ...S.btn('#4ecdc4'), fontSize:11 }}>📋</button>
</div>
<div style={{ display:'flex', gap:8, flexWrap:'wrap' }}>
<button onClick={()=>resetShareLink(l.id, l.name)} disabled={shareBusy} style={{ ...S.btn('#f59e0b'), fontSize:10, opacity:shareBusy?0.5:1 }}>
🔄 Zurücksetzen
</button>
<button onClick={()=>deleteShareLink(l.id, l.name)} disabled={shareBusy} style={{ ...S.btn('#f87171'), fontSize:10, opacity:shareBusy?0.5:1 }}>
🗑 Löschen
</button>
</div>
</div>
))}
</div>
)}
<div style={{ display:'flex', gap:8, flexWrap:'wrap' }}>
<input value={newLinkName} onChange={e=>setNewLinkName(e.target.value)}
onKeyDown={e=>e.key==='Enter' && createShareLink()}
placeholder="Name (z.B. Sina)" style={{ ...S.inp, flex:1, minWidth:140 }}/>
<button onClick={createShareLink} disabled={shareBusy || !newLinkName.trim()} style={{ ...S.btn('#4ecdc4'), opacity:(shareBusy || !newLinkName.trim())?0.5:1 }}>
{shareBusy?'…':' Neuen Link erstellen'}
</button>
</div>
</div>
)}
</div>
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10, marginBottom:14 }}>
Raum Duisburg 47259 · EDEKA · REWE · Netto · Kaufland · Penny · Trinkgut · Lidl · ALDI Süd · HORNBACH · Cache 1h
</div>
{/* Tabs */}
<div style={{ display:'flex', gap:8, marginBottom:20 }}>
{[['angebote','🍺 Angebote'],['prospekte','📋 Prospekte']].map(([t,l]) => (
<button key={t} onClick={()=>setTab(t)} style={{ ...S.btn(tab===t?GOLD:'#444444',true), fontSize:12, padding:'6px 16px' }}>{l}</button>
))}
</div>
{/* Laden */}
{loading && (
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'40px 0' }}>
Lade (kann bis zu 20 Sekunden dauern)
</div>
)}
{/* Angebote */}
{!loading && tab==='angebote' && offers && (
<div>
{offers.offers?.length > 0 ? (
<>
<div style={{ ...S.head, marginBottom:12 }}>
{offers.offers.length} AKTUELLE ANGEBOTE · Quelle: marktguru.de
</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(2,1fr)', gap:14 }}>
{offers.offers.map((o,i) => <OfferCard key={i} offer={o}/>)}
</div>
</>
) : (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'40px 0' }}>
Aktuell keine König Pilsener Angebote bei deinen Märkten gefunden.
</div>
)}
<div style={{ marginTop:20, textAlign:'center' }}>
<a href="https://www.marktguru.de/search/k%C3%B6nig%20pilsener?zipCode=47259" target="_blank" rel="noopener noreferrer"
style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10 }}>
Alle Angebote auf marktguru.de
</a>
</div>
</div>
)}
{/* Prospekte */}
{!loading && tab==='prospekte' && prospekte && (
<div>
{prospekte.current?.length > 0 && (
<>
<div style={{ ...S.head, marginBottom:12 }}>DEINE MÄRKTE AKTUELL ({prospekte.current.length})</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(2,1fr)', gap:12, marginBottom:24 }}>
{sortBeerFirst(prospekte.current).map(b=><ProspektCard key={b.id} b={b}/>)}
</div>
</>
)}
{prospekte.future?.length > 0 && (
<>
<div style={{ ...S.head, marginBottom:12, paddingTop:12, borderTop:'1px solid rgba(255,255,255,0.08)',
color:'rgba(255,255,255,0.35)' }}>
KOMMENDE PROSPEKTE ({prospekte.future.length})
</div>
{groupByWeek(prospekte.future).map(group => (
<div key={group.label} style={{ marginBottom:20 }}>
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10, letterSpacing:1, marginBottom:8 }}>
{group.label}
</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(2,1fr)', gap:12, opacity:0.75 }}>
{sortBeerFirst(group.items).map(b=><ProspektCard key={b.id} b={b}/>)}
</div>
</div>
))}
</>
)}
{!prospekte.current?.length && !prospekte.future?.length && (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'40px 0' }}>
Keine Prospekte gefunden.
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,728 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { api, S } from '../lib.js';
const LINK_ICONS = ['🔗','📄','🌐','📊','🎯','⚙️','📁','🔧','📱','💡','🏠','🛒','📺','🎮','🎵','📧','💬','📰','🔬','🏥','🏦','✈️','🚀','🔐','📂','⭐','🔑','💻','🖥','📡'];
const FOLDER_ICONS = ['📁','📂','🗂','💼','🗃','📦','🏷','🔖','📋','📌','🗓','🖇'];
// ── Alle DockStation-Tools & Unterfunktionen ──────────────────────────────────
const TOOL_ENTRIES = [
{ url:'tool://statistik', label:'📊 Statistik', group:'3D-Druck' },
{ url:'tool://bestellungen', label:'📦 Bestellungen', group:'3D-Druck' },
{ url:'tool://kalkulator3d', label:'🧮 Kostenrechner', group:'3D-Druck' },
{ url:'tool://kanban', label:'🗂 Kanban', group:'Werkzeuge' },
{ url:'tool://whiteboard', label:'🖊 Whiteboard', group:'Werkzeuge' },
{ url:'tool://dateien', label:'📁 Dateien', group:'Werkzeuge' },
{ url:'tool://nachrichten', label:'💬 Nachrichten', group:'Werkzeuge' },
{ url:'tool://linkliste', label:'🔗 Linkliste', group:'Werkzeuge' },
{ url:'tool://codeschnipsel', label:'</> Code-Schnipsel', group:'Werkzeuge' },
{ url:'tool://media', label:'🎬 Media / Kino', group:'Freizeit' },
{ url:'tool://gebietseroberung', label:'⬡ Hex Wars', group:'Freizeit' },
{ url:'tool://koepi', label:'🍺 KöPi', group:'Freizeit' },
{ url:'tool://devtools', label:'🔧 Dev-Tools (alle)', group:'Dev-Tools' },
{ url:'tool://devtools?sub=cron', label:'⏱ Crontab', group:'Dev-Tools' },
{ url:'tool://devtools?sub=elektro', label:'⚡ Elektro', group:'Dev-Tools' },
{ url:'tool://devtools?sub=json', label:'{ } JSON', group:'Dev-Tools' },
{ url:'tool://devtools?sub=regex', label:'🔍 Regex', group:'Dev-Tools' },
{ url:'tool://devtools?sub=diff', label:'± Text Diff', group:'Dev-Tools' },
{ url:'tool://devtools?sub=netzwerk', label:'🌐 Netzwerk', group:'Dev-Tools' },
{ url:'tool://devtools?sub=datetime', label:'📅 Datetime', group:'Dev-Tools' },
{ url:'tool://devtools?sub=farben', label:'🎨 Farben', group:'Dev-Tools' },
{ url:'tool://devtools?sub=keygen', label:'🔐 Key-Gen', group:'Dev-Tools' },
{ url:'tool://devtools?sub=qrcode', label:'◻ QR-Code', group:'Dev-Tools' },
];
// ── Tool-Picker Modal ─────────────────────────────────────────────────────────
function ToolPicker({ folders, onSave, onClose, toast }) {
const [search, setSearch] = useState('');
const [folderId, setFolderId] = useState(null);
const [saving, setSaving] = useState(null);
const mob = isMob();
const filtered = TOOL_ENTRIES.filter(t =>
!search || t.label.toLowerCase().includes(search.toLowerCase()) || t.group.toLowerCase().includes(search.toLowerCase())
);
const groups = [...new Set(filtered.map(t => t.group))];
const pick = async (entry) => {
setSaving(entry.url);
try {
await onSave({ title: entry.label.replace(/^[^\s]+\s/, ''), url: entry.url, icon: entry.label.split(' ')[0], folder_id: folderId, in_quickaccess: 1 });
toast(`${entry.label} zum Schnellzugriff hinzugefügt ✓`);
onClose();
} catch(e) { toast(e.message, 'error'); setSaving(null); }
};
return (
<div onClick={e=>e.target===e.currentTarget&&onClose()}
style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.75)',zIndex:6000,
display:'flex',alignItems:mob?'flex-end':'center',justifyContent:'center',padding:mob?0:20}}>
<div style={{background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:mob?'16px 16px 0 0':14,width:'100%',maxWidth:480,
display:'flex',flexDirection:'column',maxHeight:mob?'85vh':'80vh',
paddingBottom:mob?'calc(56px + env(safe-area-inset-bottom,0px))':0}}>
{/* Header */}
<div style={{padding:'18px 20px 0',flexShrink:0}}>
{mob && <div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:12}}>
<div style={{...S.head,marginBottom:0}}>APP HINZUFÜGEN</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}></button>
</div>
{/* Ordner-Auswahl */}
{folders.length > 0 && (
<div style={{marginBottom:10}}>
<div style={{fontSize:9,fontFamily:'monospace',color:'rgba(255,255,255,0.35)',letterSpacing:1,marginBottom:5}}>IN ORDNER (optional)</div>
<div style={{display:'flex',gap:5,flexWrap:'wrap'}}>
<button onClick={()=>setFolderId(null)} style={{
...S.btn(folderId===null?'#4ecdc4':'#888888',true),fontSize:10,padding:'3px 8px'}}>
Kein Ordner
</button>
{folders.map(f=>(
<button key={f.id} onClick={()=>setFolderId(f.id)} style={{
...S.btn(folderId===f.id?'#4ecdc4':'#888888',true),fontSize:10,padding:'3px 8px'}}>
{f.icon||'📁'} {f.name}
</button>
))}
</div>
</div>
)}
<input value={search} onChange={e=>setSearch(e.target.value)}
placeholder="App oder Funktion suchen…"
style={{...S.inp,marginBottom:10,fontSize:12}}/>
</div>
{/* Liste */}
<div style={{overflowY:'auto',padding:'0 20px 20px',scrollbarWidth:'thin',scrollbarColor:'rgba(255,255,255,0.08) transparent'}}>
{groups.map(group=>(
<div key={group} style={{marginBottom:10}}>
<div style={{fontSize:9,fontFamily:'monospace',color:'rgba(255,255,255,0.3)',letterSpacing:1,marginBottom:5,paddingTop:4}}>
{group.toUpperCase()}
</div>
{filtered.filter(t=>t.group===group).map(entry=>(
<button key={entry.url} onClick={()=>pick(entry)} disabled={!!saving}
style={{display:'flex',alignItems:'center',gap:10,width:'100%',
background:saving===entry.url?'rgba(78,205,196,0.1)':'rgba(255,255,255,0.03)',
border:'1px solid rgba(255,255,255,0.07)',borderRadius:8,
padding:'9px 12px',marginBottom:4,cursor:'pointer',textAlign:'left',
transition:'background 0.12s'}}>
<span style={{fontSize:16,flexShrink:0}}>{entry.label.split(' ')[0]}</span>
<span style={{flex:1,fontSize:12,fontFamily:'monospace',color:'#fff'}}>
{entry.label.replace(/^[^\s]+\s/, '')}
</span>
{saving===entry.url
? <span style={{fontSize:10,color:'#4ecdc4',fontFamily:'monospace'}}></span>
: <span style={{fontSize:10,color:'rgba(255,255,255,0.25)',fontFamily:'monospace'}}>+ hinzufügen</span>}
</button>
))}
</div>
))}
</div>
</div>
</div>
);
}
function extIcon(url) {
try { return `https://www.google.com/s2/favicons?sz=32&domain=${new URL(url).hostname}`; }
catch { return null; }
}
function isMob() { return window.innerWidth < 768; }
// ── Folder Share Modal ────────────────────────────────────────────────────────
function FolderShareModal({ folder, onClose, toast }) {
const [username, setUsername] = useState('');
const [shares, setShares] = useState([]);
const load = () => api(`/tools/linkliste/folders/${folder.id}/shares`).then(setShares).catch(()=>{});
useEffect(()=>{ load(); },[]);
const share = async () => {
if (!username.trim()) return;
try { await api(`/tools/linkliste/folders/${folder.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); }
catch(e) { toast(e.message,'error'); }
};
const unshare = async uid => {
try { await api(`/tools/linkliste/folders/${folder.id}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); }
catch(e) { toast(e.message,'error'); }
};
const mob = isMob();
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:6000,
display:'flex',alignItems:mob?'flex-end':'center',justifyContent:'center',padding:mob?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:mob?'16px 16px 0 0':14,width:'100%',maxWidth:400,
padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
{mob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:10}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
🤝 {folder.icon} {folder.name}
</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}></button>
</div>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:10}}>
Ganzer Ordner inkl. aller Links wird geteilt
</div>
<div style={{display:'flex',gap:8,marginBottom:14}}>
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1}}/>
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
</div>
{shares.length>0 ? shares.map(s=>(
<div key={s.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:13}}>{s.username}</span>
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d',true)}></button>
</div>
)) : <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Noch mit niemandem geteilt.</div>}
</div>
</div>
);
}
// ── Share Modal ───────────────────────────────────────────────────────────────
function ShareModal({ link, onClose, toast }) {
const [username, setUsername] = useState('');
const [shares, setShares] = useState([]);
const load = () => api(`/tools/linkliste/${link.id}/shares`).then(setShares).catch(()=>{});
useEffect(()=>{ load(); },[]);
const share = async () => {
if (!username.trim()) return;
try { await api(`/tools/linkliste/${link.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); }
catch(e) { toast(e.message,'error'); }
};
const unshare = async uid => {
try { await api(`/tools/linkliste/${link.id}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); }
catch(e) { toast(e.message,'error'); }
};
const mob = isMob();
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:6000,
display:'flex',alignItems:mob?'flex-end':'center',justifyContent:'center',padding:mob?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:mob?'16px 16px 0 0':14,width:'100%',maxWidth:400,
padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
{mob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:10}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>🤝 {link.title}</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}></button>
</div>
<div style={{display:'flex',gap:8,marginBottom:14}}>
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1}}/>
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
</div>
{shares.length>0 ? shares.map(s=>(
<div key={s.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:13}}>{s.username}</span>
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d',true)}></button>
</div>
)) : <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Noch mit niemandem geteilt.</div>}
</div>
</div>
);
}
// ── Link Form ─────────────────────────────────────────────────────────────────
function LinkForm({ initial, folders, onSave, onCancel, toast }) {
const [form, setForm] = useState({ title:'', url:'', icon:'🔗', description:'', folder_id:null, ...initial });
const [busy, setBusy] = useState(false);
const set = (k,v) => setForm(p=>({...p,[k]:v}));
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;
setBusy(true);
try { await onSave({...form, url}); }
catch(e) { toast(e.message,'error'); }
setBusy(false);
};
return (
<div style={{...S.card,marginBottom:12}}>
<div style={{...S.head,marginBottom:10}}>{initial?.id?'LINK BEARBEITEN':'NEUER LINK'}</div>
<div style={{marginBottom:8}}>
<div style={{...S.head,fontSize:9,marginBottom:4}}>ICON</div>
<div style={{display:'flex',flexWrap:'wrap',gap:4,marginBottom:6}}>
{LINK_ICONS.map(ic=>(
<button key={ic} onClick={()=>set('icon',ic)} style={{
width:32,height:32,borderRadius:7,fontSize:16,cursor:'pointer',border:'none',
background:form.icon===ic?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
outline:form.icon===ic?'1px solid #4ecdc4':'none'}}>{ic}</button>
))}
</div>
</div>
<input value={form.title} onChange={e=>set('title',e.target.value)} placeholder="Titel *" style={{...S.inp,marginBottom:8}}/>
<input value={form.url} onChange={e=>set('url',e.target.value)} placeholder="URL *" autoCapitalize="none" style={{...S.inp,marginBottom:8}}/>
<input value={form.description} onChange={e=>set('description',e.target.value)} placeholder="Beschreibung (optional)" style={{...S.inp,marginBottom:8,fontSize:13}}/>
{folders.length>0 && (
<div style={{marginBottom:12}}>
<div style={{...S.head,fontSize:9,marginBottom:4}}>IN ORDNER</div>
<select value={form.folder_id||''} onChange={e=>set('folder_id',e.target.value?parseInt(e.target.value):null)}
style={{...S.inp,cursor:'pointer'}}>
<option value=""> Kein Ordner </option>
{folders.map(f=><option key={f.id} value={f.id}>{f.icon} {f.name}</option>)}
</select>
</div>
)}
<div style={{display:'flex',gap:8}}>
<button onClick={save} disabled={busy} style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0',opacity:busy?0.5:1}}>
{busy?'…':'✓ Speichern'}
</button>
<button onClick={onCancel} style={{...S.btn('#ff6b9d',true),padding:'0 16px'}}>Abbrechen</button>
</div>
</div>
);
}
// ── Folder Form ───────────────────────────────────────────────────────────────
function FolderForm({ initial, onSave, onCancel, toast }) {
const [form, setForm] = useState({ name:'', icon:'📁', ...initial });
const [busy, setBusy] = useState(false);
const save = async () => {
if (!form.name.trim()) { toast('Name erforderlich','error'); return; }
setBusy(true);
try { await onSave(form); }
catch(e) { toast(e.message,'error'); }
setBusy(false);
};
return (
<div style={{...S.card,marginBottom:12}}>
<div style={{...S.head,marginBottom:10}}>{initial?.id?'ORDNER BEARBEITEN':'NEUER ORDNER'}</div>
<div style={{marginBottom:8}}>
<div style={{...S.head,fontSize:9,marginBottom:4}}>ICON</div>
<div style={{display:'flex',flexWrap:'wrap',gap:4,marginBottom:6}}>
{FOLDER_ICONS.map(ic=>(
<button key={ic} onClick={()=>setForm(p=>({...p,icon:ic}))} style={{
width:32,height:32,borderRadius:7,fontSize:16,cursor:'pointer',border:'none',
background:form.icon===ic?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
outline:form.icon===ic?'1px solid #4ecdc4':'none'}}>{ic}</button>
))}
</div>
</div>
<input value={form.name} onChange={e=>setForm(p=>({...p,name:e.target.value}))} placeholder="Ordner-Name *"
style={{...S.inp,marginBottom:12}}/>
<div style={{display:'flex',gap:8}}>
<button onClick={save} disabled={busy} style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0',opacity:busy?0.5:1}}>
{busy?'…':'✓ Speichern'}
</button>
<button onClick={onCancel} style={{...S.btn('#ff6b9d',true),padding:'0 16px'}}>Abbrechen</button>
</div>
</div>
);
}
// ── Main Component ────────────────────────────────────────────────────────────
export default function Linkliste({ toast, mobile }) {
const [own, setOwn] = useState([]);
const [folders, setFolders] = useState([]);
const [shared, setShared] = useState([]);
const [byMe, setByMe] = useState([]);
const [sharedFolders, setSharedFolders] = useState([]);
const [sharedFoldersByMe, setSharedFoldersByMe] = useState([]);
const [tab, setTab] = useState('own');
const [showForm, setShowForm] = useState(false);
const [showFolderForm, setShowFolderForm] = useState(false);
const [showToolPicker, setShowToolPicker] = useState(false);
const [editItem, setEditItem] = useState(null);
const [editFolder,setEditFolder]= useState(null);
const [shareItem, setShareItem] = useState(null);
const [shareFolderItem, setShareFolderItem] = useState(null);
const [search, setSearch] = useState('');
const [expanded, setExpanded] = useState({}); // folder_id → bool
const [sortMode, setSortMode] = useState(false);
const dragRef = useRef(null);
const load = () => api('/tools/linkliste')
.then(d=>{ setOwn(d.own||[]); setFolders(d.folders||[]); setShared(d.shared||[]); setByMe(d.sharedByMe||[]); setSharedFolders(d.sharedFolders||[]); setSharedFoldersByMe(d.sharedFoldersByMe||[]); })
.catch(()=>{});
useEffect(()=>{ load(); },[]);
// ── CRUD Links ──────────────────────────────────────────────────────────────
const addLink = async form => {
const r = await api('/tools/linkliste',{body:form});
setOwn(p=>[...p,r]); setShowForm(false);
};
const updateLink = async form => {
const r = await api(`/tools/linkliste/${editItem.id}`,{method:'PUT',body:form});
setOwn(p=>p.map(l=>l.id===r.id?r:l)); setEditItem(null);
};
const deleteLink = async id => {
await api(`/tools/linkliste/${id}`,{method:'DELETE'});
setOwn(p=>p.filter(l=>l.id!==id)); toast('Gelöscht');
};
const toggleQA = async link => {
const val = link.in_quickaccess ? 0 : 1;
const r = await api(`/tools/linkliste/${link.id}`,{method:'PUT',body:{in_quickaccess:val}});
setOwn(p=>p.map(l=>l.id===r.id?r:l));
toast(val ? `${link.title}" zum Schnellzugriff hinzugefügt ✓` : `${link.title}" aus Schnellzugriff entfernt`);
};
// ── CRUD Folders ────────────────────────────────────────────────────────────
const addFolder = async form => {
const r = await api('/tools/linkliste/folders',{body:form});
setFolders(p=>[...p,r]); setShowFolderForm(false);
};
const updateFolder = async form => {
const r = await api(`/tools/linkliste/folders/${editFolder.id}`,{method:'PUT',body:form});
setFolders(p=>p.map(f=>f.id===r.id?r:f)); setEditFolder(null);
};
const deleteFolder = async id => {
await api(`/tools/linkliste/folders/${id}`,{method:'DELETE'});
setFolders(p=>p.filter(f=>f.id!==id));
// Links aus Ordner herauslösen (Backend macht das, Frontend nachführen)
setOwn(p=>p.map(l=>l.folder_id===id?{...l,folder_id:null}:l));
toast('Ordner gelöscht');
};
const toggleFolderQA = async folder => {
const val = folder.in_quickaccess ? 0 : 1;
const r = await api(`/tools/linkliste/folders/${folder.id}`,{method:'PUT',body:{in_quickaccess:val}});
setFolders(p=>p.map(f=>f.id===r.id?r:f));
toast(val ? `Ordner „${folder.name}" zum Schnellzugriff hinzugefügt ✓` : `Ordner „${folder.name}" aus Schnellzugriff entfernt`);
};
// ── Sortierung ──────────────────────────────────────────────────────────────
const moveLink = (id, dir) => {
setOwn(prev => {
const arr = [...prev];
const idx = arr.findIndex(l=>l.id===id);
const nxt = idx+dir;
if (nxt<0||nxt>=arr.length) return prev;
[arr[idx],arr[nxt]] = [arr[nxt],arr[idx]];
// Persist
api('/tools/linkliste/sort',{method:'PUT',body:{links:arr.map(l=>l.id)}}).catch(()=>{});
return arr;
});
};
const moveFolder = (id, dir) => {
setFolders(prev => {
const arr = [...prev];
const idx = arr.findIndex(f=>f.id===id);
const nxt = idx+dir;
if (nxt<0||nxt>=arr.length) return prev;
[arr[idx],arr[nxt]] = [arr[nxt],arr[idx]];
api('/tools/linkliste/sort',{method:'PUT',body:{folders:arr.map(f=>f.id)}}).catch(()=>{});
return arr;
});
};
// ── Suche ───────────────────────────────────────────────────────────────────
const q = search.trim().toLowerCase();
const matchLink = l =>
l.title.toLowerCase().includes(q) ||
l.url.toLowerCase().includes(q) ||
(l.description||'').toLowerCase().includes(q);
const matchFolder = f => f.name.toLowerCase().includes(q);
// Für "Meine" Tab: flache Liste aller Links die passen + Ordner die passen
const filteredOwn = q ? own.filter(matchLink) : own;
const filteredFolders = q ? folders.filter(f => matchFolder(f) || own.some(l=>l.folder_id===f.id&&matchLink(l))) : folders;
const filteredShared = shared.filter(l=>!q||matchLink(l));
const filteredByMe = byMe.filter(l=>!q||matchLink(l));
// ── Drag für Sortierung ─────────────────────────────────────────────────────
const onDragStart = (e,id) => { dragRef.current = id; e.dataTransfer.effectAllowed='move'; };
const onDragOver = (e,id,type) => {
e.preventDefault();
if (dragRef.current===id) return;
if (type==='link') {
const arr=[...own]; const from=arr.findIndex(l=>l.id===dragRef.current); const to=arr.findIndex(l=>l.id===id);
if (from<0||to<0) return;
arr.splice(to,0,arr.splice(from,1)[0]); setOwn(arr);
}
if (type==='folder') {
const arr=[...folders]; const from=arr.findIndex(f=>f.id===dragRef.current); const to=arr.findIndex(f=>f.id===id);
if (from<0||to<0) return;
arr.splice(to,0,arr.splice(from,1)[0]); setFolders(arr);
}
};
const onDrop = (type) => {
if (type==='link') api('/tools/linkliste/sort',{method:'PUT',body:{links:own.map(l=>l.id)}}).catch(()=>{});
if (type==='folder') api('/tools/linkliste/sort',{method:'PUT',body:{folders:folders.map(f=>f.id)}}).catch(()=>{});
dragRef.current=null;
};
// ── Render ──────────────────────────────────────────────────────────────────
const QABadge = ({active}) => (
<span style={{
display:'inline-block', width:7, height:7, borderRadius:'50%',
background:active?'#4ecdc4':'rgba(255,255,255,0.15)',
boxShadow:active?'0 0 5px #4ecdc4':'none',
marginRight:3, flexShrink:0,
}}/>
);
function LinkCard({ link, isShared=false, isByMe=false, inSort=false }) {
const [imgOk, setImgOk] = useState(true);
const favicon = (link.icon==='🔗'||!link.icon) ? extIcon(link.url) : null;
const showFavicon = !!favicon && imgOk;
return (
<div style={{...S.card,marginBottom:6,padding:'10px 12px'}}
draggable={sortMode&&!isShared&&!isByMe}
onDragStart={sortMode?e=>onDragStart(e,link.id):undefined}
onDragOver={sortMode?e=>onDragOver(e,link.id,'link'):undefined}
onDrop={sortMode?()=>onDrop('link'):undefined}>
<div style={{display:'flex',alignItems:'center',gap:10}}>
{/* Sort handle */}
{sortMode && !isShared && !isByMe && (
<span style={{color:'rgba(255,255,255,0.2)',fontSize:14,cursor:'grab',flexShrink:0}}></span>
)}
{/* Icon */}
<div style={{width:32,height:32,borderRadius:8,flexShrink:0,overflow:'hidden',
background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.08)',
display:'flex',alignItems:'center',justifyContent:'center',fontSize:16}}>
{showFavicon
? <img src={favicon} onError={()=>setImgOk(false)} style={{width:18,height:18,objectFit:'contain'}} alt=""/>
: <span>{link.icon||'🔗'}</span>}
</div>
{/* Info */}
<div style={{flex:1,minWidth:0}}>
<a href={link.url} target="_blank" rel="noopener noreferrer"
style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700,
textDecoration:'none',display:'block',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>
{link.title}
</a>
<div style={{color:'rgba(78,205,196,0.55)',fontFamily:'monospace',fontSize:10,
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',marginTop:1}}>
{link.url}
</div>
{link.description&&<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginTop:1}}>{link.description}</div>}
{isShared&&<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,marginTop:1}}>von {link.owner_name}</div>}
{isByMe&&<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,marginTop:1}}> {link.shared_with_name}</div>}
</div>
{/* Sort buttons */}
{sortMode && !isShared && !isByMe && (
<div style={{display:'flex',flexDirection:'column',gap:2,flexShrink:0}}>
<button onClick={()=>moveLink(link.id,-1)} style={{background:'rgba(255,255,255,0.05)',border:'none',
borderRadius:4,color:'rgba(255,255,255,0.5)',cursor:'pointer',padding:'1px 7px',fontSize:11}}></button>
<button onClick={()=>moveLink(link.id,+1)} style={{background:'rgba(255,255,255,0.05)',border:'none',
borderRadius:4,color:'rgba(255,255,255,0.5)',cursor:'pointer',padding:'1px 7px',fontSize:11}}></button>
</div>
)}
</div>
{/* Aktionen */}
{!sortMode && (
<div style={{display:'flex',gap:5,marginTop:8,flexWrap:'wrap'}}>
{!isShared && !isByMe && (
<button onClick={()=>toggleQA(link)} style={{
background: link.in_quickaccess?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.04)',
border: `1px solid ${link.in_quickaccess?'#4ecdc4':'rgba(255,255,255,0.15)'}`,
borderRadius:6,color:link.in_quickaccess?'#4ecdc4':'rgba(255,255,255,0.5)',
cursor:'pointer',fontFamily:'monospace',fontSize:10,padding:'4px 10px',
display:'flex',alignItems:'center',gap:4,whiteSpace:'nowrap'}}>
<QABadge active={!!link.in_quickaccess}/> Schnellzugriff
</button>
)}
{!isShared && !isByMe && (
<>
<button onClick={()=>setShareItem(link)} style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 10px'}}>
🤝{link.share_count>0?` (${link.share_count})`:''}
</button>
<button onClick={()=>setEditItem(link)} style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'4px 10px'}}></button>
<button onClick={()=>deleteLink(link.id)} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 10px'}}></button>
</>
)}
</div>
)}
</div>
);
}
function FolderSection({ folder }) {
const isOpen = !!expanded[folder.id];
const folderLinks = filteredOwn.filter(l=>l.folder_id===folder.id);
return (
<div style={{marginBottom:8}}>
<div style={{...S.card,padding:'10px 12px',marginBottom:isOpen?0:0,
borderRadius:isOpen?'10px 10px 0 0':'10px',borderBottom:isOpen?'1px solid rgba(255,255,255,0.06)':undefined}}
draggable={sortMode}
onDragStart={sortMode?e=>onDragStart(e,folder.id):undefined}
onDragOver={sortMode?e=>onDragOver(e,folder.id,'folder'):undefined}
onDrop={sortMode?()=>onDrop('folder'):undefined}>
<div style={{display:'flex',alignItems:'center',gap:10}}>
{sortMode && <span style={{color:'rgba(255,255,255,0.2)',fontSize:14,cursor:'grab',flexShrink:0}}></span>}
<button onClick={()=>setExpanded(p=>({...p,[folder.id]:!isOpen}))}
style={{background:'none',border:'none',cursor:'pointer',color:'rgba(255,255,255,0.4)',
fontSize:12,padding:0,flexShrink:0,width:16}}>
{isOpen?'▾':'▸'}
</button>
<span style={{fontSize:18,flexShrink:0}}>{folder.icon}</span>
<div style={{flex:1,minWidth:0}}>
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}>{folder.name}</span>
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,marginLeft:8}}>
{folderLinks.length} Link{folderLinks.length!==1?'s':''}
</span>
</div>
{sortMode && (
<div style={{display:'flex',flexDirection:'column',gap:2,flexShrink:0}}>
<button onClick={()=>moveFolder(folder.id,-1)} style={{background:'rgba(255,255,255,0.05)',border:'none',
borderRadius:4,color:'rgba(255,255,255,0.5)',cursor:'pointer',padding:'1px 7px',fontSize:11}}></button>
<button onClick={()=>moveFolder(folder.id,+1)} style={{background:'rgba(255,255,255,0.05)',border:'none',
borderRadius:4,color:'rgba(255,255,255,0.5)',cursor:'pointer',padding:'1px 7px',fontSize:11}}></button>
</div>
)}
{!sortMode && (
<div style={{display:'flex',gap:5,flexShrink:0}}>
<button onClick={()=>toggleFolderQA(folder)} style={{
background: folder.in_quickaccess?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.04)',
border: `1px solid ${folder.in_quickaccess?'#4ecdc4':'rgba(255,255,255,0.15)'}`,
borderRadius:6,color:folder.in_quickaccess?'#4ecdc4':'rgba(255,255,255,0.5)',
cursor:'pointer',fontFamily:'monospace',fontSize:10,padding:'4px 10px',
display:'flex',alignItems:'center',gap:4,whiteSpace:'nowrap'}}>
<QABadge active={!!folder.in_quickaccess}/> Schnellzugriff
</button>
<button onClick={()=>setEditFolder(folder)} style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'4px 10px'}}></button>
<button onClick={()=>setShareFolderItem(folder)} style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 10px'}}>🤝</button>
<button onClick={()=>deleteFolder(folder.id)} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 10px'}}></button>
</div>
)}
</div>
</div>
{isOpen && (
<div style={{background:'rgba(255,255,255,0.02)',border:'1px solid rgba(255,255,255,0.07)',
borderTop:'none',borderRadius:'0 0 10px 10px',padding:'8px 8px 4px'}}>
{folderLinks.length===0
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,padding:'8px 4px',textAlign:'center'}}>
Ordner ist leer bearbeite einen Link und wähle diesen Ordner
</div>
: folderLinks.map(link=>(
<LinkCard key={link.id} link={link}/>
))
}
{!sortMode && (
<button onClick={()=>{ setShowForm(true); setShowFolderForm(false); setEditItem({folder_id:folder.id}); }}
style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'5px 12px',width:'100%',textAlign:'center',marginBottom:4}}>
+ Link in diesem Ordner hinzufügen
</button>
)}
</div>
)}
</div>
);
}
const unfoldered = filteredOwn.filter(l=>!l.folder_id);
return (
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:760}}>
{shareItem && <ShareModal link={shareItem} onClose={()=>{setShareItem(null);load();}} toast={toast}/>}
{shareFolderItem && <FolderShareModal folder={shareFolderItem} onClose={()=>{setShareFolderItem(null);load();}} toast={toast}/>}
{/* Header */}
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:14,gap:8,flexWrap:'wrap'}}>
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?17:22,margin:0}}>Linkliste</h1>
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
<button onClick={load} style={{background:'transparent',border:'1px solid rgba(255,255,255,0.1)',
borderRadius:8,color:'rgba(255,255,255,0.4)',cursor:'pointer',padding:'6px 10px',fontSize:14,fontFamily:'monospace'}}></button>
{tab==='own' && (
<>
<button onClick={()=>setSortMode(p=>!p)} style={{
background: sortMode?'rgba(255,230,109,0.15)':'rgba(255,255,255,0.04)',
border: `1px solid ${sortMode?'#ffe66d':'rgba(255,255,255,0.15)'}`,
borderRadius:6,color:sortMode?'#ffe66d':'rgba(255,255,255,0.6)',
cursor:'pointer',fontFamily:'monospace',fontSize:11,padding:'6px 12px',whiteSpace:'nowrap'}}>
{sortMode?'✓ Fertig':'⇅ Sortieren'}
</button>
{!showFolderForm && !editFolder && !sortMode && (
<button onClick={()=>{setShowFolderForm(true);setShowForm(false);}}
style={{...S.btn('#ffe66d',true),fontSize:11,padding:'6px 12px'}}>+ Ordner</button>
)}
{!showForm && !editItem && !sortMode && (
<button onClick={()=>{setShowToolPicker(true);setShowForm(false);setShowFolderForm(false);}}
style={{...S.btn('#a78bfa',true),fontSize:11,padding:'6px 12px'}}>+ App</button>
)}
{!showForm && !editItem && !sortMode && (
<button onClick={()=>{setShowForm(true);setShowFolderForm(false);setEditItem(null);}}
style={{...S.btn('#4ecdc4'),padding:'7px 16px',fontSize:12}}>+ Link</button>
)}
</>
)}
</div>
</div>
{/* Formulare */}
{showToolPicker && <ToolPicker folders={folders} onSave={addLink} onClose={()=>setShowToolPicker(false)} toast={toast}/>}
{showFolderForm && !editFolder && <FolderForm onSave={addFolder} onCancel={()=>setShowFolderForm(false)} toast={toast}/>}
{editFolder && <FolderForm initial={editFolder} onSave={updateFolder} onCancel={()=>setEditFolder(null)} toast={toast}/>}
{showForm && !editItem && <LinkForm folders={folders} onSave={addLink} onCancel={()=>setShowForm(false)} toast={toast}/>}
{editItem && <LinkForm initial={editItem} folders={folders} onSave={updateLink} onCancel={()=>setEditItem(null)} toast={toast}/>}
{/* Tabs */}
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
{[['own','Meine',own.length+folders.length],['shared','Geteilt mit mir',shared.length],['byMe','Geteilt von mir',byMe.length]].map(([k,l,cnt])=>(
<button key={k} onClick={()=>{setTab(k);setSearch('');setSortMode(false);}} style={{
padding:'6px 14px',borderRadius:20,fontFamily:'monospace',fontSize:11,cursor:'pointer',
background:tab===k?'#4ecdc4':'rgba(255,255,255,0.05)',
color:tab===k?'#0d0d0f':'rgba(255,255,255,0.55)',
border:tab===k?'none':'1px solid rgba(255,255,255,0.1)',
fontWeight:tab===k?700:400,
}}>{l}{cnt>0&&<span style={{marginLeft:4,opacity:0.7}}>({cnt})</span>}</button>
))}
</div>
{/* Suche */}
<div style={{position:'relative',marginBottom:12}}>
<span style={{position:'absolute',left:12,top:'50%',transform:'translateY(-50%)',color:'rgba(255,255,255,0.5)',pointerEvents:'none'}}></span>
<input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Suchen in Links, Ordnern, URLs…"
style={{...S.inp,paddingLeft:34}}/>
{search&&<button onClick={()=>setSearch('')} style={{position:'absolute',right:10,top:'50%',transform:'translateY(-50%)',
background:'transparent',border:'none',color:'rgba(255,255,255,0.5)',cursor:'pointer',fontSize:16}}></button>}
</div>
{/* Inhalt */}
{tab==='own' && (
<div>
{/* Ordner */}
{filteredFolders.map(folder=><FolderSection key={folder.id} folder={folder}/>)}
{/* Links ohne Ordner */}
{unfoldered.length>0 && filteredFolders.length>0 && (
<div style={{...S.head,fontSize:9,margin:'12px 0 6px',color:'rgba(255,255,255,0.3)'}}>OHNE ORDNER</div>
)}
{unfoldered.map(link=><LinkCard key={link.id} link={link}/>)}
{filteredFolders.length===0 && unfoldered.length===0 && (
<div style={{...S.card,textAlign:'center',padding:40}}>
<div style={{fontSize:28,marginBottom:10}}>🔗</div>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>
{search?'Keine Treffer.':'Noch keine Links füge deinen ersten Link oder Ordner hinzu.'}
</div>
</div>
)}
</div>
)}
{tab==='shared' && (
<div>
{/* Geteilte Ordner */}
{sharedFolders.map(folder=>(
<div key={`sf-${folder.id}`} style={{marginBottom:8}}>
<div style={{...S.card,padding:'10px 12px',borderRadius:'10px 10px 0 0',
borderBottom:'1px solid rgba(255,255,255,0.06)'}}>
<div style={{display:'flex',alignItems:'center',gap:10}}>
<span style={{fontSize:18}}>{folder.icon}</span>
<div style={{flex:1}}>
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}>{folder.name}</span>
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,marginLeft:8}}>von {folder.owner_name}</span>
</div>
</div>
</div>
<div style={{background:'rgba(255,255,255,0.02)',border:'1px solid rgba(255,255,255,0.07)',
borderTop:'none',borderRadius:'0 0 10px 10px',padding:'8px 8px 4px'}}>
{(folder.links||[]).map(link=><LinkCard key={link.id} link={link} isShared/>)}
{(!folder.links||folder.links.length===0) && <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,padding:'8px',textAlign:'center'}}>Ordner ist leer</div>}
</div>
</div>
))}
{/* Einzelne Links */}
{filteredShared.map(link=><LinkCard key={link.id} link={link} isShared/>)}
{sharedFolders.length===0&&filteredShared.length===0 && (
<div style={{...S.card,textAlign:'center',padding:40}}><div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>Nichts geteilt.</div></div>
)}
</div>
)}
{tab==='byMe' && (
filteredByMe.length===0
? <div style={{...S.card,textAlign:'center',padding:40}}><div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>Noch nichts geteilt.</div></div>
: filteredByMe.map(link=><LinkCard key={link.id} link={link} isByMe/>)
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,650 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { api, S } from '../lib.js';
import { UserIcon, TrashIcon } from '../icons.jsx';
import {
getOrCreateKeyPair,
getPublicKeyJwk,
getFingerprint,
deriveSharedKey,
encryptMsg,
decryptMsg,
} from '../crypto.js';
// ── Emoji-Konvertierung ───────────────────────────────────────────────────────
const EMOJI_MAP = {
':D':'😁', ':)':'🙂', ':-)':'🙂', '(:':'🙂', ';)':'😉', ';-)':'😉',
':P':'😛', ':-P':'😛', ':p':'😛', ':O':'😮', ':-O':'😮', ':o':'😮',
':(':'😢', ':-(':'😢', ":'(":'😭', '>:(':"😠", '>:-(':"😠",
':*':'😘', ':-*':'😘', '<3':'❤️', '</3':'💔',
'xD':'😆', 'XD':'😆', 'x)':"😄",
':/':"😕", ':-/':"😕", ':|':"😐", ':-|':"😐",
'B)':"😎", 'B-)':"😎", '^_^':"😊", '-_-':"😑",
'O:)':"😇", 'O:-)':"😇", '>:)':"😈",
':3':"🐱", ':$':"😳",
};
// Ersetze Kürzel am Ende des Textes (nach Leerzeichen oder Satzanfang)
function convertEmojis(text) {
// Letztes "Wort" vor dem Leerzeichen/Newline prüfen
return text.replace(/(\S+)(\s)$/, (_, word, space) => {
return (EMOJI_MAP[word] ?? word) + space;
});
}
// ── Emoji-Picker Daten ────────────────────────────────────────────────────────
const EMOJI_GROUPS = [
{ label:'Smileys', emojis:['😀','😁','😂','🤣','😊','😇','🙂','😉','😌','😍','🥰','😘','😎','🤩','😏','😒','😞','😢','😭','😤','😠','😡','🤬','😈','👿','💀','😱','😨','😰','😓','🤔','🤐','😶','😐','😑','😬','🙄','😴','🤧','🤒','😷','🤕'] },
{ label:'Gesten', emojis:['👍','👎','👌','✌️','🤞','🤟','🤙','👋','🤚','🖐','✋','🤜','🤛','👏','🙌','🤝','🙏','💪','🫶','❤️','🧡','💛','💚','💙','💜','🖤','💔','💯','💥','✨','🎉','🔥','⭐','🌟'] },
{ label:'Dinge', emojis:['😂💀','🎮','🏆','🎯','🎲','🃏','🎸','🎵','🎤','📱','💻','⌨️','🖱️','📷','🎬','📺','🚀','🛸','🌍','🌈','☀️','🌙','⭐','🍕','🍔','🍟','🌮','🍩','🍺','☕','🧃'] },
];
function EmojiPicker({ onSelect, onClose }) {
const [group, setGroup] = useState(0);
return (
<div style={{ position:'absolute', bottom:'100%', right:0, marginBottom:6, zIndex:100,
background:'#1a1a1e', border:'1px solid rgba(255,255,255,0.12)', borderRadius:12,
width:272, boxShadow:'0 8px 32px rgba(0,0,0,0.5)' }}
onMouseDown={e => e.preventDefault()}>
<div style={{ display:'flex', borderBottom:'1px solid rgba(255,255,255,0.07)', padding:'6px 8px', gap:4 }}>
{EMOJI_GROUPS.map((g,i) => (
<button key={i} onClick={() => setGroup(i)} style={{ flex:1, background: group===i ?
'rgba(78,205,196,0.15)' : 'transparent', border:'none', borderRadius:7,
color: group===i ? '#4ecdc4' : 'rgba(255,255,255,0.4)', cursor:'pointer',
fontSize:9, fontFamily:'monospace', padding:'4px 2px' }}>{g.label}</button>
))}
</div>
<div style={{ display:'flex', flexWrap:'wrap', padding:8, gap:2, maxHeight:180, overflowY:'auto' }}>
{EMOJI_GROUPS[group].emojis.filter(e => e.length <= 2 || [...e].length <= 2).concat(
EMOJI_GROUPS[group].emojis.filter(e => [...e].length > 2)
).map((em,i) => (
<button key={i} onClick={() => onSelect(em)} style={{ background:'transparent', border:'none',
borderRadius:6, cursor:'pointer', fontSize:20, padding:'4px 5px',
transition:'background 0.1s' }}
onMouseEnter={e => e.target.style.background='rgba(255,255,255,0.08)'}
onMouseLeave={e => e.target.style.background='transparent'}>
{em}
</button>
))}
</div>
</div>
);
}
export default function Nachrichten({ toast, mobile, nav }) {
const [kp, setKp] = useState(null);
const [initError, setInitError] = useState(false);
const [users, setUsers] = useState([]);
const [activeUser, setActiveUser] = useState(null);
const [messages, setMessages] = useState([]);
const [sharedKey, setSharedKey] = useState(null); // null | CryptoKey | 'error'
const [decrypted, setDecrypted] = useState({});
const [partnerFp, setPartnerFp] = useState('');
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const [showPicker, setShowPicker] = useState(false);
const [mirc, setMirc] = useState(false);
// Preference vom Server laden
useEffect(() => {
api('/auth/preferences').then(p => { if (p.chat_design === 'mirc') setMirc(true); }).catch(()=>{});
}, []);
const toggleDesign = () => setMirc(v => {
const next = !v;
api('/auth/preferences', { method:'PUT', body:{ chat_design: next ? 'mirc' : 'modern' } }).catch(()=>{});
return next;
});
const bottomRef = useRef(null);
const pollRef = useRef(null);
const pickerRef = useRef(null);
const heartbeatRef = useRef(null);
// Presence Heartbeat: solange Nachrichten offen + Tab sichtbar → aktiv
useEffect(() => {
const sendPresence = (active) => api('/tools/nachrichten/presence', { method:'POST', body:{ active } }).catch(()=>{});
const start = () => {
if (document.visibilityState === 'visible') {
sendPresence(true);
heartbeatRef.current = setInterval(() => {
if (document.visibilityState === 'visible') sendPresence(true);
}, 30000);
}
};
const onVisibility = () => {
if (document.visibilityState === 'visible') {
sendPresence(true);
if (!heartbeatRef.current) heartbeatRef.current = setInterval(() => sendPresence(true), 30000);
} else {
sendPresence(false);
clearInterval(heartbeatRef.current);
heartbeatRef.current = null;
}
};
start();
document.addEventListener('visibilitychange', onVisibility);
return () => {
sendPresence(false);
clearInterval(heartbeatRef.current);
document.removeEventListener('visibilitychange', onVisibility);
};
}, []);
useEffect(() => {
if (!showPicker) return;
const handler = e => {
if (pickerRef.current && !pickerRef.current.contains(e.target)) setShowPicker(false);
};
document.addEventListener('mousedown', handler);
document.addEventListener('touchstart', handler);
return () => { document.removeEventListener('mousedown', handler); document.removeEventListener('touchstart', handler); };
}, [showPicker]);
// ── Initialisierung ──────────────────────────────────────────────────────────
useEffect(() => {
getOrCreateKeyPair()
.then(async k => {
setKp(k);
try {
const jwk = await getPublicKeyJwk(k);
await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(jwk) } });
} catch {}
loadUsers();
})
.catch(() => setInitError(true));
}, []);
// ── Polling ──────────────────────────────────────────────────────────────────
useEffect(() => {
clearInterval(pollRef.current);
if (!activeUser) return;
loadMessages(activeUser.id);
pollRef.current = setInterval(() => loadMessages(activeUser.id), 10_000);
return () => clearInterval(pollRef.current);
}, [activeUser?.id]);
// ── Shared Key + Partner-Fingerprint ─────────────────────────────────────────
useEffect(() => {
setSharedKey(null);
setDecrypted({});
setPartnerFp('');
if (!kp || !activeUser?.public_key) return;
try {
const pubJwk = JSON.parse(activeUser.public_key);
deriveSharedKey(kp.privateKey, pubJwk)
.then(setSharedKey)
.catch(() => setSharedKey('error'));
// Fingerprint des Partner-Keys berechnen (aus dem auf dem Server gespeicherten Public Key)
crypto.subtle.importKey('jwk', pubJwk, { name:'X25519' }, true, [])
.then(k => getFingerprint({ publicKey: k }))
.then(setPartnerFp)
.catch(() => setPartnerFp('?'));
} catch {
setSharedKey('error');
}
}, [activeUser?.id, kp]);
// ── Entschlüsseln ────────────────────────────────────────────────────────────
useEffect(() => {
if (sharedKey === null || sharedKey === 'error' || !messages.length) {
setDecrypted({});
return;
}
let cancelled = false;
(async () => {
const result = {};
for (const m of messages) {
if (cancelled) return;
try {
result[m.id] = await decryptMsg(sharedKey, m.encrypted_content, m.iv);
} catch {
result[m.id] = null; // null = nicht entschlüsselbar
}
}
if (!cancelled) setDecrypted(result);
})();
return () => { cancelled = true; };
}, [messages, sharedKey]);
// ── Scroll to bottom ─────────────────────────────────────────────────────────
const prevLengthRef = useRef(0);
const prevUserRef = useRef(null);
function scrollToBottom(behavior = 'instant') {
bottomRef.current?.scrollIntoView({ behavior });
}
useEffect(() => {
if (!bottomRef.current) return;
const userChanged = prevUserRef.current !== activeUser?.id;
const isFirstLoad = prevLengthRef.current === 0 && messages.length > 0;
prevLengthRef.current = messages.length;
prevUserRef.current = activeUser?.id;
if (userChanged || isFirstLoad) {
scrollToBottom('instant');
} else if (messages.length > 0) {
scrollToBottom('smooth');
}
}, [messages.length, activeUser?.id]);
// Wenn Handy-Tastatur aufklappt → ans Ende scrollen NUR wenn schon am Ende war
useEffect(() => {
if (!window.visualViewport) return;
const onResize = () => {
// Prüfe ob bottomRef nah am sichtbaren Bereich war (= User war am Ende)
if (!bottomRef.current) return;
const rect = bottomRef.current.getBoundingClientRect();
const wasAtBottom = rect.top <= (window.visualViewport?.height ?? window.innerHeight) + 100;
if (wasAtBottom) {
setTimeout(() => scrollToBottom('instant'), 50);
}
};
window.visualViewport.addEventListener('resize', onResize);
return () => window.visualViewport.removeEventListener('resize', onResize);
}, []);
// ── Handlers ─────────────────────────────────────────────────────────────────
const loadUsers = async () => {
try { const u = await api('/tools/nachrichten/users'); setUsers(u);
// Nav aus Suche: direkt zu User springen
if (nav?.userId) {
const target = u.find(x => x.id === nav.userId);
if (target) setActiveUser(target);
}
} catch {}
};
const loadMessages = async userId => {
try {
setMessages(await api(`/tools/nachrichten/messages/${userId}`));
await loadUsers();
} catch {}
};
const selectUser = u => {
setActiveUser(u);
setMessages([]);
setDecrypted({});
prevLengthRef.current = 0;
};
const send = async (overrideText) => {
const msg = (overrideText ?? text).trim();
if (!msg || !kp || !activeUser) return;
if (!activeUser.public_key) { toast('Empfänger hat noch keinen Schlüssel', 'error'); return; }
if (sharedKey === 'error') { toast('Schlüssel inkompatibel Empfänger muss Schlüssel neu generieren', 'error'); return; }
if (!sharedKey) { toast('Schlüssel wird vorbereitet…', 'error'); return; }
setSending(true);
try {
const { encrypted_content, iv } = await encryptMsg(sharedKey, msg);
await api(`/tools/nachrichten/messages/${activeUser.id}`, { body: { encrypted_content, iv } });
setText('');
await loadMessages(activeUser.id);
} catch(e) { toast(e.message, 'error'); }
setSending(false);
};
const deleteMsg = async id => {
try {
await api(`/tools/nachrichten/messages/${id}`, { method:'DELETE' });
setMessages(p => p.filter(m => m.id !== id));
} catch(e) { toast(e.message, 'error'); }
};
const deleteConversation = async () => {
if (!window.confirm(`Gesamten Verlauf mit ${activeUser.username} löschen?`)) return;
try {
await api(`/tools/nachrichten/conversation/${activeUser.id}`, { method:'DELETE' });
setMessages([]);
await loadUsers();
} catch(e) { toast(e.message, 'error'); }
};
// ── Layout ───────────────────────────────────────────────────────────────────
const showList = !mobile || !activeUser;
const showChat = !mobile || !!activeUser;
if (initError) return (
<div style={{ padding:32, color:'rgba(255,255,255,0.45)', fontFamily:'monospace', fontSize:12, lineHeight:2 }}>
Verschlüsselung konnte nicht initialisiert werden.<br/>
Dein Browser unterstützt X25519 möglicherweise nicht (erfordert Chrome 113+/Firefox 130+/Safari 17.4+).
</div>
);
const mobileStyle = mobile ? {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 'calc(56px + env(safe-area-inset-bottom, 0px))',
zIndex: 10,
} : {
height: '100vh',
};
return (
<div style={{ display:'flex', overflow:'hidden', background:'#111114', ...mobileStyle }}>
{/* ── Konversationsliste ─────────────────────────────────────────── */}
{showList && (
<div style={{
width: mobile ? '100%' : 230,
borderRight: mobile ? 'none' : '1px solid rgba(255,255,255,0.06)',
display:'flex', flexDirection:'column', background:'#0d0d0f', flexShrink:0,
}}>
<div style={{ padding:'16px 14px 12px', borderBottom:'1px solid rgba(255,255,255,0.06)',
display:'flex', alignItems:'center', justifyContent:'space-between' }}>
<div style={{ ...S.head, marginBottom:0 }}>NACHRICHTEN</div>
<div style={{ display:'flex', gap:5 }}>
<button onClick={toggleDesign} title={mirc ? 'Zu Modern wechseln' : 'Zu mIRC wechseln'}
style={{ background: 'rgba(255,255,255,0.05)',
border:'1px solid rgba(255,255,255,0.1)',
borderRadius:7, color:'rgba(255,255,255,0.55)',
cursor:'pointer', padding:'3px 8px', fontSize:10, fontFamily:'monospace', lineHeight:1 }}>
{mirc ? '◈ mIRC' : '◈ Modern'}
</button>
<button onClick={() => { loadUsers(); if (activeUser) loadMessages(activeUser.id); }} title="Neu laden"
style={{ background:'transparent', border:'1px solid rgba(255,255,255,0.1)', borderRadius:7,
color:'rgba(255,255,255,0.35)', cursor:'pointer', padding:'3px 8px',
fontSize:13, fontFamily:'monospace', lineHeight:1 }}></button>
</div>
</div>
<div style={{ flex:1, overflowY:'auto' }}>
{users.length === 0 ? (
<div style={{ color:'rgba(255,255,255,0.28)', fontSize:11, fontFamily:'monospace',
padding:18, textAlign:'center', lineHeight:1.8 }}>
Keine anderen Benutzer vorhanden
</div>
) : users.map(u => (
<button key={u.id} onClick={() => selectUser(u)} style={{
width:'100%', padding:'11px 14px', border:'none', textAlign:'left', cursor:'pointer',
background: activeUser?.id === u.id ? 'rgba(78,205,196,0.07)' : 'transparent',
borderLeft: `2px solid ${activeUser?.id === u.id ? '#4ecdc4' : 'transparent'}`,
display:'flex', alignItems:'center', gap:10,
}}>
<div style={{ width:32, height:32, borderRadius:'50%', flexShrink:0,
background:'linear-gradient(135deg,rgba(255,107,157,0.2),rgba(78,205,196,0.2))',
border:'1px solid rgba(255,255,255,0.07)',
display:'flex', alignItems:'center', justifyContent:'center' }}>
<UserIcon size={14} color="rgba(255,255,255,0.5)"/>
</div>
<div style={{ flex:1, minWidth:0 }}>
<div style={{
color: u.unread > 0 ? '#fff' : 'rgba(255,255,255,0.65)',
fontWeight: u.unread > 0 ? 700 : 400,
fontSize:12, fontFamily:'monospace',
whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis',
}}>{u.username}</div>
{u.last_message_at && (
<div style={{ color:'rgba(255,255,255,0.26)', fontSize:9, fontFamily:'monospace', marginTop:2 }}>
{new Date(u.last_message_at).toLocaleDateString('de-DE',
{ day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}
</div>
)}
</div>
{u.unread > 0 && (
<span style={{ background:'#4ecdc4', color:'#000', borderRadius:10,
fontSize:9, fontFamily:'monospace', padding:'2px 7px', fontWeight:700, flexShrink:0 }}>
{u.unread}
</span>
)}
</button>
))}
</div>
</div>
)}
{/* ── Chat ──────────────────────────────────────────────────────── */}
{showChat && (
<div style={{ flex:1, minHeight:0, display:'flex', flexDirection:'column', overflow:'hidden', minWidth:0 }}>
{!activeUser ? (
<div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column', gap:10 }}>
<div style={{ fontSize:38, opacity:0.1 }}></div>
<div style={{ color:'rgba(255,255,255,0.18)', fontFamily:'monospace', fontSize:11 }}>
Benutzer auswählen
</div>
</div>
) : (
<>
{/* Header */}
<div style={{ padding:'10px 14px', borderBottom:'1px solid rgba(255,255,255,0.06)',
display:'flex', alignItems:'center', gap:10, background:'#0d0d0f', flexShrink:0 }}>
{mobile && (
<button onClick={() => setActiveUser(null)} style={{ background:'transparent', border:'none',
cursor:'pointer', color:'#4ecdc4', fontFamily:'monospace', fontSize:20, padding:'0 4px', lineHeight:1 }}>
</button>
)}
<div style={{ width:28, height:28, borderRadius:'50%', flexShrink:0,
background:'linear-gradient(135deg,rgba(255,107,157,0.2),rgba(78,205,196,0.2))',
border:'1px solid rgba(255,255,255,0.07)',
display:'flex', alignItems:'center', justifyContent:'center' }}>
<UserIcon size={12} color="rgba(255,255,255,0.5)"/>
</div>
<div style={{ flex:1, minWidth:0 }}>
<div style={{ color:'#fff', fontSize:13, fontFamily:'monospace',
whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>
{activeUser.username}
</div>
{sharedKey === 'error' ? (
<div style={{ color:'#ff6b9d', fontSize:8, fontFamily:'monospace', letterSpacing:1.5, marginTop:1 }}>
SCHLÜSSEL INKOMPATIBEL
</div>
) : partnerFp ? (
<div title={`Fingerprint von ${activeUser.username} vergleiche mit dessen Anzeige unter Einstellungen → Sicherheit`}
style={{ color:'rgba(78,205,196,0.55)', fontSize:8, fontFamily:'monospace', letterSpacing:1, marginTop:2,
cursor:'help', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>
🔒 {partnerFp}
</div>
) : (
<div style={{ color:'rgba(78,205,196,0.35)', fontSize:8, fontFamily:'monospace', letterSpacing:1.5, marginTop:1 }}>
🔒 E2E VERSCHLÜSSELT · X25519 + AES-256-GCM
</div>
)}
</div>
<button onClick={deleteConversation} style={{ ...S.btn('#ff6b9d', true), fontSize:10, flexShrink:0 }}>
Verlauf löschen
</button>
</div>
{/* Nachrichten */}
<div style={{ flex:1, minHeight:0, overflowY:'auto', padding: mirc ? '0' : '14px',
display:'flex', flexDirection:'column', gap: mirc ? 0 : 10,
background: mirc ? '#000' : undefined,
fontFamily: mirc ? "'Courier New', Courier, monospace" : undefined }}>
{!activeUser.public_key && (
<InfoBox color="#ffe66d">
Dieser Benutzer hat die Nachrichten noch nicht geöffnet und besitzt noch kein Schlüsselpaar.
</InfoBox>
)}
{sharedKey === 'error' && (
<InfoBox color="#ff6b9d">
Schlüssel inkompatibel der Empfänger verwendet ein anderes Schlüsselformat.
Er muss in Einstellungen Sicherheit einen neuen Schlüssel generieren.
</InfoBox>
)}
{messages.length === 0 && activeUser.public_key && sharedKey !== 'error' && (
<div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'center',
color: mirc ? '#808080' : 'rgba(255,255,255,0.18)',
fontFamily: mirc ? "'Courier New', monospace" : 'monospace', fontSize:11 }}>
{mirc ? '*** Noch keine Nachrichten ***' : 'Noch keine Nachrichten'}
</div>
)}
{Object.values(decrypted).some(v => v === null) && (
<InfoBox color="#ffe66d">
🔑 Einige Nachrichten können nicht entschlüsselt werden.
Importiere deinen alten Schlüssel unter Einstellungen Sicherheit.
</InfoBox>
)}
{mirc ? (
/* ── mIRC Design ─────────────────────────────────────── */
<div style={{ padding:'4px 0' }}>
{messages.map(m => {
const isMine = !!m.is_mine;
const txt = decrypted[m.id];
const time = m.created_at ? (() => { const d = new Date(m.created_at.replace(' ','T')); return isNaN(d)?'':d.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'}); })() : '';
const nick = isMine ? 'du' : activeUser.username;
const nickColor = isMine ? '#00ff00' : '#00ffff';
return (
<div key={m.id} style={{ display:'flex', alignItems:'baseline', gap:0,
padding:'1px 8px', lineHeight:1.5,
background: isMine ? 'rgba(0,255,0,0.03)' : 'transparent' }}
onMouseEnter={e=>e.currentTarget.style.background='rgba(255,255,255,0.04)'}
onMouseLeave={e=>e.currentTarget.style.background=isMine?'rgba(0,255,0,0.03)':'transparent'}>
<span style={{ color:'#808080', fontSize:11, flexShrink:0, marginRight:2 }}>[{time}]</span>
<span style={{ color:'#808080', fontSize:11, flexShrink:0, marginRight:2 }}>&lt;</span>
<span style={{ color:nickColor, fontSize:11, fontWeight:700, flexShrink:0 }}>{nick}</span>
<span style={{ color:'#808080', fontSize:11, flexShrink:0, marginRight:6 }}>&gt;</span>
<span style={{ color: txt===null?'#404040':'#d0d0d0', fontSize:12, wordBreak:'break-word', flex:1 }}>
{txt===undefined ? '…' : txt===null ? '[verschlüsselt]' : txt}
</span>
{isMine && m.read_by_recipient && <span style={{color:'#004400',fontSize:9,marginLeft:4,flexShrink:0}}></span>}
<button onClick={() => deleteMsg(m.id)} style={{ background:'transparent', border:'none',
cursor:'pointer', color:'#400000', fontSize:9, padding:'0 0 0 6px', flexShrink:0, opacity:0.5 }}></button>
</div>
);
})}
</div>
) : (
/* ── Modernes Design ─────────────────────────────────── */
<>
{messages.map(m => {
const isMine = !!m.is_mine;
const txt = decrypted[m.id];
return (
<div key={m.id} style={{ display:'flex',
justifyContent: isMine ? 'flex-end' : 'flex-start', gap:6, alignItems:'flex-end' }}>
{!isMine && (
<div style={{ width:22, height:22, borderRadius:'50%', flexShrink:0,
background:'rgba(255,255,255,0.04)', border:'1px solid rgba(255,255,255,0.07)',
display:'flex', alignItems:'center', justifyContent:'center', fontSize:10 }}>👤</div>
)}
<div style={{ maxWidth:'75%', display:'flex', flexDirection:'column',
alignItems: isMine ? 'flex-end' : 'flex-start', gap:4 }}>
<div style={{
background: isMine ? 'rgba(78,205,196,0.09)' : 'rgba(255,255,255,0.04)',
border: `1px solid ${isMine ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.07)'}`,
borderRadius: isMine ? '12px 12px 3px 12px' : '12px 12px 12px 3px',
padding:'8px 12px',
}}>
{txt === undefined ? (
<span style={{ color:'rgba(255,255,255,0.18)', fontSize:12 }}></span>
) : txt === null ? (
<span style={{ color:'rgba(255,107,157,0.5)', fontSize:11, fontFamily:'monospace' }}>🔒 Nicht entschlüsselbar</span>
) : (
<span style={{ color:'#e6e6e6', fontSize:13, fontFamily:'monospace',
lineHeight:1.55, whiteSpace:'pre-wrap', wordBreak:'break-word' }}>{txt}</span>
)}
</div>
<div style={{ display:'flex', alignItems:'center', gap:5 }}>
<span style={{ color:'rgba(255,255,255,0.18)', fontSize:9, fontFamily:'monospace', whiteSpace:'nowrap' }}>
{formatMsgTime(m.created_at)}
</span>
{isMine && (
<span title={m.read_by_recipient ? 'Gelesen' : 'Gesendet'}
style={{ fontSize:10, lineHeight:1, color: m.read_by_recipient ? '#4ecdc4' : 'rgba(255,255,255,0.2)' }}>
{m.read_by_recipient ? '✓✓' : '✓'}
</span>
)}
<button onClick={() => deleteMsg(m.id)} style={{ background:'transparent', border:'none',
cursor:'pointer', padding:'0 2px', opacity:0.3, lineHeight:1,
display:'flex', alignItems:'center' }}>
<TrashIcon size={10} color="#ff6b9d"/>
</button>
</div>
</div>
</div>
);
})}
</>
)}
<div ref={bottomRef}/>
</div>
{/* Eingabe */}
<div style={{ padding:'10px 14px', flexShrink:0,
borderTop:`1px solid ${mirc?'#333':'rgba(255,255,255,0.06)'}`,
background: mirc ? '#000' : '#0d0d0f' }}>
{!activeUser.public_key || sharedKey === 'error' ? (
<div style={{ color:'rgba(255,255,255,0.2)', fontSize:11, fontFamily:'monospace',
textAlign:'center', padding:'8px 0' }}>
Senden nicht möglich
</div>
) : (
<div style={{ display:'flex', gap:8, alignItems:'stretch', position:'relative' }}>
<div ref={pickerRef} style={{ position:'absolute', bottom:'100%', right:0 }}>
{showPicker && (
<EmojiPicker
onSelect={em => { setText(t => t + em); setShowPicker(false); }}
onClose={() => setShowPicker(false)}
/>
)}
</div>
{mirc && (
<span style={{ color:'#00ff00', fontFamily:"'Courier New',monospace", fontSize:12,
alignSelf:'center', flexShrink:0 }}>[du]</span>
)}
<textarea
value={text}
onChange={e => {
const val = e.target.value;
const last = val[val.length - 1];
if (last === ' ' || last === '\n') setText(convertEmojis(val));
else setText(val);
}}
onFocus={() => setShowPicker(false)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const converted = convertEmojis(text + ' ').trimEnd();
if (converted !== text) setText(converted);
send(converted);
}
}}
placeholder={mirc ? 'Nachricht…' : 'Nachricht… (Enter senden · Shift+Enter neue Zeile)'}
rows={2}
style={{ ...S.inp, resize:'none', flex:1, fontSize: mirc ? 12 : 13,
padding:'8px 10px', lineHeight:1.45, minHeight:42,
...(mirc ? { background:'#111', border:'1px solid #444', color:'#d0d0d0',
fontFamily:"'Courier New',monospace", borderRadius:2 } : {}) }}
/>
<button onClick={() => setShowPicker(v => !v)} style={{
background: showPicker ? 'rgba(78,205,196,0.12)' : (mirc ? '#111' : 'rgba(255,255,255,0.05)'),
border:`1px solid ${showPicker ? 'rgba(78,205,196,0.3)' : (mirc ? '#444' : 'rgba(255,255,255,0.1)')}`,
borderRadius: mirc ? 2 : 8, cursor:'pointer', fontSize:17,
padding:'0 10px', flexShrink:0, alignSelf:'stretch' }}>😊</button>
</div>
)}
</div>
</>
)}
</div>
)}
</div>
);
}
function InfoBox({ color, children }) {
return (
<div style={{ background:`${color}08`, border:`1px solid ${color}25`,
borderRadius:8, padding:'10px 14px', color, fontSize:11,
fontFamily:'monospace', textAlign:'center', lineHeight:1.7 }}>
{children}
</div>
);
}
function formatMsgTime(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr.replace(' ', 'T'));
if (isNaN(d)) return '';
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
const isThisYear = d.getFullYear() === now.getFullYear();
const time = d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' });
if (isToday) return `Heute, ${time}`;
if (isThisYear) return `${d.toLocaleDateString('de-DE', { day:'numeric', month:'short' })}, ${time}`;
return `${d.toLocaleDateString('de-DE', { day:'numeric', month:'short', year:'numeric' })}, ${time}`;
}

View File

@@ -1,191 +0,0 @@
import { useState, useRef } from 'react';
import { S } from '../lib.js';
const C = {
accent: '#f59e0b',
success: '#4ade80',
error: '#f87171',
muted: '#6b7280',
};
export default function PaywallKiller() {
const [inputUrl, setInputUrl] = useState('');
const [archiveUrl, setArchiveUrl] = useState('');
const [status, setStatus] = useState('idle');
const inputRef = useRef(null);
function getTargetUrl() {
let url = inputUrl.trim();
if (!url.startsWith('http')) url = 'https://' + url;
// Mobile Subdomains auf Desktop normalisieren (m.bild.de → www.bild.de)
// damit archive.ph den richtigen Snapshot findet
try {
const u = new URL(url);
if (u.hostname.startsWith('m.')) {
u.hostname = 'www.' + u.hostname.slice(2);
url = u.toString();
} else if (u.hostname.startsWith('mobile.')) {
u.hostname = 'www.' + u.hostname.slice(7);
url = u.toString();
}
} catch {}
return url;
}
function handleSearch() {
if (!inputUrl.trim()) return;
const targetUrl = getTargetUrl();
// archive.ph Submit (no-cors, fire-and-forget) triggert Archivierung
try {
const formData = new FormData();
formData.append('url', targetUrl);
formData.append('anyway', '1');
fetch('https://archive.ph/submit/', {
method: 'POST',
mode: 'no-cors',
body: formData,
}).catch(() => {});
} catch {}
setArchiveUrl(`https://archive.ph/newest/${targetUrl}`);
setStatus('ready');
}
function handleReset() {
setInputUrl('');
setArchiveUrl('');
setStatus('idle');
setTimeout(() => inputRef.current?.focus(), 50);
}
return (
<div style={{ padding: '20px 16px', maxWidth: 640, margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<span style={{ fontSize: 20 }}>🔓</span>
<span style={{ color: C.accent, fontFamily: 'monospace', fontSize: 13, letterSpacing: 1 }}>PAYWALL-KILLER</span>
</div>
<div style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', marginBottom: 10 }}>archive.ph · Als PDF speichern</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ color: 'rgba(255,255,255,0.25)', fontFamily: 'monospace', fontSize: 9, letterSpacing: 0.5 }}>GETESTET MIT</span>
{[
{ name: 'waz.de', domain: 'waz.de' },
{ name: 'bild.de', domain: 'bild.de' },
{ name: 'heise.de', domain: 'heise.de' },
].map(({ name, domain }) => (
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 7px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 4 }}>
<img src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`} width={12} height={12} style={{ borderRadius: 2, opacity: 0.7 }} alt="" />
<span style={{ color: 'rgba(255,255,255,0.35)', fontFamily: 'monospace', fontSize: 9 }}>{name}</span>
</div>
))}
</div>
</div>
{/* Eingabe */}
<div style={{ ...S.card, marginBottom: 16 }}>
<div style={{ ...S.head, marginBottom: 10 }}>ARTIKEL-URL</div>
<input
ref={inputRef}
value={inputUrl}
onChange={e => setInputUrl(e.target.value)}
onKeyDown={e => e.key === 'Enter' && status === 'idle' && handleSearch()}
placeholder="URL einfügen (Long-Press → Einfügen)"
style={{ ...S.inp, width: '100%', boxSizing: 'border-box', marginBottom: 10 }}
/>
<div style={{ display: 'flex', gap: 8 }}>
{status === 'idle' ? (
<button onClick={handleSearch} disabled={!inputUrl.trim()}
style={{ ...S.btn(C.accent), flex: 1, opacity: !inputUrl.trim() ? 0.45 : 1, cursor: !inputUrl.trim() ? 'default' : 'pointer' }}>
🔍 Archiv suchen
</button>
) : (
<button onClick={handleReset}
style={{ background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.14)', borderRadius: 6, padding: '8px 14px', color: 'rgba(255,255,255,0.6)', cursor: 'pointer', fontFamily: 'monospace', fontSize: 12 }}>
Reset
</button>
)}
</div>
</div>
{/* Ergebnis */}
{status === 'ready' && (
<div style={{ ...S.card, borderColor: `${C.accent}55`, marginBottom: 12 }}>
<div style={{ ...S.head, marginBottom: 12 }}>ARTIKEL ÖFFNEN & ALS PDF SPEICHERN</div>
{/* Schritt 1 */}
<div style={{ display: 'flex', gap: 10, marginBottom: 4 }}>
<span style={{ background: `${C.accent}20`, border: `1px solid ${C.accent}44`, borderRadius: '50%', width: 22, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, color: C.accent, fontFamily: 'monospace', flexShrink: 0, marginTop: 1 }}>1</span>
<div style={{ flex: 1 }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontFamily: 'monospace', fontSize: 11, marginBottom: 8 }}>
Öffne den Artikel auf archive.ph. Falls CAPTCHA erscheint: einmalig lösen. Falls "No results": auf <strong style={{ color: 'rgba(255,255,255,0.5)' }}>"archive this url"</strong> klicken, kurz warten, dann nochmal öffnen.
</div>
<a href={archiveUrl} target="_blank" rel="noreferrer"
style={{ ...S.btn(C.accent), display: 'block', textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box', fontSize: 13 }}>
🌐 Artikel auf archive.ph öffnen
</a>
</div>
</div>
{/* Nochmal öffnen nach Archivierung */}
<div style={{ margin: '10px 0 16px 32px', padding: '8px 12px', background: 'rgba(255,255,255,0.03)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.07)' }}>
<div style={{ color: C.muted, fontSize: 10, fontFamily: 'monospace', marginBottom: 6 }}>
Nach "archive this url" ~1530s warten, dann:
</div>
<a href={archiveUrl} target="_blank" rel="noreferrer"
style={{ ...S.btn(C.muted), display: 'inline-block', textDecoration: 'none', fontSize: 11 }}>
🔄 Nochmal öffnen
</a>
</div>
{/* Schritt 2 */}
<div style={{ display: 'flex', gap: 10 }}>
<span style={{ background: `${C.success}20`, border: `1px solid ${C.success}44`, borderRadius: '50%', width: 22, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, color: C.success, fontFamily: 'monospace', flexShrink: 0, marginTop: 1 }}>2</span>
<div style={{ flex: 1 }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontFamily: 'monospace', fontSize: 11, marginBottom: 8 }}>
Wenn der Artikel sichtbar ist, als PDF speichern:
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{[
['📱', 'Android Chrome', 'Menü (⋮) → Teilen → Drucken → Als PDF · Kopf-/Fußzeilen: aus'],
['🍎', 'iOS Safari', 'Teilen-Button → Als PDF sichern'],
['💻', 'Desktop', 'Strg+P / Cmd+P → Als PDF · Kopf-/Fußzeilen: aus'],
].map(([icon, label, desc]) => (
<div key={label} style={{ padding: '8px 10px', background: 'rgba(255,255,255,0.03)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.07)' }}>
<div style={{ color: 'rgba(255,255,255,0.6)', fontFamily: 'monospace', fontSize: 11, marginBottom: 2 }}>{icon} {label}</div>
<div style={{ color: C.muted, fontFamily: 'monospace', fontSize: 10, lineHeight: 1.5 }}>{desc}</div>
</div>
))}
</div>
<div style={{ marginTop: 10, padding: '8px 10px', background: 'rgba(255,255,255,0.02)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ color: 'rgba(255,255,255,0.3)', fontFamily: 'monospace', fontSize: 10, lineHeight: 1.5 }}>
Der archive.ph-Balken oben ist Teil der archivierten Seite und kann nicht automatisch entfernt werden.
</div>
</div>
</div>
</div>
</div>
)}
{/* Idle-Info */}
{status === 'idle' && (
<div style={{ ...S.card }}>
<div style={{ ...S.head, marginBottom: 10 }}>SO FUNKTIONIERT ES</div>
{[
['1', 'URL des gesperrten Artikels einfügen'],
['2', 'Artikel auf archive.ph öffnen'],
['3', 'Falls nicht archiviert: "archive this url" klicken, kurz warten, nochmal öffnen'],
['4', 'Über Drucken-Funktion als PDF speichern'],
].map(([num, text]) => (
<div key={num} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 8 }}>
<span style={{ background: `${C.accent}20`, border: `1px solid ${C.accent}44`, borderRadius: '50%', width: 20, height: 20, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, color: C.accent, fontFamily: 'monospace', marginTop: 2 }}>{num}</span>
<span style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', lineHeight: 1.6 }}>{text}</span>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,686 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { api, S } from '../lib.js';
// Testmodus: API-Call als anderer User (nur Admin, nur Schocken)
async function apiAs(userId, path, opts = {}) {
const token = localStorage.getItem('sk_token');
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` };
if (userId) headers['X-Schocken-Test-As'] = String(userId);
const res = await fetch(`/api${path}`, {
method: opts.method || (opts.body ? 'POST' : 'GET'),
headers,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (!res.ok) { const e = await res.json().catch(()=>({error:'Fehler'})); throw new Error(e.error||'Fehler'); }
return res.json();
}
const MY_COLOR = '#4ecdc4';
function getMyId() {
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).id; } catch { return null; }
}
function getMyRole() {
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).role; } catch { return null; }
}
// ── Würfel-Visualisierung ─────────────────────────────────────────────────────
const DOTS = {
1: [[50,50]],
2: [[25,25],[75,75]],
3: [[25,25],[50,50],[75,75]],
4: [[25,25],[75,25],[25,75],[75,75]],
5: [[25,25],[75,25],[50,50],[25,75],[75,75]],
6: [[25,25],[75,25],[25,50],[75,50],[25,75],[75,75]],
};
function Die({ value, kept, selected, onClick, dark, size=56 }) {
const dots = value ? DOTS[value] : [];
const bg = dark ? 'rgba(40,40,60,0.9)' : kept ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.08)';
const border = selected ? '2px solid #4ecdc4' : kept ? '2px solid rgba(78,205,196,0.4)' : '1px solid rgba(255,255,255,0.15)';
return (
<div onClick={onClick} style={{
width:size, height:size, borderRadius:10, background:bg, border,
cursor:onClick?'pointer':'default', position:'relative', flexShrink:0,
transition:'all 0.15s', boxShadow: selected ? '0 0 12px rgba(78,205,196,0.4)' : 'none',
}}>
{dark && <div style={{position:'absolute',inset:0,display:'flex',alignItems:'center',justifyContent:'center',color:'rgba(255,255,255,0.2)',fontSize:22}}>🎲</div>}
{!dark && dots.map(([x,y],i) => (
<div key={i} style={{
position:'absolute',
left:`${x}%`, top:`${y}%`,
transform:'translate(-50%,-50%)',
width:size>40?10:7, height:size>40?10:7,
borderRadius:'50%', background:'#fff',
}}/>
))}
{!value && !dark && <div style={{position:'absolute',inset:0,display:'flex',alignItems:'center',justifyContent:'center',color:'rgba(255,255,255,0.15)',fontSize:10,fontFamily:'monospace'}}>?</div>}
</div>
);
}
// ── Spieler-Zeile ─────────────────────────────────────────────────────────────
function PlayerRow({ player, chips, has16th, isMe, isCurrent, result, drinkLosses, game }) {
const isLoserH1 = game.has_16th === player.id;
return (
<div style={{
...S.card, padding:'10px 14px', marginBottom:8,
border: isCurrent ? '1px solid rgba(78,205,196,0.4)' : '1px solid rgba(255,255,255,0.07)',
background: isMe ? 'rgba(78,205,196,0.04)' : 'rgba(255,255,255,0.02)',
}}>
<div style={{display:'flex',alignItems:'center',gap:10}}>
<div style={{flex:1}}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,display:'flex',alignItems:'center',gap:6}}>
{isCurrent && <span style={{color:MY_COLOR}}></span>}
<strong>{player.username}</strong>
{isMe && <span style={{color:'rgba(255,255,255,0.3)',fontSize:10}}>(du)</span>}
{isLoserH1 && <span title="Verlierer 1. Hälfte" style={{fontSize:14}}>🪶</span>}
</div>
</div>
<div style={{display:'flex',alignItems:'center',gap:12}}>
{drinkLosses > 0 && (
<span style={{color:'#f59e0b',fontFamily:'monospace',fontSize:11}}>🍺×{drinkLosses}</span>
)}
<div style={{textAlign:'center'}}>
<div style={{color:chips>0?'#ff6b9d':MY_COLOR,fontFamily:'Space Mono,monospace',fontSize:18,fontWeight:700,lineHeight:1}}>{chips}</div>
<div style={{color:'rgba(255,255,255,0.25)',fontSize:9,fontFamily:'monospace'}}>Scheiben</div>
</div>
</div>
</div>
{result && (
<div style={{marginTop:6,padding:'4px 8px',background:'rgba(255,255,255,0.04)',borderRadius:6,
color:MY_COLOR,fontFamily:'monospace',fontSize:11}}>
{result.label}
</div>
)}
</div>
);
}
// ── Würfelbereich ─────────────────────────────────────────────────────────────
function DiceArea({ game, myId, onRoll, onReady, onEvaluate }) {
const [selectedKeep, setSelectedKeep] = useState([]);
const [goDark, setGoDark] = useState(false);
const [flipSixes, setFlipSixes] = useState(false);
const [rolling, setRolling] = useState(false);
const round = game.current_round;
const players = game.players;
const activePlayers = getActivePlayers(game);
const myState = round?.playerStates?.[myId];
const currentPlayer = activePlayers[game.current_player_idx % activePlayers.length];
const isMyTurn = currentPlayer?.id === myId;
const allReady = activePlayers.every(p => round?.ready?.[p.id]);
const allDone = round?.phase === 'reveal';
const isFirstRound = !!game.first_round;
const myDice = myState?.dice || [null, null, null];
const myRolls = myState?.roll_count || 0;
const maxRolls = round?.max_rolls || 3;
const canFlipSixes = myDice.filter(d => d===6).length >= 2 && myRolls < maxRolls - 1;
// Becher umdrehen Phase — warte bis alle ready
if (!round || !allReady) {
const iMReady = round?.ready?.[myId];
const readyCount = Object.keys(round?.ready || {}).length;
const activeCount = activePlayers.length;
return (
<div>
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:12,textAlign:'center',marginBottom:12}}>
{readyCount}/{activeCount} haben den Becher umgedreht
</div>
{!iMReady && (
<button onClick={onReady} style={{...S.btn('#4ecdc4'),width:'100%',fontSize:14,padding:'12px'}}>
🎲 Becher umdrehen
</button>
)}
{iMReady && (
<div style={{color:MY_COLOR,fontFamily:'monospace',fontSize:12,textAlign:'center',padding:'12px 0'}}>
Becher umgedreht warte auf andere
</div>
)}
{isFirstRound && iMReady && readyCount === activeCount && (
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,textAlign:'center',marginTop:8}}>
Alle bereit Becher werden gleichzeitig gehoben!
</div>
)}
</div>
);
}
// Auswertungsphase — alle Würfel sichtbar
if (allDone) {
const isFirstPlayer = activePlayers[0]?.id === myId || players[0]?.id === myId;
return (
<div>
<div style={{color:MY_COLOR,fontFamily:'monospace',fontSize:12,textAlign:'center',marginBottom:14}}>
🎲 Alle Becher oben wer hat was?
</div>
{/* Alle Spieler mit Würfeln und Ergebnis */}
{activePlayers.map(p => {
const ps = round.playerStates?.[p.id];
const isP = p.id === myId;
return (
<div key={p.id} style={{
...S.card, padding:'10px 14px', marginBottom:8,
border: isP ? '1px solid rgba(78,205,196,0.3)' : '1px solid rgba(255,255,255,0.07)',
}}>
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:8}}>
<span style={{color:isP?MY_COLOR:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:12,fontWeight:700}}>
{p.username}{isP?' (du)':''}
</span>
{ps?.dark && <span style={{color:'rgba(255,255,255,0.4)',fontSize:10,fontFamily:'monospace'}}>🌑 war dunkel</span>}
{ps?.roll_count && <span style={{color:'rgba(255,255,255,0.3)',fontSize:10,fontFamily:'monospace',marginLeft:'auto'}}>{ps.roll_count}. Wurf</span>}
</div>
<div style={{display:'flex',gap:8,marginBottom:8}}>
{(ps?.dice||[null,null,null]).map((d,i) => <Die key={i} value={d} size={44}/>)}
</div>
{ps?.result && (
<div style={{
padding:'4px 10px', borderRadius:6, display:'inline-block',
background: ps.result.type==='schock_aus'?'rgba(239,68,68,0.15)':
ps.result.type==='julchen'?'rgba(255,230,109,0.15)':
ps.result.type==='general'?'rgba(78,205,196,0.12)':'rgba(255,255,255,0.05)',
border: ps.result.type==='schock_aus'?'1px solid rgba(239,68,68,0.4)':
ps.result.type==='julchen'?'1px solid rgba(255,230,109,0.4)':
ps.result.type==='general'?'1px solid rgba(78,205,196,0.3)':'1px solid rgba(255,255,255,0.08)',
color: ps.result.type==='schock_aus'?'#ef4444':
ps.result.type==='julchen'?'#ffe66d':
ps.result.type==='general'?MY_COLOR:'rgba(255,255,255,0.6)',
fontFamily:'monospace', fontSize:11,
}}>
{ps.result.label} {ps.result.scheiben > 0 ? `${ps.result.scheiben} Scheibe${ps.result.scheiben!==1?'n':''}` : ''}
</div>
)}
</div>
);
})}
{isFirstPlayer && (
<button onClick={onEvaluate} style={{...S.btn('#ffe66d'),width:'100%',marginTop:4}}>
Scheiben verteilen
</button>
)}
{!isFirstPlayer && (
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,textAlign:'center',padding:'8px 0'}}>
Warte auf {activePlayers[0]?.username} zum Auswerten
</div>
)}
</div>
);
}
// Mein Würfeln
return (
<div>
{/* Status */}
<div style={{color:isMyTurn?MY_COLOR:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11,textAlign:'center',marginBottom:10}}>
{isMyTurn ? `▶ Dein Wurf (${myRolls}/${maxRolls||'?'})` : `${currentPlayer?.username} würfelt…`}
</div>
{/* Würfel */}
<div style={{display:'flex',gap:10,justifyContent:'center',marginBottom:14}}>
{myDice.map((d, i) => (
<Die
key={i}
value={d}
dark={myState?.dark}
kept={selectedKeep.includes(i)}
selected={selectedKeep.includes(i)}
onClick={isMyTurn && myRolls > 0 && !myState?.done ? () => {
setSelectedKeep(prev =>
prev.includes(i) ? prev.filter(x=>x!==i) : [...prev,i]
);
} : undefined}
size={64}
/>
))}
</div>
{/* Optionen */}
{isMyTurn && !myState?.done && (
<div style={{display:'flex',flexDirection:'column',gap:8}}>
{myRolls > 0 && (
<div style={{display:'flex',gap:8,fontSize:11,fontFamily:'monospace',color:'rgba(255,255,255,0.5)'}}>
{selectedKeep.length > 0 && <span>📌 {selectedKeep.length} Würfel stehen lassen</span>}
</div>
)}
{canFlipSixes && (
<label style={{display:'flex',alignItems:'center',gap:8,cursor:'pointer',fontFamily:'monospace',fontSize:11,color:'rgba(255,255,255,0.6)'}}>
<input type="checkbox" checked={flipSixes} onChange={e=>setFlipSixes(e.target.checked)}/>
Zwei Sechsen zu Einer Eins umdrehen
</label>
)}
{myRolls > 0 && myRolls < (maxRolls||3) && (
<label style={{display:'flex',alignItems:'center',gap:8,cursor:'pointer',fontFamily:'monospace',fontSize:11,color:'rgba(255,255,255,0.6)'}}>
<input type="checkbox" checked={goDark} onChange={e=>setGoDark(e.target.checked)}/>
Dunkel legen (Becher nicht heben)
</label>
)}
<button
disabled={rolling}
onClick={async () => {
setRolling(true);
try { await onRoll(selectedKeep, flipSixes, goDark); setSelectedKeep([]); setFlipSixes(false); setGoDark(false); }
finally { setRolling(false); }
}}
style={{...S.btn(MY_COLOR), padding:'10px', fontSize:13}}
>
{rolling ? '🎲 Würfle…' : myRolls === 0 ? '🎲 Becher heben' : goDark ? '🎲 Dunkel würfeln' : '🎲 Nochmal würfeln'}
</button>
</div>
)}
{myState?.done && !allDone && (
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11,textAlign:'center',padding:'8px 0'}}>
{myState.dark ? '🎲 Du bist dunkel — warte auf andere…' : `✓ Du stehst im ${myRolls}. — warte auf andere…`}
</div>
)}
{/* Andere Spieler Status */}
<div style={{marginTop:12,display:'flex',flexWrap:'wrap',gap:6}}>
{activePlayers.filter(p => p.id !== myId).map(p => {
const ps = round.playerStates?.[p.id];
return (
<div key={p.id} style={{
padding:'4px 10px', borderRadius:20,
background: ps?.done ? 'rgba(78,205,196,0.1)' : 'rgba(255,255,255,0.04)',
border: ps?.done ? '1px solid rgba(78,205,196,0.3)' : '1px solid rgba(255,255,255,0.08)',
color: ps?.done ? MY_COLOR : 'rgba(255,255,255,0.4)',
fontFamily:'monospace', fontSize:10,
}}>
{ps?.done ? '✓' : '⏳'} {p.username} {ps?.dark && '🌑'}
</div>
);
})}
</div>
</div>
);
}
// ── Schockstock ───────────────────────────────────────────────────────────────
function Schockstock({ total=15, stock, players, chips }) {
const used = total - stock;
return (
<div style={{...S.card,padding:'10px 14px',marginBottom:12}}>
<div style={{display:'flex',alignItems:'center',gap:8,marginBottom:8}}>
<span style={{color:'rgba(255,255,255,0.55)',fontFamily:'monospace',fontSize:11,letterSpacing:1}}>SCHOCKSTOCK</span>
<span style={{color:MY_COLOR,fontFamily:'monospace',fontSize:11,marginLeft:'auto'}}>{stock} verbleibend</span>
</div>
<div style={{display:'flex',gap:3,flexWrap:'wrap'}}>
{Array.from({length:16}).map((_,i) => {
const is16th = i === 15;
const isUsed = !is16th && i >= stock;
return (
<div key={i} style={{
width:14, height:14, borderRadius:'50%',
background: is16th
? 'rgba(245,158,11,0.6)'
: isUsed
? 'rgba(255,255,255,0.1)'
: '#4ecdc4',
border: is16th ? '1px solid #f59e0b' : '1px solid rgba(255,255,255,0.1)',
title: is16th ? '16. Scheibe' : '',
}}/>
);
})}
</div>
</div>
);
}
// ── Neue Runde Button ─────────────────────────────────────────────────────────
function NewRoundButton({ game, myId, onReady }) {
// Der Beginner der neuen Runde startet mit Becher umdrehen
const isStarter = game.beginner_id === myId;
return (
<div style={{...S.card,padding:'14px',textAlign:'center',marginBottom:12,border:'1px solid rgba(78,205,196,0.2)'}}>
<div style={{color:'rgba(255,255,255,0.6)',fontFamily:'monospace',fontSize:12,marginBottom:10}}>
Neue Runde {isStarter ? 'du fängst an!' : `${game.players.find(p=>p.id===game.beginner_id)?.username} fängt an`}
</div>
<button onClick={onReady} style={{...S.btn('#4ecdc4'),width:'100%',fontSize:14,padding:'12px'}}>
🎲 Becher umdrehen
</button>
</div>
);
}
// ── Spielende ─────────────────────────────────────────────────────────────────
function GameOver({ game }) {
const dl = game.drink_losses;
const loser = game.players.find(p => dl[p.id] > 0);
const isDoppelfeige = game.loser_h1 === game.loser_h2;
return (
<div style={{...S.card,padding:'20px',textAlign:'center',border:'1px solid rgba(245,158,11,0.3)'}}>
<div style={{fontSize:48,marginBottom:12}}>{isDoppelfeige ? '🪶🪶' : '🍺'}</div>
{isDoppelfeige ? (
<div style={{color:'#f59e0b',fontFamily:'Space Mono,monospace',fontSize:16,fontWeight:700,marginBottom:8}}>
DOPPELFEIGE!
</div>
) : (
<div style={{color:'#f59e0b',fontFamily:'Space Mono,monospace',fontSize:16,fontWeight:700,marginBottom:8}}>
RUNDE VORBEI
</div>
)}
{loser && (
<div style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:13,marginBottom:16}}>
{loser.username} zahlt die nächste Runde! 🍺
</div>
)}
<div style={{marginTop:12}}>
<div style={{...S.head,marginBottom:8}}>GETRÄNKE-STATISTIK</div>
{game.players.map(p => (
<div key={p.id} style={{display:'flex',justifyContent:'space-between',padding:'4px 0',
borderBottom:'1px solid rgba(255,255,255,0.05)',fontFamily:'monospace',fontSize:12}}>
<span style={{color:'rgba(255,255,255,0.7)'}}>{p.username}</span>
<span style={{color:dl[p.id]>0?'#f59e0b':MY_COLOR}}>🍺 {dl[p.id]||0}</span>
</div>
))}
</div>
</div>
);
}
// ── Aktive Spieler berechnen (Frontend) ───────────────────────────────────────
function getActivePlayers(game) {
const { players, player_chips, stock, status, loser_h1, loser_h2 } = game;
if (status === 'endkampf') {
return players.filter(p => p.id === loser_h1 || p.id === loser_h2);
}
if (stock > 0) return players;
return players.filter(p => player_chips[p.id] > 0);
}
// ── Neues Spiel Modal ─────────────────────────────────────────────────────────
function NewGameModal({ onClose, onCreate, toast }) {
const [users, setUsers] = useState([]);
const [picked, setPicked] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => { api('/tools/schocken/users').then(setUsers).catch(()=>{}); }, []);
const toggle = id => setPicked(p => p.includes(id) ? p.filter(x=>x!==id) : [...p,id]);
const create = async () => {
if (!picked.length) return;
setLoading(true);
try {
const r = await api('/tools/schocken', { body: { player_ids: picked } });
toast('Spiel erstellt! 🎲');
onCreate(r.id);
} catch(e) { toast(e.message||'Fehler','error'); }
finally { setLoading(false); }
};
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.85)',display:'flex',alignItems:'center',justifyContent:'center',zIndex:1000,padding:20}}>
<div style={{...S.card,maxWidth:380,width:'100%'}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:18}}>
<span style={{color:'#fff',fontFamily:'Space Mono,monospace',fontSize:13,fontWeight:700,letterSpacing:1}}>NEUES SPIEL</span>
<button onClick={onClose} style={S.btn('#666666',true)}></button>
</div>
<div style={{...S.head,marginBottom:8}}>MITSPIELER WÄHLEN</div>
<div style={{maxHeight:240,overflowY:'auto',marginBottom:16}}>
{users.map(u => (
<div key={u.id} onClick={()=>toggle(u.id)} style={{
display:'flex',alignItems:'center',gap:10,padding:'8px 10px',
cursor:'pointer',borderRadius:8,marginBottom:4,
background: picked.includes(u.id) ? 'rgba(78,205,196,0.1)' : 'rgba(255,255,255,0.03)',
border: picked.includes(u.id) ? '1px solid rgba(78,205,196,0.3)' : '1px solid rgba(255,255,255,0.06)',
}}>
<span style={{color:picked.includes(u.id)?MY_COLOR:'rgba(255,255,255,0.5)',fontSize:14}}>
{picked.includes(u.id)?'☑':'☐'}
</span>
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13}}>{u.username}</span>
</div>
))}
</div>
<div style={{display:'flex',gap:8}}>
<button onClick={onClose} style={{...S.btn('#666666'),flex:1}}>Abbrechen</button>
<button onClick={create} disabled={!picked.length||loading} style={{...S.btn('#4ecdc4'),flex:1}}>
{loading ? 'Erstelle…' : `Starten (${picked.length+1} Spieler)`}
</button>
</div>
</div>
</div>
);
}
// ── Spielliste ────────────────────────────────────────────────────────────────
function GameList({ games, myId, onSelect, onNew, onDelete, isAdmin }) {
const active = games.filter(g => g.status !== 'finished');
const finished = games.filter(g => g.status === 'finished');
const statusLabel = s => ({
lobby:'Lobby', half1:'1. Hälfte', half2:'2. Hälfte', endkampf:'Endkampf', finished:'Beendet'
}[s]||s);
const GameRow = ({ g }) => {
const others = g.players.filter(p=>p.id!==myId).map(p=>p.username).join(', ');
return (
<div style={{...S.card,padding:'12px 14px',marginBottom:8,display:'flex',alignItems:'center',gap:10,border:'1px solid rgba(255,255,255,0.07)'}}>
<button onClick={()=>onSelect(g.id)} style={{flex:1,background:'none',border:'none',cursor:'pointer',textAlign:'left',padding:0}}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,marginBottom:3}}>
🎲 mit <strong>{others}</strong>
</div>
<div style={{color:'rgba(255,255,255,0.35)',fontSize:11,fontFamily:'monospace'}}>
{g.players.length} Spieler · {statusLabel(g.status)}
</div>
</button>
{isAdmin && (
<button onClick={async e => {
e.stopPropagation();
if (!window.confirm('Spiel löschen?')) return;
try {
await apiAs(myId, `/tools/schocken/${g.id}/cancel`, { body:{} });
onDelete(g.id);
} catch(e) { alert(e.message||'Fehler'); }
}} style={{...S.btn('#ff6b9d',true),flexShrink:0}}>🗑</button>
)}
</div>
);
};
return (
<div>
<button onClick={onNew} style={{...S.btn('#4ecdc4'),width:'100%',marginBottom:20,padding:'10px 14px',fontSize:13}}>
+ Neues Spiel starten
</button>
{active.length > 0 && <>
<div style={{...S.head,marginBottom:8}}>LAUFEND ({active.length})</div>
{active.map(g=><GameRow key={g.id} g={g}/>)}
</>}
{finished.length > 0 && <>
<div style={{...S.head,marginTop:16,marginBottom:8}}>BEENDET</div>
{finished.map(g=><GameRow key={g.id} g={g}/>)}
</>}
{games.length===0 && (
<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:13,textAlign:'center',paddingTop:40}}>
Noch keine Spiele starte eines!
</div>
)}
</div>
);
}
// ── Spielansicht ──────────────────────────────────────────────────────────────
function GameView({ game, myId, onRefresh, toast, isAdmin=false, onSeatChange }) {
const players = game.players;
const chips = game.player_chips;
const dl = game.drink_losses;
const round = game.current_round;
const activePlayers = getActivePlayers(game);
const currentPlayer = activePlayers[game.current_player_idx % activePlayers.length];
const handleRoll = async (keepDice, flipSixes, goDark) => {
await apiAs(myId, `/tools/schocken/${game.id}/roll`, {
body: { keep_dice: keepDice, flip_sixes: flipSixes, go_dark: goDark }
});
onRefresh();
};
const handleReady = async () => {
await apiAs(myId, `/tools/schocken/${game.id}/ready`, { body: {} });
onRefresh();
};
const handleEvaluate = async () => {
const result = await apiAs(myId, `/tools/schocken/${game.id}/evaluate`, { body: {} });
if (result.round_result?.eventMsg) toast(result.round_result.eventMsg);
onRefresh();
};
const handleStart = async () => {
await apiAs(myId, `/tools/schocken/${game.id}/start`, { body: {} });
onRefresh();
};
const statusLabel = { lobby:'Lobby', half1:'1. Hälfte', half2:'2. Hälfte', endkampf:'Endkampf', finished:'Beendet' };
return (
<div>
{/* Phase-Header */}
<div style={{...S.card,padding:'8px 14px',marginBottom:12,display:'flex',alignItems:'center',gap:10}}>
<span style={{color:MY_COLOR,fontFamily:'monospace',fontSize:11,letterSpacing:1}}>{statusLabel[game.status]}</span>
<span style={{flex:1}}/>
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>Runde {game.round_number}</span>
</div>
{/* Testmodus-Switcher (nur Admin) */}
{isAdmin && (
<div style={{marginBottom:12,padding:'8px 12px',background:'rgba(245,158,11,0.08)',border:'1px solid rgba(245,158,11,0.25)',borderRadius:8}}>
<div style={{color:'#f59e0b',fontFamily:'monospace',fontSize:10,letterSpacing:1,marginBottom:6}}>🧪 TESTMODUS SPIELER WECHSELN</div>
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
{players.map(p => (
<button key={p.id} onClick={() => onSeatChange(p.id)}
style={{...S.btn(myId===p.id?'#f59e0b':'#666666',true),fontSize:10}}>
{p.username}{myId===p.id?' ✓':''}
</button>
))}
</div>
</div>
)}
{/* Schockstock */}
<Schockstock stock={game.stock} players={players} chips={chips}/>
{/* Spieler */}
<div style={{marginBottom:12}}>
{players.map(p => (
<PlayerRow
key={p.id}
player={p}
chips={chips[p.id]||0}
has16th={game.has_16th}
isMe={p.id===myId}
isCurrent={currentPlayer?.id===p.id}
result={round?.playerStates?.[p.id]?.result}
drinkLosses={dl[p.id]||0}
game={game}
/>
))}
</div>
{/* Lobby */}
{game.status==='lobby' && (
<div style={{textAlign:'center'}}>
{players[0]?.id===myId ? (
<button onClick={handleStart} style={{...S.btn('#4ecdc4'),width:'100%',fontSize:14,padding:'12px'}}>
🎲 Spiel starten
</button>
) : (
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12,padding:'20px 0'}}>
Warte auf {players[0]?.username} um das Spiel zu starten
</div>
)}
</div>
)}
{/* Spielfeld */}
{game.status!=='lobby' && game.status!=='finished' && (
<div style={{...S.card,padding:'14px',border:'1px solid rgba(78,205,196,0.15)'}}>
<DiceArea
game={game}
myId={myId}
onRoll={handleRoll}
onReady={handleReady}
onEvaluate={handleEvaluate}
/>
</div>
)}
{/* Spielende */}
{game.status==='finished' && <GameOver game={game}/>}
</div>
);
}
// ── Hauptkomponente ───────────────────────────────────────────────────────────
export default function Schocken({ toast }) {
const [games, setGames] = useState([]);
const [activeId, setActiveId] = useState(null);
const [game, setGame] = useState(null);
const [showNew, setShowNew] = useState(false);
const [loading, setLoading] = useState(true);
const [testSeat, setTestSeat] = useState(null); // null = eigener Account, sonst userId
const myRealId = getMyId();
const isAdmin = getMyRole() === 'admin';
// Im Testmodus: als anderen Spieler agieren
const myId = (isAdmin && testSeat) ? testSeat : myRealId;
const loadGames = useCallback(async () => {
try { setGames(await api('/tools/schocken')); }
catch { toast?.('Fehler beim Laden','error'); }
finally { setLoading(false); }
}, []);
const loadGame = useCallback(async () => {
if (!activeId) { setGame(null); return; }
try { setGame(await api(`/tools/schocken/${activeId}`)); }
catch {}
}, [activeId]);
useEffect(() => { loadGames(); }, [loadGames]);
useEffect(() => { loadGame(); }, [loadGame]);
// Polling wenn Spiel aktiv
useEffect(() => {
if (!activeId) return;
const iv = setInterval(loadGame, 5000);
return () => clearInterval(iv);
}, [activeId, loadGame]);
if (loading) return <div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',padding:40,textAlign:'center'}}>Lade</div>;
return (
<div style={{maxWidth:560,margin:'0 auto'}}>
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:24,flexWrap:'wrap'}}>
{activeId && (
<button onClick={()=>{setActiveId(null);setGame(null);loadGames();}} style={S.btn('#666666',true)}> Zurück</button>
)}
<h2 style={{margin:0,fontSize:15,fontFamily:'monospace',color:'rgba(255,255,255,0.55)',letterSpacing:2,fontWeight:400}}>
🎲 SCHOCKEN
</h2>
<div style={{flex:1}}/>
{activeId && game?.status !== 'finished' && isAdmin && (
<button onClick={async () => {
if (!window.confirm('Spiel wirklich abbrechen? Keine Punkte werden vergeben.')) return;
try {
await apiAs(myRealId, `/tools/schocken/${activeId}/cancel`, { body:{} });
setActiveId(null); setGame(null); loadGames();
toast('Spiel abgebrochen.');
} catch(e) { toast(e.message||'Fehler','error'); }
}} style={S.btn('#ff6b9d', true)}> Abbrechen</button>
)}
</div>
{!activeId
? <GameList games={games} myId={myId} onSelect={setActiveId} onNew={()=>setShowNew(true)} onDelete={id=>setGames(prev=>prev.filter(g=>g.id!==id))} isAdmin={isAdmin}/>
: game && myId
? <GameView game={game} myId={myId} onRefresh={loadGame} toast={toast} isAdmin={isAdmin} onSeatChange={id=>{setTestSeat(id);}} />
: <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',textAlign:'center',paddingTop:40}}>Lade</div>
}
{showNew && <NewGameModal onClose={()=>setShowNew(false)} toast={toast} onCreate={id=>{setShowNew(false);setActiveId(id);loadGames();}}/>}
</div>
);
}

View File

@@ -1,492 +0,0 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { api, S } from '../lib.js';
// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
const uid = () => Math.random().toString(36).slice(2,8);
const dist = (x1,y1,x2,y2) => Math.sqrt((x2-x1)**2+(y2-y1)**2);
const snapToGrid = (v, g) => Math.round(v / g) * g;
function parseDim(s) {
if (!s) return null;
const n = parseFloat(String(s).replace(/[^0-9.,\-]/g,'').replace(',','.'));
return isNaN(n) ? null : n;
}
function evalFormula(expr, vars) {
try {
const sanitized = expr.replace(/[^0-9+\-*/().,%a-zA-Z_\s]/g,'');
const keys = Object.keys(vars), vals = keys.map(k=>vars[k]);
// eslint-disable-next-line no-new-func
return new Function(...keys, `"use strict"; return (${sanitized});`)(...vals);
} catch { return null; }
}
function buildVars(shapes) {
const vars = {};
shapes.forEach(s => {
const n = (s.name||'').replace(/[^a-zA-Z0-9_]/g,'_');
if (!n) return;
if (s.type==='rect') {
const l=parseDim(s.dimW), b=parseDim(s.dimH);
if (l!=null) { vars[`${n}_l`]=l; vars[`${n}_b`]=b??l; }
if (l!=null&&b!=null) { vars[`${n}_fl`]=+(l*b).toFixed(4); vars[`${n}_um`]=+(2*(l+b)).toFixed(4); vars[`${n}_d`]=+(Math.sqrt(l*l+b*b)).toFixed(4); }
}
if (s.type==='line') { const v=parseDim(s.dimLen); if(v!=null) vars[`${n}_len`]=v; }
if (s.type==='circle') {
const r=parseDim(s.dimR);
if(r!=null){ vars[`${n}_r`]=r; vars[`${n}_d`]=+(r*2).toFixed(4); vars[`${n}_fl`]=+(Math.PI*r*r).toFixed(4); vars[`${n}_um`]=+(2*Math.PI*r).toFixed(4); }
}
});
return vars;
}
// ── Row-Komponente (AUSSERHALB um Input-Bug zu vermeiden) ─────────────────────
function DimRow({ label, value, field, placeholder, onChange }) {
return (
<div style={{marginBottom:8}}>
<div style={{...S.head,marginBottom:3,fontSize:9}}>{label}</div>
<input value={value||''} onChange={e=>onChange({[field]:e.target.value})}
placeholder={placeholder} autoCapitalize="none"
style={{...S.inp,fontSize:12,padding:'6px 8px'}}/>
</div>
);
}
// ── Maßlinie ─────────────────────────────────────────────────────────────────
function DimLine({x1,y1,x2,y2,label,color='#4ecdc4',offset=18}) {
const mx=(x1+x2)/2, my=(y1+y2)/2;
const dx=x2-x1, dy=y2-y1, len=Math.sqrt(dx*dx+dy*dy);
if (len<4) return null;
const nx=-dy/len*offset, ny=dx/len*offset;
return (
<g>
<line x1={x1+nx} y1={y1+ny} x2={x2+nx} y2={y2+ny} stroke={color} strokeWidth={1} strokeDasharray="3,2" opacity={0.7}/>
<line x1={x1} y1={y1} x2={x1+nx} y2={y1+ny} stroke={color} strokeWidth={0.7} opacity={0.4}/>
<line x1={x2} y1={y2} x2={x2+nx} y2={y2+ny} stroke={color} strokeWidth={0.7} opacity={0.4}/>
<text x={mx+nx} y={my+ny} textAnchor="middle" dominantBaseline="middle"
fontSize={9} fill={color} fontFamily="'Courier New',monospace" style={{userSelect:'none'}}
stroke="#0a0a0c" strokeWidth={3} paintOrder="stroke">{label}</text>
</g>
);
}
// ── Shape + Mittelpunkt + Versatz ─────────────────────────────────────────────
function Shape({ s, selected, onClick }) {
const sel = selected===s.id;
const c = sel ? '#4ecdc4' : (s.color||'#7ecaff');
const offset = parseDim(s.offset) || 0; // Versatz in px (scaled)
if (s.type==='rect') {
const x=Math.min(s.x,s.x+s.w), y=Math.min(s.y,s.y+s.h);
const w=Math.abs(s.w), h=Math.abs(s.h);
const cx=x+w/2, cy=y+h/2;
const offPx = s.offsetPx||0;
return (
<g onClick={e=>{e.stopPropagation();onClick(s.id);}} style={{cursor:'pointer'}}>
<rect x={x} y={y} width={w} height={h}
fill={sel?'rgba(78,205,196,0.06)':'rgba(126,202,255,0.04)'}
stroke={c} strokeWidth={sel?1.5:1}/>
{/* Mittelpunkt */}
<line x1={cx-6} y1={cy} x2={cx+6} y2={cy} stroke={c} strokeWidth={0.8} opacity={0.5}/>
<line x1={cx} y1={cy-6} x2={cx} y2={cy+6} stroke={c} strokeWidth={0.8} opacity={0.5}/>
{/* Versatz */}
{offPx!==0&&<rect x={x-offPx} y={y-offPx} width={w+2*offPx} height={h+2*offPx}
fill="none" stroke={offPx>0?'#ffe66d':'#ff6b9d'} strokeWidth={1} strokeDasharray="4,3" opacity={0.7}/>}
{s.name&&<text x={cx} y={cy+2} textAnchor="middle" dominantBaseline="middle"
fontSize={10} fill={c} fontFamily="'Courier New',monospace" style={{userSelect:'none'}}
stroke="#0a0a0c" strokeWidth={3} paintOrder="stroke">{s.name}</text>}
{s.dimW&&<DimLine x1={x} y1={y+h} x2={x+w} y2={y+h} label={s.dimW} offset={14}/>}
{s.dimH&&<DimLine x1={x+w} y1={y} x2={x+w} y2={y+h} label={s.dimH} offset={14}/>}
{sel&&<>
<rect x={x-3} y={y-3} width={6} height={6} fill={c} rx={1}/>
<rect x={x+w-3} y={y-3} width={6} height={6} fill={c} rx={1}/>
<rect x={x-3} y={y+h-3} width={6} height={6} fill={c} rx={1}/>
<rect x={x+w-3} y={y+h-3} width={6} height={6} fill={c} rx={1}/>
</>}
</g>
);
}
if (s.type==='line') {
const len=dist(s.x1,s.y1,s.x2,s.y2);
const mx=(s.x1+s.x2)/2, my=(s.y1+s.y2)/2;
return (
<g onClick={e=>{e.stopPropagation();onClick(s.id);}} style={{cursor:'pointer'}}>
<line x1={s.x1} y1={s.y1} x2={s.x2} y2={s.y2} stroke="transparent" strokeWidth={12}/>
<line x1={s.x1} y1={s.y1} x2={s.x2} y2={s.y2} stroke={c} strokeWidth={sel?2:1.5} strokeLinecap="round"/>
<circle cx={mx} cy={my} r={2} fill={c} opacity={0.6}/>
{(s.dimLen||s.name)&&len>20&&<DimLine x1={s.x1} y1={s.y1} x2={s.x2} y2={s.y2} label={s.dimLen||s.name} offset={12}/>}
{sel&&<><circle cx={s.x1} cy={s.y1} r={4} fill={c}/><circle cx={s.x2} cy={s.y2} r={4} fill={c}/></>}
</g>
);
}
if (s.type==='circle') {
const r=Math.max(1,s.r);
const offPx=s.offsetPx||0;
return (
<g onClick={e=>{e.stopPropagation();onClick(s.id);}} style={{cursor:'pointer'}}>
<circle cx={s.cx} cy={s.cy} r={r} fill={sel?'rgba(78,205,196,0.06)':'rgba(126,202,255,0.04)'} stroke={c} strokeWidth={sel?1.5:1}/>
{offPx!==0&&<circle cx={s.cx} cy={s.cy} r={r+offPx} fill="none" stroke={offPx>0?'#ffe66d':'#ff6b9d'} strokeWidth={1} strokeDasharray="4,3" opacity={0.7}/>}
{/* Mittelpunkt */}
<line x1={s.cx-6} y1={s.cy} x2={s.cx+6} y2={s.cy} stroke={c} strokeWidth={0.8} opacity={0.5}/>
<line x1={s.cx} y1={s.cy-6} x2={s.cx} y2={s.cy+6} stroke={c} strokeWidth={0.8} opacity={0.5}/>
{/* Radiuslinie */}
<line x1={s.cx} y1={s.cy} x2={s.cx+r} y2={s.cy} stroke={c} strokeWidth={0.7} strokeDasharray="3,2" opacity={0.4}/>
{s.name&&<text x={s.cx} y={s.cy+2} textAnchor="middle" dominantBaseline="middle"
fontSize={10} fill={c} fontFamily="'Courier New',monospace" style={{userSelect:'none'}}
stroke="#0a0a0c" strokeWidth={3} paintOrder="stroke">{s.name}</text>}
{s.dimR&&<DimLine x1={s.cx} y1={s.cy} x2={s.cx+r} y2={s.cy} label={'r='+s.dimR} offset={8} color="#ffe66d"/>}
{sel&&<circle cx={s.cx} cy={s.cy} r={4} fill={c}/>}
</g>
);
}
if (s.type==='text') {
return (
<g onClick={e=>{e.stopPropagation();onClick(s.id);}} style={{cursor:'pointer'}}>
<text x={s.x} y={s.y} fontSize={12} fill={sel?'#4ecdc4':'rgba(255,255,255,0.75)'}
fontFamily="'Courier New',monospace" style={{userSelect:'none'}}>{s.label||'Text'}</text>
{sel&&<rect x={s.x-2} y={s.y-14} width={Math.max(40,(s.label||'Text').length*7+4)} height={18}
fill="none" stroke="#4ecdc4" strokeWidth={1} strokeDasharray="3,2"/>}
</g>
);
}
return null;
}
// ── Properties Panel ──────────────────────────────────────────────────────────
function PropsPanel({ shape, onChange, onDelete, vars, scale, onScaleChange }) {
if (!shape) return (
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:10,padding:'14px 0',textAlign:'center',lineHeight:1.8}}>
Form auswählen oder zeichnen.<br/>
<span style={{fontSize:9,opacity:0.6}}>V=Auswählen R=Rect L=Linie C=Kreis</span>
</div>
);
const shapeVarKeys = Object.keys(vars).filter(k=>k.startsWith((shape.name||'').replace(/[^a-zA-Z0-9_]/g,'_')+'_'));
return (
<div>
<div style={{...S.head,marginBottom:10}}>EIGENSCHAFTEN</div>
<DimRow label="NAME" value={shape.name} field="name" placeholder="z.B. Kasten" onChange={onChange}/>
{shape.type==='rect'&&<>
<DimRow label="LÄNGE" value={shape.dimW} field="dimW" placeholder="225mm" onChange={onChange}/>
<DimRow label="BREITE" value={shape.dimH} field="dimH" placeholder="30mm" onChange={onChange}/>
<DimRow label="VERSATZ (+ aussen / innen)" value={shape.dimOffset} field="dimOffset" placeholder="5mm" onChange={onChange}/>
</>}
{shape.type==='line'&&<DimRow label="LÄNGE" value={shape.dimLen} field="dimLen" placeholder="100mm" onChange={onChange}/>}
{shape.type==='circle'&&<>
<DimRow label="RADIUS" value={shape.dimR} field="dimR" placeholder="50mm" onChange={onChange}/>
<DimRow label="VERSATZ (+ aussen / innen)" value={shape.dimOffset} field="dimOffset" placeholder="5mm" onChange={onChange}/>
</>}
{shape.type==='text'&&<DimRow label="TEXT" value={shape.label} field="label" placeholder="Beschriftung" onChange={onChange}/>}
<button onClick={onDelete}
style={{...S.btn('#ff6b9d',true),width:'100%',textAlign:'center',padding:'7px 0',fontSize:11,marginTop:4}}>
Löschen
</button>
{shapeVarKeys.length>0&&(
<div style={{marginTop:12}}>
<div style={{...S.head,marginBottom:5,fontSize:9}}>VARIABLEN</div>
{shapeVarKeys.map(k=>(
<div key={k} style={{display:'flex',justifyContent:'space-between',padding:'3px 0',
borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
<code style={{color:'#4ecdc4',fontSize:10}}>{k}</code>
<span style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:10}}>
{typeof vars[k]==='number'?vars[k].toFixed(2):vars[k]}
</span>
</div>
))}
</div>
)}
<div style={{marginTop:14}}>
<div style={{...S.head,marginBottom:5,fontSize:9}}>MASSSTAB (mm/px)</div>
<div style={{display:'flex',gap:6,alignItems:'center'}}>
<input type="number" value={scale} onChange={e=>onScaleChange(parseFloat(e.target.value)||1)}
step={0.1} min={0.1} style={{...S.inp,flex:1,fontSize:12,padding:'5px 8px'}}/>
<span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:9}}>mm/px</span>
</div>
</div>
</div>
);
}
// ── Main ──────────────────────────────────────────────────────────────────────
const TOOLS = [
{id:'select',label:'▲',title:'Auswählen (V)'},
{id:'rect', label:'▭',title:'Rechteck (R)'},
{id:'line', label:'',title:'Linie (L)'},
{id:'circle',label:'○',title:'Kreis (C)'},
{id:'text', label:'T', title:'Text (T)'},
];
const GRID = 10;
const CALC_BTNS = ['(',')',' ','7','8','9','×','4','5','6','÷','1','2','3','-','0','.','=','+'];
export default function Skizze({ toast, mobile }) {
const [shapes, setShapes] = useState([]);
const [selected, setSelected] = useState(null);
const [tool, setTool] = useState('select');
const [drawing, setDrawing] = useState(null);
const [formula, setFormula] = useState('');
const [results, setResults] = useState([]);
const [snapGrid, setSnapGrid] = useState(true);
const [scale, setScale] = useState(1); // mm per pixel
const svgRef = useRef(null);
const formulaRef= useRef(null);
const vars = buildVars(shapes);
const selShape = shapes.find(s=>s.id===selected)||null;
// Keyboard shortcuts
useEffect(()=>{
const h = e => {
if (e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA') return;
const map={v:'select',r:'rect',l:'line',c:'circle',t:'text'};
if (map[e.key.toLowerCase()]) setTool(map[e.key.toLowerCase()]);
if ((e.key==='Delete'||e.key==='Backspace')&&selected) deleteSelected();
if (e.key==='Escape') { setSelected(null); setDrawing(null); }
};
window.addEventListener('keydown',h);
return()=>window.removeEventListener('keydown',h);
},[selected]);
const getSVGPoint = useCallback(e=>{
const rect=svgRef.current?.getBoundingClientRect();
if (!rect) return{x:0,y:0};
const cx=e.touches?e.touches[0].clientX:e.clientX;
const cy=e.touches?e.touches[0].clientY:e.clientY;
const x=cx-rect.left, y=cy-rect.top;
return {x:snapGrid?snapToGrid(x,GRID):x, y:snapGrid?snapToGrid(y,GRID):y};
},[snapGrid]);
const onSVGDown = useCallback(e=>{
if (tool==='select'){setSelected(null);return;}
const pt=getSVGPoint(e);
if (tool==='text'){
setShapes(p=>[...p,{id:uid(),type:'text',x:pt.x,y:pt.y,label:'Text',name:''}]);
setTool('select'); return;
}
setDrawing({tool,startX:pt.x,startY:pt.y,curX:pt.x,curY:pt.y});
},[tool,getSVGPoint]);
const onSVGMove = useCallback(e=>{
if (!drawing) return;
const pt=getSVGPoint(e);
setDrawing(d=>({...d,curX:pt.x,curY:pt.y}));
},[drawing,getSVGPoint]);
const onSVGUp = useCallback(e=>{
if (!drawing) return;
const pt=getSVGPoint(e);
const dx=pt.x-drawing.startX, dy=pt.y-drawing.startY;
if (Math.abs(dx)<4&&Math.abs(dy)<4){setDrawing(null);return;}
let s;
if (drawing.tool==='rect') s={id:uid(),type:'rect',x:drawing.startX,y:drawing.startY,w:dx,h:dy,name:'',dimW:'',dimH:'',dimOffset:''};
if (drawing.tool==='line') s={id:uid(),type:'line',x1:drawing.startX,y1:drawing.startY,x2:pt.x,y2:pt.y,name:'',dimLen:''};
if (drawing.tool==='circle') s={id:uid(),type:'circle',cx:drawing.startX,cy:drawing.startY,r:dist(drawing.startX,drawing.startY,pt.x,pt.y),name:'',dimR:'',dimOffset:''};
if (s){setShapes(p=>[...p,s]);setSelected(s.id);}
setDrawing(null);
},[drawing,getSVGPoint]);
const deleteSelected = ()=>{setShapes(p=>p.filter(s=>s.id!==selected));setSelected(null);};
// Update shape + auto-resize visual proportions based on scale
const updateShape = patch => {
setShapes(p=>p.map(s=>{
if (s.id!==selected) return s;
const upd={...s,...patch};
// Auto-resize rect
if (upd.type==='rect') {
const mW=parseDim(upd.dimW), mH=parseDim(upd.dimH);
if (mW!=null&&mW>0) {
upd.w=Math.sign(upd.w||1)*mW/scale;
if (mH!=null&&mH>0) upd.h=Math.sign(upd.h||1)*mH/scale;
}
// Versatz in px
const mO=parseDim(upd.dimOffset);
upd.offsetPx=mO!=null?mO/scale:0;
}
// Auto-resize circle
if (upd.type==='circle') {
const mR=parseDim(upd.dimR);
if (mR!=null&&mR>0) upd.r=mR/scale;
const mO=parseDim(upd.dimOffset);
upd.offsetPx=mO!=null?mO/scale:0;
}
return upd;
}));
};
const calcFormula = () => {
if (!formula.trim()) return;
const result=evalFormula(formula,vars);
const isArea=formula.toLowerCase().includes('_fl');
setResults(p=>[{
id:uid(), expr:formula,
result:result!=null?`${typeof result==='number'?result.toFixed(4).replace(/.?0+$/,''):result}${isArea?' mm²':' mm'}`:null,
},...p].slice(0,30));
};
const insertCalc = btn => {
if (btn==='='){calcFormula();return;}
const map={'×':'*','÷':'/',' ':' '};
const char=map[btn]??btn;
setFormula(f=>f+char);
formulaRef.current?.focus();
};
// Preview while drawing
let preview=null;
if (drawing){
const dx=drawing.curX-drawing.startX, dy=drawing.curY-drawing.startY;
const pc='#4ecdc4';
if (drawing.tool==='rect') preview=<rect x={Math.min(drawing.startX,drawing.curX)} y={Math.min(drawing.startY,drawing.curY)} width={Math.abs(dx)} height={Math.abs(dy)} fill="rgba(78,205,196,0.04)" stroke={pc} strokeWidth={1} strokeDasharray="4,3"/>;
if (drawing.tool==='line') preview=<line x1={drawing.startX} y1={drawing.startY} x2={drawing.curX} y2={drawing.curY} stroke={pc} strokeWidth={1.5} strokeDasharray="4,3"/>;
if (drawing.tool==='circle'){const r=dist(drawing.startX,drawing.startY,drawing.curX,drawing.curY); preview=<circle cx={drawing.startX} cy={drawing.startY} r={r} fill="rgba(78,205,196,0.04)" stroke={pc} strokeWidth={1} strokeDasharray="4,3"/>;}
}
const canvasH = mobile ? 260 : 420;
return (
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:1100}}>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:12,flexWrap:'wrap',gap:8}}>
<div style={{display:'flex',alignItems:'center',gap:12,flexWrap:'wrap'}}>
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?16:22,margin:0}}>Skizzen-Rechner</h1>
<div style={{
display:'flex',alignItems:'center',gap:6,
background:'repeating-linear-gradient(45deg,rgba(255,230,109,0.12),rgba(255,230,109,0.12) 6px,rgba(0,0,0,0) 6px,rgba(0,0,0,0) 12px)',
border:'1px solid rgba(255,230,109,0.3)',borderRadius:8,
padding:'4px 10px',flexShrink:0,
}}>
<span style={{fontSize:14}}>🚧</span>
<span style={{color:'#ffe66d',fontFamily:'monospace',fontSize:10,fontWeight:700,letterSpacing:0.5}}>IM AUFBAU</span>
</div>
</div>
<div style={{display:'flex',gap:8,alignItems:'center'}}>
<label style={{display:'flex',alignItems:'center',gap:5,cursor:'pointer'}}>
<input type="checkbox" checked={snapGrid} onChange={e=>setSnapGrid(e.target.checked)} style={{accentColor:'#4ecdc4'}}/>
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>Raster</span>
</label>
<button onClick={()=>{setShapes([]);setSelected(null);}}
style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'5px 10px'}}> Leeren</button>
</div>
</div>
{/* Toolbar */}
<div style={{display:'flex',gap:5,marginBottom:10,flexWrap:'wrap'}}>
{TOOLS.map(t=>(
<button key={t.id} onClick={()=>setTool(t.id)} title={t.title} style={{
padding:'7px 13px',borderRadius:8,fontFamily:'monospace',fontSize:t.id==='text'?14:16,
cursor:'pointer',fontWeight:700,
background:tool===t.id?'#4ecdc4':'rgba(255,255,255,0.06)',
color: tool===t.id?'#0d0d0f':'rgba(255,255,255,0.6)',
border: tool===t.id?'none':'1px solid rgba(255,255,255,0.1)',
}}>{t.label}</button>
))}
<span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:9,alignSelf:'center',marginLeft:4}}>
{TOOLS.find(t=>t.id===tool)?.title}
</span>
</div>
{/* Canvas + Panel */}
<div style={{display:'flex',gap:12,flexDirection:mobile?'column':'row'}}>
<svg ref={svgRef} style={{flex:1,minWidth:0,background:'#0a0a0c',border:'1px solid rgba(255,255,255,0.08)',borderRadius:10,display:'block',cursor:tool==='select'?'default':tool==='text'?'text':'crosshair'}}
height={canvasH}
onMouseDown={onSVGDown} onMouseMove={onSVGMove} onMouseUp={onSVGUp}
onTouchStart={onSVGDown} onTouchMove={onSVGMove} onTouchEnd={onSVGUp}>
<defs>
<pattern id="g10" width={GRID} height={GRID} patternUnits="userSpaceOnUse">
<path d={`M ${GRID} 0 L 0 0 0 ${GRID}`} fill="none" stroke="rgba(255,255,255,0.04)" strokeWidth={0.5}/>
</pattern>
<pattern id="g50" width={50} height={50} patternUnits="userSpaceOnUse">
<rect width={50} height={50} fill="url(#g10)"/>
<path d="M 50 0 L 0 0 0 50" fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={0.5}/>
</pattern>
</defs>
{snapGrid&&<rect width="100%" height="100%" fill="url(#g50)"/>}
{shapes.map(s=><Shape key={s.id} s={s} selected={selected} onClick={id=>{if(tool==='select')setSelected(id);}}/>)}
{preview}
</svg>
<div style={{width:mobile?'100%':210,flexShrink:0}}>
<div style={{...S.card}}>
<PropsPanel shape={selShape} onChange={updateShape} onDelete={deleteSelected}
vars={vars} scale={scale} onScaleChange={setScale}/>
</div>
</div>
</div>
{/* Rechner */}
<div style={{...S.card,marginTop:12}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
<div style={{...S.head,marginBottom:0}}>RECHNER</div>
{results.length>0&&<button onClick={()=>setResults([])} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'3px 8px'}}> Verlauf</button>}
</div>
{/* Alle Variablen */}
{Object.keys(vars).length>0&&(
<div style={{display:'flex',flexWrap:'wrap',gap:'3px 14px',marginBottom:10,padding:'7px 10px',
background:'rgba(0,0,0,0.3)',borderRadius:7,border:'1px solid rgba(255,255,255,0.05)'}}>
{Object.entries(vars).map(([k,v])=>(
<span key={k} style={{cursor:'pointer'}} onClick={()=>{setFormula(f=>f+k);formulaRef.current?.focus();}}>
<code style={{color:'#4ecdc4',fontSize:10}}>{k}</code>
<span style={{color:'rgba(255,255,255,0.55)',fontFamily:'monospace',fontSize:10}}>={typeof v==='number'?v.toFixed(2):v}</span>
</span>
))}
</div>
)}
{/* Input */}
<div style={{display:'flex',gap:8,marginBottom:8}}>
<input ref={formulaRef} value={formula} onChange={e=>setFormula(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&calcFormula()}
placeholder="z.B. Kasten_l / 2"
autoCapitalize="none"
style={{...S.inp,flex:1,fontFamily:"'Courier New',monospace",fontSize:13}}/>
<button onClick={calcFormula} style={{...S.btn('#4ecdc4'),padding:'0 16px',flexShrink:0}}>= Berechnen</button>
</div>
{/* Operator-Buttons */}
<div style={{display:'grid',gridTemplateColumns:'repeat(5,1fr)',gap:5,marginBottom:10}}>
{['(',')',' × ',' ÷ ',' '].map((b,i)=>(
b.trim()===''?<div key={i}/>:
<button key={i} onClick={()=>insertCalc(b.trim())} style={{
background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',
borderRadius:7,color:'rgba(255,255,255,0.7)',cursor:'pointer',
fontFamily:'monospace',fontSize:14,padding:'6px 0',fontWeight:700,
}}>{b.trim()==='×'?'×':b.trim()==='÷'?'÷':b.trim()}</button>
))}
</div>
{/* Ergebnisse */}
{results.length===0&&Object.keys(vars).length===0&&(
<div style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:11,textAlign:'center',padding:'8px 0'}}>
Formen zeichnen benennen hier rechnen
</div>
)}
{results.map(r=>(
<div key={r.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
padding:'7px 10px',marginBottom:5,borderRadius:7,
background:r.result?'rgba(78,205,196,0.06)':'rgba(255,107,157,0.06)',
border:`1px solid ${r.result?'rgba(78,205,196,0.14)':'rgba(255,107,157,0.14)'}`}}>
<code style={{color:'rgba(255,255,255,0.45)',fontSize:11,fontFamily:"'Courier New',monospace",
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',maxWidth:'60%'}}>
{r.expr}
</code>
<div style={{display:'flex',alignItems:'center',gap:8}}>
<code style={{color:r.result?'#4ecdc4':'#ff6b9d',fontSize:14,fontFamily:"'Courier New',monospace",fontWeight:700}}>
{r.result||'❌ Fehler'}
</code>
<button onClick={()=>setResults(p=>p.filter(x=>x.id!==r.id))}
style={{background:'transparent',border:'none',cursor:'pointer',
color:'rgba(255,255,255,0.5)',fontSize:12,padding:'0 2px',lineHeight:1}}></button>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,510 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, PieChart, Pie, Legend } from 'recharts';
const api = (path, opts={}) => {
const token = localStorage.getItem('sk_token');
return fetch('/api' + path, {
headers: { 'Content-Type':'application/json', ...(token?{'Authorization':'Bearer '+token}:{}) },
...(opts.body ? { method: opts.method||'POST', body: JSON.stringify(opts.body) } : { method: opts.method||'GET' }),
}).then(r => r.json());
};
const S = {
card: { background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.07)', borderRadius:12, padding:'16px 18px', marginBottom:12 },
head: { color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10, letterSpacing:1, marginBottom:10, fontWeight:700 },
val: (c='#fff') => ({ color:c, fontFamily:"'Space Mono',monospace", fontSize:22, fontWeight:700 }),
sub: { color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10 },
inp: { background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.12)', borderRadius:8, padding:'8px 12px', color:'#fff', fontFamily:'monospace', fontSize:12, outline:'none', width:'100%', boxSizing:'border-box' },
btn: (c='#4ecdc4') => ({ background:`${c}18`, border:`1px solid ${c}44`, borderRadius:8, padding:'8px 14px', color:c, fontFamily:'monospace', fontSize:11, cursor:'pointer' }),
};
const CATS = ['Filament','Zubehör','Ersatzteile','Werkzeug','Sonstiges'];
const CAT_COLORS = { Filament:'#4ecdc4', Zubehör:'#ffe66d', Ersatzteile:'#ff6b9d', Werkzeug:'#60a5fa', Sonstiges:'#c084fc' };
const fmt = v => `${(v||0).toFixed(2)}`;
// Lokales Datum als YYYY-MM-DD (kein UTC-Bug wie bei toISOString)
function todayLocal() {
const d = new Date();
const y = d.getFullYear(), m = String(d.getMonth()+1).padStart(2,'0'), day = String(d.getDate()).padStart(2,'0');
return `${y}-${m}-${day}`;
}
const fmtMonth = m => {
if (!m) return '';
if (/^\d{4}-\d{2}$/.test(m)) {
const [y,mo] = m.split('-');
return ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'][parseInt(mo)-1]+' '+y.slice(2);
}
if (/^\d{4}-\d{2}-\d{2}$/.test(m)) {
const [,mo,d] = m.split('-');
return `${d}.${mo}.`;
}
return m;
};
// Bei Wochen-Granularität: zeigt "15.06. 21.06." statt nur den Montag
const fmtMonthRange = (m, granularity) => {
if (!m || granularity !== 'week' || !/^\d{4}-\d{2}-\d{2}$/.test(m)) return fmtMonth(m);
const [y,mo,d] = m.split('-').map(Number);
const start = new Date(y, mo-1, d);
const end = new Date(y, mo-1, d+6);
const f = dt => `${String(dt.getDate()).padStart(2,'0')}.${String(dt.getMonth()+1).padStart(2,'0')}.`;
return `${f(start)} ${f(end)}`;
};
const PERIODS = [
{ id:'week', label:'Diese Woche' },
{ id:'month', label:'Dieser Monat' },
{ id:'year', label:'Dieses Jahr' },
{ id:'custom', label:'Eigener Zeitraum' },
{ id:'all', label:'Gesamt'},
];
// Generiere Monat-Optionen (letzten 24 Monate)
function getMonthOptions() {
const opts = [];
const now = new Date();
for (let i = 0; i < 24; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const val = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`;
const label = d.toLocaleDateString('de-DE', { month:'long', year:'numeric' });
opts.push({ val, label });
}
return opts;
}
// Generiere Jahr-Optionen
function getYearOptions() {
const opts = [];
const now = new Date().getFullYear();
for (let y = now; y >= now - 5; y--) opts.push(y);
return opts;
}
function periodRange(id, custom) {
const now = new Date();
const pad = n => String(n).padStart(2,'0');
const iso = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
if (id==='week') {
const d = new Date(now);
const day = d.getDay() || 7; // 1=Mo … 7=So
d.setDate(d.getDate() - (day - 1)); // zurück auf Montag
return { from:iso(d), to:iso(now) };
}
if (id==='month') { const d=new Date(now.getFullYear(),now.getMonth(),1); return { from:iso(d), to:iso(now) }; }
if (id==='year') { return { from:`${now.getFullYear()}-01-01`, to:iso(now) }; }
if (id==='custom' && custom) {
if (custom.type==='month' && custom.month) {
const [y,m] = custom.month.split('-').map(Number);
const last = new Date(y, m, 0);
return { from:`${y}-${pad(m)}-01`, to:iso(last) };
}
if (custom.type==='year' && custom.year) {
return { from:`${custom.year}-01-01`, to:`${custom.year}-12-31` };
}
}
return {};
}
function KPI({ label, value, color, sub }) {
return (
<div style={{flex:1,minWidth:130}}>
<div style={S.sub}>{label}</div>
<div style={S.val(color)}>{value}</div>
{sub && <div style={{...S.sub,marginTop:2}}>{sub}</div>}
</div>
);
}
// Bestellungen im gewählten Zeitraum
function OrdersInPeriod({ fromDate, toDate }) {
const [orders, setOrders] = useState(null);
useEffect(() => {
const q = (fromDate || toDate) ? `?from=${fromDate}&to=${toDate}` : '';
api(`/tools/statistik/month-orders${q}`).then(setOrders).catch(()=>setOrders([]));
}, [fromDate, toDate]);
const STATUS_ORDER = ['warteliste','in_arbeit','fertig','bezahlt','abgeschlossen'];
const STATUS_COLOR = { warteliste:'#ffe66d', in_arbeit:'#4ecdc4', fertig:'#6bcb77', bezahlt:'#c084fc', abgeschlossen:'#4ade80' };
const STATUS_LABEL = { warteliste:'Warteliste', in_arbeit:'In Arbeit', fertig:'Fertig', bezahlt:'Bezahlt', abgeschlossen:'Abgeschlossen' };
const periodLabel = (fromDate || toDate) ? 'IM ZEITRAUM' : 'GESAMT';
// Gruppieren nach Status
const grouped = {};
(orders||[]).forEach(o => {
const st = !!o.bezahlt && !!o.abgeholt ? 'abgeschlossen' : !!o.bezahlt ? 'bezahlt' : o.status;
if (!grouped[st]) grouped[st] = [];
grouped[st].push({ ...o, _st: st });
});
// Innerhalb jeder Gruppe nach Datum sortieren (neueste zuerst)
Object.values(grouped).forEach(g => g.sort((a,b) => new Date(b.created_at) - new Date(a.created_at)));
return (
<div style={S.card}>
<div style={S.head}>BESTELLUNGEN {periodLabel} {orders ? `(${orders.length})` : ''}</div>
{!orders
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Lädt</div>
: !orders.length
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Keine Bestellungen im Zeitraum.</div>
: STATUS_ORDER.filter(st => grouped[st]?.length).map(st => {
const col = STATUS_COLOR[st];
const group = grouped[st];
const total = group.reduce((s,o)=>s+(o.revenue||0),0);
return (
<div key={st} style={{marginBottom:16}}>
{/* Status-Header mit Summe */}
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',
padding:'6px 10px',background:`${col}10`,borderRadius:8,marginBottom:6}}>
<div style={{display:'flex',alignItems:'center',gap:8}}>
<span style={{background:`${col}20`,border:`1px solid ${col}44`,borderRadius:10,
padding:'2px 10px',color:col,fontFamily:'monospace',fontSize:10}}>
{STATUS_LABEL[st]}
</span>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10}}>
{group.length} Bestellung{group.length!==1?'en':''}
</span>
</div>
{total > 0 && (
<span style={{color:col,fontFamily:"'Space Mono',monospace",fontSize:12,fontWeight:700}}>
Σ {fmt(total)}
</span>
)}
</div>
{/* Einträge */}
{group.map(o => (
<div key={o.id} style={{display:'flex',alignItems:'center',gap:10,padding:'7px 10px',
borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,flexShrink:0,width:60}}>
{new Date(o.created_at).toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit'})}
</span>
<span style={{color:'rgba(255,255,255,0.8)',fontFamily:'monospace',fontSize:12,flex:1}}>
{o.name}
</span>
<span style={{color:o.revenue>0?'#4ade80':'rgba(255,255,255,0.25)',
fontFamily:"'Space Mono',monospace",fontSize:12,fontWeight:700,flexShrink:0}}>
{fmt(o.revenue||0)}
</span>
</div>
))}
</div>
);
})
}
</div>
);
}
export default function Statistik({ mobile, user, toast }) {
const [fromDate, setFromDate] = useState('');
const [toDate, setToDate] = useState(todayLocal());
const [data, setData] = useState(null);
const [loading,setLoading] = useState(false);
const [tab, setTab] = useState('overview');
const [expForm,setExpForm] = useState({ date: todayLocal(), category:'Filament', description:'', amount:'' });
const [saving, setSaving] = useState(false);
function load() {
setLoading(true);
const q = (fromDate || toDate) ? `?from=${fromDate}&to=${toDate}` : '';
api('/tools/statistik/overview'+q)
.then(d => setData(d))
.catch(()=>{})
.finally(()=>setLoading(false));
}
useEffect(() => { load(); }, [fromDate, toDate]);
async function addExpense() {
if (!expForm.description || !expForm.amount) return;
setSaving(true);
try {
await api('/tools/statistik/expenses', { body: expForm });
setExpForm(p => ({ ...p, description:'', amount:'' }));
load();
} finally { setSaving(false); }
}
async function delExpense(id) {
await api(`/tools/statistik/expenses/${id}`, { method:'DELETE' });
load();
}
const netColor = (data?.netProfit||0) >= 0 ? '#4ade80' : '#f87171';
return (
<div style={{ padding: mobile?'14px 14px 90px':'28px 36px', maxWidth:900 }}>
{/* ── Header ── */}
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:20,flexWrap:'wrap',gap:10}}>
<h2 style={{color:'#fff',fontFamily:'monospace',fontSize:18,fontWeight:700,margin:0}}>
📊 3D-Druck Statistik
</h2>
{/* Zeitraum */}
<div style={{display:'flex',gap:8,alignItems:'center',flexWrap:'wrap'}}>
<div style={{display:'flex',alignItems:'center',gap:6}}>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11}}>Von</span>
<input type="date" value={fromDate} onChange={e=>setFromDate(e.target.value)}
style={{background:'rgba(255,255,255,0.06)',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:8,padding:'5px 10px',color:'#fff',fontFamily:'monospace',fontSize:11,cursor:'pointer'}}/>
</div>
<div style={{display:'flex',alignItems:'center',gap:6}}>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11}}>Bis</span>
<input type="date" value={toDate} onChange={e=>setToDate(e.target.value)}
style={{background:'rgba(255,255,255,0.06)',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:8,padding:'5px 10px',color:'#fff',fontFamily:'monospace',fontSize:11,cursor:'pointer'}}/>
</div>
{fromDate && (
<button onClick={()=>{setFromDate('');setToDate(todayLocal());}}
style={{background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.1)',
borderRadius:8,padding:'5px 10px',color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11,cursor:'pointer'}}>
Zurücksetzen
</button>
)}
</div>
</div>
{/* ── Tabs ── */}
<div style={{display:'flex',gap:4,marginBottom:16,overflowX:'auto',overflowY:'hidden',
scrollbarWidth:'none',WebkitOverflowScrolling:'touch',paddingBottom:2}}>
{[['overview','📈 Übersicht'],['revenue','💰 Einnahmen'],['expenses','💸 Ausgaben'],['orders','📦 Bestellungen']].map(([id,label])=>(
<button key={id} onClick={()=>setTab(id)} style={{
background: tab===id ? 'rgba(255,255,255,0.08)' : 'transparent',
border: `1px solid ${tab===id ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.08)'}`,
borderRadius:8, padding:'6px 14px', flexShrink:0, whiteSpace:'nowrap',
color: tab===id ? '#fff' : 'rgba(255,255,255,0.4)',
fontFamily:'monospace', fontSize:11, cursor:'pointer',
}}>{label}</button>
))}
</div>
{loading && <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Lädt</div>}
{/* ══ ÜBERSICHT ══ */}
{tab==='overview' && data && (
<>
{/* KPI Kacheln */}
<div style={{...S.card}}>
<div style={S.head}>EINNAHMEN & GEWINN</div>
<div style={{display:'flex',flexWrap:'wrap',gap:20,marginBottom:16}}>
<KPI label="Eingenommen" value={fmt(data.totalRevenue)} color="#4ade80" sub="bezahlt"/>
<KPI label="Offen" value={fmt(data.openRevenue)} color="#ffe66d" sub="in Arbeit/Fertig"/>
<KPI label="Materialkosten" value={fmt(data.totalBaseCost)} color="#f87171" sub="Selbstkosten"/>
<KPI label="Rohgewinn" value={fmt(data.totalProfit)} color="#60a5fa" sub="nach Materialkosten"/>
</div>
<div style={{borderTop:'1px solid rgba(255,255,255,0.06)',paddingTop:16,display:'flex',flexWrap:'wrap',gap:20}}>
<KPI label="Ausgaben" value={fmt(data.totalExpenses)} color="#f87171" sub="Einkäufe"/>
<KPI label="NETTOGEWINN" value={fmt(data.netProfit)} color={netColor} sub="Rohgewinn Ausgaben"/>
<KPI label="Bestellungen" value={data.totalOrders} color="rgba(255,255,255,0.6)" sub="im Zeitraum"/>
</div>
</div>
{/* Status-Verteilung */}
<div style={{...S.card}}>
<div style={S.head}>BESTELLUNGEN NACH STATUS</div>
<div style={{display:'flex',flexWrap:'wrap',gap:8}}>
{[
['Warteliste', data.byStatus.warteliste, '#ffe66d'],
['In Arbeit', data.byStatus.in_arbeit, '#4ecdc4'],
['Fertig', data.byStatus.fertig, '#6bcb77'],
['Bezahlt', data.byStatus.bezahlt, '#c084fc'],
['Abgeschlossen', data.byStatus.abgeschlossen, '#4ade80'],
].map(([label, val, color]) => val > 0 && (
<div key={label} style={{background:`${color}12`,border:`1px solid ${color}33`,
borderRadius:8,padding:'8px 14px',textAlign:'center'}}>
<div style={{color,fontFamily:"'Space Mono',monospace",fontSize:18,fontWeight:700}}>{val}</div>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:9}}>{label}</div>
</div>
))}
</div>
</div>
{/* Balkendiagramm Einnahmen vs Ausgaben */}
{data.monthlyData.length > 0 && (
<div style={S.card}>
<div style={S.head}>EINNAHMEN VS. AUSGABEN {data.granularity==='day'?'(TÄGLICH)':data.granularity==='week'?'(WÖCHENTLICH)':'(MONATLICH)'}</div>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={data.monthlyData} margin={{top:0,right:0,bottom:0,left:0}}>
<XAxis dataKey="month" tickFormatter={fmtMonth} tick={{fill:'rgba(255,255,255,0.3)',fontSize:10}} axisLine={false} tickLine={false}/>
<YAxis tick={{fill:'rgba(255,255,255,0.3)',fontSize:10}} axisLine={false} tickLine={false} tickFormatter={v=>`${v}`} width={45}/>
<Tooltip contentStyle={{background:'#1a1d2e',border:'1px solid rgba(255,255,255,0.1)',borderRadius:8,fontFamily:'monospace',fontSize:11,color:'#fff'}}
labelStyle={{color:'rgba(255,255,255,0.6)'}} itemStyle={{color:'#fff'}}
formatter={(v,n)=>[`${v.toFixed(2)}`, n==='revenue'?'Einnahmen':'Ausgaben']}
labelFormatter={m => fmtMonthRange(m, data.granularity)}/>
<Bar dataKey="revenue" fill="#4ade80" radius={[4,4,0,0]} maxBarSize={28}/>
<Bar dataKey="expenses" fill="#f87171" radius={[4,4,0,0]} maxBarSize={28}/>
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* Kumulierter Gewinn + Ausgaben nach Kategorie */}
<div style={{display:'flex',gap:12,flexWrap:'wrap'}}>
{data.cumulativeData.length > 1 && (
<div style={{...S.card,flex:1,minWidth:200}}>
<div style={S.head}>NETTOGEWINN IM ZEITVERLAUF</div>
<ResponsiveContainer width="100%" height={160}>
<LineChart data={data.cumulativeData} margin={{top:5,right:5,bottom:0,left:0}}>
<XAxis dataKey="month" tickFormatter={fmtMonth} tick={{fill:'rgba(255,255,255,0.3)',fontSize:9}} axisLine={false} tickLine={false}/>
<YAxis tick={{fill:'rgba(255,255,255,0.3)',fontSize:9}} axisLine={false} tickLine={false} tickFormatter={v=>`${v}`} width={42}/>
<Tooltip contentStyle={{background:'#1a1d2e',border:'1px solid rgba(255,255,255,0.1)',borderRadius:8,fontFamily:'monospace',fontSize:11,color:'#fff'}}
labelStyle={{color:'rgba(255,255,255,0.6)'}} itemStyle={{color:'#4ecdc4'}}
formatter={v=>[`${v.toFixed(2)}`, 'Gewinn kumuliert']} labelFormatter={m => fmtMonthRange(m, data.granularity)}/>
<Line type="monotone" dataKey="value" stroke="#4ecdc4" strokeWidth={2} dot={false}/>
</LineChart>
</ResponsiveContainer>
</div>
)}
{Object.keys(data.byCategory).length > 0 && (
<div style={{...S.card,flex:1,minWidth:200}}>
<div style={S.head}>AUSGABEN NACH KATEGORIE</div>
<ResponsiveContainer width="100%" height={160}>
<PieChart>
<Pie data={Object.entries(data.byCategory).map(([name,value])=>({name,value}))}
cx="50%" cy="50%" innerRadius={40} outerRadius={65}
dataKey="value" paddingAngle={3}>
{Object.entries(data.byCategory).map(([name],i)=>(
<Cell key={name} fill={CAT_COLORS[name]||'#888'}/>
))}
</Pie>
<Tooltip contentStyle={{background:'#1a1d2e',border:'1px solid rgba(255,255,255,0.1)',borderRadius:8,fontFamily:'monospace',fontSize:11,color:'#fff'}}
labelStyle={{color:'rgba(255,255,255,0.6)'}} itemStyle={{color:'#fff'}}
formatter={v=>[`${v.toFixed(2)}`]}/>
<Legend iconType="circle" iconSize={8}
formatter={v=><span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:9}}>{v}</span>}/>
</PieChart>
</ResponsiveContainer>
</div>
)}
</div>
</>
)}
{/* ══ AUSGABEN ══ */}
{tab==='expenses' && (
<>
{/* Formular */}
<div style={S.card}>
<div style={S.head}>NEUE AUSGABE</div>
<div style={{display:'flex',gap:8,flexWrap:'wrap'}}>
<input type="date" value={expForm.date}
onChange={e=>setExpForm(p=>({...p,date:e.target.value}))}
style={{...S.inp,width:140}}/>
<select value={expForm.category}
onChange={e=>setExpForm(p=>({...p,category:e.target.value}))}
style={{...S.inp,width:130}}>
{CATS.map(c=><option key={c}>{c}</option>)}
</select>
<input placeholder="Beschreibung" value={expForm.description}
onChange={e=>setExpForm(p=>({...p,description:e.target.value}))}
style={{...S.inp,flex:1,minWidth:140}}/>
<input type="number" placeholder="0.00" step="0.01" min="0" value={expForm.amount}
onChange={e=>setExpForm(p=>({...p,amount:e.target.value}))}
onKeyDown={e=>e.key==='Enter'&&addExpense()}
style={{...S.inp,width:90,textAlign:'right'}}/>
<button onClick={addExpense} disabled={saving}
style={{...S.btn('#4ade80'),opacity:saving?0.5:1,whiteSpace:'nowrap'}}>
{saving?'…':'+ Hinzufügen'}
</button>
</div>
</div>
{/* Liste */}
<div style={S.card}>
<div style={S.head}>AUSGABEN {(fromDate||toDate!==todayLocal())&&'(IM ZEITRAUM)'}</div>
{!data?.expenses?.length
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Keine Ausgaben im gewählten Zeitraum.</div>
: data.expenses.map(e => (
<div key={e.id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 0',
borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,flexShrink:0,width:80}}>
{e.date}
</span>
<span style={{background:`${CAT_COLORS[e.category]||'#888'}18`,
border:`1px solid ${CAT_COLORS[e.category]||'#888'}44`,
borderRadius:6,padding:'2px 8px',color:CAT_COLORS[e.category]||'#888',
fontFamily:'monospace',fontSize:9,flexShrink:0}}>
{e.category}
</span>
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:12,flex:1}}>
{e.description}
</span>
<span style={{color:'#f87171',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700,flexShrink:0}}>
{fmt(e.amount)}
</span>
<button onClick={()=>delExpense(e.id)}
style={{background:'rgba(248,113,113,0.1)',border:'1px solid rgba(248,113,113,0.2)',
borderRadius:6,padding:'3px 8px',color:'#f87171',fontFamily:'monospace',fontSize:10,cursor:'pointer',flexShrink:0}}>
</button>
</div>
))
}
{data?.expenses?.length > 0 && (
<div style={{display:'flex',justifyContent:'flex-end',paddingTop:10}}>
<span style={{color:'#f87171',fontFamily:"'Space Mono',monospace",fontSize:14,fontWeight:700}}>
Σ {fmt(data.totalExpenses)}
</span>
</div>
)}
</div>
</>
)}
{/* ══ EINNAHMEN ══ */}
{tab==='revenue' && data && (
<div style={S.card}>
<div style={S.head}>
IM ZEITRAUM BEZAHLTE AUFTRÄGE ({data.paidOrders?.length||0}) · {fmt(data.totalRevenue)} EINGENOMMEN
</div>
{!data.paidOrders?.length
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>
Keine bezahlten Aufträge im gewählten Zeitraum.
</div>
: data.paidOrders.map(o => (
<div key={o.id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 0',
borderBottom:'1px solid rgba(255,255,255,0.05)',flexWrap:'wrap'}}>
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,flexShrink:0}}>
{o.bezahlt_am ? new Date(o.bezahlt_am).toLocaleDateString('de-DE') : '—'}
</span>
<span style={{color:'rgba(255,255,255,0.8)',fontFamily:'monospace',fontSize:12,flex:1}}>
{o.name}
</span>
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,flexShrink:0}}>
Erstellt: {new Date(o.created_at).toLocaleDateString('de-DE')}
</span>
<div style={{display:'flex',gap:6,flexShrink:0}}>
<span style={{color:'#f87171',fontFamily:"'Space Mono',monospace",fontSize:11}}>
-{fmt(o.base_cost)}
</span>
<span style={{color:'#4ade80',fontFamily:"'Space Mono',monospace",fontSize:12,fontWeight:700}}>
{fmt(o.revenue)}
</span>
</div>
</div>
))
}
{data.paidOrders?.length > 0 && (
<div style={{display:'flex',justifyContent:'space-between',paddingTop:10,
borderTop:'1px solid rgba(255,255,255,0.07)'}}>
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11}}>
Materialkosten: {fmt(data.totalBaseCost)}
</span>
<span style={{color:'#60a5fa',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
Rohgewinn: {fmt(data.totalProfit)}
</span>
</div>
)}
</div>
)}
{/* ══ BESTELLUNGEN (dieser Monat) ══ */}
{tab==='orders' && (
<OrdersInPeriod fromDate={fromDate} toDate={toDate}/>
)}
</div>
);
}

View File

@@ -1,132 +0,0 @@
import { useState } from 'react';
import { S } from '../lib.js';
// ── Wettrechner (Surebet / Arbitrage) ────────────────────────────────────────
// Prinzip: bei 3 sich gegenseitig ausschließenden Ergebnissen mit Quoten
// o1,o2,o3 existiert ein garantierter Gewinn, wenn 1/o1 + 1/o2 + 1/o3 < 1
// ("Arbitrage-Prozentsatz" < 100%). Der Einsatz pro Ergebnis wird proportional
// zu 1/o_i verteilt — dadurch ist die Auszahlung bei JEDEM der drei Ausgänge
// exakt gleich hoch.
function parseOdd(v) {
const n = parseFloat(String(v).replace(',', '.'));
return Number.isFinite(n) && n > 1 ? n : null;
}
function parseMoney(v) {
const n = parseFloat(String(v).replace(',', '.'));
return Number.isFinite(n) && n > 0 ? n : null;
}
const fmtEUR = n => n.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
const fmtPct = n => n.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' %';
export default function Wettrechner() {
const [stake, setStake] = useState('100');
const [outcomes, setOutcomes] = useState([
{ label: '', odd: '' },
{ label: '', odd: '' },
{ label: '', odd: '' },
]);
const setOutcome = (i, field, value) => {
setOutcomes(prev => prev.map((o, idx) => idx === i ? { ...o, [field]: value } : o));
};
const budget = parseMoney(stake);
const odds = outcomes.map(o => parseOdd(o.odd));
const allOddsValid = odds.every(o => o !== null);
const canCalculate = allOddsValid && budget !== null;
let result = null;
if (canCalculate) {
const invProbs = odds.map(o => 1 / o);
const sumInv = invProbs.reduce((a, b) => a + b, 0);
const arbitragePercent = sumInv * 100;
const isProfitable = sumInv < 1;
const stakes = invProbs.map(p => budget * p / sumInv);
const payout = budget / sumInv;
const profit = payout - budget;
const profitPercent = (payout / budget - 1) * 100;
result = { arbitragePercent, isProfitable, stakes, payout, profit, profitPercent };
}
return (
<div style={{ padding: 20, maxWidth: 640 }}>
<div style={{ ...S.head, marginBottom: 4 }}>WETTRECHNER</div>
<div style={{ ...S.sub, marginBottom: 16 }}>
Trag die Quoten für 3 sich gegenseitig ausschließende Ergebnisse ein (z.B. Sieg A / Unentschieden / Sieg B).
Der Rechner sagt dir, wie viel du auf jedes Ergebnis setzen musst, damit die Auszahlung immer gleich hoch ist
egal welches Ergebnis eintritt.
</div>
<div style={S.card}>
<div style={{ ...S.sub, marginBottom: 6 }}>GESAMTEINSATZ</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<span style={{ color: 'rgba(255,255,255,0.4)', fontFamily: "'Space Mono',monospace" }}></span>
<input value={stake} onChange={e => setStake(e.target.value)} inputMode="decimal"
placeholder="100" style={{ ...S.inp, maxWidth: 160 }} />
</div>
{outcomes.map((o, i) => (
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
<input value={o.label} onChange={e => setOutcome(i, 'label', e.target.value)}
placeholder={`Ergebnis ${i + 1} (optional, z.B. "Sieg A")`}
style={{ ...S.inp, flex: 2, minWidth: 160 }} />
<input value={o.odd} onChange={e => setOutcome(i, 'odd', e.target.value)} inputMode="decimal"
placeholder="Quote, z.B. 2.50" style={{ ...S.inp, flex: 1, minWidth: 100 }} />
</div>
))}
</div>
{!canCalculate && (
<div style={{ ...S.sub, marginTop: 12 }}>
Bitte Gesamteinsatz und alle drei Quoten (jeweils größer als 1) eintragen.
</div>
)}
{result && (
<div style={{ ...S.card, marginTop: 16, borderColor: result.isProfitable ? 'rgba(74,222,128,0.35)' : 'rgba(248,113,113,0.35)' }}>
<div style={{
fontFamily: "'Space Mono',monospace", fontSize: 13, fontWeight: 700, marginBottom: 14,
color: result.isProfitable ? '#4ade80' : '#f87171',
}}>
{result.isProfitable
? `✓ Garantierter Gewinn möglich (Arbitrage: ${fmtPct(result.arbitragePercent)})`
: `✕ Kein garantierter Gewinn möglich (Summe der Quoten-Kehrwerte: ${fmtPct(result.arbitragePercent)}, muss unter 100 % liegen)`}
</div>
{outcomes.map((o, i) => (
<div key={i} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
padding: '8px 0', borderBottom: i < 2 ? '1px solid rgba(255,255,255,0.06)' : 'none',
}}>
<span style={{ color: 'rgba(255,255,255,0.7)', fontFamily: 'monospace', fontSize: 13 }}>
{o.label || `Ergebnis ${i + 1}`} <span style={{ color: 'rgba(255,255,255,0.35)' }}>(Quote {odds[i]})</span>
</span>
<span style={{ color: '#ffe66d', fontFamily: "'Space Mono',monospace", fontSize: 16, fontWeight: 700 }}>
{fmtEUR(result.stakes[i])}
</span>
</div>
))}
<div style={{ marginTop: 14, paddingTop: 14, borderTop: '1px solid rgba(255,255,255,0.1)', display: 'flex', flexWrap: 'wrap', gap: 20 }}>
<div>
<div style={{ ...S.sub, marginBottom: 2 }}>Auszahlung (immer gleich)</div>
<div style={{ color: '#4ecdc4', fontFamily: "'Space Mono',monospace", fontSize: 16, fontWeight: 700 }}>
{fmtEUR(result.payout)}
</div>
</div>
<div>
<div style={{ ...S.sub, marginBottom: 2 }}>Gewinn / Verlust</div>
<div style={{
color: result.isProfitable ? '#4ade80' : '#f87171',
fontFamily: "'Space Mono',monospace", fontSize: 16, fontWeight: 700,
}}>
{result.profit >= 0 ? '+' : ''}{fmtEUR(result.profit)} ({result.profitPercent >= 0 ? '+' : ''}{fmtPct(result.profitPercent)})
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,975 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { api, S } from '../lib.js';
// ── Konstanten ────────────────────────────────────────────────────────────────
const COLORS = ['#FFFFFF','#FF6B9D','#4ECDC4','#FFE66D','#60A5FA','#A78BFA','#FB923C','#4ADE80','#F87171','#000000'];
const SIZES = [2, 4, 8, 14, 22];
const FONT_SIZES = [12, 16, 20, 28, 40, 56];
const TOOLS = [
{ id:'select', label:'↖', tip:'Auswählen / Verschieben' },
{ id:'pen', label:'✏', tip:'Stift' },
{ id:'line', label:'', tip:'Linie' },
{ id:'rect', label:'▭', tip:'Rechteck' },
{ id:'ellipse', label:'◯', tip:'Ellipse' },
{ id:'text', label:'T', tip:'Text' },
{ id:'eraser', label:'🧹', tip:'Radierer' },
];
const CURSOR_COLORS = ['#FF6B9D','#4ECDC4','#FFE66D','#60A5FA','#A78BFA','#FB923C'];
const BG = '#0D0D0F';
const getId = () => Math.random().toString(36).slice(2);
const HANDLE_R = 6; // Resize-Handle Radius in Screen-Px
// ── Bounding-Box-Berechnung ───────────────────────────────────────────────────
function getBounds(el) {
switch (el.type) {
case 'pen': case 'eraser': {
if (!el.points?.length) return null;
const xs = el.points.map(p => p.x), ys = el.points.map(p => p.y);
const pad = (el.size||4) * (el.type==='eraser'?3:1);
return { x: Math.min(...xs)-pad, y: Math.min(...ys)-pad,
w: Math.max(...xs)-Math.min(...xs)+pad*2, h: Math.max(...ys)-Math.min(...ys)+pad*2 };
}
case 'line': {
const pad = el.size||4;
return { x: Math.min(el.x1,el.x2)-pad, y: Math.min(el.y1,el.y2)-pad,
w: Math.abs(el.x2-el.x1)+pad*2, h: Math.abs(el.y2-el.y1)+pad*2 };
}
case 'rect':
return { x: Math.min(el.x,el.x+el.w), y: Math.min(el.y,el.y+el.h),
w: Math.abs(el.w), h: Math.abs(el.h) };
case 'ellipse':
return { x: el.cx-Math.abs(el.rx), y: el.cy-Math.abs(el.ry),
w: Math.abs(el.rx)*2, h: Math.abs(el.ry)*2 };
case 'text': {
const fs = el.fontSize || 20;
const w = (el.text?.length || 4) * fs * 0.6;
return { x: el.x, y: el.y - fs, w, h: fs * 1.3 };
}
default: return null;
}
}
// Punkt-in-Bounds Hit-Test
function hitTest(el, cx, cy) {
const b = getBounds(el);
if (!b) return false;
return cx >= b.x && cx <= b.x+b.w && cy >= b.y && cy <= b.y+b.h;
}
// ── Element verschieben ───────────────────────────────────────────────────────
function moveElement(el, dx, dy) {
switch (el.type) {
case 'pen': case 'eraser':
return { ...el, points: el.points.map(p => ({ x: p.x+dx, y: p.y+dy })) };
case 'line':
return { ...el, x1: el.x1+dx, y1: el.y1+dy, x2: el.x2+dx, y2: el.y2+dy };
case 'rect':
return { ...el, x: el.x+dx, y: el.y+dy };
case 'ellipse':
return { ...el, cx: el.cx+dx, cy: el.cy+dy };
case 'text':
return { ...el, x: el.x+dx, y: el.y+dy };
default: return el;
}
}
// ── Element skalieren (Resize via Ecke rechts-unten) ─────────────────────────
function resizeElement(el, nx, ny) {
const b = getBounds(el);
if (!b) return el;
const scaleX = b.w > 1 ? Math.max(0.05, (nx - b.x) / b.w) : 1;
const scaleY = b.h > 1 ? Math.max(0.05, (ny - b.y) / b.h) : 1;
switch (el.type) {
case 'pen': case 'eraser':
return { ...el, points: el.points.map(p => ({ x: b.x + (p.x-b.x)*scaleX, y: b.y + (p.y-b.y)*scaleY })) };
case 'line':
return { ...el, x1: b.x+(el.x1-b.x)*scaleX, y1: b.y+(el.y1-b.y)*scaleY,
x2: b.x+(el.x2-b.x)*scaleX, y2: b.y+(el.y2-b.y)*scaleY };
case 'rect':
return { ...el, w: el.w * scaleX, h: el.h * scaleY };
case 'ellipse':
return { ...el, rx: el.rx * scaleX, ry: el.ry * scaleY };
case 'text':
return { ...el, fontSize: Math.max(8, Math.round((el.fontSize||20) * ((scaleX+scaleY)/2))) };
default: return el;
}
}
// ── Canvas rendern ────────────────────────────────────────────────────────────
function renderElements(ctx, elements, vp, selectedId, forExport) {
const W = ctx.canvas.width, H = ctx.canvas.height;
ctx.save();
// Hintergrund
ctx.fillStyle = BG;
ctx.fillRect(0, 0, W, H);
ctx.translate(vp.x, vp.y);
ctx.scale(vp.zoom, vp.zoom);
for (const el of elements) {
ctx.save();
ctx.globalAlpha = 1;
ctx.strokeStyle = el.color || '#fff';
ctx.fillStyle = el.color || '#fff';
ctx.lineWidth = (el.size || 4) / vp.zoom;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
switch (el.type) {
case 'pen': {
if (!el.points?.length) break;
ctx.beginPath();
ctx.moveTo(el.points[0].x, el.points[0].y);
for (const p of el.points.slice(1)) ctx.lineTo(p.x, p.y);
ctx.stroke();
break;
}
case 'eraser': {
if (!el.points?.length) break;
ctx.strokeStyle = BG;
ctx.lineWidth = (el.size||4) * 3 / vp.zoom;
ctx.beginPath();
ctx.moveTo(el.points[0].x, el.points[0].y);
for (const p of el.points.slice(1)) ctx.lineTo(p.x, p.y);
ctx.stroke();
break;
}
case 'line': {
ctx.beginPath(); ctx.moveTo(el.x1, el.y1); ctx.lineTo(el.x2, el.y2); ctx.stroke();
break;
}
case 'rect':
ctx.strokeRect(el.x, el.y, el.w, el.h);
break;
case 'ellipse':
ctx.beginPath();
ctx.ellipse(el.cx, el.cy, Math.abs(el.rx||1), Math.abs(el.ry||1), 0, 0, Math.PI*2);
ctx.stroke();
break;
case 'text': {
const fs = el.fontSize || 20;
ctx.font = `${fs}px monospace`;
ctx.fillText(el.text, el.x, el.y);
break;
}
}
ctx.restore();
// Auswahl-Rahmen + Resize-Handle
if (!forExport && selectedId === el.id) {
const b = getBounds(el);
if (b) {
ctx.save();
ctx.strokeStyle = '#4ECDC4';
ctx.lineWidth = 1.5 / vp.zoom;
ctx.setLineDash([4/vp.zoom, 3/vp.zoom]);
ctx.strokeRect(b.x-2/vp.zoom, b.y-2/vp.zoom, b.w+4/vp.zoom, b.h+4/vp.zoom);
ctx.setLineDash([]);
// Resize-Handle rechts-unten
const hx = b.x+b.w, hy = b.y+b.h, hr = HANDLE_R/vp.zoom;
ctx.fillStyle = '#4ECDC4';
ctx.beginPath(); ctx.arc(hx, hy, hr, 0, Math.PI*2); ctx.fill();
ctx.restore();
}
}
}
ctx.restore();
}
// Offscreen-Export (ohne Auswahl-Rahmen, original Viewport)
function exportToPng(elements, title) {
if (!elements?.length) return; // Nichts zu exportieren
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
for (const el of elements) {
const b = getBounds(el); if (!b) continue;
minX=Math.min(minX,b.x); minY=Math.min(minY,b.y);
maxX=Math.max(maxX,b.x+b.w); maxY=Math.max(maxY,b.y+b.h);
}
if (!isFinite(minX)) { minX=0; minY=0; maxX=800; maxY=600; }
const pad=40, W=Math.max(800,maxX-minX+pad*2), H=Math.max(600,maxY-minY+pad*2);
const offscreen = document.createElement('canvas');
offscreen.width=W; offscreen.height=H;
const ctx = offscreen.getContext('2d');
renderElements(ctx, elements, { x: -minX+pad, y: -minY+pad, zoom:1 }, null, true);
const a = document.createElement('a');
a.href = offscreen.toDataURL('image/png');
a.download = (title||'whiteboard').replace(/[^\w\s-]/g,'').trim()+'.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// ── Share-Modal ───────────────────────────────────────────────────────────────
function ShareModal({ wbId, onClose, toast }) {
const [allUsers, setAllUsers] = useState([]);
const [perms, setPerms] = useState([]);
const [saving, setSaving] = useState(false);
useEffect(() => {
Promise.all([api('/tools/whiteboard/users-list'), api(`/tools/whiteboard/${wbId}/data`)])
.then(([u,d]) => { setAllUsers(u.users||[]); setPerms(d.permissions||[]); }).catch(()=>{});
}, [wbId]);
const roleOf = uid => perms.find(p=>p.user_id===uid)?.role||'none';
const setRole = (uid, role) => setPerms(prev => {
const f = prev.filter(p=>p.user_id!==uid);
return role==='none' ? f : [...f,{user_id:uid,role}];
});
const save = async () => {
setSaving(true);
try {
await api(`/tools/whiteboard/${wbId}/permissions`,{ method:'PUT',
body:{ permissions: allUsers.map(u=>({ user_id:u.id, role: roleOf(u.id)==='none'?null:roleOf(u.id) })) }});
toast('Berechtigungen gespeichert ✓'); onClose();
} catch(e){ toast(e.message,'error'); } finally { setSaving(false); }
};
const mob = window.innerWidth < 768;
return (
<div onClick={e=>e.target===e.currentTarget&&onClose()} style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.75)',zIndex:2000,
display:'flex',alignItems:mob?'flex-end':'center',justifyContent:'center',padding:mob?0:20}}>
<div style={{background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.12)',borderRadius:mob?'16px 16px 0 0':14,
width:'100%',maxWidth:440,display:'flex',flexDirection:'column',maxHeight:mob?'80vh':'75vh',
paddingBottom:mob?'calc(56px + env(safe-area-inset-bottom,0px))':0}}>
<div style={{padding:'18px 20px 0',flexShrink:0}}>
{mob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
<div style={{...S.head,marginBottom:0}}>TEILEN</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}></button>
</div>
</div>
<div style={{overflowY:'auto',padding:'0 20px',flex:1}}>
{allUsers.length===0&&<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Keine anderen Benutzer.</div>}
{allUsers.map(u=>(
<div key={u.id} style={{display:'flex',alignItems:'center',gap:8,padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<span style={{flex:1,fontFamily:'monospace',fontSize:13,color:'#fff'}}>{u.username}</span>
{['none','view','edit'].map(r=>(
<button key={r} onClick={()=>setRole(u.id,r)} style={{...S.btn(r==='edit'?'#4ECDC4':r==='view'?'#60A5FA':'#888888',true),
fontSize:10,padding:'3px 8px',
background:roleOf(u.id)===r?(r==='edit'?'rgba(78,205,196,0.2)':r==='view'?'rgba(96,165,250,0.2)':'rgba(136,136,136,0.2)'):'transparent',
fontWeight:roleOf(u.id)===r?700:400}}>
{r==='none'?'✕ kein':r==='view'?'👁 lesen':'✏ bearbeiten'}
</button>
))}
</div>
))}
</div>
<div style={{padding:'14px 20px 20px',flexShrink:0,borderTop:'1px solid rgba(255,255,255,0.07)',display:'flex',gap:8,justifyContent:'flex-end'}}>
<button onClick={onClose} style={S.btn('#888888',true)}>Abbrechen</button>
<button onClick={save} disabled={saving} style={S.btn('#4ECDC4',true)}>{saving?'…':'Speichern'}</button>
</div>
</div>
</div>
);
}
// ── Hauptkomponente ───────────────────────────────────────────────────────────
export default function Whiteboard({ toast, mobile }) {
// View
const [view, setView] = useState('list');
const [boards, setBoards] = useState([]);
const [unreadIds, setUnreadIds] = useState(new Set());
const [loading, setLoading] = useState(true);
const [current, setCurrent] = useState(null);
const [creating, setCreating] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [editingId, setEditingId] = useState(null);
const [editTitle, setEditTitle] = useState('');
// Canvas
const [elements, setElements] = useState([]);
const [undoStack, setUndoStack] = useState([]);
const [tool, setTool] = useState('pen');
const [color, setColor] = useState('#FFFFFF');
const [size, setSize] = useState(1);
const [fontSize, setFontSize] = useState(2); // Index in FONT_SIZES
const [viewport, setViewport] = useState({x:0,y:0,zoom:1});
const [saving, setSaving] = useState(false);
const [shareModal,setShareModal]= useState(false);
const [textInput, setTextInput] = useState(null);
const [textPreview, setTextPreview] = useState(null); // Live-Vorschau während Tippen
const [selectedId,setSelectedId]= useState(null);
// Kollaboration
const [collab, setCollab] = useState([]);
const wsRef = useRef(null);
const cursorTimers = useRef({});
// Canvas Refs
const canvasRef = useRef(null);
const drawing = useRef(false);
const currentEl = useRef(null);
const startPos = useRef({x:0,y:0});
const isPanning = useRef(false);
const isDragging = useRef(false); // Element verschieben
const isResizing = useRef(false); // Element resize
const panStart = useRef({x:0,y:0});
const dragStart = useRef({x:0,y:0}); // Startpos für Drag
const vpRef = useRef({x:0,y:0,zoom:1});
const elementsRef = useRef([]);
const selectedRef = useRef(null);
const textPreviewRef = useRef(null);
// ── Hilfsfunktionen ──────────────────────────────────────────────────────
const syncVp = (vp) => { vpRef.current=vp; setViewport({...vp}); };
const toCanvas = (e) => {
const rect = canvasRef.current.getBoundingClientRect();
const sx = (e.clientX ?? e.touches?.[0]?.clientX ?? 0) - rect.left;
const sy = (e.clientY ?? e.touches?.[0]?.clientY ?? 0) - rect.top;
return { x:(sx-vpRef.current.x)/vpRef.current.zoom, y:(sy-vpRef.current.y)/vpRef.current.zoom, sx, sy };
};
// Ist der Pointer auf dem Resize-Handle des selektierten Elements?
const onResizeHandle = (cx, cy) => {
if (!selectedRef.current) return false;
const el = elementsRef.current.find(e=>e.id===selectedRef.current);
if (!el) return false;
const b = getBounds(el); if (!b) return false;
const hxS = (b.x+b.w)*vpRef.current.zoom + vpRef.current.x;
const hyS = (b.y+b.h)*vpRef.current.zoom + vpRef.current.y;
const rect = canvasRef.current.getBoundingClientRect();
const mxS = cx + rect.left - rect.left;
const myS = cy + rect.top - rect.top;
// cx/cy sind schon in Screen-Koordinaten (sx,sy aus toCanvas)
return Math.hypot(cx - (b.x+b.w)*vpRef.current.zoom - vpRef.current.x,
cy - (b.y+b.h)*vpRef.current.zoom - vpRef.current.y) <= HANDLE_R + 4;
};
// ── Element-Operationen ──────────────────────────────────────────────────
const broadcast = (els) => {
wsRef.current?.readyState===1 && wsRef.current.send(JSON.stringify({type:'elements',elements:els}));
};
const pushElement = (el) => {
const next = [...elementsRef.current, el];
elementsRef.current = next;
setUndoStack(prev => [...prev, elementsRef.current.slice(0,-1)]);
setElements([...next]);
broadcast(next);
};
const updateLastElement = (el) => {
const next = [...elementsRef.current.slice(0,-1), el];
elementsRef.current = next;
setElements([...next]);
};
const finalizeLastElement = (el) => {
const next = [...elementsRef.current.slice(0,-1), el];
elementsRef.current = next;
setElements([...next]);
broadcast(next);
};
const updateElement = (id, updater) => {
const next = elementsRef.current.map(e => e.id===id ? updater(e) : e);
elementsRef.current = next;
setElements([...next]);
broadcast(next);
setUndoStack(prev => [...prev, elementsRef.current]);
};
const undo = () => {
if (!undoStack.length) return;
const prev = undoStack[undoStack.length-1];
setUndoStack(s => s.slice(0,-1));
elementsRef.current = prev;
setElements([...prev]);
broadcast(prev);
setSelectedId(null); selectedRef.current=null;
};
// ── Laden / Speichern ────────────────────────────────────────────────────
const loadBoards = useCallback(() => {
setLoading(true);
Promise.all([
api('/tools/whiteboard'),
api('/tools/whiteboard/unread').catch(() => ({ count: 0 })),
]).then(([d, u]) => {
setBoards(d.whiteboards || []);
// unread count kommt global — wir markieren alle als "möglicherweise unread"
// getGameForUser gibt uns keine IDs, also nutzen wir den globalen count als Indikator
}).catch(() => toast('Fehler', 'error')).finally(() => setLoading(false));
// Einzelne unread IDs vom Backend holen
api('/tools/whiteboard/unread-ids').then(d => setUnreadIds(new Set(d.ids || []))).catch(() => {});
}, []);
const renameBoard = async (id, title) => {
if (!title?.trim()) return;
try {
await api(`/tools/whiteboard/${id}/title`,{method:'PATCH',body:{title:title.trim()}});
setBoards(prev=>prev.map(b=>b.id===id?{...b,title:title.trim()}:b));
if (current?.id===id) setCurrent(prev=>({...prev,title:title.trim()}));
} catch(e){ toast(e.message,'error'); } finally { setEditingId(null); }
};
useEffect(()=>{ loadBoards(); },[loadBoards]);
const openBoard = async (board) => {
setUnreadIds(prev => { const s = new Set(prev); s.delete(board.id); return s; });
try {
const d = await api(`/tools/whiteboard/${board.id}/data`);
const els = JSON.parse(typeof d.elements==='string'?d.elements:JSON.stringify(d.elements||[]));
const vp = JSON.parse(typeof d.viewport==='string'?d.viewport:JSON.stringify(d.viewport||{x:0,y:0,zoom:1}));
elementsRef.current=els; vpRef.current=vp;
setElements(els); setViewport(vp); setUndoStack([]);
setCurrent({id:board.id,title:d.title,role:d.role});
setView('canvas'); connectWs(board.id);
} catch(e){ toast(e.message,'error'); }
};
const closeBoard = () => {
disconnectWs(); setView('list'); setCurrent(null);
setElements([]); setUndoStack([]); setCollab([]);
setSelectedId(null); selectedRef.current=null;
loadBoards();
};
const save = async () => {
if (saving) return; setSaving(true);
try {
await api(`/tools/whiteboard/${current.id}/save`,{body:{elements:elementsRef.current,viewport:vpRef.current}});
toast('Gespeichert ✓');
} catch(e){ toast(e.message,'error'); } finally { setSaving(false); }
};
// ── WebSocket ─────────────────────────────────────────────────────────────
const connectWs = useCallback((wbId) => {
const token = localStorage.getItem('sk_token');
const proto = location.protocol==='https:'?'wss':'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws/whiteboard?token=${token}&id=${wbId}`);
wsRef.current = ws;
const cc = {}; let ci=0;
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
switch(msg.type) {
case 'user_join':
if(!cc[msg.userId]) cc[msg.userId]=CURSOR_COLORS[ci++%CURSOR_COLORS.length];
setCollab(prev=>[...prev.filter(c=>c.userId!==msg.userId),{userId:msg.userId,username:msg.username,color:cc[msg.userId],x:0,y:0}]); break;
case 'user_leave':
setCollab(prev=>prev.filter(c=>c.userId!==msg.userId)); break;
case 'active_users':
msg.users.forEach(u=>{ if(!cc[u.userId]) cc[u.userId]=CURSOR_COLORS[ci++%CURSOR_COLORS.length]; });
setCollab(msg.users.map(u=>({...u,color:cc[u.userId]||CURSOR_COLORS[0],x:0,y:0}))); break;
case 'cursor':
if(!cc[msg.userId]) cc[msg.userId]=CURSOR_COLORS[ci++%CURSOR_COLORS.length];
setCollab(prev=>prev.map(c=>c.userId===msg.userId?{...c,x:msg.x,y:msg.y}:c));
clearTimeout(cursorTimers.current[msg.userId]);
cursorTimers.current[msg.userId]=setTimeout(()=>setCollab(prev=>prev.map(c=>c.userId===msg.userId?{...c,x:-9999,y:-9999}:c)),3000); break;
case 'elements':
elementsRef.current=msg.elements; setElements([...msg.elements]); break;
}
};
ws.onclose=()=>setCollab([]); ws.onerror=()=>{};
},[]);
const disconnectWs = useCallback(()=>{ wsRef.current?.close(); wsRef.current=null; setCollab([]); },[]);
// ── Canvas-Rendering ──────────────────────────────────────────────────────
const render = useCallback(() => {
const canvas = canvasRef.current; if(!canvas) return;
const ctx = canvas.getContext('2d');
renderElements(ctx, elementsRef.current, vpRef.current, selectedRef.current, false);
// Cursor anderer User
for (const c of collab) {
if (c.x===-9999) continue;
const sx = c.x*vpRef.current.zoom+vpRef.current.x;
const sy = c.y*vpRef.current.zoom+vpRef.current.y;
ctx.save(); ctx.fillStyle=c.color; ctx.font='10px monospace';
ctx.fillText('▶ '+c.username, sx+6, sy-4); ctx.restore();
}
// Text-Vorschau während Tippen
if (textPreviewRef.current) {
const { text, x, y, fontSize, color } = textPreviewRef.current;
const vp = vpRef.current;
const sx = x * vp.zoom + vp.x;
const sy = y * vp.zoom + vp.y;
ctx.save();
ctx.font = `${fontSize * vp.zoom}px monospace`;
ctx.fillStyle = color;
ctx.globalAlpha = 0.85;
ctx.fillText(text || '|', sx, sy);
// Cursor-Blinken simulieren: kleiner Strich hinter dem Text
if (text) {
const tw = ctx.measureText(text).width;
ctx.fillRect(sx + tw + 1, sy - fontSize * vp.zoom * 0.8, 2, fontSize * vp.zoom);
}
ctx.restore();
}
}, [collab, textPreview]);
useEffect(()=>{ render(); }, [elements, viewport, selectedId, collab, render]);
// Canvas-Resize
useEffect(()=>{
if(view!=='canvas') return;
const resize=()=>{ const c=canvasRef.current; if(!c) return; c.width=c.offsetWidth; c.height=c.offsetHeight; render(); };
resize(); window.addEventListener('resize',resize);
return ()=>window.removeEventListener('resize',resize);
},[view, render]);
// ── Pointer-Events ────────────────────────────────────────────────────────
const onPointerDown = (e) => {
if (current?.role==='view') return;
e.preventDefault();
const { x, y, sx, sy } = toCanvas(e);
// Panning: Mittelklick oder Leerzeichen-Drag (über CSS cursor:grab bei select-tool)
if (e.button===1) {
isPanning.current=true;
panStart.current={ x: sx-vpRef.current.x, y: sy-vpRef.current.y }; return;
}
// Select-Tool
if (tool==='select') {
// Resize-Handle?
if (onResizeHandle(sx, sy)) {
isResizing.current=true;
dragStart.current={x,y}; return;
}
// Element treffen?
const hit = [...elementsRef.current].reverse().find(el=>hitTest(el,x,y));
if (hit) {
selectedRef.current=hit.id; setSelectedId(hit.id);
isDragging.current=true;
dragStart.current={x,y}; return;
}
// Leerer Klick → Auswahl aufheben
selectedRef.current=null; setSelectedId(null); return;
}
// Text
if (tool==='text') {
textPreviewRef.current = { text:'', x, y, fontSize:FONT_SIZES[fontSize], color };
render();
setTextInput({ sx, sy, x, y }); return;
}
drawing.current=true; startPos.current={x,y};
if (tool==='pen'||tool==='eraser') {
const el={id:getId(),type:tool,color,size:SIZES[size],points:[{x,y}]};
currentEl.current=el; pushElement(el);
} else if (tool==='line') {
const el={id:getId(),type:'line',color,size:SIZES[size],x1:x,y1:y,x2:x,y2:y};
currentEl.current=el; pushElement(el);
} else if (tool==='rect') {
const el={id:getId(),type:'rect',color,size:SIZES[size],x,y,w:0,h:0};
currentEl.current=el; pushElement(el);
} else if (tool==='ellipse') {
const el={id:getId(),type:'ellipse',color,size:SIZES[size],cx:x,cy:y,rx:0,ry:0};
currentEl.current=el; pushElement(el);
}
};
const onPointerMove = (e) => {
const { x, y, sx, sy } = toCanvas(e);
wsRef.current?.readyState===1 && wsRef.current.send(JSON.stringify({type:'cursor',x,y}));
if (isPanning.current) {
syncVp({ ...vpRef.current, x: sx-panStart.current.x, y: sy-panStart.current.y }); return;
}
if (isDragging.current && selectedRef.current) {
const dx=x-dragStart.current.x, dy=y-dragStart.current.y;
dragStart.current={x,y};
const next=elementsRef.current.map(el=>el.id===selectedRef.current?moveElement(el,dx,dy):el);
elementsRef.current=next; setElements([...next]); return;
}
if (isResizing.current && selectedRef.current) {
const next=elementsRef.current.map(el=>el.id===selectedRef.current?resizeElement(el,x,y):el);
elementsRef.current=next; setElements([...next]); return;
}
if (!drawing.current||!currentEl.current) return;
const el=currentEl.current;
if (tool==='pen'||tool==='eraser') {
const updated={...el,points:[...el.points,{x,y}]};
currentEl.current=updated; updateLastElement(updated);
} else if (tool==='line') {
const updated={...el,x2:x,y2:y}; currentEl.current=updated; updateLastElement(updated);
} else if (tool==='rect') {
const updated={...el,w:x-el.x,h:y-el.y}; currentEl.current=updated; updateLastElement(updated);
} else if (tool==='ellipse') {
const updated={...el,rx:x-el.cx,ry:y-el.cy}; currentEl.current=updated; updateLastElement(updated);
}
};
const onPointerUp = () => {
isPanning.current=false;
if (isDragging.current||isResizing.current) {
broadcast(elementsRef.current);
setUndoStack(prev=>[...prev, elementsRef.current]);
}
isDragging.current=false; isResizing.current=false;
if (!drawing.current) return;
drawing.current=false;
if (currentEl.current) { finalizeLastElement(currentEl.current); currentEl.current=null; }
};
const onWheel = (e) => {
e.preventDefault();
const delta=e.deltaY>0?0.9:1.1;
const rect=canvasRef.current.getBoundingClientRect();
const mx=e.clientX-rect.left, my=e.clientY-rect.top;
const nz=Math.max(0.1,Math.min(8, vpRef.current.zoom*delta));
syncVp({ x: mx-(mx-vpRef.current.x)*(nz/vpRef.current.zoom),
y: my-(my-vpRef.current.y)*(nz/vpRef.current.zoom), zoom:nz });
};
const zoom = (factor) => {
const nz=Math.max(0.1,Math.min(8,vpRef.current.zoom*factor));
const canvas=canvasRef.current; if(!canvas) return;
const mx=canvas.width/2, my=canvas.height/2;
syncVp({ x: mx-(mx-vpRef.current.x)*(nz/vpRef.current.zoom),
y: my-(my-vpRef.current.y)*(nz/vpRef.current.zoom), zoom:nz });
};
// Tastenkürzel
useEffect(()=>{
if(view!=='canvas') return;
const onKey=(e)=>{
if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA') return;
if((e.ctrlKey||e.metaKey)&&e.key==='z'){ e.preventDefault(); undo(); }
if((e.ctrlKey||e.metaKey)&&e.key==='s'){ e.preventDefault(); save(); }
if((e.ctrlKey||e.metaKey)&&(e.key==='='||e.key==='+')){ e.preventDefault(); zoom(1.2); }
if((e.ctrlKey||e.metaKey)&&e.key==='-'){ e.preventDefault(); zoom(0.8); }
if((e.ctrlKey||e.metaKey)&&e.key==='0'){ e.preventDefault(); syncVp({x:0,y:0,zoom:1}); }
if(e.key==='Escape'){ setSelectedId(null); selectedRef.current=null; }
if((e.key==='Delete'||e.key==='Backspace')&&selectedRef.current&&e.target.tagName!=='INPUT'){
e.preventDefault();
const next=elementsRef.current.filter(el=>el.id!==selectedRef.current);
setUndoStack(prev=>[...prev,elementsRef.current]);
elementsRef.current=next; setElements([...next]); broadcast(next);
setSelectedId(null); selectedRef.current=null;
}
};
window.addEventListener('keydown',onKey);
return ()=>window.removeEventListener('keydown',onKey);
},[view, undoStack]);
// ── Cursor-Style ──────────────────────────────────────────────────────────
const getCursor = () => {
if (tool==='select') return isDragging.current?'grabbing':isResizing.current?'nwse-resize':'default';
if (tool==='eraser') return 'cell';
if (tool==='text') return 'text';
return 'crosshair';
};
// ── Render: Liste ─────────────────────────────────────────────────────────
if (view==='list') return (
<div style={{padding:mobile?'14px 14px 90px':'36px 44px'}}>
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:24,flexWrap:'wrap'}}>
<h2 style={{margin:0,fontSize:15,fontFamily:'monospace',color:'rgba(255,255,255,0.55)',letterSpacing:2,fontWeight:400}}>WHITEBOARD</h2>
<div style={{flex:1}}/>
{creating ? (
<div style={{display:'flex',gap:6}}>
<input autoFocus value={newTitle} onChange={e=>setNewTitle(e.target.value)}
onKeyDown={async e=>{
if(e.key==='Enter'&&newTitle.trim()){
try{ const wb=await api('/tools/whiteboard',{body:{title:newTitle.trim()}}); setCreating(false);setNewTitle(''); await openBoard(wb); }
catch(err){ toast(err.message,'error'); }
}
if(e.key==='Escape'){setCreating(false);setNewTitle('');}
}}
placeholder="Name des Whiteboards" style={{...S.inp,width:mobile?160:220,fontSize:12}}/>
<button onClick={async()=>{
if(!newTitle.trim()) return;
try{ const wb=await api('/tools/whiteboard',{body:{title:newTitle.trim()}}); setCreating(false);setNewTitle(''); await openBoard(wb); }
catch(err){ toast(err.message,'error'); }
}} style={S.btn('#4ECDC4',true)}>Erstellen</button>
<button onClick={()=>{setCreating(false);setNewTitle('');}} style={S.btn('#888888',true)}></button>
</div>
) : (
<button onClick={()=>setCreating(true)} style={S.btn('#4ECDC4')}>+ Neu</button>
)}
</div>
{loading&&<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Lädt</div>}
{!loading&&boards.length===0&&(
<div style={{...S.card,textAlign:'center',padding:40,color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:12}}>
Noch kein Whiteboard. Erstelle eines mit "+ Neu".
</div>
)}
{boards.map(b=>(
<div key={b.id} style={{...S.card,display:'flex',alignItems:'center',gap:12,marginBottom:8,cursor:'pointer'}}
onClick={()=>{ if(editingId!==b.id) openBoard(b); }}>
<div style={{fontSize:24,position:'relative',flexShrink:0}}>
🖊
{unreadIds.has(b.id) && (
<span style={{position:'absolute',top:-3,right:-3,width:8,height:8,
borderRadius:'50%',background:'#4ecdc4',boxShadow:'0 0 5px #4ecdc4'}}/>
)}
</div>
<div style={{flex:1,minWidth:0}}>
{editingId===b.id?(
<input autoFocus value={editTitle} onChange={e=>setEditTitle(e.target.value)}
onKeyDown={e=>{if(e.key==='Enter') renameBoard(b.id,editTitle); if(e.key==='Escape') setEditingId(null);}}
onBlur={()=>renameBoard(b.id,editTitle)}
onClick={e=>e.stopPropagation()}
style={{...S.inp,fontSize:12,fontWeight:700,padding:'3px 8px',width:'100%'}}/>
):(
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}
onDoubleClick={e=>{e.stopPropagation();if(b.role==='owner'||b.role==='edit'){setEditingId(b.id);setEditTitle(b.title);}}}>
{b.title}
</div>
)}
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,marginTop:2}}>
{b.role==='owner'?'👑 Eigenes':b.role==='edit'?'✏ Bearbeiten':'👁 Lesen'}
{b.owner_name&&b.role!=='owner'&&` · von ${b.owner_name}`}
{' · '+new Date(b.updated_at).toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'})}
</div>
</div>
<div style={{display:'flex',gap:4,flexShrink:0}}>
<button onClick={e=>{e.stopPropagation();
api(`/tools/whiteboard/${b.id}/data`).then(d=>{
const els=JSON.parse(typeof d.elements==='string'?d.elements:JSON.stringify(d.elements||[]));
exportToPng(els,b.title);
}).catch(()=>toast('Export fehlgeschlagen','error'));
}} title="Als PNG exportieren"
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.25)',cursor:'pointer',fontSize:14,padding:'4px 6px'}}></button>
{(b.role==='owner'||b.role==='edit')&&(
<button onClick={e=>{e.stopPropagation();setEditingId(b.id);setEditTitle(b.title);}} title="Umbenennen"
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',cursor:'pointer',fontSize:14,padding:'4px 6px'}}></button>
)}
{b.role==='owner'&&(
<button onClick={e=>{e.stopPropagation();if(confirm(`"${b.title}" löschen?`))
api(`/tools/whiteboard/${b.id}`,{method:'DELETE'}).then(loadBoards).catch(err=>toast(err.message,'error'));
}} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',cursor:'pointer',fontSize:16,padding:'4px 6px'}}></button>
)}
</div>
</div>
))}
</div>
);
// ── Render: Canvas ────────────────────────────────────────────────────────
const canEdit = current?.role !== 'view';
return (
<div style={{display:'flex',flexDirection:'column',height:'100%',background:BG,position:'relative'}}>
{shareModal&&current?.role==='owner'&&(
<ShareModal wbId={current.id} onClose={()=>setShareModal(false)} toast={toast}/>
)}
{textInput&&(
mobile ? (
<div style={{position:'fixed',bottom:0,left:0,right:0,zIndex:500,
background:'#1a1a1e',borderTop:'1px solid rgba(255,255,255,0.15)',
padding:'12px 16px',paddingBottom:'calc(12px + env(safe-area-inset-bottom,0px))'}}>
<div style={{fontSize:10,fontFamily:'monospace',color:'rgba(255,255,255,0.35)',marginBottom:6}}>
TEXT EINGEBEN · Enter = bestätigen · Esc = abbrechen
</div>
<div style={{display:'flex',gap:8}}>
<input autoFocus
onChange={e=>{
textPreviewRef.current={text:e.target.value,x:textInput.x,y:textInput.y,fontSize:FONT_SIZES[fontSize],color};
render();
}}
onKeyDown={e=>{
if(e.key==='Enter'){
const v=e.target.value; textPreviewRef.current=null; setTextInput(null);
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
}
if(e.key==='Escape'){ textPreviewRef.current=null; setTextInput(null); render(); }
}}
placeholder="Text eingeben…"
style={{...S.inp,flex:1,fontSize:14,color}}/>
<button onMouseDown={e=>{
e.preventDefault();
const inp=e.currentTarget.previousSibling;
const v=inp?.value||''; textPreviewRef.current=null; setTextInput(null);
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
}} style={S.btn('#4ECDC4',true)}></button>
<button onMouseDown={e=>{e.preventDefault();textPreviewRef.current=null;setTextInput(null);render();}} style={S.btn('#888888',true)}></button>
</div>
</div>
) : (
<div style={{position:'absolute',left:textInput.sx,top:textInput.sy,zIndex:100,pointerEvents:'auto'}}>
<input autoFocus
onChange={e=>{
textPreviewRef.current={text:e.target.value,x:textInput.x,y:textInput.y,fontSize:FONT_SIZES[fontSize],color};
render();
}}
onKeyDown={e=>{
if(e.key==='Enter'){
const v=e.target.value; textPreviewRef.current=null; setTextInput(null);
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
}
if(e.key==='Escape'){ textPreviewRef.current=null; setTextInput(null); render(); }
}}
onBlur={e=>{
const v=e.target.value; textPreviewRef.current=null; setTextInput(null);
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
}}
style={{background:'rgba(0,0,0,0.7)',border:'none',
outline:`1px dashed ${color}`,borderRadius:4,padding:'2px 6px',
color,fontSize:FONT_SIZES[fontSize],fontFamily:'monospace',minWidth:120}}/>
</div>
)
)}
{/* ── Topbar ───────────────────────────────────────────────────────── */}
<div style={{display:'flex',alignItems:'center',gap:8,padding:'8px 12px',
background:'rgba(0,0,0,0.6)',borderBottom:'1px solid rgba(255,255,255,0.08)',flexShrink:0,flexWrap:'wrap'}}>
<button onClick={closeBoard} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.5)',cursor:'pointer',fontSize:18,padding:'2px 6px',lineHeight:1}}></button>
{editingId===current?.id?(
<input autoFocus value={editTitle} onChange={e=>setEditTitle(e.target.value)}
onKeyDown={e=>{if(e.key==='Enter') renameBoard(current.id,editTitle); if(e.key==='Escape') setEditingId(null);}}
onBlur={()=>renameBoard(current.id,editTitle)}
style={{...S.inp,fontSize:13,fontWeight:700,flex:1,padding:'3px 8px'}}/>
):(
<span onDoubleClick={()=>{if(current?.role!=='view'){setEditingId(current.id);setEditTitle(current.title);}}}
title={current?.role!=='view'?'Doppelklick zum Umbenennen':''}
style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700,flex:1,minWidth:0,
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',cursor:current?.role!=='view'?'text':'default'}}>
{current?.title}
{current?.role==='view'&&<span style={{color:'rgba(255,255,255,0.3)',fontSize:10,marginLeft:8}}>👁 nur lesen</span>}
</span>
)}
{collab.length>0&&(
<div style={{display:'flex',gap:4,alignItems:'center'}}>
{collab.map(c=>(
<span key={c.userId} style={{background:c.color,color:'#000',fontFamily:'monospace',fontSize:9,borderRadius:10,padding:'2px 7px',fontWeight:700}}>
{c.username[0].toUpperCase()}
</span>
))}
</div>
)}
<button onClick={()=>exportToPng(elementsRef.current,current?.title)}
style={{...S.btn('#60A5FA',true),fontSize:11}}> PNG</button>
{canEdit&&<button onClick={save} disabled={saving} style={{...S.btn('#4ECDC4',true),fontSize:11}}>{saving?'…':'💾 Speichern'}</button>}
{current?.role==='owner'&&(
<button onClick={()=>setShareModal(true)} style={{...S.btn('#A78BFA',true),fontSize:11}}>👥 Teilen</button>
)}
</div>
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
{canEdit&&(
<div style={{display:'flex',alignItems:'center',gap:5,padding:'5px 10px',
background:'rgba(0,0,0,0.45)',borderBottom:'1px solid rgba(255,255,255,0.06)',
flexShrink:0,flexWrap:'wrap'}}>
{/* Tools */}
<div style={{display:'flex',gap:3}}>
{TOOLS.map(t=>(
<button key={t.id} onClick={()=>{
setTool(t.id); setSelectedId(null); selectedRef.current=null;
textPreviewRef.current=null; setTextInput(null); render();
}} title={t.tip}
style={{background:tool===t.id?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
border:`1px solid ${tool===t.id?'#4ECDC4':'rgba(255,255,255,0.1)'}`,
borderRadius:7,padding:'5px 9px',color:tool===t.id?'#4ECDC4':'rgba(255,255,255,0.7)',
cursor:'pointer',fontSize:14,lineHeight:1,fontFamily:'monospace'}}>
{t.label}
</button>
))}
</div>
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
{/* Farben */}
<div style={{display:'flex',gap:3}}>
{COLORS.map(c=>(
<button key={c} onClick={()=>setColor(c)}
style={{width:18,height:18,borderRadius:'50%',background:c,
border:color===c?'2px solid #fff':'1px solid rgba(255,255,255,0.2)',cursor:'pointer',padding:0,flexShrink:0}}/>
))}
</div>
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
{/* Strichstärke */}
<div style={{display:'flex',gap:3,alignItems:'center'}}>
{SIZES.map((s,i)=>(
<button key={i} onClick={()=>setSize(i)}
style={{width:18,height:18,borderRadius:'50%',
background:size===i?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
border:`1px solid ${size===i?'#4ECDC4':'rgba(255,255,255,0.15)'}`,
cursor:'pointer',padding:0,display:'flex',alignItems:'center',justifyContent:'center'}}>
<div style={{width:s+2,height:s+2,borderRadius:'50%',background:size===i?'#4ECDC4':'#888'}}/>
</button>
))}
</div>
{/* Textgröße — nur bei Text-Tool */}
{tool==='text'&&<>
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:9}}>TEXT</span>
<div style={{display:'flex',gap:3,alignItems:'center'}}>
{FONT_SIZES.map((fs,i)=>(
<button key={i} onClick={()=>setFontSize(i)}
style={{padding:'2px 6px',borderRadius:6,fontFamily:'monospace',fontSize:Math.min(14,fs/2)+1,
background:fontSize===i?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
border:`1px solid ${fontSize===i?'#4ECDC4':'rgba(255,255,255,0.15)'}`,
color:fontSize===i?'#4ECDC4':'rgba(255,255,255,0.6)',cursor:'pointer'}}>
{fs}
</button>
))}
</div>
</>}
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
{/* Undo + Löschen */}
<button onClick={undo} disabled={!undoStack.length} title="Rückgängig (Ctrl+Z)"
style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',
borderRadius:7,padding:'5px 9px',color:undoStack.length?'rgba(255,255,255,0.7)':'rgba(255,255,255,0.2)',
cursor:undoStack.length?'pointer':'default',fontSize:12,fontFamily:'monospace'}}></button>
{selectedId&&(
<button onClick={()=>{
const next=elementsRef.current.filter(el=>el.id!==selectedId);
setUndoStack(prev=>[...prev,elementsRef.current]);
elementsRef.current=next; setElements([...next]); broadcast(next);
setSelectedId(null); selectedRef.current=null;
}} style={{background:'rgba(255,107,157,0.08)',border:'1px solid rgba(255,107,157,0.25)',
borderRadius:7,padding:'5px 9px',color:'#ff6b9d',cursor:'pointer',fontSize:12,fontFamily:'monospace'}}>
Löschen
</button>
)}
<button onClick={()=>{if(confirm('Alles löschen?')){
elementsRef.current=[];setElements([]);setUndoStack([]);broadcast([]);
setSelectedId(null);selectedRef.current=null;
}}} style={{background:'rgba(255,107,157,0.05)',border:'1px solid rgba(255,107,157,0.15)',
borderRadius:7,padding:'5px 9px',color:'rgba(255,107,157,0.5)',cursor:'pointer',fontSize:12,fontFamily:'monospace'}}>
🗑
</button>
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
{/* Zoom */}
<button onClick={()=>zoom(1.2)} style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',borderRadius:7,padding:'5px 9px',color:'rgba(255,255,255,0.7)',cursor:'pointer',fontSize:14,fontFamily:'monospace'}}></button>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10,minWidth:32,textAlign:'center'}}>
{Math.round(viewport.zoom*100)}%
</span>
<button onClick={()=>zoom(0.8)} style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',borderRadius:7,padding:'5px 9px',color:'rgba(255,255,255,0.7)',cursor:'pointer',fontSize:14,fontFamily:'monospace'}}></button>
<button onClick={()=>syncVp({x:0,y:0,zoom:1})}
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.25)',cursor:'pointer',fontFamily:'monospace',fontSize:9}}>
Reset
</button>
</div>
)}
{/* ── Canvas ───────────────────────────────────────────────────────── */}
<canvas ref={canvasRef}
onPointerDown={onPointerDown} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerLeave={onPointerUp} onWheel={onWheel}
style={{flex:1,width:'100%',touchAction:'none',cursor:getCursor()}}
/>
{/* Hinweis: Select-Tool Aktionen */}
{tool==='select'&&selectedId&&!mobile&&(
<div style={{position:'absolute',bottom:12,left:'50%',transform:'translateX(-50%)',
background:'rgba(0,0,0,0.7)',border:'1px solid rgba(255,255,255,0.1)',
borderRadius:8,padding:'5px 12px',fontFamily:'monospace',fontSize:10,color:'rgba(255,255,255,0.4)',
pointerEvents:'none',whiteSpace:'nowrap'}}>
Ziehen: verschieben · Ecke () ziehen: skalieren · Entf: löschen
</div>
)}
</div>
);
}

View File

@@ -1,21 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { readFileSync } from 'fs'
// version.txt wird im Dockerfile VOR dem Build generiert (date +%s)
// Damit haben __BUILD_TIME__ im Bundle und /api/build-time exakt denselben Wert
let buildTime;
try {
buildTime = readFileSync('./version.txt', 'utf8').trim();
} catch {
// Fallback für lokale Entwicklung ohne Dockerfile
buildTime = String(Date.now());
}
export default defineConfig({
plugins: [react()],
server: { proxy: { '/api': 'http://localhost:4000' } },
define: {
__BUILD_TIME__: JSON.stringify(buildTime),
},
})

View File

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