Compare commits

740 Commits
v1.0.0 ... main

Author SHA1 Message Date
37b75effb4 feat: neues Tool Wettrechner (Surebet/Arbitrage-Rechner) unter Freizeit 2026-07-12 12:55:20 +02:00
a28b96b424 fix: KoePi Tage-Countdown entfernt, nur noch echtes Start-/Enddatum verwendet 2026-07-12 11:59:33 +02:00
5b4b0b6908 revert: MakerWorld Statistik-Integration erstmal zurueckgebaut 2026-07-12 11:41:23 +02:00
b0d090d85f fix: trust proxy setzen - MakerWorld-Bookmarklet nutzte http:// statt https:// (Mixed-Content-Block) 2026-07-12 09:27:05 +02:00
ff681a8cf2 fix: MakerWorld Bookmarklet als Base64-Payload - robuster gegen Tastatur/URL-Feld-Veraenderungen 2026-07-12 09:17:31 +02:00
f0a3289d9e fix: MakerWorld Bookmarklet-Kopieren mit execCommand-Fallback + sichtbarem Textfeld 2026-07-11 13:27:16 +02:00
dfb0b5d1ba fix: MakerWorld Bookmarklet - Chrome Suchmaschinen-Trick statt blockiertem Lesezeichen-Tap 2026-07-11 11:13:55 +02:00
142eda070c feat: MakerWorld komplett auf Bookmarklet-Ansatz umgestellt (kein Puppeteer/Login mehr) 2026-07-11 09:28:15 +02:00
9f1dd4e3b8 feat: MakerWorld Raw-Fetch-Diagnose vor Puppeteer-Fallback 2026-07-11 09:08:43 +02:00
65041c0cca feat: MakerWorld ausfuehrliches Logging fuer Diagnose 2026-07-11 08:58:32 +02:00
1557959eb0 fix: MakerWorld Stealth-Plugin gegen Cloudflare-Erkennung, Debug-Screenshot CSP-konform 2026-07-11 07:24:46 +02:00
98f21a4b05 fix: MakerWorld haengender Puppeteer-Prozess - Lock-Cleanup + hartes Timeout 2026-07-11 07:03:21 +02:00
ae13d1b239 feat: MakerWorld Debug-Screenshot + bessere Fehlerdiagnose bei fehlgeschlagenem Abruf 2026-07-11 02:28:48 +02:00
59cced35be fix: Statistik-Tab-Leiste scrollt eigenstaendig horizontal (Mobile) 2026-07-11 02:20:35 +02:00
9b947aeb89 feat: MakerWorld-Anbindung pro Nutzer (eigener Login, eigenes Profil, eigene Daten) 2026-07-11 02:16:20 +02:00
24b40d1284 feat: MakerWorld Login-Automatisierung mit persistentem Puppeteer-Profil und Code-Eingabe via App 2026-07-11 02:08:59 +02:00
fd34c0d6ec feat: MakerWorld Creator-Center Statistik-Tab (Cookie-basierter HTML-Fetch, __NEXT_DATA__-Extraktion) 2026-07-11 02:07:36 +02:00
debb31ce4c feat: Schocken fuer alle Nutzer sichtbar, Steuerung jetzt ueber Pro-User-Sichtbarkeit statt hartem adminOnly 2026-07-10 20:56:46 +02:00
fcf9e61172 fix: Checkbox in Sichtbare-Bereiche-Modal jetzt zuverlässig sichtbar (custom statt native, appearance:none umgangen) 2026-07-10 20:52:30 +02:00
bdc451fb8e feat: HA-Entitaeten fuer Push-Erinnerungen und Kanban; neues Feature Sidebar-Sichtbarkeit pro Nutzer V2 2026-07-10 20:49:05 +02:00
d372bed860 feat: HA-Entitaeten fuer Push-Erinnerungen und Kanban; neues Feature Sidebar-Sichtbarkeit pro Nutzer 2026-07-10 20:47:57 +02:00
37ebad91c6 fix: KoePi HA-Button ist reiner Anzeige-Refresh ohne Pushover 2026-07-10 20:26:37 +02:00
ccef4a3f4d feat: KoePi/Bestellungen/Presence Attribute erweitert - Details statt nur Zahlen 2026-07-10 20:11:18 +02:00
bbf937c671 feat: HA-Entitaeten mit Bereichs-Praefixen, Media-Anfragen quittierbar per Button, umfangreiche 3D-Statistik 2026-07-10 19:57:10 +02:00
12094901a4 feat: HA-Entitaeten fuer Media-Anfragen, offene Bestellungen und 3D-Druck-Monatsumsatz 2026-07-10 19:38:28 +02:00
8a3b6ab912 feat: KöPi HA-Button sendet immer Pushover, Angebote-Sensor zeigt alle Angebote, neue Start/Ende-Datum-Sensoren 2026-07-10 19:24:00 +02:00
6ee5689545 fix: backend/frontend package.json endgültig getrennt und korrekt zugeordnet 2026-07-10 18:07:04 +02:00
795263c93d fix: frontend/package.json wiederhergestellt (war fälschlich mit backend/package.json überschrieben) 2026-07-10 18:01:24 +02:00
5cbed956f6 feat: Home Assistant MQTT Integration - KöPi Angebote, Änderungs-Sensor, Check-Button, Nutzer-online-Sensor 2026-07-10 17:53:35 +02:00
aa5c67d9f5 fix: KöPi Pushover ohne Emergency-Priority (weder Cron noch Testbutton) 2026-07-10 14:19:20 +02:00
6b733bc9cf feat: KöPi Tages-Cron um 06:00 Uhr mit Pushover, Admin-Testbutton zum manuellen Auslösen 2026-07-10 13:58:30 +02:00
1900d73490 feat: KöPi Tages-Cron um 06:00 Uhr mit Pushover-Benachrichtigung bei Angebotsänderung 2026-07-10 13:26:33 +02:00
940bf3f103 fix: KöPi - Bilder mit korrekten Namen von dir 2026-07-09 16:28:23 +02:00
15392b2841 fix: KöPi - 0,5l Bild gewinnt wenn beide Größen in Beschreibung 2026-07-09 16:18:19 +02:00
1de8c37186 debug: KöPi - Beschreibungen loggen 2026-07-09 16:15:52 +02:00
e2512e3699 fix: KöPi - Bildauswahl präziser, Bilder kleiner 2026-07-09 15:52:22 +02:00
5a14fec92d feat: KöPi - 11er Kasten mit kasten_front.png 2026-07-09 15:47:36 +02:00
b53f58956a fix: KöPi - Bilder maxHeight 220px, nie abgeschnitten 2026-07-09 15:03:42 +02:00
0ba26c36ca fix: KöPi - Scrollen für lazy-loaded Bilder 2026-07-09 14:51:08 +02:00
810c7455fe fix: KöPi - Bilder mit korrekten sec-fetch Headers laden 2026-07-09 13:56:37 +02:00
b6a340c7e6 fix: KöPi - Bilder in separatem Tab laden und screenshotten 2026-07-09 12:54:50 +02:00
03a4f20124 fix: KöPi - Screenshot mit BoundingBox clip für korrekte Bildgröße 2026-07-09 12:31:27 +02:00
2a06e93e90 fix: KöPi - Bild auto-height, warte auf alle Bilder vor Screenshot 2026-07-09 12:00:22 +02:00
5c05a2fc7d fix: KöPi - Bilder via Puppeteer Element-Screenshot 2026-07-09 11:13:08 +02:00
2f3368a7e3 fix: KöPi - alle Bilder im Browser-Kontext laden, dunkler Hintergrund 2026-07-09 10:57:33 +02:00
682d79b42d fix: KöPi - Bild volle Breite oben in der Karte 2026-07-09 10:17:17 +02:00
51aa706856 fix: KöPi - OfferCard Layout, Bilder sequenziell, Badge Position 2026-07-09 10:03:46 +02:00
66919b509a fix: KöPi - data: URL crash behoben, Prospekte Links korrigiert 2026-07-09 09:45:03 +02:00
eed658ccc4 fix: KöPi - Bilder via Node+Cookies, NEU Badge weg, Prospekt URLs 2026-07-09 09:02:47 +02:00
d37e4e768b fix: KöPi - Duplikate korrekt zusammengefasst, Bilder Base64 2026-07-09 08:56:05 +02:00
a9973a8a07 fix: KöPi - Bilder als Base64, Adresse statisch, Duplikate zusammengefasst 2026-07-09 01:30:38 +02:00
f3418e51ac fix: KöPi - Bild Proxy mit Referer, Adresse, kein Produktuntertitel 2026-07-09 01:09:42 +02:00
0eec03d5c7 fix: KöPi - exakte marktguru Selektoren, saubere Anzeige 2026-07-09 00:57:46 +02:00
d2b8706b26 feat: KöPi - marktguru.de als Quelle, echte Angebote mit Flaschengröße 2026-07-09 00:41:58 +02:00
73ce4264ae feat: KöPi - nur spezifische Filialen anzeigen 2026-07-09 00:02:57 +02:00
c544e14171 fix: KöPi - klickbar, Bilder via Proxy, Datum angezeigt 2026-07-08 23:47:05 +02:00
1326958098 fix: KöPi - nur König Pilsener anzeigen, Flaschengröße ergänzt 2026-07-08 17:28:08 +02:00
017decf59f feat: KöPi - echte Angebote mit Bild, Preis, Markt direkt in der App 2026-07-08 17:09:09 +02:00
ec872eb76d feat: KöPi - Puppeteer/Chromium für echte Produktangebote 2026-07-08 12:58:15 +02:00
8324e0e3fe fix: KöPi - pageInformation.shelfContents.contents 2026-07-08 12:38:51 +02:00
15836de95f fix: KöPi - korrekter Pfad template.shelfContents 2026-07-08 12:29:13 +02:00
ead13bf17a fix: KöPi - korrekter Pfad für shelfContents 2026-07-08 12:19:19 +02:00
dd0d7e77b3 feat: KöPi - König Pilsener Angebote & Prospekte via kaufDA.de 2026-07-08 12:16:10 +02:00
847d54bff6 fix: Schocken - normale Zahlen absteigend als Zahl verglichen (621 > 554) 2026-07-05 14:33:54 +02:00
49270d800f fix: Schocken - Auswertung korrigiert (Augensumme, Scheiben vom Gewinner) 2026-07-05 01:07:42 +02:00
cb287585f2 feat: Schocken - Admin kann Spiele aus der Liste löschen 2026-07-05 00:51:55 +02:00
efda0a1cab fix: Schocken - Abbrechen löscht Spiel komplett 2026-07-05 00:40:33 +02:00
bd558ab34f feat: Schocken - Würfel bei Aufdecken sichtbar, Admin-Abbrechen 2026-07-05 00:33:56 +02:00
3e75333b6a fix: Schocken - Testmodus nutzt apiAs für alle Aktionen 2026-07-05 00:24:20 +02:00
1806d0bdfe feat: Schocken - Testmodus Spieler-Switcher für Admin 2026-07-05 00:17:26 +02:00
19619cb57c feat: Schocken - erstes spielbares Grundgerüst (admin only) 2026-07-05 00:08:26 +02:00
772cbc62ba feat: Pushover - Admin bekommt Nachricht wenn User online geht 2026-07-04 15:09:13 +02:00
98265883ac feat: Media Streaming - Admin Cache-Clear Button pro Anbieter 2026-07-04 14:03:24 +02:00
e774dd3ac5 fix: Media - HBO Max/WOW/RTL+ ohne Jahresfilter für Top10 2026-07-04 13:44:24 +02:00
eea6431760 fix: Media - HBO Max alle Monetarisierungstypen für Top10 2026-07-04 13:33:09 +02:00
a9db8d1877 fix: Media - HBO Max korrekte TMDb Provider-ID 1899 2026-07-04 13:25:42 +02:00
8c5baaba98 feat: Media Streaming - Max (HBO) als Anbieter hinzugefügt 2026-07-04 13:19:15 +02:00
8425688160 feat: Whiteboard Dot in Liste, Favoriten eingeklappt + Modal bei Klick 2026-07-04 12:49:19 +02:00
31cb7f7411 feat: Whiteboard Badge - Punkt bei neuen Änderungen/Teilen 2026-06-29 13:33:41 +02:00
fbc3acb2c6 fix: Hex Wars in Dashboard-Suche auffindbar 2026-06-29 12:44:38 +02:00
1c34b07eb0 fix: Hex Wars - Resign setzt move_count>=2, Badge + Liste für Gewinner 2026-06-29 12:34:00 +02:00
dfe217ccd3 feat: Hex Wars - Badge auch bei beendeten/abgebrochenen Spielen 2026-06-29 12:09:26 +02:00
3baccf988b fix: Nav - Scroll und einklappbare Gruppen wiederhergestellt 2026-06-29 12:03:27 +02:00
59596fcc96 fix: Hex Wars Badge - gleiche Farbe und Position wie Nachrichten 2026-06-29 11:58:05 +02:00
ed3e56fb43 feat: Hex Wars - Badge in Sidebar und BottomNav wenn man dran ist 2026-06-29 11:47:44 +02:00
4b1e8ae59d fix: Hex Wars - Kundschaften-Regel im Regelwerk ergänzt 2026-06-28 19:26:49 +02:00
0e2d730ec9 fix: Hex Wars - Spielliste zeigt current_turn sofort korrekt 2026-06-28 19:19:53 +02:00
247bcf5d44 feat: Hex Wars - Kundschaften, Mine-Alert auto-clear, Spielliste live 2026-06-28 18:52:23 +02:00
a6b29c38ad feat: Hex Wars - zufällige Startecken 2026-06-28 18:37:08 +02:00
b914046994 fix: Hex Wars - terrain wird nach Explosion gespeichert 2026-06-28 17:20:27 +02:00
61bed6236c fix: Hex Wars - Mine-Feld wird Felsen, Kopf außerhalb Blast-Radius 2026-06-28 17:09:35 +02:00
3afffdec03 feat: Hex Wars - Kettenexplosionen, Felder werden zu Felsen 2026-06-28 16:57:05 +02:00
631a0f86b7 fix: Hex Wars - getGameForUser crash bei alten Spielen und finished-Branch 2026-06-28 15:22:11 +02:00
2ec631166b fix: Hex Wars - Kopf nach Mine korrekt, volles Grid bei Spielende 2026-06-28 15:15:33 +02:00
628272e762 fix: Hex Wars - Mine Legende, Kopf nach Explosion auf altes Feld 2026-06-28 14:53:42 +02:00
c543ea3401 fix: Hex Wars - Mine gibt keine -3 Punkte mehr 2026-06-28 14:42:19 +02:00
19837567cd fix: HelpModal schließbar, mobile Nav scrollbar + einklappbar 2026-06-28 13:45:26 +02:00
41405f3f18 fix: Hex Wars - myColor ReferenceError in HelpModal 2026-06-28 13:28:38 +02:00
0ec55acfc5 fix: Sidebar scrollbar + einklappbare Gruppen 2026-06-28 13:18:23 +02:00
93807f7672 feat: Hex Wars - Schlangen-Mechanik, nur vom Kopf aus expandieren 2026-06-28 13:02:55 +02:00
c9de0867e5 fix: Hex Wars - visualViewport für korrekte Mobile-Höhe 2026-06-26 17:33:13 +02:00
85a72258f5 fix: Hex Wars - Grid passt aufs Handy, weniger Overhead 2026-06-26 17:10:15 +02:00
df9947303c feat: Hex Wars - Server-Scores, Minenexplosion, Dominanzsieg, Regel-Update 2026-06-26 16:50:57 +02:00
afd2aa6029 fix: Hex Wars - Felsen im Nebel, kein Scrollen auf Mobile, Leaderboard-Refresh 2026-06-26 16:30:21 +02:00
cfb66c23dc fix: Hex Wars - Spieler 2 kann Züge machen, Felsen immer sichtbar 2026-06-26 16:17:25 +02:00
5e4b06aa86 feat: Hex Wars - Fog of War, Terrain, Minen, Gold, Felsen 2026-06-26 15:57:44 +02:00
ada2625813 feat: Hex Wars - Zoom, Zug-Bestätigung, Design-Fix, Admin-Löschen 2026-06-26 11:30:16 +02:00
c90490b64b fix: Hex Wars - undefined-Bug, Diagonale, Topliste, move_count 2026-06-25 23:50:25 +02:00
26764c5ab2 fix: Hex Wars - myId aus JWT, responsives Grid, Umbenennung 2026-06-25 23:39:21 +02:00
4c349e00d3 fix: Gebietseroberung - Admin sieht alle Nutzer 2026-06-25 23:30:19 +02:00
7e19b2fe7f feat: Gebietseroberung Multiplayer-Spiel 2026-06-25 23:22:25 +02:00
580b49a0a1 Whiteboard: Pushover an alle Zugreifer wenn jemand speichert 2026-06-25 22:24:35 +02:00
4ee72a2113 Whiteboard: Tool-Wechsel bricht Text-Eingabe ab und cleart Cursor-Vorschau 2026-06-25 22:02:49 +02:00
d53d0f6b77 Whiteboard: Text Live-Vorschau beim Tippen auf Canvas; Linkliste: doppelter Kanban-Eintrag entfernt 2026-06-25 21:54:24 +02:00
b940823d91 Whiteboard: PNG-Export Fix, Text-Input Mobile, Suche + Linkliste ergänzt; Kanban in Suche ergänzt 2026-06-25 21:42:30 +02:00
ad7aad0b2c Whiteboard: Select/Move/Resize, Textgrößen-Auswahl, Zoom-Buttons, PNG-Export 2026-06-25 21:25:03 +02:00
da27a2f5ce Whiteboard: Radierer-Fix (destination-out → Hintergrundfarbe), Icon 🧹 2026-06-25 21:15:39 +02:00
267c149eec Whiteboard: Titel nachträglich umbenennbar (Doppelklick oder ✎-Button, in Liste und Topbar) 2026-06-25 21:06:53 +02:00
c5407afd48 Whiteboard: Name beim Erstellen eingeben, Pushover bei neuen Berechtigungen 2026-06-25 20:50:16 +02:00
e96946e246 Whiteboard: neues Tool mit Canvas, Undo, Kollaboration via WebSocket, Berechtigungssystem 2026-06-25 20:38:03 +02:00
8eea117321 Admin: Test-Push-Button pro User (nur sichtbar wenn Pushover eingerichtet) 2026-06-25 00:11:41 +02:00
e6c80ab7ee Linkliste: Apps & Dev-Tools-Unterfunktionen als Schnellzugriff hinzufügbar (tool://-Schema) 2026-06-24 23:15:35 +02:00
8f8e49f749 Media Favoriten: Dequittierung für Admin + Pushover-Info an User 2026-06-24 22:28:22 +02:00
7fe64c37eb Media Favoriten: Pushover-Benachrichtigung an User bei Admin-Quittierung 2026-06-24 22:12:04 +02:00
6ac424f378 Kanban: erstes Tool unter Werkzeuge (vor Dateien) 2026-06-24 01:55:37 +02:00
cd285030a5 Fix: Kanban-Modal auf Mobile über Bottom-Nav positionieren (paddingBottom nav-height) 2026-06-24 01:50:37 +02:00
84748e90d9 Fix: prioOf undefined in KanbanCard (fehlende Definition nach str_replace) 2026-06-24 01:45:53 +02:00
c2495232cc Kanban: Modal-Scroll auf Mobile (Buttons immer sichtbar), Erstellungsdatum auf Karte 2026-06-24 01:42:30 +02:00
418781f574 Kanban: Design-Fix (padding/header wie andere Tools), Spaltenfarben, Verschieben-nach im Modal 2026-06-24 01:34:19 +02:00
c72ddc6b39 Kanban Board: neues Tool in Werkzeuge (Spalten konfigurierbar, Drag&Drop + Pfeile, Prioritäten) 2026-06-24 01:10:41 +02:00
1d97791237 Fortschrittsanzeige beim Upload (Dateien-Tool + öffentliche Upload-Seite), Drag&Drop auf Upload-Seite ergänzt 2026-06-23 23:52:32 +02:00
25bca76dad Fix: ReferenceError 'period is not defined' in Statistik-Ausgaben-Tab (Altlast aus Button-basierter Zeitraumauswahl) 2026-06-21 10:58:13 +02:00
278359855c feat: Neuerscheinungen-Schwelle auf 10 Titel erhöht, Stufen bis 2 Jahre erweitert 2026-06-20 01:29:58 +02:00
afd2174f0e feat: Neuerscheinungen-Zeitfenster wird stufenweise vergrößert (21/45/90/180 Tage) bei wenigen TMDb-Treffern 2026-06-20 01:11:06 +02:00
1aacd8a192 debug: rohe Discover-Ergebnisse für Neuerscheinungen-Zeitfenster prüfen (Disney+ etc.) 2026-06-20 01:07:34 +02:00
2cf03e71d2 debug: Top10 vs Neuerscheinungen Cache-Inhalt vergleichen 2026-06-20 00:26:39 +02:00
9098661d0f fix: Neuerscheinungen-Fenster auf 21 Tage verkürzt, schließt Top10-Titel explizit aus 2026-06-20 00:05:58 +02:00
ed45b2a957 feat: Sub-Tabs Top10/Neuerscheinungen mit Sprachgewichtung, debug-network entfernt (Endpoint existiert nicht bei TMDb) 2026-06-19 23:45:23 +02:00
8b058aaa97 feat: Neuerscheinungen-Endpoint pro Anbieter (zuverlässig, nur Release-Datum), Network-ID-Debug für Originals-Recherche 2026-06-19 23:01:47 +02:00
0652511716 revert: Tudum-HTML-Parsing entfernt - echtes Markup ist React/Next.js, kein Plain-Markdown wie angenommen 2026-06-19 22:52:53 +02:00
7f470070d9 debug: Tudum-Fetch und Regex-Parsing-Ergebnis prüfen 2026-06-19 22:15:29 +02:00
03043fc8ba feat: Netflix echte Tudum-Charts via leichtgewichtigem HTML-Parsing (keine XLSX), Fallback auf TMDb-Näherung bei Fehler 2026-06-19 15:55:31 +02:00
a7756ba0ca fix: RTL+ Fallback ohne Jahresfilter bei kleinen Katalogen, frisches 90-Tage-Fenster für aktuelle Hits vorgezogen 2026-06-19 15:40:36 +02:00
46fe7bc588 debug: RTL+ Treffer-Anzahl pro Schwelle/Jahresfilter prüfen 2026-06-19 14:43:10 +02:00
26c753b835 feat: Admin-Endpoint zum Leeren des Streaming-Caches 2026-06-19 14:09:11 +02:00
dbfeac5e20 fix: Trending-Strategie entfernt (zu instabil), harte Sprachquote ohne Crossover-Ausnahme, robuster 3-Stufen-Fallback 2026-06-19 13:57:44 +02:00
3e9b8b531f feat: Trending+Provider-Check für frische Hits, Fallback bei kleinen Katalogen (RTL+), 6h Cache 2026-06-19 13:47:57 +02:00
4cec47b083 revert: Netflix XLSX entfernt - überlastet Container (CPU 90%, RAM voll). Netflix läuft wieder über TMDb-Näherung 2026-06-19 12:01:18 +02:00
3ebf99637d feat: Netflix Top10 mit echten offiziellen Daten, Sprachgewichtung DE/EN bevorzugt für restliche Anbieter 2026-06-19 11:04:46 +02:00
837e3d0a4d feat: Diversitäts-Filter gegen Sprachregion-Überflutung, höhere vote_count-Schwelle 2026-06-19 09:58:12 +02:00
4e29c8ecfc feat: Zeitraum auf 2 Jahre verkürzt, Hinweistext im Streaming-Tab, Herkunftsland bei Serien 2026-06-19 09:39:56 +02:00
70500a4332 fix: Route-Reihenfolge - all-providers vor :providerId, sonst 404 2026-06-19 08:26:25 +02:00
2dbbf65e43 feat: 5-Jahres-Filter für Streaming Top10, Endpoint für komplette TMDb-Provider-Liste DE 2026-06-19 08:19:51 +02:00
6d652e292f fix: Amazon Prime ID 9|119 kombiniert (Duplikat-Bug bei TMDb), Paramount+ hinzugefügt 2026-06-19 01:38:14 +02:00
026455f4ba feat: Streaming-Tab mit Top 10 pro Anbieter (Netflix, Disney+, Prime, Apple TV+, WOW, RTL+) 2026-06-19 01:34:42 +02:00
39116d33a5 fix: Zeitzone Europe/Berlin im Dockerfile fest verankern (DST-sicher) 2026-06-19 01:20:24 +02:00
3980aeedd0 fix: UTC-Bug bei Datum-Defaults - new Date().toISOString() durch lokale Formatierung ersetzt 2026-06-19 01:13:18 +02:00
82565f2482 fix: Tooltip zeigt vollen Wochenbereich (15.06.-21.06.) statt nur Wochenanfang 2026-06-19 01:06:08 +02:00
0331b9b2a0 cleanup: Debug-Endpoint entfernt 2026-06-19 00:51:39 +02:00
672f18329f debug: Bucket-Zeitraum Diagnose-Endpoint 2026-06-19 00:45:30 +02:00
c9702780ec fix: UTC-Bug in getBucketKey und Gap-Filling - lokalen Date-Konstruktor nutzen 2026-06-18 21:48:13 +02:00
a064cd4e37 refactor: Statistik Zeitraum-Buttons → Von/Bis Datumfelder, Standard = Gesamt 2026-06-18 21:22:40 +02:00
a6d47887d6 refactor: Statistik Zeitraum-Buttons → Von/Bis Datumfelder, Standard = Gesamt 2026-06-18 20:32:16 +02:00
d19822e5dc fix: Admin-Warnung auch wenn eigene Favoritenliste leer ist 2026-06-18 20:16:47 +02:00
109dcd9997 fix: Herzen weiß/rot mit expliziter Farbe, Admin-Hinweis in Favoritenliste, Tooltip weiß 2026-06-18 20:08:06 +02:00
c1471a3584 fix: 🤍 weiß für nicht-Favoriten, ❤ rot für Favoriten überall 2026-06-18 19:50:04 +02:00
f60f5669fc fix: Herzen einheitlich rot, Admin-Hinweis auf Favoriten-Startseite 2026-06-18 18:49:25 +02:00
0c53234a14 fix: setUnackedFavs als Prop, user-notify beim Favoriten-Tab, Admin-Badge in Liste 2026-06-18 18:20:32 +02:00
79ddbf2f21 fix: Dashboard-Signatur ohne Default, längerer Timeout für Media-Navigation 2026-06-18 16:58:55 +02:00
c3871c96ee fix: User-Badge verschwindet beim Klick, grünes Badge in Favoriten bleibt 2026-06-18 16:47:31 +02:00
161e43f58a fix: Poll sofort bei Dashboard-Besuch, User-Badge bleibt bis Favorit gelöscht, Navigation zu Favoriten 2026-06-18 16:41:50 +02:00
6884078d03 feat: Dashboard Auto-Refresh bei Navigation, Bestätigungs-Badge auf Favoriten, User→Favoriten-Tab 2026-06-18 16:12:01 +02:00
d73c32d8f6 fix: unackedFavs Badge-Bedingung nutzt .count statt direkten Objekt-Vergleich 2026-06-18 15:42:02 +02:00
e953e6a9ac feat: User-Benachrichtigung wenn Admin Favoriten quittiert 2026-06-18 15:31:11 +02:00
62565d95a8 fix: Custom Event für Media-Navigation (Dashboard-Badge → Favoriten-Tab) 2026-06-18 15:10:04 +02:00
f17a957d6f fix: initTab vor setActive setzen, keine Auto-Quittierung beim Badge-Klick 2026-06-18 14:36:36 +02:00
c1e3d059c9 fix: setTimeout damit Media gemountet ist bevor initTab gesetzt wird 2026-06-18 12:34:58 +02:00
ef0656cf4f fix: xREL Endpoint nutzt media_type im Cache-Key, TV-Jahresfilter deaktiviert 2026-06-18 12:17:33 +02:00
7bfd9d003e debug: TV xREL debug endpoint 2026-06-18 12:10:33 +02:00
5f6319c310 fix: TV xREL - imdb_id aus external_ids, kein Jahresfilter für Serien, separater Cache-Key 2026-06-18 12:07:21 +02:00
fe6fa0324f fix: Build-Fehler JSX, xREL TV-Support mit type:tv Parameter 2026-06-18 12:01:25 +02:00
940e1860b7 feat: Dashboard-Badge → Favoriten-Tab, ❤ Button im Modal, xREL für TV ausblenden 2026-06-18 11:55:22 +02:00
3244ac7644 fix: TV cast als String-Array statt Objekte (schwarze Seite) 2026-06-18 11:44:17 +02:00
4708f27912 fix: TV-Modal schwarze Seite (xREL skip), aufklappbare Sektionen, TV-Staffelinfo 2026-06-18 11:27:10 +02:00
5c4bbaa9ab fix: TV-Modal, Suche/Favoriten nach Typ getrennt, Pushover korrekte Tabelle 2026-06-18 09:08:20 +02:00
3e93188c1d fix: Route-Reihenfolge Favoriten, Dashboard-Badge, TV-Suche, User-gruppierte Admin-Liste 2026-06-18 07:31:10 +02:00
7f1ba07500 feat: Serien-Suche, Admin-Favoriten, Pushover, Dashboard-Badge 2026-06-18 02:21:06 +02:00
f77c3c60d4 fix: useState Hooks vor Early Return verschieben 2026-06-18 01:36:32 +02:00
fa68a9083b feat: Datum-Picker beim Bezahlt/Abgeholt markieren 2026-06-18 01:30:21 +02:00
336ad848fc fix: Diese Woche startet am Montag statt rollierend -6 Tage 2026-06-18 01:21:14 +02:00
6a18644549 fix: Granularität korrigiert, Debug-Endpoints entfernt 2026-06-17 22:20:13 +02:00
cc87b93ae4 debug: debug3 monthlyData+cumulativeData direkt prüfen 2026-06-17 22:11:40 +02:00
d1f0bb6c56 debug: base_cost pro Auftrag prüfen 2026-06-17 21:14:40 +02:00
de14683e09 fix: Nettogewinn-Linie zieht Materialkosten ab, Bestellungen nach Status gruppiert mit Summen 2026-06-17 20:48:03 +02:00
19dbdbeea7 fix: Bestellungen nach gewähltem Zeitraum, Übersicht reagiert auf Zeitraum-Wechsel 2026-06-17 17:50:47 +02:00
d4fb2d7527 feat: Tab Einnahmen (bezahlt_am Zeitraum) + Bestellungen (created_at diesen Monat) 2026-06-17 17:32:34 +02:00
f4302c8aad debug: Revenue-Aufschlüsselung im Debug-Endpoint 2026-06-17 17:19:07 +02:00
dd63a8d959 debug: Auftrags-Debug Endpoint + Filter-Fix 2026-06-17 16:36:41 +02:00
29e09f2ed4 fix: Statistik komplett überarbeitet - korrekte Filterung, Diagramme nach Zeitraum, Granularität 2026-06-17 15:24:22 +02:00
c1db0c1410 fix: revenue aus order_items korrekt berechnet, custom Zeitraum (Monat/Jahr) 2026-06-17 15:02:07 +02:00
23f798e1d1 fix: Statistik nutzt bezahlt_am statt created_at für Einnahmen-Zeitraum 2026-06-17 13:45:21 +02:00
b6840078e2 fix: Statistik in toolRegistry mit ChartIcon korrekt registriert 2026-06-17 13:34:45 +02:00
bac3c9e641 fix: recharts zu frontend dependencies hinzufügen 2026-06-17 12:28:37 +02:00
ee12b7d57e feat: 3D-Statistik Tool mit Haushaltsbuch, Diagrammen und Ausgaben-Tracker 2026-06-17 12:03:19 +02:00
cf03c49d03 feat: Datetime-Tab 'In X Zeit' - Stunden+Minuten eingeben → Zieldatum anzeigen 2026-06-17 00:44:12 +02:00
6e1f0836c2 fix: Abgeschlossene Bestellungen nicht in Dashboard-Badges mitzählen 2026-06-17 00:31:36 +02:00
050924458f fix: SW-Version dynamisch per Build-Time, sofort Reload bei neuem Deploy 2026-06-17 00:23:00 +02:00
947c1b9db4 fix: xREL Refresh überschreibt bestehende Releases nicht wenn kein Ergebnis 2026-06-17 00:00:17 +02:00
5297572c29 feat: 📦 Badge in Listenansicht wenn abgeholt aber noch nicht bezahlt 2026-06-16 21:28:09 +02:00
ca2bdc3267 feat: Abgeholt-Status + Archiv-Drawer für abgeschlossene Bestellungen 2026-06-16 20:21:46 +02:00
fe6fc33165 fix: Badge-Container nowrap verhindert Zeilenumbruch 2026-06-16 16:30:50 +02:00
f35629cc96 fix: Gesamt-Gewinn, kurze Badges (W/A/F/B), Stats sofort beim Mount laden 2026-06-16 15:59:08 +02:00
30ecc89ba6 feat: 3D-Statistik Gewinn-Sektion + Status-Badges im zugeklappten Zustand 2026-06-16 14:48:09 +02:00
6f51302700 chore: Chromium + puppeteer-core entfernen (~500MB Image-Ersparnis) 2026-06-16 14:23:12 +02:00
af15f46845 debug: Disk-Analyse Endpoint 2026-06-16 13:40:11 +02:00
f5c9628035 fix: rekursive Ordner-Löschung, Multer-Cleanup, Waisen-Bereinigung (Disk+DB) 2026-06-16 13:19:29 +02:00
57f6badde5 fix: upload_share_logs Schritt entfernt - Dateien bereits in files Tabelle 2026-06-15 21:58:40 +02:00
c8ae201e62 fix: Sync try-catch sauber, kein WITH RECURSIVE, zeigt Erfolg-Meldung 2026-06-15 21:21:22 +02:00
28eef2a39f feat: WebDAV Echtzeit-Sync bei Datei/Ordner erstellen und löschen 2026-06-14 02:25:11 +02:00
c3b8ff96de fix: WebDAV in Backup-Tab, mkdirp robuster, Sync-Fehler blockieren keine Uploads 2026-06-14 02:17:40 +02:00
3d76ae9ce5 feat: Sync legt alle Ordner an (inkl. leere), Upload-Freigaben in _Upload-Freigaben/ 2026-06-14 02:06:04 +02:00
e2d28e285c feat: WebDAV Sync mit vollständiger Ordnerstruktur (user/ordner/unterordner/datei) 2026-06-14 01:46:51 +02:00
5ded22aef7 feat: WebDAV Sync-Button - alle lokalen Dateien auf Synology kopieren 2026-06-14 01:30:12 +02:00
d564323c00 feat: Synology WebDAV Integration - Admin-Einstellungen + Datei-Spiegelung 2026-06-14 00:19:00 +02:00
d29c3b5530 fix: dvh + interactive-widget + minHeight:0 für Tastatur-Layout 2026-06-13 23:17:32 +02:00
666936f45c fix: mobile Tastatur schwarzer Screen - height:100% statt 100vh, minHeight:0 auf main 2026-06-13 21:12:56 +02:00
15fe572205 fix: keyboard scroll nur wenn User bereits am Ende war 2026-06-13 19:52:39 +02:00
8ca1159231 fix: visualViewport resize → scroll to bottom wenn Tastatur aufklappt 2026-06-13 19:37:33 +02:00
d4e9a0aff5 fix: Chat scrollt bei User-Wechsel und erstem Laden sofort ans Ende 2026-06-13 19:27:59 +02:00
cbb8d59639 fix: ScrollToTop hört auf window + Ref, main bekommt height:100vh 2026-06-13 18:28:36 +02:00
8784f607cb fix: mainRef vor Early Returns (React Hooks Rule) 2026-06-13 18:12:29 +02:00
7758487c9f feat: Scroll-to-Top Button (erscheint ab 300px Scroll) 2026-06-13 18:07:17 +02:00
65707617dd fix: sharePws in localStorage persistieren (PW-Button bleibt nach Reload) 2026-06-13 17:27:02 +02:00
56c91f21d9 fix: /verify Endpoint für PW-Check ohne Auto-Download 2026-06-13 13:54:35 +02:00
d14251da72 feat: PW-Kopier-Button in Link-Liste, Bezeichnung ohne PW sichtbar, kein Auto-Download 2026-06-13 13:32:12 +02:00
e6f5189448 fix: PW-Verify bei Datei-Download (Binary statt JSON), Datum+Uhrzeit 2026-06-13 13:13:06 +02:00
617946893f fix: files Tabelle nutzt originalname/mimetype/filename statt name/mime_type/path 2026-06-13 13:00:52 +02:00
355ead1e94 debug: try/catch in GET /, expires_at check in debug endpoint 2026-06-13 12:49:21 +02:00
953f8550d7 fix: public-shares Route vor Auto-Loader (/:id Match-Konflikt) 2026-06-13 12:42:53 +02:00
cef01107cb debug: sample tokens in debug endpoint 2026-06-13 12:39:04 +02:00
1a93fc611d debug: Tabellen-Status Endpoint 2026-06-13 12:34:26 +02:00
9f84dd2929 fix: CREATE TABLE IF NOT EXISTS, sofort google.de Redirect, publicShares direkt neu laden 2026-06-13 12:29:21 +02:00
1f487cec33 fix: public_file_shares Tabelle in db.js, google.de Redirect bei Fehler, saubere Router-Trennung 2026-06-13 12:15:03 +02:00
d550972848 fix: Share-URL-Pfad korrigiert, PW 12 Stellen+Sonderzeichen sichtbar, Standard 1h, aktive Links in Dateiliste 2026-06-13 11:47:24 +02:00
5d4c6cae17 fix: öffentlicher Share-Endpunkt getrennt, Passwort Pflicht + Generator, kein unbegrenzter Ablauf 2026-06-13 11:38:44 +02:00
ac5084e1ee fix: Fragment-Wrapper um nebeneinander stehende IconBtns 2026-06-13 10:58:32 +02:00
c3140aab7e feat: öffentliche Datei/Ordner-Freigabe mit Link, Ablauf & Passwort; Paywall-Killer in Suche 2026-06-13 10:53:32 +02:00
b9a596a3b5 fix: mobile Subdomains normalisieren (m.bild.de → www.bild.de) 2026-06-12 17:17:09 +02:00
f19deb1897 fix: kein Countdown, ehrlicher UX mit Retry-Button und klaren Hinweisen 2026-06-12 17:10:29 +02:00
115ad2eb87 feat: auto-archivierung via archive.ph submit + Countdown 2026-06-12 16:58:31 +02:00
135678bd12 ux: getestet-mit Badges im Header, Kopf-/Fußzeilen Hinweis entfernt 2026-06-12 16:53:11 +02:00
7bf9295337 refactor: Paywall-Killer nur Browser-Flow, kein Server mehr 2026-06-12 16:24:09 +02:00
51dcaffe0e fix: /render Token als Query-Parameter statt Authorization-Header 2026-06-12 16:18:34 +02:00
5469a17a3f feat: /render endpoint bereinigt archive.ph HTML (Banner weg, Links original) 2026-06-12 16:04:41 +02:00
2e89669c9b fix: kein Server-PDF, Browser-Druck-Anleitung stattdessen 2026-06-12 15:46:07 +02:00
c916267200 fix: window.open durch <a target=_blank> ersetzt (PWA-kompatibel) 2026-06-12 15:39:21 +02:00
d0dc2fc3d1 fix: Snapshot-URL Eingabe nach CAPTCHA, direkter Hash-URL für PDF 2026-06-12 15:12:10 +02:00
88cd754fb5 fix: kein iframe, neuer Tab + Checkbox-Bestätigung vor PDF 2026-06-12 14:44:31 +02:00
25b906bee6 feat: iframe CAPTCHA-Flow, PDF von archive.ph Snapshot-URL 2026-06-12 14:33:39 +02:00
762783b8ab feat: PDF via 12ft.io, weitere Browser-Links (archive.ph, removepaywall) 2026-06-12 14:10:20 +02:00
bf5e458e28 fix: PDF immer von Wayback-URL, nicht Original-URL 2026-06-12 13:49:22 +02:00
07c7a6935a fix: Paywall-Entfernung via JS DOM statt nur CSS 2026-06-12 13:32:09 +02:00
a415d3c691 fix: CDX via Backend-Proxy /cdx statt Browser-fetch 2026-06-12 13:03:40 +02:00
321f65a802 fix: CDX fetch ohne custom headers (CORS preflight vermeiden) 2026-06-12 12:54:55 +02:00
4c909392e5 fix: Wayback komplett im Browser, Server nur noch für PDF 2026-06-12 12:47:58 +02:00
6eb9e1cc72 fix: alle Routes wiederhergestellt (wayback, wayback-save, pdf) 2026-06-12 12:34:06 +02:00
1ab3d2494f fix: module.exports = router abgeschnitten 2026-06-12 12:29:54 +02:00
8877587309 feat: PDF lädt Original-URL + blendet Paywall-Overlays per CSS aus 2026-06-12 12:24:06 +02:00
c75c488d3b fix: wayback-save fire-and-forget + CDX polling statt Body-Wait 2026-06-12 12:10:52 +02:00
a712d495d7 feat: Wayback auto-save wenn kein Snapshot existiert 2026-06-12 12:06:44 +02:00
51e52d76c4 fix: Wayback-Suche via Backend-Proxy, Clipboard-Button entfernt 2026-06-12 11:36:15 +02:00
cc2d65c18f fix: CSP connect-src web.archive.org, Clipboard ohne Permission-Dialog 2026-06-12 11:25:37 +02:00
45ac6dd8c0 fix: PDF nur Wayback Machine, archive.ph nur Browser-Link 2026-06-12 11:19:16 +02:00
589cb18f8c fix: Puppeteer nutzt System-Chromium direkt, kein @sparticuz 2026-06-12 11:06:00 +02:00
c83f78595f fix: Wayback CDX API im Frontend + archive.ph immer anzeigen 2026-06-12 08:53:58 +02:00
373bb3b6a0 fix: Paywall-Killer sucht via iframe im Browser statt Server (429-Bypass) 2026-06-12 08:37:24 +02:00
77c48bb9d0 fix: archive.ph Session-Cookie + Browser-Headers gegen 429 2026-06-12 02:43:33 +02:00
b26800aa4e debug: archive.is raw response endpoint 2026-06-12 02:11:34 +02:00
902d5fd99e fix: archive.is Suche via HTML-Parse, Button-Farben gefixt 2026-06-12 01:42:42 +02:00
38c27ac5bb fix: archive.is Prüfung via Redirect-Analyse statt Statuscode 2026-06-12 00:57:52 +02:00
1cf92c9a2f feat: Paywall-Killer Tool (archive.is + PDF-Export) 2026-06-12 00:09:17 +02:00
eef03510af fix: Suchen-Tab immer refresh=1 beim xREL-Suchen damit kein alter Cache zurückkommt 2026-06-11 16:03:00 +02:00
243e9c486a fix: Jahresfilter deaktiviert im Suchen-Tab (noYearFilter=year=0) 2026-06-11 15:59:20 +02:00
54f271935a fix: Jahresfilter ±3 Jahre, unsinnige movieYear-Zeile entfernt 2026-06-11 15:52:09 +02:00
945a947824 feat: Originaltitel in Klammern, xREL Jahresfilter, Wildcard-Suche (hulk*2008), Suche neueste zuerst 2026-06-11 15:34:46 +02:00
6b566d721f feat: Suchen-Tab mit Filmsuche, Such-Endpoint mit Sprach-/Duplikat-Filter 2026-06-11 14:33:25 +02:00
cd50239fd0 fix: alle xREL release_date Typen – de-retail, de-hd, de-rental, us-retail, us-hd, r5 2026-06-11 13:30:58 +02:00
a4263ba9d4 fix: xrelDates State im Modal, ERSCHEINUNGSDATEN von xREL statt TMDb 2026-06-11 12:55:41 +02:00
f2aa5cf328 feat: Erscheinungsdaten von xREL (us-cine/de-cine/us-web etc.) statt TMDb 2026-06-11 12:13:18 +02:00
38d47d65f0 feat: Erscheinungsdaten von xREL (us-cine/de-cine/us-web/de-disc) 2026-06-11 11:07:39 +02:00
c6d2fc2f11 feat: Erscheinungsdaten direkt von xREL (us-cine/de-cine/us-web etc.) 2026-06-11 10:55:41 +02:00
5374935022 debug: ext_info info endpoint 2026-06-11 10:50:51 +02:00
1c6f4e816d fix: Erscheinungsdaten nur DE+US, alle Typen 2-5 (Kino/Web/Retail) 2026-06-11 10:37:35 +02:00
8d99ceba04 feat: alle Erscheinungsdaten im Modal (Kinostart DE/US, Web, Retail, Blu-ray) 2026-06-11 10:09:50 +02:00
71c411a350 feat: DE/US Kinostart + US Digital Release-Datum im Modal 2026-06-11 09:46:55 +02:00
5941cc26d8 fix: xREL immer suchen wenn kein befüllter Cache, Titel-Match vereinfacht 2026-06-11 09:36:55 +02:00
cc38347363 fix: xREL 3 Suchstrategien, normalisierte Queries, debug-xrel3 Endpoint 2026-06-11 09:32:46 +02:00
7afedfbb02 fix: searchXrel via detailRef statt detail-State, Cache-Load mit Titeln nach TMDb-Response 2026-06-11 09:24:39 +02:00
8c817cad2b fix: xREL erster Klick sucht frisch, Umlaut-Normalisierung (ä→ae, ö→oe, ü→ue, ß→ss) 2026-06-11 08:18:02 +02:00
ca50fd2808 fix: xREL Badges persistent – onXrelLoaded Callback propagiert zu Media-Parent 2026-06-11 08:10:18 +02:00
6101bc2221 fix: xREL Cache-Logik sauber – Suchen lädt frisch, Reload zeigt Cache, Aktualisieren refresht 2026-06-11 02:30:04 +02:00
c677e04dc6 fix: kein Auto-Load im Modal, Button immer frisch suchen, Cache nur für befüllte Einträge 2026-06-11 02:21:09 +02:00
7ff6ce6da2 fix: Badges + Releases persistent nach Reload via Cache-Check ohne xREL-Call 2026-06-11 02:13:26 +02:00
9076fbcc0b fix: xREL leere Cache-Einträge bei manuellem Suchen ignorieren, Titel als Query-Params 2026-06-11 02:01:35 +02:00
26059556ac feat: xREL on-demand per Button im Modal, Badge nach Fund, kein Auto-Check mehr 2026-06-11 01:50:55 +02:00
8cc0134059 fix: Badge=Release-Cache unified, 3er-Batches parallel, COMPLETE.BLURAY filter korrekt 2026-06-11 01:36:20 +02:00
32447b53a7 fix: badge 1 Call statt 3, COMPLETE.BLURAY/UHD/remux filter, Cache-Button für Admins, Produktionsland 2026-06-11 00:39:18 +02:00
ad4f2dbb72 fix: xREL parallel queries, complete/bluray/hevc/remux filter, Produktionsland im Modal 2026-06-10 23:35:33 +02:00
82ee05f902 fix: xREL sauber – 1 Cache-Key/Film, Modal sofort, xREL async, Hindi-Filter, max5/≤1080p 2026-06-10 23:18:28 +02:00
0d819b719c fix: xREL IMDb-ID exact match via uris[], strenger Titel-Vergleich als Fallback 2026-06-10 23:01:55 +02:00
77c5af01c9 fix: xREL Sonderzeichen im Titel entfernen, leere Ergebnisse nur 30min cachen, Cache-Clear Endpoint 2026-06-10 22:38:40 +02:00
bceaf49bfa fix: xREL badges – stabile ID-String dependency, kein cancelled-Flag Race 2026-06-10 22:20:09 +02:00
69535562d6 fix: xREL – 1 API-Call/Film, SQLite-Cache 12h, German+DL Filter 2026-06-10 22:14:30 +02:00
3f09e5571b fix: xREL Cache (6h), 429-Retry, 600ms Pause zwischen Requests 2026-06-10 22:09:27 +02:00
45cf571b21 fix: xREL Cache (6h), 429-Retry, 600ms Pause zwischen Requests 2026-06-10 21:30:28 +02:00
3bd8ab901e fix: xREL badges – sequenziell 1 Film pro Request, 400ms Pause, kein Rate-Limit 2026-06-10 20:55:13 +02:00
4333522bd6 fix: xREL badges – eigener useEffect auf movies state, cancelled-Flag 2026-06-10 20:16:58 +02:00
102ab274c7 fix: modal X-Button fixed, xREL parallel (Scene+P2P), Badge sequential batches 2026-06-10 19:19:28 +02:00
e53e8e4331 fix: modal X-Button fixed, xREL debug-endpoint 2026-06-10 17:39:04 +02:00
24975513b6 fix: xREL progressive 5er-Batches, Badge-Coverage, Dirname zweizeilig nach German. 2026-06-10 17:21:06 +02:00
0f65d5d68e fix: xREL – ersten movie-Treffer nehmen, P2P alle Seiten, korrekte Felder aus Debug 2026-06-06 23:54:37 +02:00
7b18e7fb32 debug: xREL debug-endpoint temporär 2026-06-06 23:48:41 +02:00
1eb8cf9b19 fix: xREL 2-Schritt (ext_info_id), korrekte Response-Struktur, P2P main_lang Filter 2026-06-06 22:48:49 +02:00
ec3afbbd3c fix: xREL korrekte Response-Struktur (results/p2p_results), German-Filter, Size-Objekt 2026-06-06 22:25:49 +02:00
2f277c43d9 fix: media – FSK alle Einträge, Duplikate, Jahres-Filter, xREL Response-Struktur, xREL-Cover-Badge 2026-06-06 22:05:44 +02:00
e7c9c0f52f feat: media modal – FSK-Fix, TMDb+IMDb Links, xREL Releases 2026-06-06 21:14:41 +02:00
2e206da879 fix: media – FSK nur Top10 parallel, kein FSK-Lookup bei allen Filmen (Timeout-Fix) 2026-06-06 20:52:30 +02:00
ae9291b8d6 feat: media – portal modal scroll fix, alle Kinofilme, KW-Neustarts, Top-Badges, GlobalSearch 2026-06-06 20:36:09 +02:00
87c02652b5 fix: media modal scrolling auf mobile (overflow scroll + touch) 2026-06-06 19:32:16 +02:00
4a5e314aea feat: media – Genres, FSK-Badges, TR-Filter, Film-Modal, Favoriten-Kalender 2026-06-06 19:05:44 +02:00
8a28a97b81 fix: media – TMDb img-src in CSP, Platzierungs-Badge auf Postern 2026-06-06 18:07:26 +02:00
3bcc1cd9e6 fix: media – authenticate middleware, doppelter Route-Load, DevTools-Layout 2026-06-06 17:57:59 +02:00
5717e67b6d fix: media – requireAdmin korrekt, Header, Token-Hinweis 2026-06-06 16:28:29 +02:00
9969079bdd feat: Kino-Feature mit TMDb-Proxy, Favoriten, Admin-Token-Einstellung 2026-06-06 16:08:54 +02:00
97da66616b fix: media.jsx – useS durch S-Import ersetzen (build-Fehler) 2026-06-06 15:42:11 +02:00
83b6dad9e3 feat: drag&drop upload, default 1h links, Media-Tool mit Kino-Stub 2026-06-06 15:35:35 +02:00
2189a5c652 Upload-Freigabe: URL-Shortener entfernt, einfaches Kopieren der Original-URL 2026-06-06 02:00:59 +02:00
9d05233fe1 Fix Kurz-URL: Shortening server-seitig beim Erstellen, kein CORS, sync kopieren 2026-06-06 01:55:14 +02:00
6c3ab332c0 Upload-Freigabe: Link-Button generiert automatisch Kurz-URL via is.gd 2026-06-06 01:46:34 +02:00
9cfbd00728 Upload-Freigabe: Link-Button generiert automatisch Kurz-URL via is.gd 2026-06-06 01:41:28 +02:00
96acfd9f8e Upload-Freigabe: DickenDock-Text entfernt, Kurz-URL Button via is.gd 2026-06-06 01:36:31 +02:00
1c118206b3 Upload-Freigabe: google.de Redirect, pro Datei Text, PW-Button persistent, Ordner-Icons 2026-06-06 01:17:27 +02:00
f1233ef16d Upload-Freigabe: Tab umbenannt, SQLite-boolean fix, Entfernen-Button fuer deaktivierte Links 2026-06-06 01:03:36 +02:00
439ec5f7b9 Fix: doppelter useRef/useCallback Import entfernt 2026-06-06 00:51:56 +02:00
f50a9840f7 Fix Upload-Seite: als React-Komponente statt externer HTML, SW-Probleme beseitigt 2026-06-06 00:46:40 +02:00
e7ebf2d831 Upload-Share: Route registriert, Fehlversuche-Limit, PW kopieren, bessere Fehlerbehandlung 2026-06-05 23:25:45 +02:00
7e19a6580e Fix: Log-Button in Upload-Ordner ohne rgba in S.btn 2026-06-05 21:37:05 +02:00
957b9d1ffe Fix: Admin-Benutzer API-Pfad korrigiert, Log-Felder dunkler Hintergrund 2026-06-05 20:41:35 +02:00
eafc997f01 Fix dateien.jsx: doppeltes schließendes div entfernt 2026-06-05 20:24:02 +02:00
7b8369f569 Fix dateien.jsx: Fragment-Syntax-Fehler behoben, Upload-Tab korrekt isoliert 2026-06-05 20:13:57 +02:00
dd9a91b5d7 Upload-Freigabe: öffentlicher Upload-Link mit Passwort, Ablaufzeit, max. Größe; Admin-Verwaltung 2026-06-05 20:01:52 +02:00
8a5e8cc003 Suche: Benutzer findbar, Klick oeffnet Chat direkt 2026-06-05 19:09:04 +02:00
55af529c76 Fix Kalender Uhrzeit: UTC-Z korrekt nach Europe/Berlin konvertieren 2026-06-05 18:43:56 +02:00
5b71c90831 Suche: Kalenderdatum leserlich, Keywords fuer alle Dev-Tools, gbit/s und mehr 2026-06-05 18:08:56 +02:00
49992490df Fix Kalender-Suche: nur zukuenftige Events cachen, nicht historische ab 2003 2026-06-05 17:48:20 +02:00
18a6edd4b6 Suche: Debug-Endpoint fuer Kalender-Cache 2026-06-05 16:55:03 +02:00
ca55ecbf5f Fix Kalender-Suche: Sync-Fehler sichtbar, force-sync beim Kalender-Refresh, Backdrop opak 2026-06-05 16:45:47 +02:00
197ea6fbff Suche: nur zukuenftige Kalendereintraege anzeigen 2026-06-05 16:25:56 +02:00
139f13e0aa Fix Kalender-Suche: calendar_event_cache Tabelle, Sync-Endpoint, Dashboard-Trigger 2026-06-05 16:19:17 +02:00
3b13e1c292 Fix Suche: Kalender-Sync awaitable, max 6s Timeout, user_id-Sicherheit geprüft 2026-06-05 13:26:27 +02:00
afc56d9fca Suche: Notizen, Kalendereinträge, Board, Changelog jetzt vollständig im Frontend und Backend 2026-06-05 13:17:24 +02:00
f5988d3af6 Suche: vollständig - alle Tools, Einstellungen, Elektro-Sektionen, Todos, Dateien, Push, Kalender, QR-Codes 2026-06-05 09:45:05 +02:00
a2527b8541 Suche: Key-Gen Einträge, Einstellungs-Sektionen, Link-Ordner, Settings-Navigation 2026-06-05 09:26:12 +02:00
f4e64811c1 Fix Suche: SearchResultItem als Top-Level, type-Feld fix, Position zwischen Titel und Schnellzugriff 2026-06-05 09:14:04 +02:00
90fed8fb5f Dashboard: Globale Suche mit Live-Ergebnissen fuer Tools, Links, Code, Archiv, Bestellungen 2026-06-05 08:50:00 +02:00
e9e639b654 DevTools Mobile: Pfeil-Buttons statt Gradient-Fades, kein Chip-Clipping 2026-06-05 01:35:23 +02:00
eea77f30da DevTools Mobile: Scroll-Fades abgerundet, links-Fade dynamisch bei Scroll-Position 2026-06-05 01:22:31 +02:00
ee47a8bcc7 DevTools Mobile: border statt outline (kein clip), Fade-Hint rechts mit Pfeil 2026-06-05 01:19:16 +02:00
6f8d1559d9 DevTools Mobile: horizontale scroll-Chips statt 2-Spalten-Grid 2026-06-05 01:13:32 +02:00
ceb21a7c1f Fix Build: ungueltige Regex in DevTools-Nav durch String-Spread ersetzt 2026-06-05 01:06:42 +02:00
66a4b6b823 DevTools: QR-Code fix (toCanvas statt create), Generieren-Button, 2-Spalten-Nav, CAD-Skizzen Schild zurueck 2026-06-05 01:02:49 +02:00
b79e32fc20 Fix: CAD-Skizzen Baustellenschild zurueck, Key-Gen wieder in TOOL_DEFS, QR-Code browser-kompatibel 2026-06-05 00:48:37 +02:00
fa1eca6867 QR-Generator, Ordner-Sharing, Baustellenschild weg, Button-Fix 2026-06-05 00:28:53 +02:00
8ef217c49b Linkliste: Button-Styling fix, legacy Quick-Links im Dashboard loeschbar 2026-06-05 00:10:00 +02:00
e7cd93c766 Linkliste: Ordner, Schnellzugriff-Toggle, Sortierung per Drag/Pfeile, Ordner-Modal im Dashboard 2026-06-04 21:27:34 +02:00
c5ccfccf3e Dashboard: Build-Timestamp als lesbares Datum formatiert (TT.MM.JJJJ HH:MM) 2026-06-04 10:02:36 +02:00
414fba39c7 Fix PWA-Reload: __BUILD_TIME__ und version.txt aus selber Quelle; Fix QuickLinkIcon custom icons 2026-06-04 09:36:30 +02:00
8946152012 Fix SW v5: Cross-Origin-Requests nicht abfangen, Favicons laden wieder in PWA 2026-06-04 09:15:10 +02:00
1b304567c8 Fix QuickLinkIcon: Favicon immer aus URL ableiten wenn kein gespeichertes Icon, Fallback auf Emoji 2026-06-04 08:46:25 +02:00
b9d3c63fdc DevTools: Key-Gen wieder in Navigation eingetragen 2026-06-03 11:31:15 +02:00
82114ea285 Nav: gelber Update-Punkt, Update-Menueeintrag und UpdateModal entfernt 2026-06-03 06:27:10 +02:00
9e57f7a567 PWA-Reload sauber: nur bei App-Oeffnen oder Dashboard-Klick, niemals bei Eingaben; version.txt im Build, SW v4, Carousel 4 Icons, Kalender ohne Komma 2026-06-02 22:14:02 +02:00
d85a837f4d Fix Schnellzugriff: negatives Margin entfernt, overflow hidden - exakt 4 Icons pro Reihe 2026-06-02 21:19:27 +02:00
9f8891395a Fix: doppelte SW-Registrierung entfernt, Reload bei App-Fokus; Schnellzugriff gleichgross; Kalender ohne Komma 2026-06-02 21:15:22 +02:00
fe899f5287 Dashboard: Kalender naechster Eintrag mit TT.MM. HH:MM, Schnellzugriff 4 pro Reihe zentriert 2026-06-02 21:02:18 +02:00
b0b4c27f00 Fix PWA-Update: window global statt dynamic import, synchroner Reload bei Seitenwechsel 2026-06-02 19:38:40 +02:00
b698818791 DevTools: Farb-Rechner mit HEX/RGB/RGBA/HSL/HSV/CMYK, Color Picker, Alpha, WCAG-Kontrast 2026-06-01 15:41:21 +02:00
57bf7e6944 Fix: JSON sanitize fuer Fragmente, bessere Anleitungen; Backend index.html Expires-Header 2026-06-01 14:37:43 +02:00
20c82f2fed DevTools JSON: typografische Anfuehrungszeichen fix, XML-Konvertierung, Builder-Anleitung 2026-06-01 14:23:25 +02:00
3cf5a689d6 PWA: index.html immer no-store, Reload beim naechsten Seitenwechsel nach Update 2026-06-01 14:01:03 +02:00
f09f2bf03e PWA: SW network-first für HTML, sofortiger Reload bei Update; DevTools: JSON Viewer+Builder 2026-06-01 13:52:24 +02:00
2b6f8da0f9 DevTools Regex: schwarze Seite behoben, Zahlenbereich enthält-nicht, Test mit Exakt-Toggle 2026-06-01 13:38:17 +02:00
a6dd7ab09c Fix: Build-Fehler devtools.jsx (fehlender Funktionskopf) und kalkulator3d.jsx (doppelter fontSize-Key) 2026-06-01 13:11:23 +02:00
244cb37470 DevTools Regex: Tastatur-Bug behoben (RuleCard als Top-Level), Zahlenbereich enthält/exakt 2026-06-01 13:07:04 +02:00
0b31804d21 DevTools: Regex-Builder (visuell + Erklärungsmodus) hinzugefügt 2026-06-01 12:43:10 +02:00
ec3bf2d176 DevTools TextDiff: kein Zeilenumbruch in Textarea, Zeilennummern bleiben korrekt 2026-06-01 11:51:16 +02:00
3f63a6dcd7 DevTools TextDiff: Unified Diff Format mit gepaarten Zeilennummern 2026-06-01 11:41:21 +02:00
9995571573 DevTools TextDiff: Side-by-Side Ansicht mit gepaarten Zeilen und Highlighting in Textareas 2026-06-01 11:32:04 +02:00
ef4ab5fc80 DevTools: Datei laden Button, Zeilennummern in Diff+Textarea, eigene Dateigröße in Geschwindigkeit 2026-06-01 11:17:39 +02:00
de73a60a23 DevTools: Text Diff und Netzwerk-Rechner (Geschwindigkeit, IP/Subnetz, CIDR) hinzugefügt 2026-06-01 11:04:14 +02:00
0536df82e7 DevTools: Key-Gen mit Verwendungsbeispielen, Base64- und JWT-Platzhalter entfernt 2026-06-01 10:44:41 +02:00
0e2a2818fd DevTools: Key-Generator (Laravel, JWT, AES, UUID, NanoID, Passwörter, Hex/Base64 Tokens) 2026-06-01 10:36:53 +02:00
0dcd9f3543 DevTools Differenz: optionale Uhrzeit pro Seite, Sekunden/Minuten/Stunden nur bei Zeitangabe 2026-06-01 10:20:27 +02:00
b38e0c1a08 DevTools Geburtstag: optionale Uhrzeit, Alter mit Jahren/Monaten/Tagen/Std/Min, Gelebte Tage mit Nachkommastelle 2026-06-01 10:10:22 +02:00
bc775f67d3 DevTools Datetime: Felder-leeren statt Jetzt-verwenden, Wochentag beim Geburtstag, Dezimalstellen, Datum ohne Uhrzeit 2026-06-01 09:55:31 +02:00
34a00dce8f DevTools: Moseszeit-Formel korrigiert (moses + 94687200 = unix, Anzeige Europe/Berlin) 2026-06-01 09:42:24 +02:00
5bdc17a073 DevTools: Moseszeit-Umrechnung korrigiert (Unix UTC + Jahr+3 Versatz) 2026-06-01 09:29:32 +02:00
6dfe140d13 DevTools: Datetime-Tool (Umrechner/Geburtstag/Differenz), dunkle Texte in DevTools+Skizze aufgehellt 2026-06-01 09:18:47 +02:00
34639a044d DevTools: Standard-Strompreis auf 0.38 €/kWh geändert 2026-05-31 23:42:10 +02:00
441ffcad76 DevTools: Leistung-Sektion Titel um R-Formeln ergänzt 2026-05-31 23:38:02 +02:00
8eafe38737 Fix: Elektro-Rechner Inputs verlieren Fokus nicht mehr nach erster Zahl 2026-05-31 23:31:46 +02:00
ca48bceded Nav: Baustellenschild bei CAD-Skizzen und Dev-Tools hinter den Namen verschoben 2026-05-31 23:13:05 +02:00
0dcdfcbd52 Fix: ArchivModal kalkulator3d API response format 2026-05-31 14:47:54 +02:00
70fac26709 Fix: Elektro-Input Remount, Bestellungen Ladeschleife durch kalkulator3d API-Format 2026-05-31 14:41:21 +02:00
9955fc77b7 Dev-Tools: Elektro-Rechner (Ohm, Leistung, Kosten, Widerstände, Wirkungsgrad) 2026-05-30 15:41:09 +02:00
cf415d2b76 Auto-Reload: nur beim Seitenwechsel, nicht mitten in der Arbeit 2026-05-30 01:47:42 +02:00
7768aebe65 Skizze: umbenannt zu 🚧 CAD-Skizzen 2026-05-30 01:39:41 +02:00
eed4f293bc Nachrichten: kein Pushover wenn Empfänger aktiv im Chat 2026-05-30 01:33:15 +02:00
2c521b67b7 Auto-Reload bei neuem Build: no-cache index.html, build-time Check, SW skip-waiting 2026-05-30 01:25:37 +02:00
7a76c3b86c Dev-Tools: YAML raus, Crontab-Generator rein (Erklären + Erstellen) 2026-05-30 00:55:40 +02:00
f246e1e438 YAML: dreistufiger Fix - Unit → Stack → best-effort 2026-05-30 00:34:36 +02:00
e8e1ca9fae YAML: Stack-Fix, best-effort Output auch bei nicht-parsebarem YAML 2026-05-30 00:04:55 +02:00
f4f1889dce Dockerfile: python3 + make + g++ für better-sqlite3 Build 2026-05-29 23:49:56 +02:00
2d2178b172 YAML: robustere Auto-Fix Einrückung mit Einheits-Erkennung 2026-05-29 19:50:57 +02:00
f26d898088 YAML: Auto-Fix bei Einrückungsfehlern; Skizze: Baustellen-Badge 2026-05-29 19:38:40 +02:00
a1b00a0f0d YAML Formatter: js-yaml, Baumansicht mit Linien, Einklappen, Mobile-Fix 2026-05-29 19:26:07 +02:00
b065c5350a Dev-Tools: neues Tool mit YAML Formatter, Skizze BETA label 2026-05-29 19:13:04 +02:00
0d6b131cd0 Skizze: Proportionen, Input-Fix, Operator-Buttons, Mittelpunkt, Versatz, Verlauf löschen 2026-05-29 18:43:55 +02:00
f1a3c91f17 Skizzen-Rechner: neues Tool unter Werkzeuge (MVP) 2026-05-29 15:34:57 +02:00
5afa28e523 Changelog: ToDo-Auswahl zum Miterledigen; Admin-Stats: Code-Schnipsel 2026-05-29 15:07:57 +02:00
eb95535afa History: wiederhergestellte Version anzeigen, Lösch-Bestätigung, Admin-Stats Codes 2026-05-29 14:45:30 +02:00
05450e2b7e Code-Schnipsel History: Aktuell ganz oben, korrekter Diff, History-Einträge löschbar 2026-05-29 14:07:39 +02:00
ce1449d21f Code-Schnipsel History: Diff vs. aktuellem Code, Wiederherstellen nur bei Unterschied 2026-05-29 13:56:11 +02:00
82fcfd07c5 Code-Schnipsel: Diff-History, Beschreibung sichtbar, Multi-Tag-Filter, Geteilt-von-mir Tab, nur genutzte Tags 2026-05-29 13:45:33 +02:00
fa1562bdce Code-Schnipsel: History-Button Fix, Tag-Split nur Komma, Tag-Vorschläge 2026-05-29 13:34:22 +02:00
449a0aa2f7 Code-Schnipsel: neue Funktion unter Werkzeuge 2026-05-29 13:16:39 +02:00
d755dcf68a Schnellzugriff: Pfeile entfernt; Benutzerliste: mobil-optimiertes Layout 2026-05-29 10:58:08 +02:00
f01ee37280 Schnellzugriff Mobile: Pfeile nur wenn wischbar, overflow:hidden, Seitenindikatoren 2026-05-29 10:52:26 +02:00
4f2c06f817 Schnellzugriff Mobile: max 2 Zeilen, horizontal wischbar mit Snap 2026-05-29 10:45:32 +02:00
2e033d8ad4 Schnellzugriff: Reihenfolge per Drag & Drop änderbar 2026-05-29 10:32:37 +02:00
3d5925b603 Changelog: Was ist neu? nur für normale User 2026-05-29 10:17:02 +02:00
b1e2bfdeec Changelog: Was ist neu? Button + Unread-Tracking 2026-05-29 10:09:47 +02:00
b91b742dd8 Changelog: build_time fix im Eintrag gespeichert statt dynamisch 2026-05-29 09:59:12 +02:00
bc85460ea3 Fix: hidden-Button Farben korrigiert 2026-05-29 09:50:43 +02:00
ff179a5d0d Nachrichten: hidden User nur für Admins sichtbar 2026-05-29 09:38:03 +02:00
c3047ac4d6 Changelog: Build-Zeitpunkt als Timestamp, Benutzer ausblenden Toggle 2026-05-29 09:35:30 +02:00
3ca1ab44a6 Changelog: Stichwort statt Version, Datum + Uhrzeit bei Einträgen 2026-05-29 09:31:09 +02:00
0142d35901 Changelog-Modal: klickbar auf Versionierung, Admin kann Einträge anlegen/löschen 2026-05-29 09:13:38 +02:00
9b54bb700b Pushover: Emergency-Priorität mit Retry, has_pushover Badge in Admin 2026-05-29 09:00:10 +02:00
8f7dbbf3c9 Admin: Letzte Aktivität pro User (nur echte Aktionen, kein Polling) 2026-05-28 19:56:36 +02:00
d475c8f64f Push-Scheduler: auf volle Minute synchronisiert 2026-05-28 19:31:11 +02:00
c8e6974dc7 Push-Scheduler: Standardzeit auf jetzt korrigiert 2026-05-28 19:16:33 +02:00
ee2b625ed5 Push-Erinnerungen: Widget, Backend-Scheduler, Pushover-Versand jede Minute 2026-05-28 19:11:05 +02:00
7ec2879f73 Push-Erinnerungen: Widget, Backend-Scheduler, Pushover-Versand jede Minute 2026-05-28 19:00:48 +02:00
cc6a1c6dc7 Board: Auto-Update fix, gelber Punkt für Ersteller, Poll 30s 2026-05-28 18:53:12 +02:00
b66a373764 Board: gelber Punkt bei befördertem Wunsch, promoted_at Tracking 2026-05-28 18:47:42 +02:00
5800b1a624 Fix: promoted_from_wish 0-Rendering in JSX 2026-05-28 18:40:02 +02:00
f03d6a173a Board: Eingabe-Fix (ItemRow außerhalb), Promote-Button Wunsch→Roadmap 2026-05-28 18:37:04 +02:00
afae09dd6e ToDo-Board: BoardModal, Roadmap, Wünsche, Unread-Polling 2026-05-28 18:26:23 +02:00
2041168ca1 Nachrichten: position:fixed auf Mobile - Header/Input immer sichtbar, kein Leerraum 2026-05-28 15:27:51 +02:00
4f0d353956 Nachrichten: Header/Input fixiert, Auto-Scroll instant beim Laden, mobiler Leerraum fix 2026-05-28 15:21:32 +02:00
82e16c4a3f Nachrichten: Leerraum auf Mobile fix - flex statt berechneter Höhe 2026-05-28 15:11:50 +02:00
c082051d44 Nachrichten: Design-Label, server-seitige Präferenz, mobiler Leerraum fix 2026-05-28 15:05:45 +02:00
82a9609f0c Nachrichten: Emoji-Button volle Höhe, mIRC-Design-Toggle 2026-05-28 14:56:11 +02:00
637a77af2d Neue Linkliste unter Werkzeuge mit Teilen und Schnellzugriff-Integration 2026-05-28 14:46:19 +02:00
34fa756291 Nachrichten: Senden-Button entfernt, Login-Sicherheit: IPs bei Lockout, Attempts-Bereinigung 2026-05-28 14:12:49 +02:00
70d9eeab78 Login-Sicherheit: Account-Lockout, konfigurierbar, Admin-Verwaltung, .env für JWT-Secret 2026-05-28 14:02:32 +02:00
2da72f1246 Archiv: Geteilt-von-mir Tab + 3D-Datei Downloads für Owner und Empfänger 2026-05-28 13:39:33 +02:00
83e88745fa Festpreis-Input-Fix, Archiv-Teilen (read-only) 2026-05-28 13:30:39 +02:00
188f121cda Bestellungen: Festpreis pro Position zurücksetzen 2026-05-28 13:22:40 +02:00
fb8562db7a Bestellungen: Preiskategorie pro Position als Festpreis übernehmen 2026-05-28 13:16:52 +02:00
cab9659709 Archiv: Datei-Badge, Bestellungen: Preiskategorie als Festpreis übernehmen 2026-05-28 13:12:01 +02:00
a3cf805f0b Fix: Modell-Ordner wird nicht mehr leer angelegt beim Bearbeiten 2026-05-28 13:02:33 +02:00
2256e37e75 Leeren Modell-Unterordner automatisch löschen 2026-05-28 12:55:44 +02:00
6b2cbb0206 3D-Dateien im Archiv bearbeitbar - hinzufügen und löschen 2026-05-28 12:50:48 +02:00
da1a1e9641 3D-Modelle Ordner + Dateianhang im Kalkulator 2026-05-28 12:45:19 +02:00
ab752d7c93 3D-Stats Höhe fix, Dateilimits zu Benutzer-Tab, Gesamtlimit statt Anzahl 2026-05-28 12:34:12 +02:00
903b6d204f Nachrichten-Widget aus Dashboard entfernt 2026-05-28 12:25:30 +02:00
c6542128f9 Fix: Speicheranzeige Dateien - sinnvolle Werte statt falscher Disk-Info 2026-05-28 12:20:28 +02:00
7182431ce7 Fix: Dateien schwarze Seite (JSX-Fehler im Speicherbalken) 2026-05-28 12:07:10 +02:00
340e4bcc67 Fehlerbehebung Dateien und Smilies in Nachrichten 2026-05-28 11:56:50 +02:00
970872111b Speicherplatz in Dateien, Smilies in Nachrichten 2026-05-28 11:49:53 +02:00
d932f1edb5 Smilies bei Nachrichten 2026-05-28 11:37:32 +02:00
d60e3a29d8 Reload Button bei Nachrichten verbessert 2026-05-28 11:29:02 +02:00
b32c79b9e2 Reload Button bei Nachrichten, Favicons Repariert 2026-05-28 11:25:58 +02:00
7bac5ab6aa Reload Button, Versionsschrift heller, Datum unter Dashboard 2026-05-28 11:18:44 +02:00
d547725289 Passwort Falsch Abfrage und Version auf Dashboard 2026-05-28 11:03:33 +02:00
155d6424bb Dateien nach "frontend/src" hochladen 2026-05-28 10:34:39 +02:00
fea1a10287 Dateien nach "frontend/public" hochladen 2026-05-28 10:33:48 +02:00
b466a0c0d8 Dateien nach "frontend/public" hochladen 2026-05-28 10:11:22 +02:00
168d465245 Dateien nach "frontend/src" hochladen 2026-05-28 10:10:37 +02:00
c9c15970f8 Dateien nach "frontend/public" hochladen 2026-05-28 10:10:01 +02:00
b775cfcbb6 Dateien nach "frontend/public" hochladen 2026-05-28 09:58:15 +02:00
f7c27f9d50 Dateien nach "frontend/src" hochladen 2026-05-28 09:57:56 +02:00
10c9b802eb Dateien nach "backend/src" hochladen 2026-05-28 09:29:26 +02:00
8b6fe7dd47 Dateien nach "frontend/src/tools" hochladen 2026-05-28 09:24:40 +02:00
180ab6dee7 Dateien nach "backend/src/tools/dateien" hochladen 2026-05-28 08:09:00 +02:00
2e9bbeeece Dateien nach "frontend/src/tools" hochladen 2026-05-28 08:08:36 +02:00
166b3be251 Dateien nach "frontend/src/tools" hochladen 2026-05-28 07:54:56 +02:00
53b66eaf96 Dateien nach "backend/src/tools/dateien" hochladen 2026-05-28 07:54:26 +02:00
eef8ef2042 Dateien nach "backend/src" hochladen 2026-05-28 07:53:59 +02:00
4eef953bbb Dateien nach "backend/src" hochladen 2026-05-28 01:52:36 +02:00
89f08c4852 Dateien nach "frontend/src" hochladen 2026-05-28 01:52:11 +02:00
b9c6e9a7ba Dateien nach "frontend/src/tools" hochladen 2026-05-28 01:51:56 +02:00
134bbcc99c Dateien nach "frontend/src/tools" hochladen 2026-05-28 01:45:22 +02:00
8835789a05 Dateien nach "frontend/src/tools" hochladen 2026-05-28 01:38:16 +02:00
c35512e8d4 Dateien nach "frontend/src/tools" hochladen 2026-05-28 01:31:21 +02:00
51b5bbbdcd Dateien nach "frontend/src/tools" hochladen 2026-05-28 01:16:30 +02:00
c4406f54de Dateien nach "backend/src/tools/nachrichten" hochladen 2026-05-28 01:09:54 +02:00
2564d2fbfb Dateien nach "/" hochladen 2026-05-28 01:09:22 +02:00
0fcdf32b2e docker-compose.yml aktualisiert 2026-05-28 01:00:46 +02:00
cf84ebcc2b Dateien nach "backend/src/tools/nachrichten" hochladen 2026-05-28 00:54:34 +02:00
c9417777bb Dateien nach "frontend/src/tools" hochladen 2026-05-28 00:54:10 +02:00
fa5e2dc09a Dateien nach "frontend/src/tools" hochladen 2026-05-28 00:50:18 +02:00
d69613259c Dateien nach "backend/src/tools/nachrichten" hochladen 2026-05-28 00:44:30 +02:00
4bec52ee55 Dateien nach "frontend/src/tools" hochladen 2026-05-28 00:44:06 +02:00
f3d36fbd95 Dateien nach "frontend/src" hochladen 2026-05-28 00:33:24 +02:00
8ae4267edf Dateien nach "frontend/src/tools" hochladen 2026-05-28 00:32:59 +02:00
172e150e2b Dateien nach "backend/src/tools/nachrichten" hochladen 2026-05-28 00:23:59 +02:00
67c8d01533 Dateien nach "backend/src" hochladen 2026-05-28 00:23:38 +02:00
0659f55dc7 Dateien nach "frontend/src/tools" hochladen 2026-05-28 00:23:06 +02:00
17917e2997 Dateien nach "frontend/src" hochladen 2026-05-28 00:22:44 +02:00
71c9061e33 Dateien nach "backend/src/tools/nachrichten" hochladen 2026-05-27 19:02:31 +02:00
ffb7b42466 Dateien nach "backend/src" hochladen 2026-05-27 19:02:06 +02:00
14a67787a9 Dateien nach "frontend/src" hochladen 2026-05-27 19:01:26 +02:00
2da50e2c71 Dateien nach "frontend/src/tools" hochladen 2026-05-27 19:00:39 +02:00
a2799155e6 Dateien nach "frontend/src/tools" hochladen 2026-05-27 18:26:18 +02:00
3b7f2b60a2 Dateien nach "frontend/src/tools" hochladen 2026-05-27 14:56:41 +02:00
0ce2a0673a Dateien nach "frontend/src" hochladen 2026-05-27 13:23:33 +02:00
47db59fd7b Dateien nach "backend/src" hochladen 2026-05-27 13:09:51 +02:00
493f28830c Dateien nach "backend/src/tools/dateien" hochladen 2026-05-27 09:58:50 +02:00
552572040c Dateien nach "backend/src/routes" hochladen 2026-05-27 09:58:27 +02:00
89f1affc88 Dateien nach "backend/src" hochladen 2026-05-27 09:58:07 +02:00
9ead5a2236 Dateien nach "frontend/src/tools" hochladen 2026-05-27 09:57:32 +02:00
ad5bb374c0 Dateien nach "frontend/src/tools" hochladen 2026-05-27 09:52:22 +02:00
14dd32334e Dateien nach "backend/src" hochladen 2026-05-27 09:51:56 +02:00
c174ff0515 Dateien nach "backend/src" hochladen 2026-05-27 09:33:45 +02:00
c4d511fd09 Dateien nach "backend/src/tools/dateien" hochladen 2026-05-27 09:33:26 +02:00
6f28a09aa4 Dateien nach "backend/src/tools/dateien" hochladen 2026-05-27 09:25:04 +02:00
05ee3deae4 Dateien nach "frontend/src/tools" hochladen 2026-05-27 09:24:06 +02:00
90de8252e2 Dateien nach "frontend/src" hochladen 2026-05-27 09:23:54 +02:00
5fb2fa4189 Dateien nach "backend/src/tools/dateien" hochladen 2026-05-27 09:10:52 +02:00
e08d09213b Dateien nach "frontend/src" hochladen 2026-05-27 09:09:48 +02:00
f24332719f Dateien nach "frontend/src" hochladen 2026-05-27 09:09:32 +02:00
b87f6ba7c9 Dateien nach "frontend/src/tools" hochladen 2026-05-27 09:09:12 +02:00
337f1acb7a Dateien nach "frontend/src" hochladen 2026-05-27 08:55:11 +02:00
7e3612fd38 Dateien nach "frontend/src/tools" hochladen 2026-05-27 08:54:53 +02:00
64ea7dc147 Dateien nach "frontend" hochladen 2026-05-27 08:54:28 +02:00
f46ef0d219 Dateien nach "backend/src/tools/dateien" hochladen 2026-05-27 08:38:07 +02:00
d44fa1d5fa Dateien nach "frontend/src" hochladen 2026-05-27 08:37:20 +02:00
fb6b25f3ed Dateien nach "frontend/src/tools" hochladen 2026-05-27 08:37:02 +02:00
a08d4adb4f Dateien nach "frontend/src" hochladen 2026-05-27 08:35:24 +02:00
d48a0e5d31 Dateien nach "frontend/src/tools" hochladen 2026-05-27 08:34:53 +02:00
e02e125806 Dateien nach "frontend/src" hochladen 2026-05-27 08:13:57 +02:00
cf63623fd6 Dateien nach "frontend/src/tools" hochladen 2026-05-27 08:13:25 +02:00
debe8f72c2 Dateien nach "backend/src/tools/dateien" hochladen 2026-05-27 08:08:17 +02:00
24822a007d Dateien nach "frontend/src/tools" hochladen 2026-05-26 20:37:48 +02:00
31f7adec35 Dateien nach "backend/src/tools/dateien" hochladen 2026-05-26 20:37:12 +02:00
1cdc7613e6 Dateien nach "frontend/src/tools" hochladen 2026-05-26 15:54:43 +02:00
ec290bb5a4 Dateien nach "frontend/src" hochladen 2026-05-26 15:54:15 +02:00
9b26faf71a Dateien nach "frontend/src" hochladen 2026-05-26 15:53:46 +02:00
c279797ba9 Dateien nach "backend/src/tools/dateien" hochladen 2026-05-26 15:52:44 +02:00
68af1da97e Dateien nach "backend/src" hochladen 2026-05-26 15:52:10 +02:00
77e16b51c2 Dateien nach "backend/src/routes" hochladen 2026-05-26 15:51:38 +02:00
37ccdfbdda Dateien nach "backend/src/tools/dateien" hochladen 2026-05-26 15:38:13 +02:00
c8b327e457 Dateien nach "backend/src/routes" hochladen 2026-05-26 15:37:47 +02:00
7ec5af740a Dateien nach "backend/src" hochladen 2026-05-26 15:37:30 +02:00
64a7683bfb Dateien nach "frontend/src/tools" hochladen 2026-05-26 15:37:05 +02:00
033527af39 Dateien nach "frontend/src" hochladen 2026-05-26 15:36:48 +02:00
0c2074d7aa Dateien nach "frontend/src" hochladen 2026-05-26 15:24:20 +02:00
e12c1ea01e Dateien nach "frontend/src" hochladen 2026-05-26 15:18:05 +02:00
d656027a20 Dateien nach "frontend/src" hochladen 2026-05-26 15:12:17 +02:00
777ebcbb02 Dateien nach "backend/src" hochladen 2026-05-26 15:07:44 +02:00
f0bca08be3 Dateien nach "frontend/src" hochladen 2026-05-26 15:07:02 +02:00
63c814c244 Dateien nach "frontend/src" hochladen 2026-05-26 15:04:15 +02:00
9354c1399d Dateien nach "frontend/src" hochladen 2026-05-26 15:01:38 +02:00
d713a738ee Dateien nach "frontend/src" hochladen 2026-05-26 14:57:28 +02:00
7b6bf964cb Dateien nach "backend/src/routes" hochladen 2026-05-26 14:49:49 +02:00
0222778aab Dateien nach "frontend/src" hochladen 2026-05-26 14:49:06 +02:00
75b5944d12 Dateien nach "frontend/src" hochladen 2026-05-26 14:36:24 +02:00
242bfac96b Dateien nach "backend/src/routes" hochladen 2026-05-26 14:29:55 +02:00
1aa635783b Dateien nach "backend/src" hochladen 2026-05-26 14:29:34 +02:00
061c8bad3b Dateien nach "frontend/src" hochladen 2026-05-26 14:29:05 +02:00
74b534a2a7 Dateien nach "frontend/src/tools" hochladen 2026-05-26 14:21:32 +02:00
a0687ebb6e Dateien nach "frontend/src" hochladen 2026-05-26 14:21:16 +02:00
cc407f38fb Dateien nach "backend/src/routes" hochladen 2026-05-26 14:12:47 +02:00
5f64d58edf Dateien nach "frontend/src" hochladen 2026-05-26 14:12:22 +02:00
b1942fdcb2 Dateien nach "frontend/src" hochladen 2026-05-26 14:01:40 +02:00
373e19f447 Dateien nach "backend/src/routes" hochladen 2026-05-26 14:01:13 +02:00
e8c845f67a Dateien nach "backend/src/routes" hochladen 2026-05-26 13:53:51 +02:00
a2f51f0d81 Dateien nach "frontend/src" hochladen 2026-05-26 13:53:13 +02:00
bd6e656d73 Dateien nach "frontend/src" hochladen 2026-05-26 13:42:38 +02:00
3f785d3d9e Dateien nach "backend/src/routes" hochladen 2026-05-26 13:33:38 +02:00
56f206b7d9 Dateien nach "frontend/src" hochladen 2026-05-26 13:32:32 +02:00
e8e7024b7c Dateien nach "backend/src" hochladen 2026-05-26 13:14:28 +02:00
88bf0a57e2 Dateien nach "backend/src/tools/bestellungen" hochladen 2026-05-26 13:14:03 +02:00
1cc14b74a6 Dateien nach "frontend/src/tools" hochladen 2026-05-26 13:13:25 +02:00
f7c329b3de Dateien nach "frontend/src/tools" hochladen 2026-05-26 13:04:50 +02:00
52a77ad1ae Dateien nach "frontend/src/tools" hochladen 2026-05-26 12:53:36 +02:00
c315760a43 Dateien nach "frontend/src/tools" hochladen 2026-05-26 12:45:19 +02:00
bd358c581f Dateien nach "backend/src/tools/bestellungen" hochladen 2026-05-26 12:39:58 +02:00
fafd9e3121 Dateien nach "frontend/src/tools" hochladen 2026-05-26 12:39:25 +02:00
079b9a20f0 Dateien nach "frontend/src" hochladen 2026-05-26 12:02:27 +02:00
09fe7d2f06 Dateien nach "frontend/src/tools" hochladen 2026-05-26 11:53:08 +02:00
589fa52505 Dateien nach "frontend/src" hochladen 2026-05-26 11:52:49 +02:00
ce999104af frontend/App.jsx gelöscht 2026-05-26 11:52:35 +02:00
fcb2bce35e Dateien nach "frontend" hochladen 2026-05-26 11:52:13 +02:00
26927537a6 Dateien nach "frontend/public" hochladen 2026-05-26 11:42:03 +02:00
17a378bb40 Dateien nach "frontend" hochladen 2026-05-26 11:40:50 +02:00
206e6e9e7d Dateien nach "frontend" hochladen 2026-05-26 11:34:24 +02:00
f3d9c3fd80 Dateien nach "frontend/public" hochladen 2026-05-26 11:33:53 +02:00
df767ff3b2 Dateien nach "frontend/src" hochladen 2026-05-26 11:33:22 +02:00
1d61867153 Dateien nach "frontend/src/tools" hochladen 2026-05-26 11:32:40 +02:00
364a6c3193 Dateien nach "frontend/public" hochladen 2026-05-26 11:31:33 +02:00
4d9d385537 Dateien nach "frontend" hochladen 2026-05-26 11:16:23 +02:00
e3dc3b5439 Dateien nach "frontend/public" hochladen 2026-05-26 11:16:01 +02:00
c33a5a499c Dateien nach "frontend/src/tools" hochladen 2026-05-26 11:14:52 +02:00
c3446e4f53 Dateien nach "frontend/src" hochladen 2026-05-26 11:14:24 +02:00
f9c40ce8f2 Dateien nach "frontend" hochladen 2026-05-26 10:24:51 +02:00
032c56468e Dateien nach "frontend" hochladen 2026-05-26 10:24:14 +02:00
0d304974ee Dateien nach "frontend/public" hochladen 2026-05-26 10:23:35 +02:00
95be17a2db Dateien nach "frontend/src/tools" hochladen 2026-05-26 10:22:46 +02:00
2a45def43d Dateien nach "frontend/src" hochladen 2026-05-26 10:22:07 +02:00
4727631709 Dateien nach "frontend/public" hochladen 2026-05-26 09:29:00 +02:00
2e635e16ed Dateien nach "frontend" hochladen 2026-05-26 09:28:39 +02:00
b8b1432f20 Dateien nach "frontend/src" hochladen 2026-05-26 09:28:16 +02:00
7c2de6fa7f Dateien nach "frontend/src/tools" hochladen 2026-05-26 09:27:51 +02:00
9b196ec3be Dateien nach "frontend/src/tools" hochladen 2026-05-26 08:58:32 +02:00
ee19b13461 Dateien nach "frontend/src/tools" hochladen 2026-05-26 01:42:56 +02:00
8673a79bb1 Dateien nach "frontend/src/tools" hochladen 2026-05-26 01:33:18 +02:00
a624329e21 Dateien nach "frontend/src/tools" hochladen 2026-05-26 01:20:01 +02:00
ae5f30ff1e Dateien nach "backend/src/routes" hochladen 2026-05-26 01:13:36 +02:00
332e7a21ab Dateien nach "backend/src" hochladen 2026-05-26 01:13:04 +02:00
0c0ee34ede Dateien nach "frontend/src/tools" hochladen 2026-05-26 01:12:40 +02:00
9bf8415a43 Dateien nach "frontend/src" hochladen 2026-05-26 01:12:07 +02:00
360deb7257 Dateien nach "frontend/src/tools" hochladen 2026-05-26 00:56:24 +02:00
638b83d26b Dateien nach "backend/src" hochladen 2026-05-26 00:55:53 +02:00
9172fdd535 Dateien nach "backend/src/tools/bestellungen" hochladen 2026-05-26 00:55:31 +02:00
9773cf3013 Dateien nach "backend/src/tools/bestellungen" hochladen 2026-05-26 00:47:34 +02:00
39f8b0db4f Dateien nach "frontend/src/tools" hochladen 2026-05-26 00:38:22 +02:00
ae9cfb444c Dateien nach "backend/src" hochladen 2026-05-26 00:37:48 +02:00
4ba72ad3a8 Dateien nach "backend/src/tools/bestellungen" hochladen 2026-05-26 00:37:32 +02:00
6c1f9dfbf7 Dateien nach "backend/src/tools/bestellungen" hochladen 2026-05-26 00:16:07 +02:00
4554644f87 Dateien nach "backend/src" hochladen 2026-05-26 00:15:43 +02:00
eb7a03328e Dateien nach "frontend/src/tools" hochladen 2026-05-26 00:15:04 +02:00
bee374c458 Dateien nach "frontend/src" hochladen 2026-05-26 00:14:45 +02:00
80d1ed6a05 Dateien nach "frontend/src" hochladen 2026-05-26 00:14:27 +02:00
82a12de20f Dateien nach "frontend/src" hochladen 2026-05-26 00:01:51 +02:00
01a671f262 Dateien nach "backend/src/tools/bestellungen" hochladen 2026-05-25 23:56:18 +02:00
2b9f3758dc Dateien nach "backend/src" hochladen 2026-05-25 23:55:13 +02:00
f4aace299c Dateien nach "frontend/src/tools" hochladen 2026-05-25 23:54:30 +02:00
05737af18e Dateien nach "frontend/src" hochladen 2026-05-25 23:54:10 +02:00
c83ea713c3 Dateien nach "frontend/src" hochladen 2026-05-25 23:35:45 +02:00
91bd239587 Dateien nach "frontend/src" hochladen 2026-05-25 23:29:25 +02:00
cccf4f4bb3 Dateien nach "frontend/src" hochladen 2026-05-25 23:15:56 +02:00
9d3bb4f622 Dateien nach "frontend/src" hochladen 2026-05-25 23:10:45 +02:00
58528edafe Dateien nach "frontend/src/tools" hochladen 2026-05-25 23:04:10 +02:00
f1167e964c Dateien nach "frontend/src" hochladen 2026-05-25 23:03:53 +02:00
ac99e3ced1 frontend/src/toolRegistry.js gelöscht 2026-05-25 23:03:31 +02:00
edaf8611b2 frontend/src/App.jsx gelöscht 2026-05-25 23:03:27 +02:00
6551e3fecb Dateien nach "backend/src/tools/kalkulator3d" hochladen 2026-05-25 22:48:11 +02:00
5bfac5af0e backend/src/tools/kalkulator3d/routes.js gelöscht 2026-05-25 22:47:22 +02:00
fb168da21e Dateien nach "backend/src" hochladen 2026-05-25 22:47:08 +02:00
a9457fc83b backend/src/db.js gelöscht 2026-05-25 22:46:52 +02:00
c360df31c5 Dateien nach "frontend/src/tools" hochladen 2026-05-25 22:46:12 +02:00
aeb4d2098c frontend/src/tools/kalkulator3d.jsx gelöscht 2026-05-25 22:45:01 +02:00
7ff1900f80 Dateien nach "frontend/src" hochladen 2026-05-25 22:34:55 +02:00
232f1650df frontend/src/App.jsx gelöscht 2026-05-25 22:34:44 +02:00
6ca5d5bba8 Dateien nach "frontend/src" hochladen 2026-05-25 22:09:19 +02:00
98686bebda frontend/src/App.jsx gelöscht 2026-05-25 22:08:50 +02:00
b0b7cc859e Dateien nach "frontend/src/tools" hochladen 2026-05-25 21:53:02 +02:00
1fcf222b68 frontend/src/tools/kalkulator3d.jsx gelöscht 2026-05-25 21:52:42 +02:00
2f483b0d15 Dateien nach "frontend/src" hochladen 2026-05-25 21:52:33 +02:00
597f7489ff frontend/src/App.jsx gelöscht 2026-05-25 21:52:04 +02:00
bb6548370e frontend/src/toolRegistry.js gelöscht 2026-05-25 21:51:59 +02:00
6498ba70ce Dateien nach "frontend/src" hochladen 2026-05-25 21:38:34 +02:00
eeeecc6d40 Dateien nach "frontend/src/tools" hochladen 2026-05-25 21:38:18 +02:00
c80b51afb2 frontend/src/tools/kalkulator3d.jsx gelöscht 2026-05-25 21:37:41 +02:00
b42986fbe1 frontend/src/App.jsx gelöscht 2026-05-25 21:37:36 +02:00
3bc8919731 Dateien nach "frontend/src" hochladen 2026-05-25 21:30:27 +02:00
246a7375c6 Dateien nach "frontend/src/tools" hochladen 2026-05-25 21:30:03 +02:00
773a3e5f5e frontend/src/tools/Kalkulator3D.jsx gelöscht 2026-05-25 21:28:59 +02:00
0d520e54d9 frontend/src/toolRegistry.js gelöscht 2026-05-25 21:28:53 +02:00
bc15b64f6e frontend/src/App.jsx gelöscht 2026-05-25 21:28:48 +02:00
995c19de43 Dateien nach "frontend/src/tools" hochladen 2026-05-25 20:45:33 +02:00
e342b20cf0 Dateien nach "frontend/src" hochladen 2026-05-25 20:45:10 +02:00
368f5d228f frontend/src/App.jsx gelöscht 2026-05-25 20:44:21 +02:00
d75775bade frontend/src/tools/Kalkulator3D.jsx gelöscht 2026-05-25 20:44:07 +02:00
4e28e2b3bc Dateien nach "frontend/src/tools" hochladen 2026-05-25 20:32:54 +02:00
84d6ebb872 frontend/src/tools/Kalkulator3D.jsx gelöscht 2026-05-25 20:32:07 +02:00
76daa1c804 Dateien nach "frontend/src" hochladen 2026-05-25 20:31:45 +02:00
9d4f6cfc61 frontend/src/App.jsx gelöscht 2026-05-25 20:31:22 +02:00
f4e3895b7a Dateien nach "frontend/src" hochladen 2026-05-25 20:02:46 +02:00
bfdbe269f7 frontend/src/ /App.jsx gelöscht 2026-05-25 20:01:33 +02:00
3badab67e5 Dateien nach "frontend/src/ " hochladen 2026-05-25 20:00:34 +02:00
4388d22807 frontend/src/App.jsx gelöscht 2026-05-25 19:59:18 +02:00
5bcf4f0330 Dateien nach "backend/src/routes" hochladen 2026-05-25 19:41:10 +02:00
d0adfc07ac Dateien nach "frontend/src" hochladen 2026-05-25 19:40:05 +02:00
fe8509165c frontend/src/App.jsx gelöscht 2026-05-25 19:39:32 +02:00
f090d4d24f Dateien nach "frontend" hochladen 2026-05-25 16:07:41 +02:00
ae7faf4ddf frontend/index.html gelöscht 2026-05-25 16:07:20 +02:00
4b9eaec582 frontend/src/App.jsx aktualisiert 2026-05-25 16:00:30 +02:00
09d20e005b frontend/src/toolRegistry.js aktualisiert 2026-05-25 16:00:20 +02:00
4919f7ea72 Dateien nach "frontend/src" hochladen 2026-05-25 16:00:13 +02:00
1ac6691c10 frontend/App.jsx gelöscht 2026-05-25 15:59:52 +02:00
c552d3fd15 frontend/toolRegistry.js gelöscht 2026-05-25 15:59:46 +02:00
2f02f6d53f frontend/public/sw.js aktualisiert 2026-05-25 15:58:07 +02:00
c7015ee278 frontend/public/manifest.json aktualisiert 2026-05-25 15:57:59 +02:00
b12b854912 Dateien nach "frontend/public" hochladen 2026-05-25 15:57:53 +02:00
eaed8617e5 frontend/toolRegistry.js aktualisiert 2026-05-25 15:57:19 +02:00
ee9a9dd063 frontend/App.jsx aktualisiert 2026-05-25 15:57:12 +02:00
5d34619c67 Dateien nach "frontend" hochladen 2026-05-25 15:57:03 +02:00
f57ac344e2 frontend/vite.config.js aktualisiert 2026-05-25 15:56:37 +02:00
0ff2b04b09 frontend/generate-icons.mjs aktualisiert 2026-05-25 15:56:28 +02:00
a09cc57212 frontend/package.json aktualisiert 2026-05-25 15:56:19 +02:00
3a6022be8a frontend/index.html aktualisiert 2026-05-25 15:56:06 +02:00
3fb18d1390 Dateien nach "frontend" hochladen 2026-05-25 15:55:53 +02:00
2310043891 frontend/generate-icons.mjs gelöscht 2026-05-25 15:55:22 +02:00
878ddee92b frontend/src/App.jsx gelöscht 2026-05-25 15:55:12 +02:00
63775f04b9 frontend/src/toolRegistry.js gelöscht 2026-05-25 15:55:07 +02:00
036a875bb8 frontend/vite.config.js gelöscht 2026-05-25 15:54:55 +02:00
db1eff7a99 frontend/package.json gelöscht 2026-05-25 15:54:48 +02:00
f1c785445f frontend/index.html gelöscht 2026-05-25 15:54:42 +02:00
d9b4652f51 frontend/generate-icons.mjs aktualisiert 2026-05-25 15:47:50 +02:00
1370643172 backend/src/index.js aktualisiert 2026-05-25 15:36:21 +02:00
8a57a44f02 frontend/src/App.jsx aktualisiert 2026-05-25 15:35:16 +02:00
913252329a Dateien nach "frontend" hochladen 2026-05-25 15:34:53 +02:00
8380a045d3 frontend/vite.config.js aktualisiert 2026-05-25 15:34:31 +02:00
a8d8e26e20 frontend/package.json aktualisiert 2026-05-25 15:34:09 +02:00
512f77589b frontend/index.html aktualisiert 2026-05-25 15:33:42 +02:00
3c361daf3d Dateien nach "frontend/public" hochladen 2026-05-25 14:43:45 +02:00
e8b75b9c5f frontend/src/App.jsx aktualisiert 2026-05-25 14:43:04 +02:00
a2f63832cd frontend/package.json aktualisiert 2026-05-25 14:41:29 +02:00
52329f2a78 frontend/vite.config.js aktualisiert 2026-05-25 14:38:49 +02:00
1c0c06fa2e docker-compose.yml aktualisiert 2026-05-25 14:24:59 +02:00
9f0b0afaaf Dateien nach "frontend/public" hochladen 2026-05-25 14:02:27 +02:00
fba7d3474b frontend/vite.config.js aktualisiert 2026-05-25 13:57:42 +02:00
2351a2bd90 frontend/package.json aktualisiert 2026-05-25 13:57:17 +02:00
648131ebee docker-compose.yml aktualisiert 2026-05-25 13:30:17 +02:00
af0886a486 docker-compose.yml aktualisiert 2026-05-25 13:26:07 +02:00
77 changed files with 27792 additions and 639 deletions

4
.env.example Normal file
View File

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

5
.gitignore vendored Normal file
View File

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

View File

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

View File

@@ -7,6 +7,9 @@
"better-sqlite3": "^9.4.3",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1"
"multer": "^1.4.5-lts.1",
"ws": "^8.17.1",
"puppeteer-core": "^22.0.0",
"mqtt": "^5.10.1"
}
}
}

View File

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

View File

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

View File

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

620
backend/src/mqtt.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,383 @@
const express = require('express');
const https = require('https');
const db = require('../../db');
const mqttClient = require('../../mqtt');
const { authenticate } = require('../../middleware/auth');
const router = express.Router();
// ── Cache ─────────────────────────────────────────────────────────────────────
const cache = {};
function cacheGet(k) { const e=cache[k]; if(!e||Date.now()>e.ex) { delete cache[k]; return null; } return e.d; }
function cacheSet(k,d,ms=60*60*1000) { cache[k]={d,ex:Date.now()+ms}; }
// ── marktguru Cookie (Standort Duisburg 47259) ────────────────────────────────
const MG_SETTINGS = encodeURIComponent(JSON.stringify({
location: { name:'Duisburg', uniqueName:'duisburg', longitude:6.7625, latitude:51.4332, zipCodes:['47259'] },
locationSource: 'manual', hidePageflipMenu: false, cookieAccept: true, userGroup: 35,
}));
// ── Gewünschte Händler ────────────────────────────────────────────────────────
const TARGET_RETAILERS = ['edeka','e center','rewe','netto','kaufland','penny','trinkgut'];
const RETAILER_ADDRESSES = {
'edeka': 'Düsseldorfer Landstr. 361',
'e center': 'Düsseldorfer Landstr. 361',
'rewe': 'Mündelheimer Str. 132 / Keniastr. 39',
'netto': 'Im Bonnefeld 19-21',
'kaufland': 'Auf der Höhe 20',
'penny': 'Sittardsberger Allee 10',
'trinkgut': 'Keniastraße 39',
};
function isTargetRetailer(name) {
if (!name) return false;
const n = name.toLowerCase();
return TARGET_RETAILERS.some(r => n.includes(r));
}
function getAddress(retailer) {
if (!retailer) return '';
const n = retailer.toLowerCase();
for (const [key, addr] of Object.entries(RETAILER_ADDRESSES)) {
if (n.includes(key)) return addr;
}
return '';
}
// ── Angebote via Puppeteer von marktguru ─────────────────────────────────────
async function scrapeMarktguru(forceRefresh = false) {
const cacheKey = 'koepi:mg';
if (!forceRefresh) {
const cached = cacheGet(cacheKey);
if (cached) return cached;
}
let puppeteer;
try { puppeteer = require('puppeteer-core'); }
catch(e) { return { offers:[], error:'puppeteer-core nicht installiert', scrapedAt: new Date().toISOString() }; }
const browser = await puppeteer.launch({
executablePath: '/usr/bin/chromium-browser',
args: ['--no-sandbox','--disable-setuid-sandbox','--disable-dev-shm-usage','--disable-gpu','--single-process'],
headless: true,
});
try {
const page = await browser.newPage();
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
await page.setViewport({ width:1280, height:800 });
await page.setCookie({ name:'mg_user-settings', value:MG_SETTINGS, domain:'.marktguru.de', path:'/' });
await page.goto('https://www.marktguru.de/search/k%C3%B6nig%20pilsener?zipCode=47259', {
waitUntil: 'networkidle2', timeout: 30000,
});
// Seite nach unten scrollen damit lazy-loaded Bilder geladen werden
await page.evaluate(async () => {
await new Promise(resolve => {
let totalHeight = 0;
const distance = 300;
const timer = setInterval(() => {
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= document.body.scrollHeight) {
clearInterval(timer);
resolve();
}
}, 200);
});
});
await new Promise(r => setTimeout(r, 3000));
const offers = await page.evaluate(() => {
// Exakte Selektoren basierend auf marktguru HTML-Struktur
const cards = document.querySelectorAll('li.offer-list-item');
return Array.from(cards).map(card => {
const name = card.querySelector('h3')?.textContent?.trim() || '';
const brand = card.querySelector('dd.brand a')?.textContent?.trim() || '';
const price = card.querySelector('span.price')?.textContent?.trim() || '';
const oldPrice = card.querySelector('.price-bubble .old-price, .crossed')?.textContent?.trim() || '';
const retailer = card.querySelector('dd.retailer-name a')?.textContent?.trim() || '';
const dateRange= card.querySelector('dd.valid')?.textContent?.trim() || '';
const daysLeft = card.querySelector('dd.time-left span')?.textContent?.trim() || '';
const daysText = card.querySelector('dd.time-left')?.textContent?.trim() || '';
const validity = daysLeft ? `Noch ${daysLeft} Tag${daysLeft==='1'?'':'e'}` :
card.querySelector('dt.time-left')?.textContent?.includes('Brandneu') ? 'Neu' : '';
const descEl = card.querySelector('.info div, .info');
const description = descEl?.textContent?.trim() || '';
const img = card.querySelector('img.offer-list-item-img');
const badge = card.querySelector('.badge, .discount-badge')?.textContent?.trim() || '';
const imgSrc = img?.src?.replace('/medium.webp', '/large.webp') || img?.src || null;
return { name, brand, price, oldPrice, retailer, dateRange, validity, description, badge, address: '', image: imgSrc };
});
});
// Lokale Bilder auswählen basierend auf Beschreibung
for (const offer of offers) {
const desc = (offer.description || '').toLowerCase();
console.log('DESC:', JSON.stringify(desc), '| RETAILER:', offer.retailer);
if (desc.includes('steini')) {
offer.imageLocal = '/koepi/kasten_steini.png';
} else if (desc.includes('11 x 0,5') || desc.includes('11x0,5')) {
offer.imageLocal = '/koepi/kasten_lang.png';
} else if (desc.includes('6 x') || desc.includes('6x') || (desc.includes('flasche') && !desc.includes('kasten'))) {
offer.imageLocal = '/koepi/traeger.png';
} else if (desc.includes('24 x 0,33') || desc.includes('24x0,33') || desc.includes('24 x 0,33')) {
offer.imageLocal = '/koepi/kasten_033.png';
} else if (desc.includes('0,5')) {
offer.imageLocal = '/koepi/kasten_050.png';
} else if (desc.includes('0,33')) {
offer.imageLocal = '/koepi/kasten_033.png';
} else {
offer.imageLocal = '/koepi/kasten_050.png';
}
offer.image = null; // CDN nicht mehr nötig
console.log('IMAGE:', offer.imageLocal, '| RETAILER:', offer.retailer);
}
// Filtern auf Ziel-Händler + nur König Pilsener
const filtered = offers.filter(o => {
const isKP = (o.name + ' ' + o.brand + ' ' + o.description).toLowerCase();
const hasKP = isKP.includes('pilsen') || isKP.includes('pilsener');
return hasKP && isTargetRetailer(o.retailer);
});
// Händler normalisieren: E center = EDEKA
for (const o of filtered) {
if (o.retailer?.toLowerCase().includes('e center')) o.retailerDisplay = o.retailer;
else o.retailerDisplay = o.retailer;
}
// Deduplizieren: gleicher normalisierter Händler + gleicher Preis → ein Eintrag
// Kürzeste Beschreibung nehmen (sauberste)
const merged = new Map();
for (const o of filtered) {
const normalizedRetailer = o.retailer?.toLowerCase().replace('e center','edeka') || '';
const key = `${normalizedRetailer}|${o.price}`;
if (merged.has(key)) {
const existing = merged.get(key);
// Kürzere Beschreibung bevorzugen
if (o.description && (!existing.description || o.description.length < existing.description.length)) {
existing.description = o.description;
}
} else {
merged.set(key, { ...o });
}
}
const unique = [...merged.values()].map(o => ({ ...o, address: getAddress(o.retailer) }));
const result = { offers: unique, total: unique.length, scrapedAt: new Date().toISOString(), source: 'marktguru.de' };
cacheSet(cacheKey, result);
return result;
} finally {
await browser.close();
}
}
// ── Prospekte von kaufda ──────────────────────────────────────────────────────
function fetchUrl(url) {
return new Promise((resolve, reject) => {
const req = https.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html', 'Accept-Language': 'de-DE,de;q=0.9',
}
}, res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => resolve({ status: res.statusCode, body: d }));
});
req.setTimeout(15000, () => { req.destroy(); reject(new Error('Timeout')); });
req.on('error', reject);
});
}
function extractNextData(html) {
const marker = 'application/json">';
const ni = html.indexOf('NEXT_DATA');
if (ni === -1) return null;
const st = html.indexOf(marker, ni) + marker.length;
const en = html.indexOf('</script>', st);
try { return JSON.parse(html.slice(st, en)); } catch { return null; }
}
async function scrapeProspekte() {
const cacheKey = 'koepi:prospekte';
const cached = cacheGet(cacheKey);
if (cached) return cached;
const { body } = await fetchUrl(
'https://www.kaufda.de/shelf?query=K%C3%B6nig+Pilsener&lat=51.3750&lng=6.7680&zip=47259&city=Duisburg'
);
const nd = extractNextData(body);
const contents = nd?.props?.pageProps?.pageInformation?.shelfContents?.contents || [];
const seen = new Map();
for (const item of contents) {
const c = item.content;
if (!c || item.contentType !== 'brochure') continue;
const id = c.contentId;
if (!id || seen.has(id)) continue;
const pub = c.publisher?.name || '';
const store = c.closestStore;
seen.set(id, {
id, publisher: pub,
title: c.title || '',
store: store?.name || pub,
city: store?.city || 'Duisburg',
street: store ? `${store.street||''} ${store.streetNumber||''}`.trim() : '',
zip: store?.zip || '47259',
image: c.brochureImages?.find(i => i.size === '260x270')?.url || c.brochureImage?.url || null,
validFrom: c.validFrom || null,
validTo: c.validUntil || null,
pageCount: c.pageCount || null,
url: `https://www.kaufda.de/Geschaefte/${encodeURIComponent(pub.replace(/\s+/g,'-'))}`,
isTarget: isTargetRetailer(pub),
badges: c.contentBadges?.map(b => b.name) || [],
});
}
const all = [...seen.values()];
const result = {
targeted: all.filter(b => b.isTarget),
others: all.filter(b => !b.isTarget),
scrapedAt: new Date().toISOString(),
};
cacheSet(cacheKey, result);
return result;
}
// ── Bild-Proxy (für kaufda Bilder) ───────────────────────────────────────────
router.get('/img', authenticate, (req, res) => {
const url = req.query.url;
if (!url || !url.startsWith('https://')) return res.status(400).end();
try {
const parsed = new URL(url);
const allowed = ['content-media.bonial.biz','publisher-media.bonial.biz'];
if (!allowed.some(h => parsed.hostname === h)) return res.status(403).end();
const r = https.get(url, { headers: {'User-Agent':'Mozilla/5.0'} }, imgRes => {
res.setHeader('Content-Type', imgRes.headers['content-type'] || 'image/jpeg');
res.setHeader('Cache-Control', 'public, max-age=3600');
imgRes.pipe(res);
});
r.on('error', () => res.status(502).end());
} catch { res.status(400).end(); }
});
// ── Tages-Cron: neue/geänderte Angebote per Pushover melden ─────────────────
const KOEPI_SETTINGS_KEY = 'koepi_last_offers';
// Vergleichbare, stabile Signatur eines Angebots (Reihenfolge-unabhängig).
// Bewusst NUR dateRange (echtes Datum), NICHT validity ("Noch X Tage") — der
// Countdown-Text ändert sich täglich von allein und würde sonst jeden Tag
// eine Pushover-Benachrichtigung auslösen, auch wenn sich am Angebot nichts
// geändert hat.
function offerSignature(offers) {
return offers
.map(o => `${(o.retailer || '').toLowerCase().trim()}|${(o.price || '').trim()}|${(o.dateRange || '').trim()}`)
.sort()
.join('\n');
}
function formatOfferMessage(offers) {
if (!offers.length) return 'Aktuell keine König Pilsener Angebote gefunden.';
return offers
.map(o => `${o.retailer || '?'}: ${o.price || '?'} (${o.dateRange || 'Gültigkeit unbekannt'})`)
.join('\n');
}
async function sendKoepiPushover(offers) {
const recipients = db.prepare(`
SELECT user_key, app_token FROM pushover_settings
WHERE user_key IS NOT NULL AND app_token IS NOT NULL
`).all();
const message = formatOfferMessage(offers);
for (const r of recipients) {
try {
await fetch('https://api.pushover.net/1/messages.json', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
token: r.app_token,
user: r.user_key,
title: '🍺 KöPi Angebote',
message,
priority: 0,
}),
});
} catch (e) { console.error('KöPi Pushover Fehler:', e.message); }
}
}
// Reiner Anzeige-Refresh für den HA-Button "KöPi Check jetzt" — scraped frisch
// und aktualisiert nur die MQTT-Entitäten. Sendet NIE Pushover und rührt den
// in admin_settings gespeicherten Vergleichsstand nicht an (der bleibt exklusiv
// dem 06:00-Cron vorbehalten, damit dessen Diff-Erkennung korrekt bleibt).
async function refreshOffersOnly() {
try {
const { offers } = await scrapeMarktguru(true);
const currentSig = offerSignature(offers);
const lastSig = db.prepare('SELECT value FROM admin_settings WHERE key=?').get(KOEPI_SETTINGS_KEY)?.value || '';
const changed = currentSig !== lastSig; // nur zur Anzeige im "geändert"-Sensor, löst nichts aus
mqttClient.publishKoepiState({ offers, changed });
console.log(`🍺 KöPi-Refresh (HA-Button): ${offers.length} Angebot(e) angezeigt, keine Pushover.`);
return { changed, sent: false, offerCount: offers.length };
} catch (e) {
console.error('KöPi Refresh Fehler:', e.message);
return { error: e.message };
}
}
// Wird täglich um 06:00 vom Scheduler in index.js aufgerufen.
// forceNotify=true (manueller Admin-Test): Pushover wird auch ohne Änderung verschickt.
async function runDailyCheck(forceNotify = false) {
try {
const { offers } = await scrapeMarktguru(true);
const currentSig = offerSignature(offers);
const lastSig = db.prepare('SELECT value FROM admin_settings WHERE key=?').get(KOEPI_SETTINGS_KEY)?.value || '';
const changed = currentSig !== lastSig;
if (!changed && !forceNotify) {
console.log('🍺 KöPi-Check: keine Änderungen an den Angeboten.');
mqttClient.publishKoepiState({ offers, changed: false });
return { changed: false, sent: false, offerCount: offers.length };
}
await sendKoepiPushover(offers);
db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)')
.run(KOEPI_SETTINGS_KEY, currentSig);
console.log(`🍺 KöPi-Check: ${offers.length} Angebot(e)${changed ? ' geändert' : ' (Test, unverändert)'} Pushover versendet.`);
mqttClient.publishKoepiState({ offers, changed });
return { changed, sent: true, offerCount: offers.length };
} catch (e) {
console.error('KöPi Tages-Check Fehler:', e.message);
return { error: e.message };
}
}
// ── Routes ────────────────────────────────────────────────────────────────────
router.get('/offers', authenticate, async (req, res) => {
try { res.json(await scrapeMarktguru()); }
catch(e) { console.error('KöPi offers:', e.message); res.status(500).json({ error: e.message }); }
});
router.get('/prospekte', authenticate, async (req, res) => {
try { res.json(await scrapeProspekte()); }
catch(e) { res.status(500).json({ error: e.message }); }
});
router.post('/clear-cache', authenticate, (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
['koepi:mg','koepi:prospekte'].forEach(k => delete cache[k]);
res.json({ ok: true });
});
// Manueller Test des Tages-Checks (Admin) — sendet Pushover auch ohne echte Änderung
router.post('/run-daily-check', authenticate, async (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
try {
const result = await runDailyCheck(true);
if (result.error) return res.status(500).json({ error: result.error });
res.json(result);
} catch(e) { res.status(500).json({ error: e.message }); }
});
module.exports = router;
module.exports.runDailyCheck = runDailyCheck;
module.exports.refreshOffersOnly = refreshOffersOnly;

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

238
backend/upload-page.html Normal file
View File

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

View File

@@ -1,15 +1,17 @@
version: '3.8'
services:
dickendock:
image: 192.168.1.26:3000/admin/dickendock:latest
build:
context: "http://192.168.1.26:3000/Dicken/dickendock.git#main"
dockerfile: Dockerfile
container_name: dickendock
restart: unless-stopped
env_file:
- .env
environment:
- JWT_SECRET=BITTE-AENDERN-langerZufallsstring
- GITEA_URL=http://192.168.1.26:3000
- GITEA_REPO=admin/dickendock
- GITEA_REPO=Dicken/dickendock
- TZ=Europe/Berlin
volumes:
- ./data:/data
ports:
- "8080:4000"
- "8080:4000"

12
env.example Normal file
View File

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

106
frontend/generate-icons.mjs Normal file
View 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.');

View File

@@ -1,8 +1,17 @@
<!DOCTYPE html>
<html lang="de">
<head>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>

View File

@@ -2,12 +2,15 @@
"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"
"react-dom": "^18.2.0",
"recharts": "^2.12.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.0.0",

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

7
frontend/public/icon.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

View 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
View 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();
});

View 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()),
},
})

File diff suppressed because it is too large Load Diff

401
frontend/src/calendar.jsx Normal file
View 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
View 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
View 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
View 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);

View File

@@ -15,6 +15,35 @@ export const api = async (path, { body, method, isFile } = {}) => {
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 = {
@@ -40,7 +69,7 @@ export const S = {
},
// Abschnitts-Überschrift
head: {
color: 'rgba(255,255,255,0.3)', fontSize: 10,
color: 'rgba(255,255,255,0.6)', fontSize: 10,
fontFamily: 'monospace', letterSpacing: 2, marginBottom: 14,
},
};

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View 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>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,274 @@
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';
}
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; }
}
// ── Angebots-Karte (marktguru) ────────────────────────────────────────────────
function OfferCard({ offer }) {
const color = publisherColor(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' }}>
<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' }}>
{offer.dateRange && (
<span style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10 }}>📅 {offer.dateRange}</span>
)}
</div>
</div>
);
}
// ── Prospekt-Karte (kaufda) ───────────────────────────────────────────────────
function ProspektCard({ b }) {
const color = publisherColor(b.publisher);
const isNew = b.badges?.includes('new');
const expiring = b.badges?.includes('expiring_soon');
return (
<a href={b.url} target="_blank" rel="noopener noreferrer" style={{ textDecoration:'none' }}>
<div style={{
...S.card, padding:0, overflow:'hidden', cursor:'pointer',
border: expiring ? '1px solid rgba(239,68,68,0.4)' : '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' }}>
<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>
{expiring && <span style={{ background:'#ef4444', color:'#fff', borderRadius:4, padding:'2px 6px', fontSize:9, fontFamily:'monospace', fontWeight:700 }}>ENDET BALD</span>}
</div>
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:11, marginBottom:3 }}>{b.title}</div>
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10, marginBottom:2 }}>
📍 {b.street || b.store}
</div>
{(b.validFrom || b.validTo) && (
<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)}`}
</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 [showAll, setShowAll] = 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); }
};
return (
<div style={{ maxWidth:700, margin:'0 auto' }}>
{/* Header */}
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:20 }}>
<h2 style={{ margin:0, fontSize:15, fontFamily:'monospace', color:'rgba(255,255,255,0.55)', letterSpacing:2, fontWeight:400 }}>
🍺 KÖPI König Pilsener
</h2>
<div style={{ flex:1 }}/>
{isAdmin && (
<>
<button onClick={testDailyCheck} disabled={cronRunning} style={{ ...S.btn('#f59e0b',true), fontSize:10, opacity:cronRunning?0.5:1 }}>
{cronRunning ? '⏳ läuft…' : '🔔 Cron testen'}
</button>
<button onClick={clearCache} style={{ ...S.btn('#666666',true), fontSize:10 }}>🗑 Cache leeren</button>
</>
)}
</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 · 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(auto-fill,minmax(180px,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.targeted?.length > 0 && (
<>
<div style={{ ...S.head, marginBottom:12 }}>DEINE MÄRKTE ({prospekte.targeted.length})</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill,minmax(180px,1fr))', gap:12, marginBottom:16 }}>
{prospekte.targeted.map(b=><ProspektCard key={b.id} b={b}/>)}
</div>
</>
)}
{prospekte.others?.length > 0 && (
<>
<button onClick={()=>setShowAll(v=>!v)} style={{ ...S.btn('#444444',true), marginBottom:10, fontSize:11 }}>
{showAll ? '▾ Weniger' : `▸ Weitere Prospekte (${prospekte.others.length})`}
</button>
{showAll && (
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill,minmax(180px,1fr))', gap:12 }}>
{prospekte.others.map(b=><ProspektCard key={b.id} b={b}/>)}
</div>
)}
</>
)}
{!prospekte.targeted?.length && !prospekte.others?.length && (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'40px 0' }}>
Keine Prospekte gefunden.
</div>
)}
</div>
)}
</div>
);
}

View File

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

1395
frontend/src/tools/media.jsx Normal file

File diff suppressed because it is too large Load Diff

View 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 }}>&lt;</span>
<span style={{ color:nickColor, fontSize:11, fontWeight:700, flexShrink:0 }}>{nick}</span>
<span style={{ color:'#808080', fontSize:11, flexShrink:0, marginRight:6 }}>&gt;</span>
<span style={{ color: txt===null?'#404040':'#d0d0d0', fontSize:12, wordBreak:'break-word', flex:1 }}>
{txt===undefined ? '…' : txt===null ? '[verschlüsselt]' : txt}
</span>
{isMine && m.read_by_recipient && <span style={{color:'#004400',fontSize:9,marginLeft:4,flexShrink:0}}></span>}
<button onClick={() => deleteMsg(m.id)} style={{ background:'transparent', border:'none',
cursor:'pointer', color:'#400000', fontSize:9, padding:'0 0 0 6px', flexShrink:0, opacity:0.5 }}></button>
</div>
);
})}
</div>
) : (
/* ── Modernes Design ─────────────────────────────────── */
<>
{messages.map(m => {
const isMine = !!m.is_mine;
const txt = decrypted[m.id];
return (
<div key={m.id} style={{ display:'flex',
justifyContent: isMine ? 'flex-end' : 'flex-start', gap:6, alignItems:'flex-end' }}>
{!isMine && (
<div style={{ width:22, height:22, borderRadius:'50%', flexShrink:0,
background:'rgba(255,255,255,0.04)', border:'1px solid rgba(255,255,255,0.07)',
display:'flex', alignItems:'center', justifyContent:'center', fontSize:10 }}>👤</div>
)}
<div style={{ maxWidth:'75%', display:'flex', flexDirection:'column',
alignItems: isMine ? 'flex-end' : 'flex-start', gap:4 }}>
<div style={{
background: isMine ? 'rgba(78,205,196,0.09)' : 'rgba(255,255,255,0.04)',
border: `1px solid ${isMine ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.07)'}`,
borderRadius: isMine ? '12px 12px 3px 12px' : '12px 12px 12px 3px',
padding:'8px 12px',
}}>
{txt === undefined ? (
<span style={{ color:'rgba(255,255,255,0.18)', fontSize:12 }}></span>
) : txt === null ? (
<span style={{ color:'rgba(255,107,157,0.5)', fontSize:11, fontFamily:'monospace' }}>🔒 Nicht entschlüsselbar</span>
) : (
<span style={{ color:'#e6e6e6', fontSize:13, fontFamily:'monospace',
lineHeight:1.55, whiteSpace:'pre-wrap', wordBreak:'break-word' }}>{txt}</span>
)}
</div>
<div style={{ display:'flex', alignItems:'center', gap:5 }}>
<span style={{ color:'rgba(255,255,255,0.18)', fontSize:9, fontFamily:'monospace', whiteSpace:'nowrap' }}>
{formatMsgTime(m.created_at)}
</span>
{isMine && (
<span title={m.read_by_recipient ? 'Gelesen' : 'Gesendet'}
style={{ fontSize:10, lineHeight:1, color: m.read_by_recipient ? '#4ecdc4' : 'rgba(255,255,255,0.2)' }}>
{m.read_by_recipient ? '✓✓' : '✓'}
</span>
)}
<button onClick={() => deleteMsg(m.id)} style={{ background:'transparent', border:'none',
cursor:'pointer', padding:'0 2px', opacity:0.3, lineHeight:1,
display:'flex', alignItems:'center' }}>
<TrashIcon size={10} color="#ff6b9d"/>
</button>
</div>
</div>
</div>
);
})}
</>
)}
<div ref={bottomRef}/>
</div>
{/* Eingabe */}
<div style={{ padding:'10px 14px', flexShrink:0,
borderTop:`1px solid ${mirc?'#333':'rgba(255,255,255,0.06)'}`,
background: mirc ? '#000' : '#0d0d0f' }}>
{!activeUser.public_key || sharedKey === 'error' ? (
<div style={{ color:'rgba(255,255,255,0.2)', fontSize:11, fontFamily:'monospace',
textAlign:'center', padding:'8px 0' }}>
Senden nicht möglich
</div>
) : (
<div style={{ display:'flex', gap:8, alignItems:'stretch', position:'relative' }}>
<div ref={pickerRef} style={{ position:'absolute', bottom:'100%', right:0 }}>
{showPicker && (
<EmojiPicker
onSelect={em => { setText(t => t + em); setShowPicker(false); }}
onClose={() => setShowPicker(false)}
/>
)}
</div>
{mirc && (
<span style={{ color:'#00ff00', fontFamily:"'Courier New',monospace", fontSize:12,
alignSelf:'center', flexShrink:0 }}>[du]</span>
)}
<textarea
value={text}
onChange={e => {
const val = e.target.value;
const last = val[val.length - 1];
if (last === ' ' || last === '\n') setText(convertEmojis(val));
else setText(val);
}}
onFocus={() => setShowPicker(false)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const converted = convertEmojis(text + ' ').trimEnd();
if (converted !== text) setText(converted);
send(converted);
}
}}
placeholder={mirc ? 'Nachricht…' : 'Nachricht… (Enter senden · Shift+Enter neue Zeile)'}
rows={2}
style={{ ...S.inp, resize:'none', flex:1, fontSize: mirc ? 12 : 13,
padding:'8px 10px', lineHeight:1.45, minHeight:42,
...(mirc ? { background:'#111', border:'1px solid #444', color:'#d0d0d0',
fontFamily:"'Courier New',monospace", borderRadius:2 } : {}) }}
/>
<button onClick={() => setShowPicker(v => !v)} style={{
background: showPicker ? 'rgba(78,205,196,0.12)' : (mirc ? '#111' : 'rgba(255,255,255,0.05)'),
border:`1px solid ${showPicker ? 'rgba(78,205,196,0.3)' : (mirc ? '#444' : 'rgba(255,255,255,0.1)')}`,
borderRadius: mirc ? 2 : 8, cursor:'pointer', fontSize:17,
padding:'0 10px', flexShrink:0, alignSelf:'stretch' }}>😊</button>
</div>
)}
</div>
</>
)}
</div>
)}
</div>
);
}
function InfoBox({ color, children }) {
return (
<div style={{ background:`${color}08`, border:`1px solid ${color}25`,
borderRadius:8, padding:'10px 14px', color, fontSize:11,
fontFamily:'monospace', textAlign:'center', lineHeight:1.7 }}>
{children}
</div>
);
}
function formatMsgTime(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr.replace(' ', 'T'));
if (isNaN(d)) return '';
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
const isThisYear = d.getFullYear() === now.getFullYear();
const time = d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' });
if (isToday) return `Heute, ${time}`;
if (isThisYear) return `${d.toLocaleDateString('de-DE', { day:'numeric', month:'short' })}, ${time}`;
return `${d.toLocaleDateString('de-DE', { day:'numeric', month:'short', year:'numeric' })}, ${time}`;
}

View File

@@ -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" ~1530s warten, dann:
</div>
<a href={archiveUrl} target="_blank" rel="noreferrer"
style={{ ...S.btn(C.muted), display: 'inline-block', textDecoration: 'none', fontSize: 11 }}>
🔄 Nochmal öffnen
</a>
</div>
{/* Schritt 2 */}
<div style={{ display: 'flex', gap: 10 }}>
<span style={{ background: `${C.success}20`, border: `1px solid ${C.success}44`, borderRadius: '50%', width: 22, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, color: C.success, fontFamily: 'monospace', flexShrink: 0, marginTop: 1 }}>2</span>
<div style={{ flex: 1 }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontFamily: 'monospace', fontSize: 11, marginBottom: 8 }}>
Wenn der Artikel sichtbar ist, als PDF speichern:
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{[
['📱', 'Android Chrome', 'Menü (⋮) → Teilen → Drucken → Als PDF · Kopf-/Fußzeilen: aus'],
['🍎', 'iOS Safari', 'Teilen-Button → Als PDF sichern'],
['💻', 'Desktop', 'Strg+P / Cmd+P → Als PDF · Kopf-/Fußzeilen: aus'],
].map(([icon, label, desc]) => (
<div key={label} style={{ padding: '8px 10px', background: 'rgba(255,255,255,0.03)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.07)' }}>
<div style={{ color: 'rgba(255,255,255,0.6)', fontFamily: 'monospace', fontSize: 11, marginBottom: 2 }}>{icon} {label}</div>
<div style={{ color: C.muted, fontFamily: 'monospace', fontSize: 10, lineHeight: 1.5 }}>{desc}</div>
</div>
))}
</div>
<div style={{ marginTop: 10, padding: '8px 10px', background: 'rgba(255,255,255,0.02)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ color: 'rgba(255,255,255,0.3)', fontFamily: 'monospace', fontSize: 10, lineHeight: 1.5 }}>
Der archive.ph-Balken oben ist Teil der archivierten Seite und kann nicht automatisch entfernt werden.
</div>
</div>
</div>
</div>
</div>
)}
{/* Idle-Info */}
{status === 'idle' && (
<div style={{ ...S.card }}>
<div style={{ ...S.head, marginBottom: 10 }}>SO FUNKTIONIERT ES</div>
{[
['1', 'URL des gesperrten Artikels einfügen'],
['2', 'Artikel auf archive.ph öffnen'],
['3', 'Falls nicht archiviert: "archive this url" klicken, kurz warten, nochmal öffnen'],
['4', 'Über Drucken-Funktion als PDF speichern'],
].map(([num, text]) => (
<div key={num} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 8 }}>
<span style={{ background: `${C.accent}20`, border: `1px solid ${C.accent}44`, borderRadius: '50%', width: 20, height: 20, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, color: C.accent, fontFamily: 'monospace', marginTop: 2 }}>{num}</span>
<span style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', lineHeight: 1.6 }}>{text}</span>
</div>
))}
</div>
)}
</div>
);
}

View File

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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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&&current?.role==='owner'&&(
<ShareModal wbId={current.id} onClose={()=>setShareModal(false)} toast={toast}/>
)}
{textInput&&(
mobile ? (
<div style={{position:'fixed',bottom:0,left:0,right:0,zIndex:500,
background:'#1a1a1e',borderTop:'1px solid rgba(255,255,255,0.15)',
padding:'12px 16px',paddingBottom:'calc(12px + env(safe-area-inset-bottom,0px))'}}>
<div style={{fontSize:10,fontFamily:'monospace',color:'rgba(255,255,255,0.35)',marginBottom:6}}>
TEXT EINGEBEN · Enter = bestätigen · Esc = abbrechen
</div>
<div style={{display:'flex',gap:8}}>
<input autoFocus
onChange={e=>{
textPreviewRef.current={text:e.target.value,x:textInput.x,y:textInput.y,fontSize:FONT_SIZES[fontSize],color};
render();
}}
onKeyDown={e=>{
if(e.key==='Enter'){
const v=e.target.value; textPreviewRef.current=null; setTextInput(null);
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
}
if(e.key==='Escape'){ textPreviewRef.current=null; setTextInput(null); render(); }
}}
placeholder="Text eingeben…"
style={{...S.inp,flex:1,fontSize:14,color}}/>
<button onMouseDown={e=>{
e.preventDefault();
const inp=e.currentTarget.previousSibling;
const v=inp?.value||''; textPreviewRef.current=null; setTextInput(null);
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
}} style={S.btn('#4ECDC4',true)}></button>
<button onMouseDown={e=>{e.preventDefault();textPreviewRef.current=null;setTextInput(null);render();}} style={S.btn('#888888',true)}></button>
</div>
</div>
) : (
<div style={{position:'absolute',left:textInput.sx,top:textInput.sy,zIndex:100,pointerEvents:'auto'}}>
<input autoFocus
onChange={e=>{
textPreviewRef.current={text:e.target.value,x:textInput.x,y:textInput.y,fontSize:FONT_SIZES[fontSize],color};
render();
}}
onKeyDown={e=>{
if(e.key==='Enter'){
const v=e.target.value; textPreviewRef.current=null; setTextInput(null);
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
}
if(e.key==='Escape'){ textPreviewRef.current=null; setTextInput(null); render(); }
}}
onBlur={e=>{
const v=e.target.value; textPreviewRef.current=null; setTextInput(null);
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
}}
style={{background:'rgba(0,0,0,0.7)',border:'none',
outline:`1px dashed ${color}`,borderRadius:4,padding:'2px 6px',
color,fontSize:FONT_SIZES[fontSize],fontFamily:'monospace',minWidth:120}}/>
</div>
)
)}
{/* ── Topbar ───────────────────────────────────────────────────────── */}
<div style={{display:'flex',alignItems:'center',gap:8,padding:'8px 12px',
background:'rgba(0,0,0,0.6)',borderBottom:'1px solid rgba(255,255,255,0.08)',flexShrink:0,flexWrap:'wrap'}}>
<button onClick={closeBoard} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.5)',cursor:'pointer',fontSize:18,padding:'2px 6px',lineHeight:1}}></button>
{editingId===current?.id?(
<input autoFocus value={editTitle} onChange={e=>setEditTitle(e.target.value)}
onKeyDown={e=>{if(e.key==='Enter') renameBoard(current.id,editTitle); if(e.key==='Escape') setEditingId(null);}}
onBlur={()=>renameBoard(current.id,editTitle)}
style={{...S.inp,fontSize:13,fontWeight:700,flex:1,padding:'3px 8px'}}/>
):(
<span onDoubleClick={()=>{if(current?.role!=='view'){setEditingId(current.id);setEditTitle(current.title);}}}
title={current?.role!=='view'?'Doppelklick zum Umbenennen':''}
style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700,flex:1,minWidth:0,
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',cursor:current?.role!=='view'?'text':'default'}}>
{current?.title}
{current?.role==='view'&&<span style={{color:'rgba(255,255,255,0.3)',fontSize:10,marginLeft:8}}>👁 nur lesen</span>}
</span>
)}
{collab.length>0&&(
<div style={{display:'flex',gap:4,alignItems:'center'}}>
{collab.map(c=>(
<span key={c.userId} style={{background:c.color,color:'#000',fontFamily:'monospace',fontSize:9,borderRadius:10,padding:'2px 7px',fontWeight:700}}>
{c.username[0].toUpperCase()}
</span>
))}
</div>
)}
<button onClick={()=>exportToPng(elementsRef.current,current?.title)}
style={{...S.btn('#60A5FA',true),fontSize:11}}> PNG</button>
{canEdit&&<button onClick={save} disabled={saving} style={{...S.btn('#4ECDC4',true),fontSize:11}}>{saving?'…':'💾 Speichern'}</button>}
{current?.role==='owner'&&(
<button onClick={()=>setShareModal(true)} style={{...S.btn('#A78BFA',true),fontSize:11}}>👥 Teilen</button>
)}
</div>
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
{canEdit&&(
<div style={{display:'flex',alignItems:'center',gap:5,padding:'5px 10px',
background:'rgba(0,0,0,0.45)',borderBottom:'1px solid rgba(255,255,255,0.06)',
flexShrink:0,flexWrap:'wrap'}}>
{/* Tools */}
<div style={{display:'flex',gap:3}}>
{TOOLS.map(t=>(
<button key={t.id} onClick={()=>{
setTool(t.id); setSelectedId(null); selectedRef.current=null;
textPreviewRef.current=null; setTextInput(null); render();
}} title={t.tip}
style={{background:tool===t.id?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
border:`1px solid ${tool===t.id?'#4ECDC4':'rgba(255,255,255,0.1)'}`,
borderRadius:7,padding:'5px 9px',color:tool===t.id?'#4ECDC4':'rgba(255,255,255,0.7)',
cursor:'pointer',fontSize:14,lineHeight:1,fontFamily:'monospace'}}>
{t.label}
</button>
))}
</div>
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
{/* Farben */}
<div style={{display:'flex',gap:3}}>
{COLORS.map(c=>(
<button key={c} onClick={()=>setColor(c)}
style={{width:18,height:18,borderRadius:'50%',background:c,
border:color===c?'2px solid #fff':'1px solid rgba(255,255,255,0.2)',cursor:'pointer',padding:0,flexShrink:0}}/>
))}
</div>
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
{/* Strichstärke */}
<div style={{display:'flex',gap:3,alignItems:'center'}}>
{SIZES.map((s,i)=>(
<button key={i} onClick={()=>setSize(i)}
style={{width:18,height:18,borderRadius:'50%',
background:size===i?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
border:`1px solid ${size===i?'#4ECDC4':'rgba(255,255,255,0.15)'}`,
cursor:'pointer',padding:0,display:'flex',alignItems:'center',justifyContent:'center'}}>
<div style={{width:s+2,height:s+2,borderRadius:'50%',background:size===i?'#4ECDC4':'#888'}}/>
</button>
))}
</div>
{/* Textgröße — nur bei Text-Tool */}
{tool==='text'&&<>
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:9}}>TEXT</span>
<div style={{display:'flex',gap:3,alignItems:'center'}}>
{FONT_SIZES.map((fs,i)=>(
<button key={i} onClick={()=>setFontSize(i)}
style={{padding:'2px 6px',borderRadius:6,fontFamily:'monospace',fontSize:Math.min(14,fs/2)+1,
background:fontSize===i?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
border:`1px solid ${fontSize===i?'#4ECDC4':'rgba(255,255,255,0.15)'}`,
color:fontSize===i?'#4ECDC4':'rgba(255,255,255,0.6)',cursor:'pointer'}}>
{fs}
</button>
))}
</div>
</>}
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
{/* Undo + Löschen */}
<button onClick={undo} disabled={!undoStack.length} title="Rückgängig (Ctrl+Z)"
style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',
borderRadius:7,padding:'5px 9px',color:undoStack.length?'rgba(255,255,255,0.7)':'rgba(255,255,255,0.2)',
cursor:undoStack.length?'pointer':'default',fontSize:12,fontFamily:'monospace'}}></button>
{selectedId&&(
<button onClick={()=>{
const next=elementsRef.current.filter(el=>el.id!==selectedId);
setUndoStack(prev=>[...prev,elementsRef.current]);
elementsRef.current=next; setElements([...next]); broadcast(next);
setSelectedId(null); selectedRef.current=null;
}} style={{background:'rgba(255,107,157,0.08)',border:'1px solid rgba(255,107,157,0.25)',
borderRadius:7,padding:'5px 9px',color:'#ff6b9d',cursor:'pointer',fontSize:12,fontFamily:'monospace'}}>
Löschen
</button>
)}
<button onClick={()=>{if(confirm('Alles löschen?')){
elementsRef.current=[];setElements([]);setUndoStack([]);broadcast([]);
setSelectedId(null);selectedRef.current=null;
}}} style={{background:'rgba(255,107,157,0.05)',border:'1px solid rgba(255,107,157,0.15)',
borderRadius:7,padding:'5px 9px',color:'rgba(255,107,157,0.5)',cursor:'pointer',fontSize:12,fontFamily:'monospace'}}>
🗑
</button>
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
{/* Zoom */}
<button onClick={()=>zoom(1.2)} style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',borderRadius:7,padding:'5px 9px',color:'rgba(255,255,255,0.7)',cursor:'pointer',fontSize:14,fontFamily:'monospace'}}></button>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10,minWidth:32,textAlign:'center'}}>
{Math.round(viewport.zoom*100)}%
</span>
<button onClick={()=>zoom(0.8)} style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',borderRadius:7,padding:'5px 9px',color:'rgba(255,255,255,0.7)',cursor:'pointer',fontSize:14,fontFamily:'monospace'}}></button>
<button onClick={()=>syncVp({x:0,y:0,zoom:1})}
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.25)',cursor:'pointer',fontFamily:'monospace',fontSize:9}}>
Reset
</button>
</div>
)}
{/* ── Canvas ───────────────────────────────────────────────────────── */}
<canvas ref={canvasRef}
onPointerDown={onPointerDown} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerLeave={onPointerUp} onWheel={onWheel}
style={{flex:1,width:'100%',touchAction:'none',cursor:getCursor()}}
/>
{/* Hinweis: Select-Tool Aktionen */}
{tool==='select'&&selectedId&&!mobile&&(
<div style={{position:'absolute',bottom:12,left:'50%',transform:'translateX(-50%)',
background:'rgba(0,0,0,0.7)',border:'1px solid rgba(255,255,255,0.1)',
borderRadius:8,padding:'5px 12px',fontFamily:'monospace',fontSize:10,color:'rgba(255,255,255,0.4)',
pointerEvents:'none',whiteSpace:'nowrap'}}>
Ziehen: verschieben · Ecke () ziehen: skalieren · Entf: löschen
</div>
)}
</div>
);
}

View File

@@ -1,7 +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),
},
})