Initial commit
106
frontend/generate-icons.mjs
Normal file
@@ -0,0 +1,106 @@
|
||||
// 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.');
|
||||
21
frontend/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!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>
|
||||
19
frontend/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
10
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 408 B |
BIN
frontend/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
frontend/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
7
frontend/public/icon.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 540 B |
BIN
frontend/public/koepi/aldi.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/public/koepi/edeka.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
frontend/public/koepi/hornbach.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
frontend/public/koepi/kasten_033.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
frontend/public/koepi/kasten_033_05.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
frontend/public/koepi/kasten_033_055.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
frontend/public/koepi/kasten_050.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
frontend/public/koepi/kasten_11er.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
frontend/public/koepi/kasten_lang.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
frontend/public/koepi/kasten_steini.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
frontend/public/koepi/kaufland.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/public/koepi/koepi_platzhalter.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
frontend/public/koepi/lidl.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/koepi/netto.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
frontend/public/koepi/netto_getraenkemarkt.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
frontend/public/koepi/palette_050.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
frontend/public/koepi/penny.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/public/koepi/rewe.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
frontend/public/koepi/traeger.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
frontend/public/koepi/trinkgut.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
28
frontend/public/manifest.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
55
frontend/public/sw.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// 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();
|
||||
});
|
||||
10
frontend/public/vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
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()),
|
||||
},
|
||||
})
|
||||
4396
frontend/src/App.jsx
Normal file
401
frontend/src/calendar.jsx
Normal file
@@ -0,0 +1,401 @@
|
||||
// ── 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>
|
||||
);
|
||||
}
|
||||
53
frontend/src/confirm.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// ── 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 };
|
||||
}
|
||||
216
frontend/src/crypto.js
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
219
frontend/src/icons.jsx
Normal file
@@ -0,0 +1,219 @@
|
||||
// ── 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);
|
||||
80
frontend/src/lib.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// ── 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,
|
||||
},
|
||||
};
|
||||
101
frontend/src/main.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
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>
|
||||
)
|
||||
178
frontend/src/toolRegistry.js
Normal file
@@ -0,0 +1,178 @@
|
||||
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);
|
||||
}
|
||||
1000
frontend/src/tools/bestellungen.jsx
Normal file
701
frontend/src/tools/codeschnipsel.jsx
Normal file
@@ -0,0 +1,701 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
1068
frontend/src/tools/dateien.jsx
Normal file
3284
frontend/src/tools/devtools.jsx
Normal file
643
frontend/src/tools/gebietseroberung.jsx
Normal file
@@ -0,0 +1,643 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
844
frontend/src/tools/kalkulator3d.jsx
Normal file
@@ -0,0 +1,844 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
513
frontend/src/tools/kanban.jsx
Normal file
@@ -0,0 +1,513 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
468
frontend/src/tools/koepi.jsx
Normal file
@@ -0,0 +1,468 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
728
frontend/src/tools/linkliste.jsx
Normal file
@@ -0,0 +1,728 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
1403
frontend/src/tools/media.jsx
Normal file
650
frontend/src/tools/nachrichten.jsx
Normal file
@@ -0,0 +1,650 @@
|
||||
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 }}><</span>
|
||||
<span style={{ color:nickColor, fontSize:11, fontWeight:700, flexShrink:0 }}>{nick}</span>
|
||||
<span style={{ color:'#808080', fontSize:11, flexShrink:0, marginRight:6 }}>></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}`;
|
||||
}
|
||||
191
frontend/src/tools/paywallkiller.jsx
Normal file
@@ -0,0 +1,191 @@
|
||||
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" ~15–30s 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>
|
||||
);
|
||||
}
|
||||
686
frontend/src/tools/schocken.jsx
Normal file
@@ -0,0 +1,686 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
492
frontend/src/tools/skizze.jsx
Normal file
@@ -0,0 +1,492 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
510
frontend/src/tools/statistik.jsx
Normal file
@@ -0,0 +1,510 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
132
frontend/src/tools/wettrechner.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
975
frontend/src/tools/whiteboard.jsx
Normal file
@@ -0,0 +1,975 @@
|
||||
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&¤t?.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>
|
||||
);
|
||||
}
|
||||
21
frontend/vite.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
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),
|
||||
},
|
||||
})
|
||||