P42 Dashboard: erster Stand
FastAPI-Backend und React-Frontend mit Konten, Sendungsverfolgung, Projekt-Artikeln, Vorrat & Einkauf mit Rezeptabgleich, Reise-Weltkarte, Kanban-Projekten mit Priorität, Server-Monitoring und den Designs Matrix und Blackwall. Betrieb per Docker hinter nginx (siehe DEPLOY.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
dd58b3d1f8
75 files changed
+11418
No files matched your search
@@ -0,0 +1,19 @@
|
||||
# Nicht ins Image: Daten, Einstellungen mit Schlüsseln, lokale Umgebungen, Build-Ergebnisse
|
||||
data
|
||||
config.json
|
||||
.env
|
||||
.git
|
||||
.vscode
|
||||
_alt-node
|
||||
docs
|
||||
deploy
|
||||
DEPLOY.md
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
start.sh
|
||||
backend/.venv
|
||||
**/__pycache__
|
||||
**/node_modules
|
||||
frontend/dist
|
||||
*.log
|
||||
*.mp4
|
||||
@@ -0,0 +1,13 @@
|
||||
# Optional: nach .env kopieren und anpassen.
|
||||
|
||||
# Port auf dem Server, unter dem der Container erreichbar ist (Ziel für nginx)
|
||||
P42_PORT=3000
|
||||
|
||||
# 127.0.0.1 = nur vom Server selbst erreichbar (nginx läuft direkt auf dem Server).
|
||||
# Läuft nginx als eigener Docker-Container (z. B. Nginx Proxy Manager), hier die lokale
|
||||
# Server-IP eintragen (z. B. 192.168.0.10) – nicht 0.0.0.0, sonst ist der Port offen im Netz.
|
||||
P42_BIND=127.0.0.1
|
||||
|
||||
# Wem die X-Forwarded-Header geglaubt werden. "*" ist sicher, solange der Port nur für nginx
|
||||
# erreichbar ist (siehe P42_BIND). Sonst die IP des Proxys eintragen, z. B. 172.17.0.1
|
||||
P42_TRUSTED_PROXIES=*
|
||||
@@ -0,0 +1,6 @@
|
||||
config.json
|
||||
data/
|
||||
backend/.venv/
|
||||
__pycache__/
|
||||
.env
|
||||
_alt-node/
|
||||
@@ -0,0 +1,100 @@
|
||||
# P42 Dashboard auf dem Server betreiben (Docker + eigener nginx)
|
||||
|
||||
Der Container öffnet **nur einen internen Port** (Standard `127.0.0.1:3000`). Von außen erreichbar wird das
|
||||
Dashboard ausschließlich über deinen nginx, der per HTTPS auf diesen Port weiterleitet.
|
||||
|
||||
- **Daten** (`data/`: Datenbank + hochgeladene Bilder) liegen auf dem Server außerhalb des Containers.
|
||||
- **Einstellungen** (`config.json`: API-Keys, Wikipedia-Kontakt) werden nur lesend eingebunden, nicht ins Image gebaut.
|
||||
- Beides bleibt bei Updates und beim Neubau des Containers erhalten.
|
||||
|
||||
## 1. Projekt auf den Server bringen
|
||||
|
||||
Das Projekt ist (noch) kein Git-Repository. Entweder per Git (empfohlen, dann klappen Updates mit einem Befehl)
|
||||
oder einfach kopieren:
|
||||
|
||||
```bash
|
||||
# lokal, im Ordner Dokumente/Projekte – ohne lokale Umgebungen und Build-Ergebnisse
|
||||
rsync -av --exclude node_modules --exclude .venv --exclude dist --exclude _alt-node \
|
||||
P42-F.W./ DEIN-USER@SERVER:~/festplatte/dockercontainer/p42/
|
||||
```
|
||||
|
||||
Mit Git: Repository anlegen (`config.json`, `data/` und `.env` stehen bereits in `.gitignore`), auf dem Server
|
||||
klonen wie beim HCRB-Projekt (Deploy-Key mit Lesezugriff).
|
||||
|
||||
## 2. Einstellungen und Daten (einmalig, auf dem Server)
|
||||
|
||||
```bash
|
||||
cd ~/festplatte/dockercontainer/p42
|
||||
cp config.example.json config.json # nur falls nicht mitkopiert – API-Keys etc. eintragen
|
||||
chmod +x deploy/*.sh
|
||||
|
||||
# Datenordner: der Container läuft als UID 1000 und muss hier schreiben dürfen
|
||||
mkdir -p data && sudo chown -R 1000:1000 data
|
||||
```
|
||||
|
||||
Bestehende Daten übernehmen: den Ordner `data/` vom bisherigen Rechner mitkopieren (Server vorher stoppen, damit die
|
||||
Datenbank vollständig ist). Neue Tabellen und Spalten legt der Server beim ersten Start selbst an.
|
||||
|
||||
## 3. Port und Bindung (optional)
|
||||
|
||||
Standard: Port `3000`, nur auf `127.0.0.1` – passt, wenn nginx **direkt auf dem Server** läuft.
|
||||
Prüfen, ob der Port frei ist (keine Ausgabe = frei): `sudo ss -ltnp | grep ':3000 '`
|
||||
|
||||
Anpassen über `.env`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
| Variable | Bedeutung |
|
||||
|---|---|
|
||||
| `P42_PORT` | Port auf dem Server (Ziel für nginx) |
|
||||
| `P42_BIND` | `127.0.0.1` für nginx auf dem Server. Läuft nginx als Docker-Container (z. B. Nginx Proxy Manager), die lokale Server-IP eintragen (`hostname -I`). Nicht `0.0.0.0`, sonst ist der Port offen im Netz. |
|
||||
| `P42_TRUSTED_PROXIES` | Wem die `X-Forwarded-*`-Header geglaubt werden. `*` ist sicher, solange nur nginx den Port erreicht. |
|
||||
|
||||
## 4. Starten / Updaten
|
||||
|
||||
```bash
|
||||
./deploy/update-docker.sh
|
||||
```
|
||||
|
||||
Baut das Image (Frontend + Backend), startet den Container und zeigt den Status. Bei einem Git-Projekt holt es vorher
|
||||
den neuen Code. Nach etwa 15 Sekunden sollte `docker compose ps` den Zustand `healthy` zeigen.
|
||||
|
||||
Erster Aufruf über die Domain: Es erscheint die Einrichtung des ersten Kontos (wird Administrator) – oder, mit
|
||||
übernommenen Daten, die normale Anmeldung.
|
||||
|
||||
## 5. nginx (machst du) – was das Dashboard braucht
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme; # sonst bekommt das Login-Cookie kein "Secure"
|
||||
client_max_body_size 16m; # Bild-Uploads bis 15 MB (nginx-Standard: 1 MB)
|
||||
proxy_read_timeout 60s; # Rezept-/Infoabrufe können ein paar Sekunden dauern
|
||||
}
|
||||
```
|
||||
|
||||
- `X-Forwarded-For` ist wichtig: Der Login-Schutz (max. 5 Fehlversuche in 10 Minuten) zählt pro echter Client-IP.
|
||||
- Ohne `client_max_body_size` schlagen Fotos über 1 MB mit Fehler 413 fehl.
|
||||
- Nginx Proxy Manager: „Forward Hostname/IP" = Server-IP, „Forward Port" = `P42_PORT`; die Größe im Reiter
|
||||
*Advanced* als `client_max_body_size 16m;` eintragen.
|
||||
|
||||
## 6. Nützliche Befehle
|
||||
|
||||
```bash
|
||||
docker compose logs -f # Log ansehen
|
||||
docker compose restart # nach Änderungen an config.json
|
||||
docker compose exec p42 python -m app.reset_password BENUTZERNAME # Passwort zurücksetzen
|
||||
```
|
||||
|
||||
**Backup:** Container kurz stoppen (`docker compose stop`), Ordner `data/` sichern, wieder starten (`docker compose start`).
|
||||
|
||||
## Hinweise
|
||||
|
||||
- **Monitoring:** Die Prüfungen laufen jetzt aus dem Container. `localhost`/`127.0.0.1` meint dort den Container
|
||||
selbst, nicht den Server – für Dienste auf dem Server dessen LAN-IP eintragen (z. B. `192.168.0.10:22`).
|
||||
- **Zeitzone** ist `Europe/Berlin` (für Uhrzeiten in Monitoring und Logs).
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# P42 Dashboard – ein Container: FastAPI liefert API und das gebaute React-Frontend aus.
|
||||
|
||||
# ---------- 1. Frontend bauen ----------
|
||||
FROM node:22-alpine AS frontend
|
||||
WORKDIR /build
|
||||
# Erst nur die Paketlisten, damit npm ci bei Code-Änderungen aus dem Cache kommt
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ---------- 2. Laufzeit ----------
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
TZ=Europe/Berlin \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=3000 \
|
||||
P42_DATA_DIR=/data
|
||||
|
||||
WORKDIR /app/backend
|
||||
COPY backend/requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Aufbau wie im Projekt: /app/backend/app, /app/frontend/dist, /app/config.json (wird eingebunden)
|
||||
COPY backend/app ./app
|
||||
COPY config.example.json /app/config.example.json
|
||||
COPY --from=frontend /build/dist /app/frontend/dist
|
||||
|
||||
# Ohne Root-Rechte laufen; /data gehört dem Benutzer (UID 1000, wie im Datenordner auf dem Server)
|
||||
RUN useradd --uid 1000 --create-home p42 && mkdir -p /data && chown p42:p42 /data
|
||||
USER p42
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:3000/api/auth/status', timeout=4).status == 200 else 1)"
|
||||
|
||||
CMD ["python", "-m", "app"]
|
||||
@@ -0,0 +1,204 @@
|
||||
# P42 Dashboard
|
||||
|
||||
Persönliche Startseite mit Konten, universeller Sendungsverfolgung, Projekt-Artikeln,
|
||||
gemeinsamer Vorrats- und Einkaufsliste (mit Rezeptabgleich), Reise-Weltkarte, Kanban-Boards,
|
||||
Schnellzugriff auf Jellyfin, Audiobookshelf und Homer sowie Server-Monitoring.
|
||||
Zwei umschaltbare Designs: Matrix und Blackwall.
|
||||
|
||||
- **Backend:** Python (FastAPI) – `backend/`
|
||||
- **Frontend:** React (Vite, React Router, TipTap-Editor) – `frontend/`
|
||||
|
||||
## Starten
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
Danach im Browser: http://localhost:3000
|
||||
|
||||
Das Skript legt beim ersten Mal die Python-Umgebung an, installiert die Pakete,
|
||||
baut das Frontend (und baut es neu, sobald sich etwas in `frontend/src` ändert)
|
||||
und startet den Server. Voraussetzungen: Python ≥ 3.11 und Node.js ≥ 20.
|
||||
|
||||
### Auf einem Server (Docker)
|
||||
|
||||
```bash
|
||||
./deploy/update-docker.sh
|
||||
```
|
||||
|
||||
Der Container öffnet nur einen internen Port (Standard `127.0.0.1:3000`), den ein nginx per HTTPS
|
||||
nach außen gibt. Daten (`data/`) und Einstellungen (`config.json`) liegen außerhalb des Containers.
|
||||
Einrichtung, Port-Optionen und nginx-Einstellungen: [DEPLOY.md](DEPLOY.md).
|
||||
|
||||
### Entwicklung (mit Live-Reload)
|
||||
|
||||
Zwei Terminals:
|
||||
|
||||
```bash
|
||||
cd backend && RELOAD=1 .venv/bin/python -m app # API auf Port 3000
|
||||
cd frontend && npm run dev # Oberfläche auf http://localhost:5173
|
||||
```
|
||||
|
||||
Vite leitet `/api` und `/uploads` automatisch ans Backend weiter.
|
||||
Die API-Dokumentation gibt es unter http://localhost:3000/api/docs.
|
||||
|
||||
## Konfiguration (`config.json`)
|
||||
|
||||
| Feld | Bedeutung |
|
||||
|---|---|
|
||||
| `port` | Port des Servers (Standard 3000) |
|
||||
| `links` | Nur Startwerte für die Links des ersten Kontos (falls es keine alten Daten gibt) |
|
||||
| `tracking.seventeenTrackApiKey` | API-Key von 17TRACK – universell, über 2000 Paketdienste |
|
||||
| `tracking.dhlApiKey` | API-Key der DHL Shipment Tracking API (optional, für DHL-Sendungen) |
|
||||
| `reisen.wikipediaKontakt` | Kontaktadresse, die bei Abrufen von Wikipedia/Wikidata mitgesendet wird (von Wikimedia verlangt). Leer = keine Wikipedia-Texte auf der Reise-Karte |
|
||||
|
||||
Nach Änderungen an `config.json` den Server neu starten.
|
||||
|
||||
## Konten
|
||||
|
||||
Die ganze Seite ist nur nach Anmeldung erreichbar.
|
||||
|
||||
- Beim allerersten Aufruf legst du das **erste Konto** an. Es wird Administrator und
|
||||
übernimmt die bisherigen Projekte (als Autor) und Links.
|
||||
- Weitere Konten legt ein Administrator unter **Menü → Benutzer verwalten** an
|
||||
(dort auch: Passwort zurücksetzen, Admin-Rechte vergeben, Konten löschen).
|
||||
- Unter **Menü → Mein Konto** ändert jeder seinen Anzeigenamen und sein Passwort.
|
||||
- Pro Konto eigenständig: **Links** auf der Startseite, **Monitoring** und die **Reise-Karte**.
|
||||
- Gemeinsam für alle Konten: **Vorrat & Einkauf** und **Kanban**.
|
||||
- **Artikel** sind gemeinsam: alle Konten können sie lesen und bearbeiten; angezeigt wird,
|
||||
wer ihn geschrieben und wer ihn zuletzt geändert hat. Löschen dürfen nur der Autor und
|
||||
Administratoren.
|
||||
|
||||
Passwort vergessen und kein anderer Admin vorhanden? Im Ordner `backend/`:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m app.reset_password BENUTZERNAME
|
||||
```
|
||||
|
||||
### Schnellzugriff-Links (Jellyfin, Audiobookshelf, Homer)
|
||||
|
||||
Jedes Konto hinterlegt seine eigenen Adressen auf der Startseite über
|
||||
„✎ Links bearbeiten“ (oder durch Klick auf eine Kachel ohne Link).
|
||||
|
||||
### Monitoring
|
||||
|
||||
Jedes Konto pflegt seine eigene Serverliste. Geprüft wird alle 30 Sekunden:
|
||||
|
||||
- **Webseite (HTTP):** online, wenn die Adresse antwortet (auch mit Anmeldeseite o. Ä.).
|
||||
- **Port (TCP):** online, wenn der Port Verbindungen annimmt, z. B. `192.168.0.10:22`.
|
||||
|
||||
Die Prüfung läuft vom Dashboard-Server aus – Adressen müssen also von dort erreichbar sein.
|
||||
|
||||
### Sendungsverfolgung
|
||||
|
||||
Ohne API-Key erkennt die Seite anhand des Nummernformats den wahrscheinlichen
|
||||
Paketdienst und zeigt Links zur jeweiligen Tracking-Seite.
|
||||
|
||||
Für Live-Daten (Paketdienst, Status, voraussichtliche Ankunft, Verlauf):
|
||||
|
||||
- **17TRACK** (empfohlen, deckt DHL, Hermes, DPD, GLS, UPS, Post usw. ab):
|
||||
Konto unter https://api.17track.net anlegen und den Key eintragen.
|
||||
Der kostenlose Tarif hat ein begrenztes Kontingent an Sendungsnummern.
|
||||
- **DHL** (optional, kostenlos): unter https://developer.dhl.com die API
|
||||
„Shipment Tracking – Unified“ freischalten. DHL-Nummern werden dann direkt bei
|
||||
DHL abgefragt, alles andere über 17TRACK.
|
||||
|
||||
### Projekte
|
||||
|
||||
Jedes Projekt ist ein Artikel mit Titelbild, Titel, Kurzbeschreibung, Status und
|
||||
formatiertem Text (Überschriften, Listen, Zitate, Links, Bilder). Über „Bearbeiten“
|
||||
wird der Artikel direkt auf der Seite geändert. Bilder lassen sich per Button,
|
||||
Einfügen (Strg+V) oder Drag & Drop in den Text setzen.
|
||||
|
||||
Nicht mehr verwendete Bilder werden automatisch gelöscht. Der Artikeltext wird beim
|
||||
Speichern serverseitig bereinigt.
|
||||
|
||||
### Vorrat & Einkauf
|
||||
|
||||
Gemeinsame Liste mit zwei Bereichen: **Vorrat** (was da ist) und **Einkaufen** (was fehlt).
|
||||
Beim Eintippen schlägt die Seite bekannte Produkte vor, damit Namen einheitlich bleiben
|
||||
("milch" wird automatisch zu "Milch"). Mengen als Anzahl, kg oder Liter.
|
||||
|
||||
- Haken bei „Gekauft“ übernimmt einen Eintrag in den Vorrat; −‑Klick bei der letzten Einheit
|
||||
im Vorrat setzt ihn auf die Einkaufsliste.
|
||||
- **Rezept abgleichen:** Link zu einer Rezeptseite (z. B. Chefkoch) oder eine Zutatenliste
|
||||
einfügen, Personenzahl angeben. Die Seite rechnet die Mengen um, vergleicht mit dem Vorrat
|
||||
und setzt Fehlendes auf die Einkaufsliste. Eiweiß/Eigelb werden zu Eiern.
|
||||
Abgerufen wird nur die eingefügte Seite (schema.org-Rezeptdaten); Links ins eigene Netz
|
||||
werden abgelehnt.
|
||||
|
||||
### Reisen
|
||||
|
||||
Weltkarte pro Konto: Städte, in denen man war, als Punkte; besuchte Länder ausgemalt.
|
||||
Klick auf Land oder Punkt zoomt hin und zeigt unten links Infos (Länderdaten lokal,
|
||||
Texte aus Wikipedia, Einwohner aus Wikidata, Orte über Open-Meteo/GeoNames).
|
||||
|
||||
### Kanban
|
||||
|
||||
Beliebig viele Projekte mit je einem Board (Offen, In Arbeit, Wartet, Erledigt). Aufgaben
|
||||
haben Titel, Beschreibung, Status und Bearbeiter; verschieben per Drag & Drop oder im Dialog.
|
||||
Projekte lassen sich mit Artikeln aus „P42 Projekte“ verknüpfen, abschließen, wieder öffnen
|
||||
und löschen (löschen: wer angelegt hat und Administratoren).
|
||||
|
||||
### Designs
|
||||
|
||||
Im Menü unter **Design** umschaltbar, gespeichert pro Browser:
|
||||
|
||||
| Design | Stil |
|
||||
|---|---|
|
||||
| Matrix | rotes Retro-Terminal mit Matrix-Regen (Standard) |
|
||||
| Blackwall | Cyberpunk-Netrunner: Pink-Rot mit Cyan, leuchtende Datenwand, abgeschrägte Panels |
|
||||
|
||||
„Animation: an/aus“ im Menü stoppt alle Hintergrund-Animationen; bei „Bewegung reduzieren“
|
||||
im Betriebssystem sind sie automatisch aus. Die Designs sind von den Vorbildern inspiriert,
|
||||
ohne Original-Logos; alle Schriften sind frei (OFL) und lokal eingebunden.
|
||||
Quellen und Lizenzen: `docs/CREDITS.md`.
|
||||
|
||||
## Daten & Backup
|
||||
|
||||
Alles liegt im Ordner `data/`: die Datenbank `p42.db` (Konten, Projekte, Links,
|
||||
Monitoring, Vorrat, Reisen, Kanban) und die Bilder in `uploads/`. Neue Tabellen und
|
||||
Spalten legt der Server beim Start selbst an (Migrationen in `backend/app/db.py`). Für ein Backup den Server kurz stoppen und
|
||||
den Ordner `data/` sichern. Ältere `projects.json`/`links.json` werden beim ersten Start
|
||||
automatisch übernommen und danach in `*.importiert` umbenannt.
|
||||
|
||||
## Aufbau
|
||||
|
||||
```
|
||||
start.sh Einrichten + Starten (lokal, ohne Docker)
|
||||
Dockerfile, docker-compose.yml Betrieb auf dem Server, siehe DEPLOY.md
|
||||
deploy/update-docker.sh Container bauen/aktualisieren
|
||||
config.json Einstellungen (nicht ins Git)
|
||||
data/ Datenbank und Bilder (nicht ins Git)
|
||||
|
||||
backend/
|
||||
requirements.txt
|
||||
app/main.py FastAPI: API-Routen, Uploads, Auslieferung des Frontends
|
||||
app/config.py liest config.json
|
||||
app/db.py SQLite-Schema, Übernahme alter JSON-Daten
|
||||
app/auth.py Konten, Anmeldung, Benutzerverwaltung
|
||||
app/projects.py Projekte/Artikel, Bild-Uploads, HTML-Bereinigung
|
||||
app/links.py Schnellzugriff-Links pro Konto
|
||||
app/monitoring.py Monitoring pro Konto (HTTP/TCP-Prüfung)
|
||||
app/haushalt.py Vorrat & Einkauf, Produktkatalog, Einheiten
|
||||
app/rezept.py Rezeptabgleich (Seite abrufen, Zutaten zerlegen, zuordnen)
|
||||
app/reisen.py Reise-Karte, Städtesuche, Infos aus Wikipedia/Wikidata
|
||||
app/kanban.py Kanban-Projekte, Aufgaben, Artikel-Verknüpfungen
|
||||
app/tracking/ Paketdienst-Erkennung, 17TRACK, DHL
|
||||
|
||||
frontend/
|
||||
src/main.jsx Routen
|
||||
src/api.js API-Aufrufe, Bild-Upload
|
||||
src/auth.jsx Anmeldestatus
|
||||
src/styles.css Grund-Styling und Matrix-Design (responsiv, volle Breite)
|
||||
src/themes.css Blackwall (überschreibt styles.css)
|
||||
src/theme.js, motion.js Design-Auswahl, Animations-Schalter
|
||||
src/components/ Layout + Menü, Hintergründe der Designs, Editor, Weltkarte, …
|
||||
src/pages/ Login, Start, Projekte, Artikel, Vorrat, Reisen, Kanban, Monitoring, …
|
||||
src/data/laender.json Länderdaten (aus world-countries, ODbL)
|
||||
public/maps/ Weltkarte (Natural Earth über world-atlas)
|
||||
|
||||
docs/ Design-Brief, Bibliotheken, Quellen & Lizenzen
|
||||
```
|
||||
|
||||
`_alt-node/` enthält die frühere Node.js-Version und kann gelöscht werden.
|
||||
Whitespace-only changes.
@@ -0,0 +1,14 @@
|
||||
"""Start mit `python -m app` (im Ordner backend/)."""
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
|
||||
from .config import PORT
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host=os.environ.get("HOST", "0.0.0.0"),
|
||||
port=int(os.environ.get("PORT", PORT)),
|
||||
reload=os.environ.get("RELOAD") == "1",
|
||||
)
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Konten, Anmeldung (Sitzungs-Cookie) und Benutzerverwaltung."""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
||||
|
||||
from .config import LINKS as CONFIG_LINKS
|
||||
from .db import get_meta, now_iso, transaction
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
SESSION_COOKIE = "p42_session"
|
||||
SESSION_SECONDS = 30 * 24 * 60 * 60
|
||||
USERNAME = re.compile(r"[A-Za-z0-9._-]{3,32}")
|
||||
MIN_PASSWORD = 8
|
||||
|
||||
# ---------- Passwörter ----------
|
||||
|
||||
_SCRYPT = {"n": 2**14, "r": 8, "p": 1, "dklen": 32}
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.scrypt(password.encode(), salt=salt, **_SCRYPT)
|
||||
return f"scrypt${salt.hex()}${digest.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
_, salt, digest = stored.split("$")
|
||||
calc = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), **_SCRYPT)
|
||||
return secrets.compare_digest(calc.hex(), digest)
|
||||
|
||||
|
||||
# Für unbekannte Benutzernamen trotzdem rechnen, damit die Antwortzeit nichts verrät
|
||||
_DUMMY_HASH = hash_password(secrets.token_hex(8))
|
||||
|
||||
# ---------- Hilfsfunktionen ----------
|
||||
|
||||
|
||||
def user_dict(row) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"displayName": row["display_name"],
|
||||
"isAdmin": bool(row["is_admin"]),
|
||||
"createdAt": row["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def _username(value) -> str:
|
||||
v = value.strip() if isinstance(value, str) else ""
|
||||
if not USERNAME.fullmatch(v):
|
||||
raise HTTPException(400, "Benutzername: 3–32 Zeichen, nur Buchstaben, Zahlen, Punkt, Minus und Unterstrich.")
|
||||
return v
|
||||
|
||||
|
||||
def _display_name(value, fallback: str = "") -> str:
|
||||
v = value.strip() if isinstance(value, str) else ""
|
||||
v = v or fallback
|
||||
if not v or len(v) > 60:
|
||||
raise HTTPException(400, "Anzeigename fehlt oder ist zu lang (max. 60 Zeichen).")
|
||||
return v
|
||||
|
||||
|
||||
def _password(value) -> str:
|
||||
if not isinstance(value, str) or len(value) < MIN_PASSWORD:
|
||||
raise HTTPException(400, f"Das Passwort muss mindestens {MIN_PASSWORD} Zeichen lang sein.")
|
||||
if len(value) > 200:
|
||||
raise HTTPException(400, "Das Passwort ist zu lang.")
|
||||
return value
|
||||
|
||||
|
||||
def _start_session(response: Response, request: Request, user_id: int) -> None:
|
||||
token = secrets.token_urlsafe(32)
|
||||
with transaction() as conn:
|
||||
conn.execute("DELETE FROM sessions WHERE expires_at < ?", (time.time(),))
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)",
|
||||
(_hash_token(token), user_id, time.time() + SESSION_SECONDS),
|
||||
)
|
||||
https = request.headers.get("x-forwarded-proto", request.url.scheme) == "https"
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE, token, max_age=SESSION_SECONDS, httponly=True, samesite="lax", secure=https, path="/",
|
||||
)
|
||||
|
||||
|
||||
def _create_user(conn, username: str, display_name: str, password: str, is_admin: bool) -> int:
|
||||
if conn.execute("SELECT 1 FROM users WHERE username = ?", (username,)).fetchone():
|
||||
raise HTTPException(400, "Diesen Benutzernamen gibt es schon.")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (username, display_name, password_hash, is_admin, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(username, display_name, hash_password(password), int(is_admin), now_iso()),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
# ---------- Abhängigkeiten für geschützte Routen ----------
|
||||
|
||||
|
||||
def current_user(request: Request) -> dict:
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if token:
|
||||
with transaction() as conn:
|
||||
row = conn.execute(
|
||||
"""SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = ? AND s.expires_at > ?""",
|
||||
(_hash_token(token), time.time()),
|
||||
).fetchone()
|
||||
if row:
|
||||
return user_dict(row)
|
||||
raise HTTPException(401, "Nicht angemeldet.")
|
||||
|
||||
|
||||
def require_admin(user: dict = Depends(current_user)) -> dict:
|
||||
if not user["isAdmin"]:
|
||||
raise HTTPException(403, "Nur für Administratoren.")
|
||||
return user
|
||||
|
||||
|
||||
# ---------- Anmelde-Schutz: max. 5 Fehlversuche in 10 Minuten ----------
|
||||
|
||||
_failures: dict[tuple, list[float]] = defaultdict(list)
|
||||
MAX_FAILURES, WINDOW = 5, 600
|
||||
|
||||
|
||||
def _check_rate_limit(key: tuple) -> None:
|
||||
now = time.time()
|
||||
_failures[key] = [t for t in _failures[key] if now - t < WINDOW]
|
||||
if len(_failures[key]) >= MAX_FAILURES:
|
||||
raise HTTPException(429, "Zu viele Fehlversuche. Bitte in ein paar Minuten erneut versuchen.")
|
||||
|
||||
|
||||
# ---------- Routen: Anmeldung ----------
|
||||
|
||||
|
||||
@router.get("/api/auth/status")
|
||||
def status(request: Request):
|
||||
with transaction() as conn:
|
||||
has_users = conn.execute("SELECT 1 FROM users LIMIT 1").fetchone() is not None
|
||||
try:
|
||||
user = current_user(request)
|
||||
except HTTPException:
|
||||
user = None
|
||||
return {"setupRequired": not has_users, "user": user}
|
||||
|
||||
|
||||
@router.post("/api/auth/setup")
|
||||
def setup(request: Request, response: Response, data: dict = Body(...)):
|
||||
"""Erstes Konto anlegen – wird Administrator und übernimmt die bisherigen Daten."""
|
||||
with transaction() as conn:
|
||||
if conn.execute("SELECT 1 FROM users LIMIT 1").fetchone():
|
||||
raise HTTPException(403, "Es gibt bereits ein Konto.")
|
||||
username = _username(data.get("username"))
|
||||
display_name = _display_name(data.get("displayName"), username)
|
||||
password = _password(data.get("password"))
|
||||
with transaction() as conn:
|
||||
if conn.execute("SELECT 1 FROM users LIMIT 1").fetchone():
|
||||
raise HTTPException(403, "Es gibt bereits ein Konto.")
|
||||
user_id = _create_user(conn, username, display_name, password, is_admin=True)
|
||||
# Bisherige Projekte ohne Autor diesem Konto zuordnen
|
||||
conn.execute("UPDATE projects SET created_by = ? WHERE created_by IS NULL", (user_id,))
|
||||
conn.execute("UPDATE projects SET updated_by = ? WHERE updated_by IS NULL", (user_id,))
|
||||
# Bisherige Links (links.json bzw. config.json) übernehmen
|
||||
legacy = get_meta(conn, "legacy_links")
|
||||
links = json.loads(legacy) if legacy else CONFIG_LINKS
|
||||
for key, url in (links or {}).items():
|
||||
if url:
|
||||
conn.execute("INSERT OR REPLACE INTO user_links VALUES (?, ?, ?)", (user_id, key, url))
|
||||
conn.execute("DELETE FROM meta WHERE key = 'legacy_links'")
|
||||
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
_start_session(response, request, user_id)
|
||||
return user_dict(row)
|
||||
|
||||
|
||||
@router.post("/api/auth/login")
|
||||
def login(request: Request, response: Response, data: dict = Body(...)):
|
||||
username = str(data.get("username") or "").strip()
|
||||
password = str(data.get("password") or "")
|
||||
key = (request.client.host if request.client else "", username.lower())
|
||||
_check_rate_limit(key)
|
||||
with transaction() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
|
||||
if not verify_password(password, row["password_hash"] if row else _DUMMY_HASH) or not row:
|
||||
_failures[key].append(time.time())
|
||||
raise HTTPException(401, "Benutzername oder Passwort falsch.")
|
||||
_failures.pop(key, None)
|
||||
_start_session(response, request, row["id"])
|
||||
return user_dict(row)
|
||||
|
||||
|
||||
@router.post("/api/auth/logout")
|
||||
def logout(request: Request, response: Response):
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if token:
|
||||
with transaction() as conn:
|
||||
conn.execute("DELETE FROM sessions WHERE token_hash = ?", (_hash_token(token),))
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.patch("/api/auth/me")
|
||||
def update_me(request: Request, data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""Eigenen Anzeigenamen oder eigenes Passwort ändern."""
|
||||
with transaction() as conn:
|
||||
if "displayName" in data:
|
||||
conn.execute("UPDATE users SET display_name = ? WHERE id = ?", (_display_name(data["displayName"]), user["id"]))
|
||||
if data.get("newPassword"):
|
||||
row = conn.execute("SELECT password_hash FROM users WHERE id = ?", (user["id"],)).fetchone()
|
||||
if not verify_password(str(data.get("currentPassword") or ""), row["password_hash"]):
|
||||
raise HTTPException(400, "Das aktuelle Passwort stimmt nicht.")
|
||||
conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (hash_password(_password(data["newPassword"])), user["id"]))
|
||||
# Andere Sitzungen (z. B. auf anderen Geräten) beenden
|
||||
current = _hash_token(request.cookies.get(SESSION_COOKIE, ""))
|
||||
conn.execute("DELETE FROM sessions WHERE user_id = ? AND token_hash != ?", (user["id"], current))
|
||||
row = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone()
|
||||
return user_dict(row)
|
||||
|
||||
|
||||
# ---------- Routen: Benutzerverwaltung (nur Admins) ----------
|
||||
|
||||
|
||||
@router.get("/api/users")
|
||||
def list_users(_: dict = Depends(require_admin)):
|
||||
with transaction() as conn:
|
||||
rows = conn.execute("SELECT * FROM users ORDER BY display_name COLLATE NOCASE").fetchall()
|
||||
return [user_dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/api/users", status_code=201)
|
||||
def create_user(data: dict = Body(...), _: dict = Depends(require_admin)):
|
||||
username = _username(data.get("username"))
|
||||
display_name = _display_name(data.get("displayName"), username)
|
||||
password = _password(data.get("password"))
|
||||
with transaction() as conn:
|
||||
user_id = _create_user(conn, username, display_name, password, bool(data.get("isAdmin")))
|
||||
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
return user_dict(row)
|
||||
|
||||
|
||||
def _ensure_other_admin(conn, user_id: int) -> None:
|
||||
others = conn.execute("SELECT COUNT(*) FROM users WHERE is_admin = 1 AND id != ?", (user_id,)).fetchone()[0]
|
||||
if not others:
|
||||
raise HTTPException(400, "Es muss mindestens ein Administrator übrig bleiben.")
|
||||
|
||||
|
||||
@router.patch("/api/users/{user_id}")
|
||||
def update_user(user_id: int, data: dict = Body(...), _: dict = Depends(require_admin)):
|
||||
with transaction() as conn:
|
||||
if not conn.execute("SELECT 1 FROM users WHERE id = ?", (user_id,)).fetchone():
|
||||
raise HTTPException(404, "Nicht gefunden.")
|
||||
if "username" in data:
|
||||
username = _username(data["username"])
|
||||
if conn.execute("SELECT 1 FROM users WHERE username = ? AND id != ?", (username, user_id)).fetchone():
|
||||
raise HTTPException(400, "Diesen Benutzernamen gibt es schon.")
|
||||
conn.execute("UPDATE users SET username = ? WHERE id = ?", (username, user_id))
|
||||
if "displayName" in data:
|
||||
conn.execute("UPDATE users SET display_name = ? WHERE id = ?", (_display_name(data["displayName"]), user_id))
|
||||
if "isAdmin" in data:
|
||||
if not data["isAdmin"]:
|
||||
_ensure_other_admin(conn, user_id)
|
||||
conn.execute("UPDATE users SET is_admin = ? WHERE id = ?", (int(bool(data["isAdmin"])), user_id))
|
||||
if data.get("password"):
|
||||
conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (hash_password(_password(data["password"])), user_id))
|
||||
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
|
||||
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
return user_dict(row)
|
||||
|
||||
|
||||
@router.delete("/api/users/{user_id}")
|
||||
def delete_user(user_id: int, admin: dict = Depends(require_admin)):
|
||||
if user_id == admin["id"]:
|
||||
raise HTTPException(400, "Das eigene Konto kann nicht gelöscht werden.")
|
||||
with transaction() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Nicht gefunden.")
|
||||
if row["is_admin"]:
|
||||
_ensure_other_admin(conn, user_id)
|
||||
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Lädt config.json (oder config.example.json) aus dem Projektordner."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DATA_DIR = Path(os.environ.get("P42_DATA_DIR") or ROOT / "data")
|
||||
FRONTEND_DIST = ROOT / "frontend" / "dist"
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
path = ROOT / "config.json"
|
||||
if not path.exists():
|
||||
path = ROOT / "config.example.json"
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
config = _load()
|
||||
|
||||
PORT = int(config.get("port", 3000))
|
||||
LINKS = config.get("links", {})
|
||||
TRACKING = config.get("tracking", {})
|
||||
# Wikimedia verlangt eine Kontaktangabe im User-Agent; leer = keine Wikipedia-Texte
|
||||
WIKIPEDIA_CONTACT = (config.get("reisen") or {}).get("wikipediaKontakt", "").strip()
|
||||
@@ -0,0 +1,337 @@
|
||||
"""SQLite-Datenbank (data/p42.db): Schema, Verbindungen und Übernahme der alten JSON-Daten."""
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .config import DATA_DIR
|
||||
|
||||
DB_FILE = DATA_DIR / "p42.db"
|
||||
|
||||
# Geteilte Vorrats- und Einkaufsliste; key = Name in casefold() für den Vergleich.
|
||||
# Pro Produkt, Liste und Einheit eine Zeile ("Mehl 1 kg" und "Mehl 2 Stück" dürfen nebeneinander stehen).
|
||||
HOUSEHOLD_TABLE = """(
|
||||
id INTEGER PRIMARY KEY,
|
||||
key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
list TEXT NOT NULL CHECK (list IN ('vorrat', 'einkauf')),
|
||||
quantity REAL NOT NULL CHECK (quantity > 0),
|
||||
unit TEXT NOT NULL DEFAULT 'stk' CHECK (unit IN ('stk', 'kg', 'l')),
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE (key, list, unit)
|
||||
)"""
|
||||
|
||||
# Kanban-Projekte (je ein Board), gemeinsam für alle Konten
|
||||
KANBAN_PROJECTS_TABLE = """(
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
position REAL NOT NULL,
|
||||
completed_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL
|
||||
)"""
|
||||
|
||||
# Aufgaben eines Kanban-Projekts; position sortiert innerhalb einer Spalte
|
||||
KANBAN_TASKS_TABLE = """(
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES kanban_projects(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL CHECK (status IN ('offen', 'arbeit', 'wartet', 'erledigt')),
|
||||
assignee INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 2 CHECK (priority IN (1, 2, 3)), -- 1 hoch, 2 mittel, 3 niedrig
|
||||
position REAL NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL
|
||||
)"""
|
||||
|
||||
SCHEMA = f"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
display_name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_links (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
cover TEXT,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS monitors (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS household_items {HOUSEHOLD_TABLE};
|
||||
|
||||
-- Produktkatalog für die Vorschläge
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
key TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
uses INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Gelernte Zuordnungen Rezeptzutat → Produkt (z. B. "margarine" → "Butter")
|
||||
CREATE TABLE IF NOT EXISTS ingredient_aliases (
|
||||
key TEXT PRIMARY KEY,
|
||||
product TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Reise-Karte pro Konto: besuchte Städte (GeoNames-ID) und von Hand markierte Länder (ISO-Code)
|
||||
CREATE TABLE IF NOT EXISTS travel_cities (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
geoname_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
admin1 TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
population INTEGER,
|
||||
elevation REAL,
|
||||
timezone TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (user_id, geoname_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS travel_countries (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, code)
|
||||
);
|
||||
|
||||
-- Zwischenspeicher für Infos von Wikipedia/Wikidata
|
||||
CREATE TABLE IF NOT EXISTS info_cache (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
fetched_at REAL NOT NULL
|
||||
);
|
||||
|
||||
-- Kanban: Projekte (je ein Board) → Aufgaben; gemeinsam für alle Konten
|
||||
CREATE TABLE IF NOT EXISTS kanban_projects {KANBAN_PROJECTS_TABLE};
|
||||
|
||||
-- Verknüpfung Kanban-Projekt ↔ Artikel aus "P42 Projekte"
|
||||
CREATE TABLE IF NOT EXISTS kanban_project_links (
|
||||
project_id INTEGER NOT NULL REFERENCES kanban_projects(id) ON DELETE CASCADE,
|
||||
article_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (project_id, article_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kanban_tasks {KANBAN_TASKS_TABLE};
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def connect() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_FILE, timeout=10)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
|
||||
@contextmanager
|
||||
def transaction():
|
||||
"""Verbindung für einen Arbeitsschritt; bei Erfolg wird gespeichert, bei Fehlern zurückgerollt."""
|
||||
conn = connect()
|
||||
try:
|
||||
with conn:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_meta(conn, key: str):
|
||||
row = conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
|
||||
return row["value"] if row else None
|
||||
|
||||
|
||||
# ---------- Übernahme der alten JSON-Dateien ----------
|
||||
|
||||
def _posts_to_html(posts: list) -> str:
|
||||
"""Ganz alte Daten mit Update-Feed in Artikeltext umwandeln."""
|
||||
parts = []
|
||||
for post in reversed(posts):
|
||||
date = datetime.fromisoformat(post["createdAt"].replace("Z", "+00:00")).strftime("%d.%m.%Y")
|
||||
parts.append(f"<h3>{date}</h3>")
|
||||
for para in filter(None, re.split(r"\n{2,}", post.get("text") or "")):
|
||||
parts.append(f"<p>{html.escape(para).replace(chr(10), '<br>')}</p>")
|
||||
parts += [f'<p><img src="/uploads/{img}" alt=""></p>' for img in post.get("images", [])]
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _import_legacy(conn) -> None:
|
||||
projects_json = DATA_DIR / "projects.json"
|
||||
if projects_json.exists():
|
||||
for p in json.loads(projects_json.read_text(encoding="utf-8")).get("projects", []):
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO projects
|
||||
(id, title, description, status, cover, content, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
p["id"], p["title"], p.get("description", ""), p.get("status", "aktiv"), p.get("cover"),
|
||||
p.get("content") or _posts_to_html(p.get("posts", [])), p["createdAt"], p["updatedAt"],
|
||||
),
|
||||
)
|
||||
projects_json.rename(projects_json.with_name("projects.json.importiert"))
|
||||
|
||||
# Links aus der Zeit vor den Konten bekommt das erste Konto
|
||||
links_json = DATA_DIR / "links.json"
|
||||
if links_json.exists():
|
||||
conn.execute("INSERT OR REPLACE INTO meta VALUES ('legacy_links', ?)", (links_json.read_text(encoding="utf-8"),))
|
||||
links_json.rename(links_json.with_name("links.json.importiert"))
|
||||
|
||||
|
||||
def _migrate(conn) -> None:
|
||||
# Vorrat/Einkauf aus der Zeit vor den Einheiten: Tabelle mit Einheit und Kommazahlen neu aufbauen
|
||||
columns = {r["name"] for r in conn.execute("PRAGMA table_info(household_items)")}
|
||||
if "unit" in columns:
|
||||
return
|
||||
conn.execute("ALTER TABLE household_items RENAME TO household_items_alt")
|
||||
conn.execute("CREATE TABLE household_items " + HOUSEHOLD_TABLE)
|
||||
conn.execute(
|
||||
"""INSERT INTO household_items (id, key, name, list, quantity, unit, updated_at, updated_by)
|
||||
SELECT id, key, name, list, quantity, 'stk', updated_at, updated_by FROM household_items_alt"""
|
||||
)
|
||||
conn.execute("DROP TABLE household_items_alt")
|
||||
|
||||
|
||||
def _migrate_travel(conn) -> None:
|
||||
# Reise-Städte aus einer Vorversion ohne Höhe/Zeitzone: Spalten ergänzen
|
||||
columns = {r["name"] for r in conn.execute("PRAGMA table_info(travel_cities)")}
|
||||
for column, kind in (("elevation", "REAL"), ("timezone", "TEXT")):
|
||||
if column not in columns:
|
||||
conn.execute(f"ALTER TABLE travel_cities ADD COLUMN {column} {kind}")
|
||||
|
||||
|
||||
def _migrate_kanban_categories(conn) -> None:
|
||||
"""Kategorien wieder entfernt: kanban_projects ohne category_id neu aufbauen.
|
||||
|
||||
Aufgaben und Artikel-Links verweisen per ON DELETE CASCADE auf kanban_projects. Ein normales
|
||||
DROP würde sie mitlöschen, deshalb das SQLite-Verfahren: Fremdschlüssel aus, neue Tabelle
|
||||
anlegen, kopieren, alte löschen, umbenennen, Verweise prüfen.
|
||||
"""
|
||||
columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_projects)")}
|
||||
if "category_id" not in columns:
|
||||
conn.execute("DROP TABLE IF EXISTS kanban_categories")
|
||||
return
|
||||
conn.commit()
|
||||
conn.execute("PRAGMA foreign_keys = OFF") # wirkt nur außerhalb einer Transaktion
|
||||
try:
|
||||
conn.execute("BEGIN")
|
||||
conn.execute("CREATE TABLE kanban_projects_neu " + KANBAN_PROJECTS_TABLE)
|
||||
conn.execute(
|
||||
"""INSERT INTO kanban_projects_neu (id, name, description, position, created_at, created_by, updated_at, updated_by)
|
||||
SELECT id, name, description, position, created_at, created_by, updated_at, updated_by FROM kanban_projects"""
|
||||
)
|
||||
conn.execute("DROP TABLE kanban_projects")
|
||||
conn.execute("ALTER TABLE kanban_projects_neu RENAME TO kanban_projects")
|
||||
conn.execute("DROP TABLE kanban_categories")
|
||||
if conn.execute("PRAGMA foreign_key_check").fetchall():
|
||||
raise RuntimeError("Kanban-Migration: ungültige Verweise, Änderungen werden zurückgenommen.")
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
def _migrate_kanban_priority(conn) -> None:
|
||||
# Aufgaben ohne Priorität: Spalte ergänzen, bestehende Aufgaben werden "mittel"
|
||||
columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_tasks)")}
|
||||
if "priority" not in columns:
|
||||
conn.execute(
|
||||
"ALTER TABLE kanban_tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 2 CHECK (priority IN (1, 2, 3))"
|
||||
)
|
||||
|
||||
|
||||
def _migrate_kanban_completed(conn) -> None:
|
||||
# Projekte "abgeschlossen": Spalte ergänzen, falls sie fehlt
|
||||
columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_projects)")}
|
||||
if "completed_at" not in columns:
|
||||
conn.execute("ALTER TABLE kanban_projects ADD COLUMN completed_at TEXT")
|
||||
|
||||
|
||||
def _migrate_kanban(conn) -> None:
|
||||
# Aufgaben aus der Zeit vor den Projekten: in "Allgemeines Board" übernehmen
|
||||
columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_tasks)")}
|
||||
if "project_id" in columns:
|
||||
return
|
||||
conn.execute("ALTER TABLE kanban_tasks RENAME TO kanban_tasks_alt")
|
||||
conn.execute("CREATE TABLE kanban_tasks " + KANBAN_TASKS_TABLE)
|
||||
if conn.execute("SELECT COUNT(*) FROM kanban_tasks_alt").fetchone()[0]:
|
||||
now = now_iso()
|
||||
project = conn.execute(
|
||||
"INSERT INTO kanban_projects (name, position, created_at, updated_at) VALUES ('Allgemeines Board', 1, ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
conn.execute(
|
||||
"""INSERT INTO kanban_tasks (id, project_id, title, description, status, assignee, position,
|
||||
created_at, created_by, updated_at, updated_by)
|
||||
SELECT id, ?, title, description, status, assignee, position, created_at, created_by, updated_at, updated_by
|
||||
FROM kanban_tasks_alt""",
|
||||
(project,),
|
||||
)
|
||||
conn.execute("DROP TABLE kanban_tasks_alt")
|
||||
|
||||
|
||||
def init() -> None:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with transaction() as conn:
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.executescript(SCHEMA)
|
||||
_migrate(conn)
|
||||
_migrate_travel(conn)
|
||||
_migrate_kanban_categories(conn)
|
||||
_migrate_kanban_completed(conn)
|
||||
_migrate_kanban(conn)
|
||||
_migrate_kanban_priority(conn)
|
||||
_import_legacy(conn)
|
||||
|
||||
|
||||
init()
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Geteilte Vorrats- und Einkaufsliste mit Produktkatalog für einheitliche Namen.
|
||||
|
||||
Alle Konten sehen und bearbeiten dieselben Listen. Jedes Produkt steht pro Liste und Einheit
|
||||
(Anzahl, kg, l) nur einmal; erneutes Hinzufügen in derselben Einheit erhöht die Menge. Neue Produktnamen landen im Katalog und werden als
|
||||
Vorschläge angeboten – "milch" wird dabei automatisch zum bereits bekannten "Milch".
|
||||
"""
|
||||
import math
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
|
||||
from .auth import current_user
|
||||
from .db import now_iso, transaction
|
||||
from .links import ValidationError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
LISTS = ("vorrat", "einkauf")
|
||||
UNITS = ("stk", "kg", "l")
|
||||
MAX_NAME = 60
|
||||
MAX_QUANTITY = 999
|
||||
MAX_PRODUCTS = 2000
|
||||
|
||||
|
||||
def _item(row) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"list": row["list"],
|
||||
"quantity": _out_quantity(row["quantity"], row["unit"]),
|
||||
"unit": row["unit"],
|
||||
"updatedAt": row["updated_at"],
|
||||
"updatedBy": {"id": row["updated_by"], "name": row["updated_by_name"]} if row["updated_by"] else None,
|
||||
}
|
||||
|
||||
|
||||
def _out_quantity(value, unit: str):
|
||||
return int(value) if unit == "stk" else round(value, 2)
|
||||
|
||||
|
||||
def _unit(value) -> str:
|
||||
if value is None:
|
||||
return "stk"
|
||||
if value not in UNITS:
|
||||
raise ValidationError("Unbekannte Einheit.")
|
||||
return value
|
||||
|
||||
|
||||
def _quantity(value, unit: str = "stk"):
|
||||
"""Anzahl: ganze Zahl ab 1. kg/l: Kommazahl ab 0,01 (zwei Nachkommastellen)."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
||||
raise ValidationError("Menge fehlt.")
|
||||
if unit == "stk":
|
||||
if value != int(value) or not 1 <= value <= MAX_QUANTITY:
|
||||
raise ValidationError(f"Anzahl muss eine ganze Zahl zwischen 1 und {MAX_QUANTITY} sein.")
|
||||
return int(value)
|
||||
value = round(value, 2)
|
||||
if not 0 < value <= MAX_QUANTITY:
|
||||
raise ValidationError(f"Menge muss zwischen 0,01 und {MAX_QUANTITY} liegen.")
|
||||
return value
|
||||
|
||||
|
||||
def _clean_name(value) -> str:
|
||||
name = re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else ""
|
||||
if not name:
|
||||
raise ValidationError("Produktname fehlt.")
|
||||
if len(name) > MAX_NAME:
|
||||
raise ValidationError(f"Produktname ist zu lang (max. {MAX_NAME} Zeichen).")
|
||||
return name
|
||||
|
||||
|
||||
def _key(name: str) -> str:
|
||||
# casefold() statt COLLATE NOCASE: SQLite vergleicht nur ASCII ohne Groß/klein ("Äpfel" ≠ "äpfel")
|
||||
return name.casefold()
|
||||
|
||||
|
||||
_UMLAUTS = str.maketrans({"ä": "a", "ö": "o", "ü": "u", "ß": "ss"})
|
||||
|
||||
|
||||
def _sort_key(name: str) -> str:
|
||||
# Deutsche Sortierung: "Äpfel" gehört zu A, nicht hinter Z
|
||||
return _key(name).translate(_UMLAUTS)
|
||||
|
||||
|
||||
def _canonical_name(conn, name: str) -> str:
|
||||
"""Schreibweise aus dem Katalog übernehmen bzw. neues Produkt aufnehmen."""
|
||||
row = conn.execute("SELECT name FROM products WHERE key = ?", (_key(name),)).fetchone()
|
||||
if row:
|
||||
conn.execute("UPDATE products SET uses = uses + 1, last_used = ? WHERE key = ?", (now_iso(), _key(name)))
|
||||
return row["name"]
|
||||
if conn.execute("SELECT COUNT(*) FROM products").fetchone()[0] >= MAX_PRODUCTS:
|
||||
raise ValidationError("Der Produktkatalog ist voll. Bitte alte Vorschläge löschen.")
|
||||
# Neue Produkte mit großem Anfangsbuchstaben, damit "milch" und "Milch" nicht doppelt entstehen
|
||||
name = name[0].upper() + name[1:]
|
||||
conn.execute("INSERT INTO products (key, name, uses, last_used) VALUES (?, ?, 1, ?)", (_key(name), name, now_iso()))
|
||||
return name
|
||||
|
||||
|
||||
def _add(conn, name: str, list_: str, quantity, unit: str, user_id: int) -> None:
|
||||
"""Produkt auf eine Liste setzen; steht es dort schon in derselben Einheit, wird die Menge erhöht."""
|
||||
conn.execute(
|
||||
"""INSERT INTO household_items (key, name, list, quantity, unit, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (key, list, unit) DO UPDATE SET
|
||||
quantity = MIN(ROUND(quantity + excluded.quantity, 2), ?),
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by""",
|
||||
(_key(name), name, list_, quantity, unit, now_iso(), user_id, MAX_QUANTITY),
|
||||
)
|
||||
|
||||
|
||||
def _get_item(conn, item_id: int):
|
||||
row = conn.execute("SELECT * FROM household_items WHERE id = ?", (item_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Eintrag nicht gefunden – vielleicht hat ihn gerade jemand anderes geändert.")
|
||||
return row
|
||||
|
||||
|
||||
def _state(conn) -> dict:
|
||||
items = conn.execute(
|
||||
"""SELECT i.*, u.display_name AS updated_by_name FROM household_items i
|
||||
LEFT JOIN users u ON u.id = i.updated_by"""
|
||||
).fetchall()
|
||||
# Häufig genutzte Produkte zuerst vorschlagen
|
||||
products = conn.execute("SELECT name, uses FROM products").fetchall()
|
||||
return {
|
||||
"items": [_item(r) for r in sorted(items, key=lambda r: _sort_key(r["name"]))],
|
||||
"products": [r["name"] for r in sorted(products, key=lambda r: (-r["uses"], _sort_key(r["name"])))],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/haushalt")
|
||||
def get_household(_: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
return _state(conn)
|
||||
|
||||
|
||||
@router.post("/api/haushalt/items", status_code=201)
|
||||
def add_item(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
list_ = data.get("list")
|
||||
if list_ not in LISTS:
|
||||
raise ValidationError("Unbekannte Liste.")
|
||||
name = _clean_name(data.get("name"))
|
||||
unit = _unit(data.get("unit"))
|
||||
quantity = _quantity(data.get("quantity", 1), unit)
|
||||
with transaction() as conn:
|
||||
_add(conn, _canonical_name(conn, name), list_, quantity, unit, user["id"])
|
||||
return _state(conn)
|
||||
|
||||
|
||||
@router.patch("/api/haushalt/items/{item_id}")
|
||||
def update_item(item_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""Menge und/oder Einheit ändern. Beim Einheitenwechsel wird mit einer bestehenden Zeile zusammengeführt."""
|
||||
with transaction() as conn:
|
||||
row = _get_item(conn, item_id)
|
||||
unit = _unit(data.get("unit", row["unit"]))
|
||||
if "quantity" in data:
|
||||
quantity = _quantity(data["quantity"], unit)
|
||||
else:
|
||||
# Nur Einheit gewechselt: Zahl bleibt, bei "Anzahl" auf ganze Stück aufrunden
|
||||
quantity = max(1, math.ceil(row["quantity"])) if unit == "stk" else row["quantity"]
|
||||
other = conn.execute(
|
||||
"SELECT id FROM household_items WHERE key = ? AND list = ? AND unit = ? AND id != ?",
|
||||
(row["key"], row["list"], unit, item_id),
|
||||
).fetchone()
|
||||
if other:
|
||||
conn.execute("DELETE FROM household_items WHERE id = ?", (item_id,))
|
||||
_add(conn, row["name"], row["list"], quantity, unit, user["id"])
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE household_items SET quantity = ?, unit = ?, updated_at = ?, updated_by = ? WHERE id = ?",
|
||||
(quantity, unit, now_iso(), user["id"], item_id),
|
||||
)
|
||||
return _state(conn)
|
||||
|
||||
|
||||
@router.post("/api/haushalt/items/{item_id}/move")
|
||||
def move_item(item_id: int, user: dict = Depends(current_user)):
|
||||
"""Einkauf → Vorrat ("gekauft") bzw. Vorrat → Einkauf ("aufgebraucht"), mit Zusammenführen."""
|
||||
with transaction() as conn:
|
||||
row = _get_item(conn, item_id)
|
||||
target = "vorrat" if row["list"] == "einkauf" else "einkauf"
|
||||
# Aufgebraucht heißt: einmal nachkaufen (1 Stück, 1 kg, 1 l), egal wie viel vorher da war
|
||||
quantity = row["quantity"] if target == "vorrat" else 1
|
||||
conn.execute("DELETE FROM household_items WHERE id = ?", (item_id,))
|
||||
_add(conn, row["name"], target, quantity, row["unit"], user["id"])
|
||||
return _state(conn)
|
||||
|
||||
|
||||
@router.delete("/api/haushalt/items/{item_id}")
|
||||
def delete_item(item_id: int, _: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
_get_item(conn, item_id)
|
||||
conn.execute("DELETE FROM household_items WHERE id = ?", (item_id,))
|
||||
return _state(conn)
|
||||
|
||||
|
||||
@router.delete("/api/haushalt/produkte")
|
||||
def delete_product(name: str, _: dict = Depends(current_user)):
|
||||
"""Vorschlag aus dem Katalog entfernen (z. B. Tippfehler). Einträge auf den Listen bleiben."""
|
||||
with transaction() as conn:
|
||||
cur = conn.execute("DELETE FROM products WHERE key = ?", (_key(name),))
|
||||
if not cur.rowcount:
|
||||
raise HTTPException(404, "Vorschlag nicht gefunden.")
|
||||
return _state(conn)
|
||||
@@ -0,0 +1,368 @@
|
||||
"""Kanban: Projekte (je ein Board) → Aufgaben mit Status, Bearbeiter und Beschreibung.
|
||||
|
||||
Alles ist für alle Konten gemeinsam. Löschen dürfen nur, wer etwas angelegt hat, und Administratoren.
|
||||
Projekte lassen sich als abgeschlossen markieren und mit Artikeln aus "P42 Projekte" verknüpfen.
|
||||
`position` (Kommazahl) sortiert Projekte und Aufgaben innerhalb einer Spalte.
|
||||
"""
|
||||
import math
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
|
||||
from .auth import current_user
|
||||
from .db import now_iso, transaction
|
||||
from .links import ValidationError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
STATUSES = ("offen", "arbeit", "wartet", "erledigt")
|
||||
MAX_NAME = 80
|
||||
MAX_TITLE = 120
|
||||
MAX_DESCRIPTION = 5000
|
||||
MAX_TASKS = 1000 # pro Projekt
|
||||
MAX_PROJECTS = 300
|
||||
MAX_LINKS = 20
|
||||
|
||||
|
||||
def _person(user_id, name) -> dict | None:
|
||||
return {"id": user_id, "name": name} if user_id is not None and name else None
|
||||
|
||||
|
||||
def _may_delete(user: dict, created_by) -> bool:
|
||||
return user["isAdmin"] or created_by == user["id"]
|
||||
|
||||
|
||||
# ---------- Eingaben prüfen ----------
|
||||
|
||||
|
||||
def _name(value, field="Name", max_len=MAX_NAME) -> str:
|
||||
v = re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else ""
|
||||
if not v:
|
||||
raise ValidationError(f"{field} fehlt.")
|
||||
if len(v) > max_len:
|
||||
raise ValidationError(f"{field} ist zu lang (max. {max_len} Zeichen).")
|
||||
return v
|
||||
|
||||
|
||||
def _description(value) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if not isinstance(value, str):
|
||||
raise ValidationError("Ungültige Beschreibung.")
|
||||
text = value.replace("\r\n", "\n").strip()
|
||||
if len(text) > MAX_DESCRIPTION:
|
||||
raise ValidationError(f"Beschreibung ist zu lang (max. {MAX_DESCRIPTION} Zeichen).")
|
||||
return text
|
||||
|
||||
|
||||
PRIORITIES = {"hoch": 1, "mittel": 2, "niedrig": 3}
|
||||
PRIORITY_NAMES = {v: k for k, v in PRIORITIES.items()}
|
||||
|
||||
|
||||
def _priority(value) -> int:
|
||||
if value not in PRIORITIES:
|
||||
raise ValidationError("Unbekannte Priorität.")
|
||||
return PRIORITIES[value]
|
||||
|
||||
|
||||
def _status(value) -> str:
|
||||
if value not in STATUSES:
|
||||
raise ValidationError("Unbekannter Status.")
|
||||
return value
|
||||
|
||||
|
||||
def _assignee(conn, value):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not conn.execute(
|
||||
"SELECT 1 FROM users WHERE id = ?", (value,)
|
||||
).fetchone():
|
||||
raise ValidationError("Unbekannter Bearbeiter.")
|
||||
return value
|
||||
|
||||
|
||||
def _position(value) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
||||
raise ValidationError("Ungültige Position.")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _links(conn, value) -> list:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list) or len(value) > MAX_LINKS or not all(isinstance(v, str) for v in value):
|
||||
raise ValidationError("Ungültige Artikel-Verknüpfung.")
|
||||
ids = list(dict.fromkeys(value))
|
||||
found = {r["id"] for r in conn.execute(
|
||||
f"SELECT id FROM projects WHERE id IN ({','.join('?' * len(ids))})", ids
|
||||
)} if ids else set()
|
||||
if len(found) != len(ids):
|
||||
raise ValidationError("Ein verknüpfter Artikel existiert nicht mehr.")
|
||||
return ids
|
||||
|
||||
|
||||
def _next(conn, table: str, where: str = "", args: tuple = ()) -> float:
|
||||
# table/where kommen nur aus diesem Modul, nie aus Anfragen
|
||||
row = conn.execute(f"SELECT MAX(position) FROM {table} {where}", args).fetchone()
|
||||
return (row[0] or 0) + 1
|
||||
|
||||
|
||||
# ---------- Ausgabe ----------
|
||||
|
||||
|
||||
def _users(conn) -> list:
|
||||
rows = conn.execute("SELECT id, display_name FROM users ORDER BY display_name COLLATE NOCASE").fetchall()
|
||||
return [{"id": u["id"], "name": u["display_name"]} for u in rows]
|
||||
|
||||
|
||||
def _project_links(conn) -> dict:
|
||||
links = {}
|
||||
for r in conn.execute(
|
||||
"""SELECT l.project_id, p.id, p.title FROM kanban_project_links l
|
||||
JOIN projects p ON p.id = l.article_id ORDER BY p.title COLLATE NOCASE"""
|
||||
):
|
||||
links.setdefault(r["project_id"], []).append({"id": r["id"], "title": r["title"]})
|
||||
return links
|
||||
|
||||
|
||||
def _projects(conn, project_id=None) -> list:
|
||||
rows = conn.execute(
|
||||
"""SELECT k.*, cu.display_name AS created_by_name, uu.display_name AS updated_by_name
|
||||
FROM kanban_projects k
|
||||
LEFT JOIN users cu ON cu.id = k.created_by
|
||||
LEFT JOIN users uu ON uu.id = k.updated_by"""
|
||||
+ (" WHERE k.id = ?" if project_id is not None else "")
|
||||
+ " ORDER BY k.position",
|
||||
(project_id,) if project_id is not None else (),
|
||||
).fetchall()
|
||||
counts = {}
|
||||
for r in conn.execute("SELECT project_id, status, COUNT(*) AS n FROM kanban_tasks GROUP BY project_id, status"):
|
||||
counts.setdefault(r["project_id"], dict.fromkeys(STATUSES, 0))[r["status"]] = r["n"]
|
||||
links = _project_links(conn)
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"name": r["name"],
|
||||
"description": r["description"],
|
||||
"position": r["position"],
|
||||
"completedAt": r["completed_at"],
|
||||
"links": links.get(r["id"], []),
|
||||
"counts": counts.get(r["id"], dict.fromkeys(STATUSES, 0)),
|
||||
"createdAt": r["created_at"],
|
||||
"createdBy": _person(r["created_by"], r["created_by_name"]),
|
||||
"updatedAt": r["updated_at"],
|
||||
"updatedBy": _person(r["updated_by"], r["updated_by_name"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _overview(conn) -> dict:
|
||||
articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall()
|
||||
return {
|
||||
"projects": _projects(conn),
|
||||
"articles": [{"id": a["id"], "title": a["title"]} for a in articles],
|
||||
"users": _users(conn),
|
||||
}
|
||||
|
||||
|
||||
def _task(row) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"projectId": row["project_id"],
|
||||
"title": row["title"],
|
||||
"description": row["description"],
|
||||
"status": row["status"],
|
||||
"assignee": row["assignee"],
|
||||
"priority": PRIORITY_NAMES[row["priority"]],
|
||||
"position": row["position"],
|
||||
"createdAt": row["created_at"],
|
||||
"createdBy": _person(row["created_by"], row["created_by_name"]),
|
||||
"updatedAt": row["updated_at"],
|
||||
"updatedBy": _person(row["updated_by"], row["updated_by_name"]),
|
||||
}
|
||||
|
||||
|
||||
def _board(conn, project_id: int) -> dict:
|
||||
projects = _projects(conn, project_id)
|
||||
if not projects:
|
||||
raise HTTPException(404, "Kanban-Projekt nicht gefunden – vielleicht wurde es gerade gelöscht.")
|
||||
project = projects[0]
|
||||
tasks = conn.execute(
|
||||
"""SELECT t.*, cu.display_name AS created_by_name, uu.display_name AS updated_by_name
|
||||
FROM kanban_tasks t
|
||||
LEFT JOIN users cu ON cu.id = t.created_by
|
||||
LEFT JOIN users uu ON uu.id = t.updated_by
|
||||
WHERE t.project_id = ? ORDER BY t.status, t.priority, t.position""",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall()
|
||||
return {
|
||||
"project": project,
|
||||
"tasks": [_task(r) for r in tasks],
|
||||
"users": _users(conn),
|
||||
"articles": [{"id": a["id"], "title": a["title"]} for a in articles],
|
||||
}
|
||||
|
||||
|
||||
def _save_links(conn, project_id: int, links: list) -> None:
|
||||
conn.execute("DELETE FROM kanban_project_links WHERE project_id = ?", (project_id,))
|
||||
conn.executemany(
|
||||
"INSERT INTO kanban_project_links (project_id, article_id) VALUES (?, ?)", [(project_id, a) for a in links]
|
||||
)
|
||||
|
||||
|
||||
# ---------- Übersicht ----------
|
||||
|
||||
|
||||
@router.get("/api/kanban")
|
||||
def get_overview(_: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
return _overview(conn)
|
||||
|
||||
|
||||
# ---------- Projekte ----------
|
||||
|
||||
|
||||
@router.post("/api/kanban/projekte", status_code=201)
|
||||
def create_project(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
name = _name(data.get("name"))
|
||||
description = _description(data.get("description"))
|
||||
with transaction() as conn:
|
||||
links = _links(conn, data.get("links"))
|
||||
if conn.execute("SELECT COUNT(*) FROM kanban_projects").fetchone()[0] >= MAX_PROJECTS:
|
||||
raise ValidationError(f"Maximal {MAX_PROJECTS} Kanban-Projekte.")
|
||||
now = now_iso()
|
||||
project_id = conn.execute(
|
||||
"""INSERT INTO kanban_projects (name, description, position, created_at, created_by, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, description, _next(conn, "kanban_projects"), now, user["id"], now, user["id"]),
|
||||
).lastrowid
|
||||
_save_links(conn, project_id, links)
|
||||
return {"id": project_id, **_overview(conn)}
|
||||
|
||||
|
||||
@router.get("/api/kanban/projekte/{project_id}")
|
||||
def get_board(project_id: int, _: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
return _board(conn, project_id)
|
||||
|
||||
|
||||
@router.patch("/api/kanban/projekte/{project_id}")
|
||||
def update_project(project_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
if not conn.execute("SELECT 1 FROM kanban_projects WHERE id = ?", (project_id,)).fetchone():
|
||||
raise HTTPException(404, "Kanban-Projekt nicht gefunden.")
|
||||
values = {}
|
||||
if "name" in data:
|
||||
values["name"] = _name(data["name"])
|
||||
if "description" in data:
|
||||
values["description"] = _description(data["description"])
|
||||
if "completed" in data:
|
||||
if not isinstance(data["completed"], bool):
|
||||
raise ValidationError("Ungültiger Wert für abgeschlossen.")
|
||||
values["completed_at"] = now_iso() if data["completed"] else None
|
||||
if "links" in data:
|
||||
_save_links(conn, project_id, _links(conn, data["links"]))
|
||||
values.update(updated_at=now_iso(), updated_by=user["id"])
|
||||
columns = ", ".join(f"{k} = ?" for k in values) # Schlüssel stammen nur aus der Liste oben
|
||||
conn.execute(f"UPDATE kanban_projects SET {columns} WHERE id = ?", (*values.values(), project_id))
|
||||
return _board(conn, project_id)
|
||||
|
||||
|
||||
@router.delete("/api/kanban/projekte/{project_id}")
|
||||
def delete_project(project_id: int, user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
row = conn.execute("SELECT * FROM kanban_projects WHERE id = ?", (project_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Kanban-Projekt nicht gefunden.")
|
||||
if not _may_delete(user, row["created_by"]):
|
||||
raise HTTPException(403, "Nur wer das Projekt angelegt hat oder ein Administrator darf es löschen.")
|
||||
conn.execute("DELETE FROM kanban_projects WHERE id = ?", (project_id,)) # Aufgaben & Links per CASCADE
|
||||
return _overview(conn)
|
||||
|
||||
|
||||
# ---------- Aufgaben ----------
|
||||
|
||||
|
||||
def _get_task(conn, task_id: int):
|
||||
row = conn.execute("SELECT * FROM kanban_tasks WHERE id = ?", (task_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Aufgabe nicht gefunden – vielleicht wurde sie gerade gelöscht.")
|
||||
return row
|
||||
|
||||
|
||||
def _end_of(conn, project_id: int, status: str) -> float:
|
||||
return _next(conn, "kanban_tasks", "WHERE project_id = ? AND status = ?", (project_id, status))
|
||||
|
||||
|
||||
@router.post("/api/kanban/projekte/{project_id}/aufgaben", status_code=201)
|
||||
def create_task(project_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
title = _name(data.get("title"), "Titel", MAX_TITLE)
|
||||
description = _description(data.get("description"))
|
||||
status = _status(data.get("status", "offen"))
|
||||
priority = _priority(data.get("priority", "mittel"))
|
||||
with transaction() as conn:
|
||||
if not conn.execute("SELECT 1 FROM kanban_projects WHERE id = ?", (project_id,)).fetchone():
|
||||
raise HTTPException(404, "Kanban-Projekt nicht gefunden.")
|
||||
if conn.execute("SELECT COUNT(*) FROM kanban_tasks WHERE project_id = ?", (project_id,)).fetchone()[0] >= MAX_TASKS:
|
||||
raise ValidationError(f"Maximal {MAX_TASKS} Aufgaben pro Projekt. Bitte erledigte Aufgaben löschen.")
|
||||
now = now_iso()
|
||||
conn.execute(
|
||||
"""INSERT INTO kanban_tasks (project_id, title, description, status, assignee, priority, position,
|
||||
created_at, created_by, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(project_id, title, description, status, _assignee(conn, data.get("assignee")), priority,
|
||||
_end_of(conn, project_id, status), now, user["id"], now, user["id"]),
|
||||
)
|
||||
return _board(conn, project_id)
|
||||
|
||||
|
||||
@router.patch("/api/kanban/aufgaben/{task_id}")
|
||||
def update_task(task_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""Felder einzeln ändern. Neuer Status oder neue Priorität ohne Position: ans Ende der Stufe."""
|
||||
with transaction() as conn:
|
||||
row = _get_task(conn, task_id)
|
||||
values = {}
|
||||
if "title" in data:
|
||||
values["title"] = _name(data["title"], "Titel", MAX_TITLE)
|
||||
if "description" in data:
|
||||
values["description"] = _description(data["description"])
|
||||
if "assignee" in data:
|
||||
values["assignee"] = _assignee(conn, data["assignee"])
|
||||
if "status" in data:
|
||||
values["status"] = _status(data["status"])
|
||||
if "priority" in data:
|
||||
values["priority"] = _priority(data["priority"])
|
||||
if "position" in data:
|
||||
values["position"] = _position(data["position"])
|
||||
elif values.get("status", row["status"]) != row["status"] or values.get("priority", row["priority"]) != row["priority"]:
|
||||
# Neue Spalte oder neue Priorität: ans Ende (innerhalb der Stufe, da nach Priorität sortiert wird)
|
||||
values["position"] = _end_of(conn, row["project_id"], values.get("status", row["status"]))
|
||||
if values:
|
||||
values.update(updated_at=now_iso(), updated_by=user["id"])
|
||||
columns = ", ".join(f"{k} = ?" for k in values) # Schlüssel stammen nur aus der Liste oben
|
||||
conn.execute(f"UPDATE kanban_tasks SET {columns} WHERE id = ?", (*values.values(), task_id))
|
||||
return _board(conn, row["project_id"])
|
||||
|
||||
|
||||
@router.delete("/api/kanban/aufgaben/{task_id}")
|
||||
def delete_task(task_id: int, user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
row = _get_task(conn, task_id)
|
||||
if not _may_delete(user, row["created_by"]):
|
||||
raise HTTPException(403, "Nur wer die Aufgabe angelegt hat oder ein Administrator darf sie löschen.")
|
||||
conn.execute("DELETE FROM kanban_tasks WHERE id = ?", (task_id,))
|
||||
return _board(conn, row["project_id"])
|
||||
|
||||
|
||||
# ---------- Für die Artikelseite ----------
|
||||
|
||||
|
||||
def boards_for_article(conn, article_id: str) -> list:
|
||||
rows = conn.execute(
|
||||
"""SELECT k.id, k.name FROM kanban_project_links l JOIN kanban_projects k ON k.id = l.project_id
|
||||
WHERE l.article_id = ? ORDER BY k.name COLLATE NOCASE""",
|
||||
(article_id,),
|
||||
).fetchall()
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Schnellzugriff-Links (Jellyfin, Audiobookshelf, Homer) – pro Konto."""
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Body, Depends
|
||||
|
||||
from .auth import current_user
|
||||
from .db import transaction
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
KEYS = ("jellyfin", "audiobookshelf", "homer")
|
||||
MAX_LEN = 500
|
||||
HOSTNAME = re.compile(r"[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\]")
|
||||
|
||||
|
||||
class ValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_url(value, field: str) -> str:
|
||||
"""Prüft eine http(s)-Adresse; ergänzt fehlendes "http://". Leer bleibt leer."""
|
||||
v = value.strip() if isinstance(value, str) else ""
|
||||
if not v:
|
||||
return ""
|
||||
if "://" not in v:
|
||||
v = f"http://{v}" # "jellyfin.local:8096" -> "http://jellyfin.local:8096"
|
||||
try:
|
||||
parsed = urlparse(v)
|
||||
host = parsed.netloc.rsplit("@", 1)[-1]
|
||||
host = host[: host.find("]") + 1] if host.startswith("[") else host.split(":")[0]
|
||||
parsed.port # wirft bei ungültigem Port
|
||||
except ValueError:
|
||||
host, parsed = "", None
|
||||
if (
|
||||
not parsed
|
||||
or parsed.scheme not in ("http", "https")
|
||||
or not HOSTNAME.fullmatch(host)
|
||||
or re.search(r"\s", v)
|
||||
or len(v) > MAX_LEN
|
||||
):
|
||||
raise ValidationError(f"Ungültige Adresse für {field}.")
|
||||
return v
|
||||
|
||||
|
||||
def _get(conn, user_id: int) -> dict:
|
||||
rows = conn.execute("SELECT key, url FROM user_links WHERE user_id = ?", (user_id,)).fetchall()
|
||||
stored = {r["key"]: r["url"] for r in rows}
|
||||
return {key: stored.get(key, "") for key in KEYS}
|
||||
|
||||
|
||||
@router.get("/api/links")
|
||||
def get_links(user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
return _get(conn, user["id"])
|
||||
|
||||
|
||||
@router.put("/api/links")
|
||||
def update_links(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
values = {key: normalize_url(data[key], key) for key in KEYS if key in data}
|
||||
with transaction() as conn:
|
||||
for key, url in values.items():
|
||||
conn.execute("INSERT OR REPLACE INTO user_links VALUES (?, ?, ?)", (user["id"], key, url))
|
||||
return _get(conn, user["id"])
|
||||
@@ -0,0 +1,72 @@
|
||||
"""P42 Dashboard – FastAPI-Backend.
|
||||
|
||||
Liefert die API unter /api, die hochgeladenen Bilder unter /uploads
|
||||
und (falls gebaut) das React-Frontend aus frontend/dist.
|
||||
"""
|
||||
import re
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import auth, haushalt, kanban, links, monitoring, projects, reisen, rezept
|
||||
from .auth import current_user
|
||||
from .config import FRONTEND_DIST
|
||||
from .tracking import track
|
||||
|
||||
app = FastAPI(title="P42 Dashboard", docs_url="/api/docs", openapi_url="/api/openapi.json")
|
||||
# Komprimiert u. a. die JS-Bundles und die Weltkarte (~740 KB → ~200 KB)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
|
||||
# ---------- Fehler einheitlich als {"error": "..."} ----------
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_error(_: Request, exc: HTTPException):
|
||||
return JSONResponse({"error": exc.detail}, status_code=exc.status_code)
|
||||
|
||||
|
||||
@app.exception_handler(projects.ValidationError)
|
||||
@app.exception_handler(links.ValidationError)
|
||||
async def validation_error(_: Request, exc: ValueError):
|
||||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||
|
||||
|
||||
# ---------- API ----------
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(links.router)
|
||||
app.include_router(projects.router)
|
||||
app.include_router(monitoring.router)
|
||||
app.include_router(haushalt.router)
|
||||
app.include_router(rezept.router)
|
||||
app.include_router(reisen.router)
|
||||
app.include_router(kanban.router)
|
||||
|
||||
|
||||
@app.get("/api/track")
|
||||
async def get_tracking(number: str = "", _: dict = Depends(current_user)):
|
||||
number = re.sub(r"\s+", "", number).upper()
|
||||
if not re.fullmatch(r"[A-Z0-9-]{6,40}", number):
|
||||
raise HTTPException(400, "Ungültige Sendungsnummer.")
|
||||
return await track(number)
|
||||
|
||||
|
||||
@app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"], include_in_schema=False)
|
||||
def api_not_found(path: str):
|
||||
raise HTTPException(404, "Nicht gefunden.")
|
||||
|
||||
|
||||
# ---------- React-Frontend (nach `npm run build`) ----------
|
||||
|
||||
if (FRONTEND_DIST / "index.html").exists():
|
||||
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
|
||||
|
||||
@app.get("/{path:path}", include_in_schema=False)
|
||||
def spa(path: str):
|
||||
file = (FRONTEND_DIST / path).resolve()
|
||||
if path and file.is_file() and file.is_relative_to(FRONTEND_DIST.resolve()):
|
||||
return FileResponse(file)
|
||||
# Alle anderen Pfade übernimmt der React-Router
|
||||
return FileResponse(FRONTEND_DIST / "index.html", headers={"Cache-Control": "no-cache"})
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Server-Monitoring pro Konto: eigene Serverliste + Erreichbarkeitsprüfung (HTTP oder TCP-Port)."""
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
|
||||
from .auth import current_user
|
||||
from .db import now_iso, transaction
|
||||
from .links import ValidationError, normalize_url
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TYPES = {"http", "tcp"}
|
||||
MAX_MONITORS = 50
|
||||
TIMEOUT = 5
|
||||
HOST_PORT = re.compile(r"(?P<host>[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\]):(?P<port>\d{1,5})")
|
||||
|
||||
|
||||
def _monitor(row) -> dict:
|
||||
return {"id": row["id"], "name": row["name"], "type": row["type"], "target": row["target"], "createdAt": row["created_at"]}
|
||||
|
||||
|
||||
def _validate(data: dict) -> dict:
|
||||
name = data.get("name").strip() if isinstance(data.get("name"), str) else ""
|
||||
if not name or len(name) > 60:
|
||||
raise ValidationError("Name fehlt oder ist zu lang (max. 60 Zeichen).")
|
||||
kind = data.get("type")
|
||||
if kind not in TYPES:
|
||||
raise ValidationError("Ungültiger Prüf-Typ.")
|
||||
target = data.get("target").strip() if isinstance(data.get("target"), str) else ""
|
||||
if kind == "http":
|
||||
target = normalize_url(target, "die Adresse")
|
||||
if not target:
|
||||
raise ValidationError("Adresse fehlt.")
|
||||
else:
|
||||
m = HOST_PORT.fullmatch(target)
|
||||
if not m or not 0 < int(m["port"]) < 65536:
|
||||
raise ValidationError("Bitte Host und Port angeben, z. B. 192.168.0.10:22")
|
||||
return {"name": name, "type": kind, "target": target}
|
||||
|
||||
|
||||
# ---------- Verwaltung ----------
|
||||
|
||||
@router.get("/api/monitors")
|
||||
def list_monitors(user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
rows = conn.execute("SELECT * FROM monitors WHERE user_id = ? ORDER BY id", (user["id"],)).fetchall()
|
||||
return [_monitor(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/api/monitors", status_code=201)
|
||||
def create_monitor(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
values = _validate(data)
|
||||
with transaction() as conn:
|
||||
count = conn.execute("SELECT COUNT(*) FROM monitors WHERE user_id = ?", (user["id"],)).fetchone()[0]
|
||||
if count >= MAX_MONITORS:
|
||||
raise ValidationError(f"Maximal {MAX_MONITORS} Server pro Konto.")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO monitors (user_id, name, type, target, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(user["id"], values["name"], values["type"], values["target"], now_iso()),
|
||||
)
|
||||
return _monitor(conn.execute("SELECT * FROM monitors WHERE id = ?", (cur.lastrowid,)).fetchone())
|
||||
|
||||
|
||||
@router.put("/api/monitors/{monitor_id}")
|
||||
def update_monitor(monitor_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
values = _validate(data)
|
||||
with transaction() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE monitors SET name = ?, type = ?, target = ? WHERE id = ? AND user_id = ?",
|
||||
(values["name"], values["type"], values["target"], monitor_id, user["id"]),
|
||||
)
|
||||
if not cur.rowcount:
|
||||
raise HTTPException(404, "Nicht gefunden.")
|
||||
return _monitor(conn.execute("SELECT * FROM monitors WHERE id = ?", (monitor_id,)).fetchone())
|
||||
|
||||
|
||||
@router.delete("/api/monitors/{monitor_id}")
|
||||
def delete_monitor(monitor_id: int, user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
cur = conn.execute("DELETE FROM monitors WHERE id = ? AND user_id = ?", (monitor_id, user["id"]))
|
||||
if not cur.rowcount:
|
||||
raise HTTPException(404, "Nicht gefunden.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------- Prüfung ----------
|
||||
|
||||
async def _check_http(client: httpx.AsyncClient, url: str) -> dict:
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
res = await client.get(url)
|
||||
except httpx.TimeoutException:
|
||||
return {"ok": False, "detail": "Zeitüberschreitung"}
|
||||
except httpx.ConnectError:
|
||||
return {"ok": False, "detail": "Nicht erreichbar"}
|
||||
except httpx.HTTPError as e:
|
||||
return {"ok": False, "detail": type(e).__name__}
|
||||
ms = round((time.perf_counter() - start) * 1000)
|
||||
# 4xx heißt: Server antwortet (z. B. Anmeldung nötig) – also erreichbar
|
||||
return {"ok": res.status_code < 500, "ms": ms, "detail": f"HTTP {res.status_code}"}
|
||||
|
||||
|
||||
async def _check_tcp(target: str) -> dict:
|
||||
m = HOST_PORT.fullmatch(target)
|
||||
host = m["host"].strip("[]")
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
_, writer = await asyncio.wait_for(asyncio.open_connection(host, int(m["port"])), TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
return {"ok": False, "detail": "Zeitüberschreitung"}
|
||||
except OSError as e:
|
||||
return {"ok": False, "detail": "Verbindung abgelehnt" if isinstance(e, ConnectionRefusedError) else "Nicht erreichbar"}
|
||||
ms = round((time.perf_counter() - start) * 1000)
|
||||
writer.close()
|
||||
return {"ok": True, "ms": ms, "detail": f"Port {m['port']} offen"}
|
||||
|
||||
|
||||
@router.get("/api/monitors/status")
|
||||
async def monitor_status(user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
rows = conn.execute("SELECT id, type, target FROM monitors WHERE user_id = ?", (user["id"],)).fetchall()
|
||||
# Zertifikate werden nicht geprüft: im Heimnetz sind selbstsignierte Zertifikate üblich
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT, verify=False, follow_redirects=True) as client:
|
||||
results = await asyncio.gather(*(
|
||||
_check_http(client, r["target"]) if r["type"] == "http" else _check_tcp(r["target"]) for r in rows
|
||||
))
|
||||
checked = now_iso()
|
||||
return [{"id": r["id"], "checkedAt": checked, **res} for r, res in zip(rows, results)]
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Projekte als Artikel (SQLite) inkl. Autor/letzter Bearbeiter; Bilder als Dateien in data/uploads."""
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
|
||||
import nh3
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from .auth import current_user
|
||||
from .config import DATA_DIR
|
||||
from .db import now_iso, transaction
|
||||
from .kanban import boards_for_article
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
UPLOAD_DIR = DATA_DIR / "uploads"
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
STATUSES = {"planung", "aktiv", "pausiert", "abgeschlossen"}
|
||||
UPLOAD_NAME = re.compile(r"[a-f0-9]{24}\.(jpg|png|gif|webp)")
|
||||
CONTENT_IMAGES = re.compile(r"/uploads/([a-f0-9]{24}\.(?:jpg|png|gif|webp))")
|
||||
MAX_CONTENT = 500_000
|
||||
MAX_UPLOAD = 15 * 1024 * 1024
|
||||
MIME = {"jpg": "image/jpeg", "png": "image/png", "gif": "image/gif", "webp": "image/webp"}
|
||||
|
||||
|
||||
class ValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
# ---------- Artikel-HTML bereinigen ----------
|
||||
|
||||
ALLOWED_TAGS = {"p", "br", "h2", "h3", "strong", "b", "em", "i", "u", "s", "ul", "ol", "li", "blockquote", "hr", "a", "img"}
|
||||
ALLOWED_ATTRS = {"a": {"href"}, "img": {"src", "alt"}}
|
||||
|
||||
|
||||
def _attribute_filter(tag: str, attr: str, value: str) -> str | None:
|
||||
# Bilder nur aus den eigenen Uploads oder per https
|
||||
if tag == "img" and attr == "src" and not (value.startswith("/uploads/") or value.startswith("https://")):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def sanitize(content: str) -> str:
|
||||
cleaned = nh3.clean(
|
||||
content,
|
||||
tags=ALLOWED_TAGS,
|
||||
clean_content_tags={"script", "style"},
|
||||
attributes=ALLOWED_ATTRS,
|
||||
attribute_filter=_attribute_filter,
|
||||
url_schemes={"http", "https", "mailto"},
|
||||
link_rel="noopener noreferrer",
|
||||
)
|
||||
# Bilder, deren Quelle entfernt wurde, ganz weglassen
|
||||
return re.sub(r"<img(?![^>]*\ssrc=)[^>]*>", "", cleaned).strip()
|
||||
|
||||
|
||||
# ---------- Validierung ----------
|
||||
|
||||
def _text(value, field: str, max_len: int, required: bool = False) -> str:
|
||||
v = value.strip() if isinstance(value, str) else ""
|
||||
if required and not v:
|
||||
raise ValidationError(f"{field} fehlt.")
|
||||
if len(v) > max_len:
|
||||
raise ValidationError(f"{field} ist zu lang (max. {max_len} Zeichen).")
|
||||
return v
|
||||
|
||||
|
||||
def _image(value) -> str | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
if not isinstance(value, str) or not UPLOAD_NAME.fullmatch(value):
|
||||
raise ValidationError("Ungültiges Bild.")
|
||||
return value
|
||||
|
||||
|
||||
def _status(value) -> str:
|
||||
if value not in STATUSES:
|
||||
raise ValidationError("Ungültiger Status.")
|
||||
return value
|
||||
|
||||
|
||||
def _content(value) -> str:
|
||||
v = sanitize(_text(value, "Artikeltext", MAX_CONTENT))
|
||||
return "" if v in ("<p></p>", "<p><br></p>") else v
|
||||
|
||||
|
||||
def _content_images(content: str | None) -> list[str]:
|
||||
return CONTENT_IMAGES.findall(content or "")
|
||||
|
||||
|
||||
# ---------- Uploads ----------
|
||||
|
||||
def _detect_image_type(data: bytes) -> str | None:
|
||||
if data[:3] == b"\xff\xd8\xff":
|
||||
return "jpg"
|
||||
if data[:4] == b"\x89PNG":
|
||||
return "png"
|
||||
if data[:3] == b"GIF":
|
||||
return "gif"
|
||||
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
return "webp"
|
||||
return None
|
||||
|
||||
|
||||
def _referenced_images(conn) -> set[str]:
|
||||
used = set()
|
||||
for row in conn.execute("SELECT cover, content FROM projects"):
|
||||
if row["cover"]:
|
||||
used.add(row["cover"])
|
||||
used.update(_content_images(row["content"]))
|
||||
return used
|
||||
|
||||
|
||||
def _remove_unused(conn, names) -> None:
|
||||
used = _referenced_images(conn)
|
||||
for n in names:
|
||||
if n and n not in used:
|
||||
(UPLOAD_DIR / n).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _cleanup_orphans() -> None:
|
||||
"""Hochgeladene, aber nie gespeicherte Bilder (älter als 1 Tag) entfernen."""
|
||||
with transaction() as conn:
|
||||
used = _referenced_images(conn)
|
||||
cutoff = time.time() - 24 * 60 * 60
|
||||
for f in UPLOAD_DIR.iterdir():
|
||||
if UPLOAD_NAME.fullmatch(f.name) and f.name not in used and f.stat().st_mtime < cutoff:
|
||||
f.unlink(missing_ok=True)
|
||||
|
||||
|
||||
_cleanup_orphans()
|
||||
|
||||
# ---------- Projekte ----------
|
||||
|
||||
_SELECT = """
|
||||
SELECT p.*, cu.display_name AS created_by_name, uu.display_name AS updated_by_name
|
||||
FROM projects p
|
||||
LEFT JOIN users cu ON cu.id = p.created_by
|
||||
LEFT JOIN users uu ON uu.id = p.updated_by
|
||||
"""
|
||||
|
||||
|
||||
def _person(user_id, name) -> dict | None:
|
||||
return {"id": user_id, "name": name} if user_id is not None and name else None
|
||||
|
||||
|
||||
def _summary(row) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"title": row["title"],
|
||||
"description": row["description"],
|
||||
"status": row["status"],
|
||||
"cover": row["cover"] or next(iter(_content_images(row["content"])), None),
|
||||
"createdAt": row["created_at"],
|
||||
"createdBy": _person(row["created_by"], row["created_by_name"]),
|
||||
"updatedAt": row["updated_at"],
|
||||
"updatedBy": _person(row["updated_by"], row["updated_by_name"]),
|
||||
}
|
||||
|
||||
|
||||
def _get(conn, project_id: str) -> dict | None:
|
||||
row = conn.execute(f"{_SELECT} WHERE p.id = ?", (project_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
# Verknüpfte Kanban-Boards für die Seitenleiste
|
||||
return {**_summary(row), "cover": row["cover"], "content": row["content"], "boards": boards_for_article(conn, project_id)}
|
||||
|
||||
|
||||
def _not_found():
|
||||
return HTTPException(404, "Nicht gefunden.")
|
||||
|
||||
|
||||
@router.get("/api/projects")
|
||||
def list_projects(_: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
return [_summary(r) for r in conn.execute(f"{_SELECT} ORDER BY p.updated_at DESC")]
|
||||
|
||||
|
||||
@router.post("/api/projects", status_code=201)
|
||||
def create_project(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
project_id = secrets.token_hex(6)
|
||||
now = now_iso()
|
||||
with transaction() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO projects (id, title, description, status, cover, content, created_at, created_by, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
project_id,
|
||||
_text(data.get("title"), "Titel", 120, required=True),
|
||||
_text(data.get("description"), "Kurzbeschreibung", 2000),
|
||||
_status(data.get("status") or "aktiv"),
|
||||
_image(data.get("cover")),
|
||||
_content(data.get("content")),
|
||||
now, user["id"], now, user["id"],
|
||||
),
|
||||
)
|
||||
return _get(conn, project_id)
|
||||
|
||||
|
||||
@router.get("/api/projects/{project_id}")
|
||||
def get_project(project_id: str, _: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
return _get(conn, project_id) or _raise(_not_found())
|
||||
|
||||
|
||||
@router.patch("/api/projects/{project_id}")
|
||||
def update_project(project_id: str, data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
fields = {}
|
||||
if "title" in data:
|
||||
fields["title"] = _text(data["title"], "Titel", 120, required=True)
|
||||
if "description" in data:
|
||||
fields["description"] = _text(data["description"], "Kurzbeschreibung", 2000)
|
||||
if "status" in data:
|
||||
fields["status"] = _status(data["status"])
|
||||
if "cover" in data:
|
||||
fields["cover"] = _image(data["cover"])
|
||||
if "content" in data:
|
||||
fields["content"] = _content(data["content"])
|
||||
fields.update(updated_at=now_iso(), updated_by=user["id"])
|
||||
|
||||
with transaction() as conn:
|
||||
old = conn.execute("SELECT cover, content FROM projects WHERE id = ?", (project_id,)).fetchone()
|
||||
if not old:
|
||||
raise _not_found()
|
||||
assignments = ", ".join(f"{k} = ?" for k in fields)
|
||||
conn.execute(f"UPDATE projects SET {assignments} WHERE id = ?", (*fields.values(), project_id))
|
||||
_remove_unused(conn, [old["cover"], *_content_images(old["content"])])
|
||||
return _get(conn, project_id)
|
||||
|
||||
|
||||
@router.delete("/api/projects/{project_id}")
|
||||
def delete_project(project_id: str, user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
row = conn.execute("SELECT cover, content, created_by FROM projects WHERE id = ?", (project_id,)).fetchone()
|
||||
if not row:
|
||||
raise _not_found()
|
||||
if not user["isAdmin"] and row["created_by"] != user["id"]:
|
||||
raise HTTPException(403, "Nur der Autor oder ein Administrator darf dieses Projekt löschen.")
|
||||
conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
|
||||
_remove_unused(conn, [row["cover"], *_content_images(row["content"])])
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/uploads", status_code=201)
|
||||
async def upload(request: Request, _: dict = Depends(current_user)):
|
||||
data = bytearray()
|
||||
async for chunk in request.stream():
|
||||
data += chunk
|
||||
if len(data) > MAX_UPLOAD:
|
||||
raise HTTPException(413, "Datei zu groß (max. 15 MB).")
|
||||
ext = _detect_image_type(bytes(data))
|
||||
if not ext:
|
||||
raise ValidationError("Bildformat nicht unterstützt (erlaubt: JPG, PNG, GIF, WebP).")
|
||||
name = f"{secrets.token_hex(12)}.{ext}"
|
||||
(UPLOAD_DIR / name).write_bytes(data)
|
||||
return {"file": name}
|
||||
|
||||
|
||||
@router.get("/uploads/{name}")
|
||||
def get_upload(name: str, _: dict = Depends(current_user)):
|
||||
path = UPLOAD_DIR / name
|
||||
if not UPLOAD_NAME.fullmatch(name) or not path.exists():
|
||||
raise _not_found()
|
||||
return FileResponse(path, media_type=MIME[path.suffix[1:]], headers={"Cache-Control": "private, max-age=31536000, immutable"})
|
||||
|
||||
|
||||
def _raise(exc: Exception):
|
||||
raise exc
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Reise-Karte pro Konto: besuchte Städte und Länder sowie kurze Infos dazu.
|
||||
|
||||
Städte werden über die Geocoding-API von Open-Meteo gesucht (Daten: GeoNames, CC BY 4.0).
|
||||
Infotexte kommen aus der deutschen Wikipedia, Einwohnerzahlen von Ländern aus Wikidata.
|
||||
Alle Abrufe laufen über den Server und werden zwischengespeichert; der Browser fragt keine Fremdserver.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
|
||||
from .auth import current_user
|
||||
from .config import WIKIPEDIA_CONTACT
|
||||
from .db import now_iso, transaction
|
||||
from .links import ValidationError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
HEADERS = {"User-Agent": "P42-Dashboard/1.0 (privates Heim-Dashboard)"}
|
||||
# Wikimedia sperrt Clients ohne Kontaktangabe (https://w.wiki/4wJS)
|
||||
WIKI_HEADERS = {"User-Agent": f"P42-Dashboard/1.0 (privates Heim-Dashboard; {WIKIPEDIA_CONTACT}) python-httpx"}
|
||||
TIMEOUT = 8
|
||||
MAX_CITIES = 2000
|
||||
CACHE_OK = 30 * 24 * 3600 # Infos ändern sich selten
|
||||
CACHE_MISS = 24 * 3600 # nichts gefunden: morgen noch mal versuchen
|
||||
COUNTRY_CODE = re.compile(r"[A-Z]{2}|X-[A-Za-z. ]{1,40}") # X-…: Gebiete ohne ISO-Code auf der Karte
|
||||
|
||||
_search_cache: dict[str, list] = {}
|
||||
|
||||
|
||||
def _city(row) -> dict:
|
||||
return {
|
||||
"id": row["id"], "geonameId": row["geoname_id"], "name": row["name"], "admin1": row["admin1"],
|
||||
"country": row["country"], "lat": row["lat"], "lon": row["lon"], "population": row["population"],
|
||||
"elevation": row["elevation"], "timezone": row["timezone"], "createdAt": row["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def _state(conn, user_id: int) -> dict:
|
||||
cities = conn.execute("SELECT * FROM travel_cities WHERE user_id = ? ORDER BY name", (user_id,)).fetchall()
|
||||
countries = conn.execute("SELECT code FROM travel_countries WHERE user_id = ? ORDER BY code", (user_id,)).fetchall()
|
||||
return {"cities": [_city(r) for r in cities], "countries": [r["code"] for r in countries]}
|
||||
|
||||
|
||||
# ---------- Verwaltung ----------
|
||||
|
||||
|
||||
@router.get("/api/reisen")
|
||||
def get_travel(user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
return _state(conn, user["id"])
|
||||
|
||||
|
||||
def _text(value, field: str, max_len: int, required: bool = True) -> str:
|
||||
v = re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else ""
|
||||
if required and not v:
|
||||
raise ValidationError(f"{field} fehlt.")
|
||||
if len(v) > max_len:
|
||||
raise ValidationError(f"{field} ist zu lang.")
|
||||
return v
|
||||
|
||||
|
||||
def _number(value, field: str, low: float, high: float) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not low <= value <= high:
|
||||
raise ValidationError(f"Ungültiger Wert für {field}.")
|
||||
return float(value)
|
||||
|
||||
|
||||
@router.post("/api/reisen/staedte", status_code=201)
|
||||
def add_city(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
geoname_id = data.get("geonameId")
|
||||
if isinstance(geoname_id, bool) or not isinstance(geoname_id, int) or geoname_id <= 0:
|
||||
raise ValidationError("Ungültige Stadt.")
|
||||
country = data.get("country")
|
||||
if not isinstance(country, str) or not re.fullmatch(r"[A-Z]{2}", country):
|
||||
raise ValidationError("Ungültiges Land.")
|
||||
population = data.get("population")
|
||||
if population is not None and (isinstance(population, bool) or not isinstance(population, int) or population < 0):
|
||||
population = None
|
||||
elevation = data.get("elevation")
|
||||
if elevation is not None and (isinstance(elevation, bool) or not isinstance(elevation, (int, float)) or not -500 <= elevation <= 9000):
|
||||
elevation = None
|
||||
timezone = data.get("timezone")
|
||||
if not isinstance(timezone, str) or not re.fullmatch(r"[A-Za-z_]+(?:/[A-Za-z0-9_+-]+){0,2}", timezone):
|
||||
timezone = None
|
||||
values = (
|
||||
user["id"], geoname_id, _text(data.get("name"), "Name", 100), _text(data.get("admin1"), "Region", 100, False),
|
||||
country, _number(data.get("lat"), "Breitengrad", -90, 90), _number(data.get("lon"), "Längengrad", -180, 180),
|
||||
population, elevation, timezone, now_iso(),
|
||||
)
|
||||
with transaction() as conn:
|
||||
if conn.execute("SELECT COUNT(*) FROM travel_cities WHERE user_id = ?", (user["id"],)).fetchone()[0] >= MAX_CITIES:
|
||||
raise ValidationError(f"Maximal {MAX_CITIES} Städte.")
|
||||
conn.execute(
|
||||
"""INSERT INTO travel_cities
|
||||
(user_id, geoname_id, name, admin1, country, lat, lon, population, elevation, timezone, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, geoname_id) DO NOTHING""",
|
||||
values,
|
||||
)
|
||||
return _state(conn, user["id"])
|
||||
|
||||
|
||||
@router.delete("/api/reisen/staedte/{city_id}")
|
||||
def delete_city(city_id: int, user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
cur = conn.execute("DELETE FROM travel_cities WHERE id = ? AND user_id = ?", (city_id, user["id"]))
|
||||
if not cur.rowcount:
|
||||
raise HTTPException(404, "Stadt nicht gefunden.")
|
||||
return _state(conn, user["id"])
|
||||
|
||||
|
||||
def _country_code(code: str) -> str:
|
||||
if not COUNTRY_CODE.fullmatch(code):
|
||||
raise ValidationError("Ungültiges Land.")
|
||||
return code
|
||||
|
||||
|
||||
@router.put("/api/reisen/laender/{code}")
|
||||
def mark_country(code: str, user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO travel_countries (user_id, code) VALUES (?, ?)", (user["id"], _country_code(code))
|
||||
)
|
||||
return _state(conn, user["id"])
|
||||
|
||||
|
||||
@router.delete("/api/reisen/laender/{code}")
|
||||
def unmark_country(code: str, user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
conn.execute("DELETE FROM travel_countries WHERE user_id = ? AND code = ?", (user["id"], _country_code(code)))
|
||||
return _state(conn, user["id"])
|
||||
|
||||
|
||||
# ---------- Städtesuche ----------
|
||||
|
||||
|
||||
@router.get("/api/reisen/suche")
|
||||
async def search_city(q: str = "", _: dict = Depends(current_user)):
|
||||
q = re.sub(r"\s+", " ", q).strip()
|
||||
if len(q) < 2 or len(q) > 80:
|
||||
return []
|
||||
key = q.casefold()
|
||||
if key in _search_cache:
|
||||
return _search_cache[key]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT, headers=HEADERS) as client:
|
||||
res = await client.get(
|
||||
"https://geocoding-api.open-meteo.com/v1/search",
|
||||
params={"name": q, "count": 8, "language": "de", "format": "json"},
|
||||
)
|
||||
res.raise_for_status()
|
||||
except httpx.HTTPError:
|
||||
raise HTTPException(502, "Die Städtesuche ist gerade nicht erreichbar.")
|
||||
results = [
|
||||
{
|
||||
"geonameId": r["id"], "name": r["name"], "admin1": r.get("admin1") or "", "countryName": r.get("country") or "",
|
||||
"country": r["country_code"], "lat": r["latitude"], "lon": r["longitude"], "population": r.get("population"),
|
||||
"elevation": r.get("elevation"), "timezone": r.get("timezone"),
|
||||
}
|
||||
for r in res.json().get("results", [])
|
||||
if r.get("country_code") and r.get("id")
|
||||
]
|
||||
if len(_search_cache) > 300:
|
||||
_search_cache.clear()
|
||||
_search_cache[key] = results
|
||||
return results
|
||||
|
||||
|
||||
# ---------- Infos aus Wikipedia / Wikidata ----------
|
||||
|
||||
|
||||
def _distance_km(a, b) -> float:
|
||||
lat1, lon1, lat2, lon2 = map(math.radians, (a[0], a[1], b[0], b[1]))
|
||||
h = math.sin((lat2 - lat1) / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin((lon2 - lon1) / 2) ** 2
|
||||
return 6371 * 2 * math.asin(math.sqrt(h))
|
||||
|
||||
|
||||
async def _summary(client, title: str):
|
||||
res = await client.get(f"https://de.wikipedia.org/api/rest_v1/page/summary/{quote(title.replace(' ', '_'), safe='')}")
|
||||
return res.json() if res.status_code == 200 else None
|
||||
|
||||
|
||||
async def _wikipedia(client, title: str, near=None, max_km=60):
|
||||
"""Artikel zum Titel; bei Begriffsklärung oder falschem Ort über die Suche den Artikel in der Nähe finden."""
|
||||
|
||||
def fits(page, exact):
|
||||
if not page or page.get("type") != "standard":
|
||||
return False
|
||||
if near is None:
|
||||
return True
|
||||
coords = page.get("coordinates")
|
||||
if not coords:
|
||||
return exact # exakter Titel ohne Koordinaten: meist trotzdem richtig
|
||||
return _distance_km(near, (coords["lat"], coords["lon"])) <= max_km
|
||||
|
||||
page = await _summary(client, title)
|
||||
if fits(page, True):
|
||||
return page
|
||||
res = await client.get(
|
||||
"https://de.wikipedia.org/w/api.php",
|
||||
params={"action": "query", "list": "search", "srsearch": title, "srlimit": 8, "format": "json"},
|
||||
)
|
||||
for hit in res.json().get("query", {}).get("search", []):
|
||||
page = await _summary(client, hit["title"])
|
||||
if fits(page, False):
|
||||
return page
|
||||
return None
|
||||
|
||||
|
||||
async def _population(client, item: str):
|
||||
"""Aktuelle Einwohnerzahl aus Wikidata (bevorzugter Wert, sonst der neueste)."""
|
||||
res = await client.get(
|
||||
"https://www.wikidata.org/w/api.php",
|
||||
params={"action": "wbgetclaims", "entity": item, "property": "P1082", "format": "json"},
|
||||
)
|
||||
claims = res.json().get("claims", {}).get("P1082", [])
|
||||
|
||||
def year(c):
|
||||
t = c.get("qualifiers", {}).get("P585", [{}])[0].get("datavalue", {}).get("value", {}).get("time", "")
|
||||
return t[1:5] if t else ""
|
||||
|
||||
best = [c for c in claims if c.get("rank") == "preferred"] or claims
|
||||
best = [c for c in best if c["mainsnak"].get("datavalue")]
|
||||
if not best:
|
||||
return None, None
|
||||
claim = max(best, key=year)
|
||||
return int(float(claim["mainsnak"]["datavalue"]["value"]["amount"])), year(claim) or None
|
||||
|
||||
|
||||
async def _capital(client, item: str):
|
||||
"""Aktuelle Hauptstadt(e) mit deutschem Namen aus Wikidata (ohne Enddatum)."""
|
||||
res = await client.get(
|
||||
"https://www.wikidata.org/w/api.php",
|
||||
params={"action": "wbgetclaims", "entity": item, "property": "P36", "format": "json"},
|
||||
)
|
||||
claims = [
|
||||
c for c in res.json().get("claims", {}).get("P36", [])
|
||||
if c["mainsnak"].get("datavalue") and "P582" not in c.get("qualifiers", {}) and c.get("rank") != "deprecated"
|
||||
]
|
||||
ids = [c["mainsnak"]["datavalue"]["value"]["id"] for c in claims][:3]
|
||||
if not ids:
|
||||
return None
|
||||
res = await client.get(
|
||||
"https://www.wikidata.org/w/api.php",
|
||||
params={"action": "wbgetentities", "ids": "|".join(ids), "props": "labels", "languages": "de|en", "format": "json"},
|
||||
)
|
||||
entities = res.json().get("entities", {})
|
||||
names = [
|
||||
(entities.get(i, {}).get("labels", {}).get("de") or entities.get(i, {}).get("labels", {}).get("en") or {}).get("value")
|
||||
for i in ids
|
||||
]
|
||||
return ", ".join(n for n in names if n) or None
|
||||
|
||||
|
||||
def _cache_get(conn, key):
|
||||
row = conn.execute("SELECT value, fetched_at FROM info_cache WHERE key = ?", (key,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
value = json.loads(row["value"])
|
||||
ttl = CACHE_OK if value.get("extract") else CACHE_MISS
|
||||
return value if time.time() - row["fetched_at"] < ttl else None
|
||||
|
||||
|
||||
@router.get("/api/reisen/info")
|
||||
async def get_info(kind: str, name: str, code: str = "", lat: float | None = None, lon: float | None = None,
|
||||
_: dict = Depends(current_user)):
|
||||
"""Kurzinfo zu einem Land (kind=land, code=ISO) oder einer Stadt (kind=stadt, code=GeoNames-ID, lat/lon)."""
|
||||
if kind not in ("land", "stadt") or not 1 <= len(name) <= 100 or len(code) > 45:
|
||||
raise ValidationError("Ungültige Anfrage.")
|
||||
if not WIKIPEDIA_CONTACT:
|
||||
return {"extract": None, "url": None, "title": None, "population": None, "populationYear": None, "disabled": True}
|
||||
key = f"{kind}:{code or name}"
|
||||
with transaction() as conn:
|
||||
cached = _cache_get(conn, key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
near = (lat, lon) if kind == "stadt" and lat is not None and lon is not None else None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT, headers=WIKI_HEADERS, follow_redirects=True) as client:
|
||||
page = await _wikipedia(client, name, near)
|
||||
info = {"extract": None, "url": None, "title": None, "population": None, "populationYear": None, "capital": None}
|
||||
if page:
|
||||
info.update(
|
||||
extract=page.get("extract"),
|
||||
url=page.get("content_urls", {}).get("desktop", {}).get("page"),
|
||||
title=page.get("title"),
|
||||
)
|
||||
if kind == "land" and page.get("wikibase_item"):
|
||||
info["population"], info["populationYear"] = await _population(client, page["wikibase_item"])
|
||||
info["capital"] = await _capital(client, page["wikibase_item"])
|
||||
except (httpx.HTTPError, ValueError):
|
||||
# Nicht zwischenspeichern: beim nächsten Klick erneut versuchen
|
||||
return {"extract": None, "url": None, "title": None, "population": None, "populationYear": None, "offline": True}
|
||||
|
||||
with transaction() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO info_cache (key, value, fetched_at) VALUES (?, ?, ?)",
|
||||
(key, json.dumps(info, ensure_ascii=False), time.time()),
|
||||
)
|
||||
return info
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Notfall: Passwort eines Kontos neu setzen (z. B. wenn der einzige Admin es vergessen hat).
|
||||
|
||||
Aufruf im Ordner backend/: .venv/bin/python -m app.reset_password BENUTZERNAME
|
||||
"""
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
from .auth import MIN_PASSWORD, hash_password
|
||||
from .db import transaction
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) != 2:
|
||||
sys.exit("Aufruf: python -m app.reset_password BENUTZERNAME")
|
||||
username = sys.argv[1]
|
||||
password = getpass.getpass(f"Neues Passwort für {username}: ")
|
||||
if len(password) < MIN_PASSWORD:
|
||||
sys.exit(f"Das Passwort muss mindestens {MIN_PASSWORD} Zeichen lang sein.")
|
||||
if getpass.getpass("Wiederholen: ") != password:
|
||||
sys.exit("Die Passwörter stimmen nicht überein.")
|
||||
with transaction() as conn:
|
||||
cur = conn.execute("UPDATE users SET password_hash = ? WHERE username = ?", (hash_password(password), username))
|
||||
if not cur.rowcount:
|
||||
sys.exit(f"Kein Konto mit dem Benutzernamen „{username}“.")
|
||||
conn.execute("DELETE FROM sessions WHERE user_id = (SELECT id FROM users WHERE username = ?)", (username,))
|
||||
print("Passwort geändert.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Rezepte abgleichen: Zutaten von einer Rezeptseite (schema.org/Recipe) oder aus eingefügtem Text lesen,
|
||||
den Produkten im Katalog zuordnen und Fehlendes auf die Einkaufsliste setzen.
|
||||
|
||||
Abgerufen wird nur die eine Seite, deren Link eingefügt wurde. Chefkoch & Co. betten die Zutaten als
|
||||
JSON-LD ("recipeIngredient") ein; dafür braucht es keine API und keinen HTML-Parser.
|
||||
"""
|
||||
import html
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
|
||||
from .auth import current_user
|
||||
from .db import transaction
|
||||
from .haushalt import _add, _canonical_name, _clean_name, _key, _quantity, _state, _unit
|
||||
from .links import ValidationError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MAX_PAGE = 3 * 1024 * 1024
|
||||
MAX_REDIRECTS = 3
|
||||
TIMEOUT = 10
|
||||
MAX_INGREDIENTS = 80
|
||||
MAX_TEXT = 10_000
|
||||
|
||||
# ---------- Seite abrufen ----------
|
||||
|
||||
|
||||
def _check_host(url: str) -> None:
|
||||
"""Nur öffentliche Adressen abrufen – sonst ließe sich über den Server das Heimnetz abfragen."""
|
||||
parts = urlsplit(url)
|
||||
if parts.scheme not in ("http", "https") or not parts.hostname:
|
||||
raise ValidationError("Bitte einen Link mit http:// oder https:// einfügen.")
|
||||
try:
|
||||
infos = socket.getaddrinfo(parts.hostname, parts.port or (443 if parts.scheme == "https" else 80))
|
||||
except socket.gaierror:
|
||||
raise ValidationError("Die Seite wurde nicht gefunden. Stimmt der Link?")
|
||||
for info in infos:
|
||||
if not ipaddress.ip_address(info[4][0].split("%")[0]).is_global:
|
||||
raise ValidationError("Links ins eigene Netzwerk werden nicht abgerufen.")
|
||||
|
||||
|
||||
async def _fetch(url: str) -> str:
|
||||
headers = {"User-Agent": "Mozilla/5.0 (P42-Dashboard; Rezeptabgleich)", "Accept": "text/html"}
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False, headers=headers) as client:
|
||||
for _ in range(MAX_REDIRECTS + 1):
|
||||
_check_host(url)
|
||||
try:
|
||||
async with client.stream("GET", url) as res:
|
||||
if res.is_redirect and res.headers.get("location"):
|
||||
url = urljoin(url, res.headers["location"])
|
||||
continue
|
||||
if res.status_code >= 400:
|
||||
raise ValidationError(f"Die Seite antwortet mit Fehler {res.status_code}.")
|
||||
data = bytearray()
|
||||
async for chunk in res.aiter_bytes():
|
||||
data += chunk
|
||||
if len(data) > MAX_PAGE:
|
||||
raise ValidationError("Die Seite ist zu groß.")
|
||||
return data.decode(res.encoding or "utf-8", errors="replace")
|
||||
except httpx.TimeoutException:
|
||||
raise ValidationError("Die Seite hat nicht rechtzeitig geantwortet.")
|
||||
except httpx.HTTPError:
|
||||
raise ValidationError("Die Seite konnte nicht abgerufen werden.")
|
||||
raise ValidationError("Zu viele Weiterleitungen.")
|
||||
|
||||
|
||||
def _find_recipe(page: str):
|
||||
for m in re.finditer(r"<script[^>]*application/ld\+json[^>]*>(.*?)</script>", page, re.S | re.I):
|
||||
try:
|
||||
data = json.loads(m.group(1).strip())
|
||||
except ValueError:
|
||||
continue
|
||||
stack = [data]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if isinstance(node, list):
|
||||
stack.extend(node)
|
||||
elif isinstance(node, dict):
|
||||
kind = node.get("@type")
|
||||
if kind == "Recipe" or (isinstance(kind, list) and "Recipe" in kind):
|
||||
return node
|
||||
stack.extend(v for k, v in node.items() if k in ("@graph", "mainEntity", "itemListElement"))
|
||||
return None
|
||||
|
||||
|
||||
def _servings(value):
|
||||
"""recipeYield kommt als Zahl, Text ("4 Portionen") oder Liste davon."""
|
||||
for v in value if isinstance(value, list) else [value]:
|
||||
m = re.search(r"\d+", str(v or ""))
|
||||
if m and 0 < int(m.group()) <= 100:
|
||||
return int(m.group())
|
||||
return None
|
||||
|
||||
|
||||
# ---------- Zutaten zerlegen ----------
|
||||
|
||||
FRACTIONS = {"½": 0.5, "¼": 0.25, "¾": 0.75, "⅓": 1 / 3, "⅔": 2 / 3, "⅛": 0.125}
|
||||
NUMBER = r"(?:\d*[½¼¾⅓⅔⅛]|\d+(?:[.,]\d+)?(?:\s+\d+/\d+)?|\d+/\d+)"
|
||||
AMOUNT = re.compile(rf"^(?P<a>{NUMBER})(?:\s*[-–]\s*(?P<b>{NUMBER}))?\s*")
|
||||
|
||||
UNITS = {
|
||||
"g", "gr", "gramm", "kg", "mg", "ml", "l", "liter", "cl", "dl", "el", "tl", "essl", "teel", "msp",
|
||||
"messerspitze", "prise", "pck", "päckchen", "packung", "pkt", "dose", "becher", "bund", "zehe", "stück",
|
||||
"stk", "tasse", "glas", "gläser", "scheibe", "schuss", "spritzer", "handvoll", "hand", "blatt", "blätter",
|
||||
"stange", "stängel", "kopf", "würfel", "beutel", "tropfen", "zweig", "knolle", "tüte", "flasche", "rolle",
|
||||
"cm", "etwas", "wenig", "einige", "tafel", "riegel", "netz", "schale", "kugel",
|
||||
}
|
||||
OPTIONAL = re.compile(r"^(?:evtl\.?|eventuell|nach belieben|n\.\s?b\.?|optional|ggf\.?)\s*", re.I)
|
||||
SIZE_WORDS = re.compile(
|
||||
r"^(?:(?:sehr\s+)?(?:kl\.|gr\.|m\.-?\s?groß(?:e|er|es|en)?|"
|
||||
r"(?:groß|klein|mittelgroß|frisch|reif|weich|kalt|warm|lauwarm|gehackt|gemahlen)(?:e|er|es|en)?)\s+)+",
|
||||
re.I,
|
||||
)
|
||||
# Eiweiß, Eigelb & Co. kauft man als ganze Eier
|
||||
EGG_PARTS = {"eiweiß": "weiß", "eiweiss": "weiß", "eiklar": "weiß", "eigelb": "gelb", "eidotter": "gelb", "dotter": "gelb"}
|
||||
EGG_PART = re.compile(r"^(eiweiß|eiweiss|eiklar|eigelb|eidotter|dotter)(?:e|en|s)?(?=\s|$)")
|
||||
|
||||
# Selbstverständliches, das meist im Haus ist – nicht vorab ankreuzen
|
||||
STAPLES = {"salz", "pfeffer", "wasser", "salz und pfeffer", "eiswasser", "leitungswasser", "öl zum braten"}
|
||||
|
||||
|
||||
def _number(text: str) -> float:
|
||||
text = text.strip().replace(",", ".")
|
||||
if text[-1] in FRACTIONS:
|
||||
return (float(text[:-1]) if text[:-1] else 0) + FRACTIONS[text[-1]]
|
||||
total = 0.0
|
||||
for part in text.split():
|
||||
num, _, den = part.partition("/")
|
||||
total += float(num) / float(den) if den else float(num)
|
||||
return total
|
||||
|
||||
|
||||
def _recipe_unit(token: str):
|
||||
t = re.sub(r"\((?:n|s|e|en|er)\)$|/(?:n|s|e|en)$", "", token.casefold().rstrip(".,;:"))
|
||||
t = t.rstrip(".")
|
||||
return t if t in UNITS else None
|
||||
|
||||
|
||||
def _plural(name: str) -> str:
|
||||
# "Ei(er)" → "Eier", "Zwiebel(n)" → "Zwiebeln", "Zehe/n" → "Zehen"
|
||||
return re.sub(r"(\w)(?:\((\w{1,3})\)|/(\w{1,2}))(?=\W|$)", lambda m: m[1] + (m[2] or m[3]), name)
|
||||
|
||||
|
||||
def parse_ingredient(raw: str):
|
||||
text = re.sub(r"\s+", " ", html.unescape(str(raw))).strip()
|
||||
if not text:
|
||||
return None
|
||||
rest = text
|
||||
optional = bool(OPTIONAL.match(rest))
|
||||
rest = OPTIONAL.sub("", rest)
|
||||
|
||||
amount = None
|
||||
m = AMOUNT.match(rest)
|
||||
if m:
|
||||
# Bei "2-3" lieber die größere Menge besorgen
|
||||
amount = _number(m["b"] or m["a"])
|
||||
rest = rest[m.end():]
|
||||
|
||||
rest = SIZE_WORDS.sub("", rest) # "1 kl. Dose Mais" → Einheit Dose
|
||||
unit = None
|
||||
first, _, tail = rest.partition(" ")
|
||||
if tail and _recipe_unit(first):
|
||||
unit, rest = _recipe_unit(first), tail
|
||||
if first.endswith(","): # "1 TL, gestr. Zimt" → Zusatz hinter dem Komma ist keine Zutat
|
||||
rest = re.sub(r"^[^ ]*\.\s*", "", rest) if re.match(r"^\w+\.\s", rest) else rest
|
||||
|
||||
# Klammern: "(oder Butter)" ist eine Alternative, alles andere ein Hinweis
|
||||
alternatives = [a.strip() for a in re.findall(r"\(oder ([^)]+)\)", rest, re.I)]
|
||||
name = re.sub(r"\s*\([^)]*\)", "", _plural(rest))
|
||||
name = name.partition(",")[0] # Hinweise hinter dem Komma ("Saft davon") gehören nicht zum Namen
|
||||
# "Schmand oder Crème fraîche" → Schmand, Crème fraîche als Alternative
|
||||
name, *others = re.split(r"\s+oder\s+", name.strip(), flags=re.I)
|
||||
alternatives += [o.strip() for o in others if o.strip()]
|
||||
name = SIZE_WORDS.sub("", name.strip()).strip(" .-")
|
||||
if not name:
|
||||
return None
|
||||
name = name[0].upper() + name[1:]
|
||||
egg = EGG_PART.match(name.casefold())
|
||||
if egg or name.casefold() == "ei":
|
||||
name = "Eier"
|
||||
return {
|
||||
"raw": text,
|
||||
"amount": round(amount, 3) if amount is not None else None,
|
||||
"unit": unit,
|
||||
"name": name[:60],
|
||||
"alternatives": [_plural(a)[:60] for a in alternatives],
|
||||
"optional": optional,
|
||||
"staple": name.casefold() in STAPLES,
|
||||
"eggPart": EGG_PARTS[egg[1]] if egg else None,
|
||||
}
|
||||
|
||||
|
||||
def merge(ingredients: list) -> list:
|
||||
"""Doppelte Zutaten (z. B. Mehl für Teig und Streusel) zusammenfassen, wenn die Einheit passt.
|
||||
|
||||
Eier: ganze Eier zählen voll, von Eigelb und Eiweiß nur der größere Teil –
|
||||
"3 Eigelb + 3 Eiweiß" sind 3 getrennte Eier, nicht 6.
|
||||
"""
|
||||
merged = {}
|
||||
for ing in ingredients:
|
||||
key = (_key(ing["name"]), ing["unit"])
|
||||
part = ing.pop("eggPart", None)
|
||||
amount = ing["amount"] if ing["amount"] is not None else (1 if part else None)
|
||||
if key not in merged:
|
||||
merged[key] = {**ing, "amount": None, "raws": [], "whole": 0.0, "parts": {}, "counted": False}
|
||||
entry = merged[key]
|
||||
entry["raws"].append(ing["raw"])
|
||||
entry["optional"] = entry["optional"] and ing["optional"]
|
||||
if amount is None:
|
||||
continue
|
||||
entry["counted"] = True
|
||||
if part:
|
||||
entry["parts"][part] = entry["parts"].get(part, 0) + amount
|
||||
else:
|
||||
entry["whole"] += amount
|
||||
result = []
|
||||
for entry in merged.values():
|
||||
if entry["counted"]:
|
||||
entry["amount"] = round(entry["whole"] + max(entry["parts"].values(), default=0), 3)
|
||||
entry["raw"] = " + ".join(entry["raws"])
|
||||
for k in ("raws", "whole", "parts", "counted"):
|
||||
del entry[k]
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
# ---------- Zuordnung zu Produkten ----------
|
||||
|
||||
_UMLAUTS = str.maketrans({"ä": "a", "ö": "o", "ü": "u", "ß": "ss"})
|
||||
|
||||
|
||||
def _stem(name: str) -> str:
|
||||
"""Grobe Grundform, damit "Apfel"/"Äpfel", "Ei"/"Eier", "Tomate"/"Tomaten" zusammenfinden."""
|
||||
s = name.casefold().translate(_UMLAUTS)
|
||||
if s in ("ei", "eier"): # zu kurz für die Endungsregel; "Eis" soll nicht zu "Eier" werden
|
||||
return "ei"
|
||||
for ending in ("en", "er", "n", "e", "s"):
|
||||
if s.endswith(ending) and len(s) - len(ending) >= 3:
|
||||
return s[: -len(ending)]
|
||||
return s
|
||||
|
||||
|
||||
def _matcher(conn):
|
||||
aliases = {r["key"]: r["product"] for r in conn.execute("SELECT key, product FROM ingredient_aliases")}
|
||||
products = [r["name"] for r in conn.execute("SELECT name FROM products")]
|
||||
exact = {_key(p): p for p in products}
|
||||
stems = {}
|
||||
for p in products:
|
||||
stems.setdefault(_stem(p), p)
|
||||
|
||||
def lookup(name: str):
|
||||
k = _key(name)
|
||||
if k in aliases:
|
||||
return aliases[k]
|
||||
if k in exact:
|
||||
return exact[k]
|
||||
if _stem(name) in stems:
|
||||
return stems[_stem(name)]
|
||||
# "brauner Zucker" → "Zucker"
|
||||
last = name.split()[-1]
|
||||
if last != name:
|
||||
return exact.get(_key(last)) or stems.get(_stem(last))
|
||||
return None
|
||||
|
||||
def match(ing: dict) -> dict:
|
||||
found = lookup(ing["name"])
|
||||
if not found:
|
||||
for alt in ing["alternatives"]:
|
||||
if found := lookup(alt):
|
||||
break
|
||||
return {**ing, "product": found or ing["name"], "known": bool(found)}
|
||||
|
||||
return match
|
||||
|
||||
|
||||
def _result(conn, title, source, servings, raw_ingredients) -> dict:
|
||||
parsed = [p for p in (parse_ingredient(r) for r in raw_ingredients[:MAX_INGREDIENTS]) if p]
|
||||
if not parsed:
|
||||
raise ValidationError("Keine Zutaten gefunden.")
|
||||
match = _matcher(conn)
|
||||
return {
|
||||
"title": title,
|
||||
"source": source,
|
||||
"servings": servings,
|
||||
"ingredients": [match(i) for i in merge(parsed)],
|
||||
}
|
||||
|
||||
|
||||
# ---------- API ----------
|
||||
|
||||
|
||||
@router.post("/api/haushalt/rezept")
|
||||
async def load_recipe(data: dict = Body(...), _: dict = Depends(current_user)):
|
||||
"""Zutaten aus einem Rezept-Link ({url}) oder eingefügtem Text ({text, servings}) lesen."""
|
||||
url = data.get("url").strip() if isinstance(data.get("url"), str) else ""
|
||||
text = data.get("text") if isinstance(data.get("text"), str) else ""
|
||||
if url:
|
||||
if not re.match(r"^https?://", url, re.I):
|
||||
url = "https://" + url
|
||||
recipe = _find_recipe(await _fetch(url))
|
||||
if not recipe or not recipe.get("recipeIngredient"):
|
||||
raise ValidationError(
|
||||
"Auf dieser Seite wurden keine Rezeptdaten gefunden. Du kannst die Zutaten stattdessen einfügen."
|
||||
)
|
||||
ingredients = recipe["recipeIngredient"]
|
||||
if isinstance(ingredients, str):
|
||||
ingredients = ingredients.splitlines()
|
||||
title = html.unescape(str(recipe.get("name") or "Rezept"))[:200]
|
||||
with transaction() as conn:
|
||||
return _result(conn, title, url, _servings(recipe.get("recipeYield")), ingredients)
|
||||
if text.strip():
|
||||
if len(text) > MAX_TEXT:
|
||||
raise ValidationError("Der Text ist zu lang.")
|
||||
servings = data.get("servings")
|
||||
servings = servings if isinstance(servings, int) and 0 < servings <= 100 else None
|
||||
lines = [line.strip(" \t-•*·") for line in text.splitlines()]
|
||||
with transaction() as conn:
|
||||
return _result(conn, "Eigene Zutatenliste", None, servings, [line for line in lines if line])
|
||||
raise ValidationError("Bitte einen Rezept-Link oder eine Zutatenliste angeben.")
|
||||
|
||||
|
||||
@router.post("/api/haushalt/rezept/uebernehmen")
|
||||
def take_over(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""Ausgewählte Zutaten auf die Einkaufsliste setzen und korrigierte Zuordnungen merken."""
|
||||
items = data.get("items") if isinstance(data.get("items"), list) else []
|
||||
aliases = data.get("aliases") if isinstance(data.get("aliases"), list) else []
|
||||
if not items and not aliases:
|
||||
raise ValidationError("Nichts ausgewählt.")
|
||||
if len(items) > MAX_INGREDIENTS or len(aliases) > MAX_INGREDIENTS:
|
||||
raise ValidationError("Zu viele Zutaten.")
|
||||
# Erst alles prüfen, dann in einem Rutsch speichern
|
||||
checked = []
|
||||
for i in items:
|
||||
if not isinstance(i, dict):
|
||||
raise ValidationError("Ungültige Zutat.")
|
||||
unit = _unit(i.get("unit"))
|
||||
checked.append((_clean_name(i.get("name")), _quantity(i.get("quantity"), unit), unit))
|
||||
learned = [(_clean_name(a.get("ingredient")), _clean_name(a.get("product"))) for a in aliases if isinstance(a, dict)]
|
||||
with transaction() as conn:
|
||||
for ingredient, product in learned:
|
||||
product = _canonical_name(conn, product)
|
||||
if _key(ingredient) == _key(product):
|
||||
conn.execute("DELETE FROM ingredient_aliases WHERE key = ?", (_key(ingredient),))
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO ingredient_aliases (key, product) VALUES (?, ?) "
|
||||
"ON CONFLICT (key) DO UPDATE SET product = excluded.product",
|
||||
(_key(ingredient), product),
|
||||
)
|
||||
for name, quantity, unit in checked:
|
||||
_add(conn, _canonical_name(conn, name), "einkauf", quantity, unit, user["id"])
|
||||
return _state(conn)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Universelle Sendungsverfolgung: erst DHL direkt, dann 17TRACK, sonst nur Erkennung + Links."""
|
||||
import logging
|
||||
import time
|
||||
|
||||
from ..config import TRACKING
|
||||
from .carriers import detect_carriers, universal_link
|
||||
from .dhl import track_dhl
|
||||
from .seventeen import track_17
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CACHE_SECONDS = 120
|
||||
_cache: dict[str, tuple[float, dict]] = {}
|
||||
|
||||
|
||||
async def track(number: str) -> dict:
|
||||
cached = _cache.get(number)
|
||||
if cached and time.monotonic() - cached[0] < CACHE_SECONDS:
|
||||
return cached[1]
|
||||
|
||||
candidates = detect_carriers(number)
|
||||
base = {"number": number, "candidates": candidates, "universalLink": universal_link(number)}
|
||||
dhl_key = TRACKING.get("dhlApiKey")
|
||||
key_17 = TRACKING.get("seventeenTrackApiKey")
|
||||
errors = []
|
||||
|
||||
if dhl_key and any(c["id"] == "dhl" for c in candidates):
|
||||
try:
|
||||
result = await track_dhl(number, dhl_key)
|
||||
if result:
|
||||
return _store(number, {**base, **result})
|
||||
except Exception as e: # noqa: BLE001 – Fehler anzeigen, nächsten Anbieter probieren
|
||||
log.warning("DHL-Abfrage fehlgeschlagen: %s", e)
|
||||
errors.append(f"DHL: {e}")
|
||||
|
||||
if key_17:
|
||||
try:
|
||||
result = await track_17(number, key_17)
|
||||
data = {**base, **result}
|
||||
return _store(number, data) if result["status"] != "pending" else data
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("17TRACK-Abfrage fehlgeschlagen: %s", e)
|
||||
errors.append(f"17TRACK: {e}")
|
||||
|
||||
return {**base, "source": None, "status": "unknown", "configured": bool(dhl_key or key_17), "errors": errors}
|
||||
|
||||
|
||||
def _store(number: str, data: dict) -> dict:
|
||||
_cache[number] = (time.monotonic(), data)
|
||||
return data
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Erkennt anhand des Nummernformats, welche Paketdienste in Frage kommen.
|
||||
|
||||
Viele Formate überschneiden sich, deshalb kann es mehrere Treffer geben.
|
||||
"""
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def universal_link(number: str) -> str:
|
||||
return f"https://t.17track.net/de#nums={quote(number)}"
|
||||
|
||||
|
||||
_DHL = "https://www.dhl.de/de/privatkunden/pakete-empfangen/verfolgen.html?piececode={}"
|
||||
|
||||
CARRIERS = [
|
||||
("dhl", "DHL", [r"\d{12}", r"\d{20}", r"(JJD|JVGL)\d{8,}", r"00340\d{15}", r"\d{10}"], _DHL),
|
||||
("deutschepost", "Deutsche Post", [r"[A-Z]{2}\d{9}DE"], _DHL),
|
||||
("ups", "UPS", [r"1Z[0-9A-Z]{16}"], "https://www.ups.com/track?loc=de_DE&tracknum={}"),
|
||||
("hermes", "Hermes", [r"\d{14}", r"\d{16}", r"H\d{19}"],
|
||||
"https://www.myhermes.de/empfangen/sendungsverfolgung/sendungsinformation#{}"),
|
||||
("dpd", "DPD", [r"\d{14}", r"\d{27,28}"], "https://tracking.dpd.de/status/de_DE/parcel/{}"),
|
||||
("gls", "GLS", [r"\d{11,12}", r"[A-Z0-9]{8}"], "https://gls-group.com/DE/de/paketverfolgung?match={}"),
|
||||
("fedex", "FedEx", [r"\d{12}", r"\d{15}"], "https://www.fedex.com/fedextrack/?trknbr={}"),
|
||||
("amazon", "Amazon Logistics", [r"TBA\d{9,}"], "https://www.amazon.de/gp/your-account/order-history"),
|
||||
("upu", "Internationale Post (UPU)", [r"[A-Z]{2}\d{9}(?!DE)[A-Z]{2}"], None),
|
||||
]
|
||||
|
||||
_COMPILED = [(cid, name, [re.compile(p) for p in patterns], link) for cid, name, patterns, link in CARRIERS]
|
||||
|
||||
|
||||
def detect_carriers(number: str) -> list[dict]:
|
||||
result = []
|
||||
for cid, name, patterns, link in _COMPILED:
|
||||
if any(p.fullmatch(number) for p in patterns):
|
||||
url = link.format(quote(number)) if link else universal_link(number)
|
||||
result.append({"id": cid, "name": name, "link": url})
|
||||
return result
|
||||
@@ -0,0 +1,64 @@
|
||||
"""DHL Shipment Tracking – Unified API.
|
||||
|
||||
Kostenloser Key: https://developer.dhl.com (API "Shipment Tracking - Unified")
|
||||
"""
|
||||
import httpx
|
||||
|
||||
API = "https://api-eu.dhl.com/track/shipments"
|
||||
|
||||
STATUS = {
|
||||
"pre-transit": "inforeceived",
|
||||
"transit": "intransit",
|
||||
"delivered": "delivered",
|
||||
"failure": "failed",
|
||||
"unknown": "unknown",
|
||||
}
|
||||
|
||||
|
||||
def _place(loc: dict | None) -> str | None:
|
||||
addr = (loc or {}).get("address")
|
||||
if not addr:
|
||||
return None
|
||||
return ", ".join(filter(None, [addr.get("addressLocality"), addr.get("countryCode")])) or None
|
||||
|
||||
|
||||
async def track_dhl(number: str, key: str) -> dict | None:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
res = await client.get(
|
||||
API,
|
||||
params={"trackingNumber": number, "language": "de"},
|
||||
headers={"DHL-API-Key": key, "Accept": "application/json"},
|
||||
)
|
||||
if res.status_code == 404:
|
||||
return None # keine DHL-Sendung -> nächsten Anbieter probieren
|
||||
res.raise_for_status()
|
||||
|
||||
shipments = res.json().get("shipments") or []
|
||||
if not shipments:
|
||||
return None
|
||||
s = shipments[0]
|
||||
status = s.get("status") or {}
|
||||
|
||||
events = [
|
||||
{"time": e.get("timestamp"), "description": e.get("description") or e.get("status"), "location": _place(e.get("location"))}
|
||||
for e in s.get("events") or []
|
||||
]
|
||||
|
||||
eta = None
|
||||
if s.get("estimatedTimeOfDelivery"):
|
||||
eta = {"from": s["estimatedTimeOfDelivery"], "to": None}
|
||||
elif s.get("estimatedDeliveryTimeFrame"):
|
||||
frame = s["estimatedDeliveryTimeFrame"]
|
||||
eta = {"from": frame.get("estimatedFrom"), "to": frame.get("estimatedThrough")}
|
||||
|
||||
return {
|
||||
"source": "DHL API",
|
||||
"carrier": "DHL Express" if s.get("service") == "express" else "DHL",
|
||||
"status": STATUS.get(status.get("statusCode"), "unknown"),
|
||||
"statusDetail": status.get("description") or status.get("status"),
|
||||
"eta": eta,
|
||||
"deliveredAt": status.get("timestamp") if status.get("statusCode") == "delivered" else None,
|
||||
"origin": _place(s.get("origin")),
|
||||
"destination": _place(s.get("destination")),
|
||||
"events": events,
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""17TRACK Tracking API v2.2 – https://api.17track.net
|
||||
|
||||
Nummern müssen erst registriert werden, danach lassen sich die Daten abrufen.
|
||||
Bei neu registrierten Nummern dauert es oft ein paar Sekunden, bis Daten da sind.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
API = "https://api.17track.net/track/v2.2"
|
||||
ALREADY_REGISTERED = -18019901
|
||||
|
||||
STATUS = {
|
||||
"NotFound": "notfound",
|
||||
"InfoReceived": "inforeceived",
|
||||
"InTransit": "intransit",
|
||||
"Expired": "expired",
|
||||
"AvailableForPickup": "pickup",
|
||||
"OutForDelivery": "outfordelivery",
|
||||
"DeliveryFailure": "failed",
|
||||
"Delivered": "delivered",
|
||||
"Exception": "exception",
|
||||
}
|
||||
|
||||
|
||||
async def _call(client: httpx.AsyncClient, endpoint: str, key: str, body: list) -> dict:
|
||||
res = await client.post(f"{API}/{endpoint}", headers={"17token": key}, json=body)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
if data.get("code") != 0:
|
||||
raise RuntimeError(f"API-Code {data.get('code')}")
|
||||
return data["data"]
|
||||
|
||||
|
||||
def _location(event: dict) -> str | None:
|
||||
if event.get("location"):
|
||||
return event["location"]
|
||||
addr = event.get("address") or {}
|
||||
return ", ".join(filter(None, [addr.get("city"), addr.get("country")])) or None
|
||||
|
||||
|
||||
async def track_17(number: str, key: str) -> dict:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
data = await _call(client, "gettrackinfo", key, [{"number": number}])
|
||||
|
||||
if not data.get("accepted"):
|
||||
# Noch nicht registriert -> registrieren (17TRACK erkennt den Paketdienst selbst)
|
||||
reg = await _call(client, "register", key, [{"number": number}])
|
||||
rejected = (reg.get("rejected") or [None])[0]
|
||||
if rejected and (rejected.get("error") or {}).get("code") != ALREADY_REGISTERED:
|
||||
raise RuntimeError((rejected.get("error") or {}).get("message") or "Registrierung abgelehnt")
|
||||
data = await _call(client, "gettrackinfo", key, [{"number": number}])
|
||||
|
||||
item = (data.get("accepted") or [None])[0]
|
||||
if not item or not item.get("track_info"):
|
||||
return {"source": "17TRACK", "status": "pending"}
|
||||
|
||||
info = item["track_info"]
|
||||
provider = ((info.get("tracking") or {}).get("providers") or [{}])[0]
|
||||
events = [
|
||||
{
|
||||
"time": e.get("time_iso") or e.get("time_utc"),
|
||||
"description": e.get("description"),
|
||||
"location": _location(e),
|
||||
}
|
||||
for e in provider.get("events") or []
|
||||
]
|
||||
latest = info.get("latest_status") or {}
|
||||
eta = (info.get("time_metrics") or {}).get("estimated_delivery_date") or {}
|
||||
shipping = info.get("shipping_info") or {}
|
||||
|
||||
return {
|
||||
"source": "17TRACK",
|
||||
"carrier": (provider.get("provider") or {}).get("name"),
|
||||
"status": STATUS.get(latest.get("status"), "unknown"),
|
||||
"statusDetail": latest.get("sub_status"),
|
||||
"eta": {"from": eta.get("from"), "to": eta.get("to")} if eta.get("from") or eta.get("to") else None,
|
||||
"deliveredAt": events[0]["time"] if latest.get("status") == "Delivered" and events else None,
|
||||
"origin": (shipping.get("shipper_address") or {}).get("country"),
|
||||
"destination": (shipping.get("recipient_address") or {}).get("country"),
|
||||
"events": events,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi==0.143.0
|
||||
uvicorn[standard]==0.54.0
|
||||
httpx==0.28.1
|
||||
nh3==0.3.7
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"port": 3000,
|
||||
"links": {
|
||||
"jellyfin": "http://jellyfin.local:8096",
|
||||
"audiobookshelf": "http://audiobookshelf.local:13378",
|
||||
"homer": "http://homer.local:8080"
|
||||
},
|
||||
"tracking": {
|
||||
"seventeenTrackApiKey": "",
|
||||
"dhlApiKey": ""
|
||||
},
|
||||
"reisen": {
|
||||
"wikipediaKontakt": ""
|
||||
}
|
||||
}
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# Container bauen und (neu) starten – auf dem Server ausführen: <projektordner>/deploy/update-docker.sh
|
||||
# Die Daten in data/ und die Einstellungen in config.json bleiben dabei unberührt.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$(readlink -f "$0")")/.."
|
||||
|
||||
# Neuen Code holen, falls das Projekt per Git auf den Server kam
|
||||
if [ -d .git ]; then
|
||||
git pull --ff-only
|
||||
fi
|
||||
|
||||
# Ohne config.json würde Docker an ihrer Stelle einen leeren Ordner anlegen
|
||||
if [ ! -f config.json ]; then
|
||||
cp config.example.json config.json
|
||||
echo "config.json wurde aus config.example.json angelegt – bei Bedarf API-Keys eintragen und das Skript erneut starten."
|
||||
fi
|
||||
|
||||
# Datenordner: der Container läuft als UID 1000 und muss hier schreiben dürfen
|
||||
mkdir -p data
|
||||
if [ "$(stat -c %u data)" != "1000" ]; then
|
||||
echo "Setze Besitzer von data/ auf UID 1000 (sudo) ..."
|
||||
sudo chown -R 1000:1000 data
|
||||
fi
|
||||
|
||||
docker compose up -d --build
|
||||
docker image prune -f >/dev/null
|
||||
|
||||
sleep 5
|
||||
docker compose ps
|
||||
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
p42:
|
||||
build: .
|
||||
image: p42-dashboard
|
||||
container_name: p42-dashboard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Nur intern erreichbar: standardmäßig an 127.0.0.1 gebunden – dein nginx leitet auf
|
||||
# http://127.0.0.1:${P42_PORT} weiter. Port/Adresse in .env änderbar (siehe .env.example).
|
||||
- "${P42_BIND:-127.0.0.1}:${P42_PORT:-3000}:3000"
|
||||
environment:
|
||||
TZ: Europe/Berlin
|
||||
# Anfragen kommen nur über nginx: dessen X-Forwarded-For/-Proto vertrauen
|
||||
# (echte Client-IP für den Login-Schutz, HTTPS für sichere Cookies)
|
||||
FORWARDED_ALLOW_IPS: "${P42_TRUSTED_PROXIES:-*}"
|
||||
volumes:
|
||||
# Datenbank + hochgeladene Bilder – bleiben bei Updates und Neubau erhalten
|
||||
- ./data:/data
|
||||
# Einstellungen (API-Keys, Wikipedia-Kontakt) – nur lesend
|
||||
- ./config.json:/app/config.json:ro
|
||||
@@ -0,0 +1,35 @@
|
||||
# Design-Brief: umschaltbare Designs
|
||||
|
||||
Selbst ausgearbeitet im Rahmen der Vorgabe ("Star Trek, Star Wars, Iron Man 2, das aktuelle bleibt").
|
||||
|
||||
## Was und für wen
|
||||
|
||||
Ein privates Heim-Dashboard für eine kleine Gruppe von Konten. Die Designs ändern nur die
|
||||
Darstellung: gleiche Seiten, gleiche Bedienung, gleiche Reihenfolge der Inhalte.
|
||||
|
||||
## Die Designs
|
||||
|
||||
(LCARS, Zielcomputer und J.A.R.V.I.S. gab es zeitweise; sie wurden auf Wunsch wieder entfernt.)
|
||||
|
||||
| Design | Stimmung (3–5 Wörter) | Erkennungsmerkmal |
|
||||
|---|---|---|
|
||||
| Matrix (Standard) | roh, nächtlich, Terminal | roter Zeichenregen, Scanlines, blinkender Cursor |
|
||||
| Blackwall (Cyberpunk) | bedrohlich, digital, scharfkantig | leuchtende Datenwand nach dem Vorbild aus dem Spiel (Wand-Korridor, Lichtband, Punkteboden, seltener Glitch), abgeschrägte Panels, Pink-Rot/Cyan, chromatisch versetzte Titel |
|
||||
|
||||
## Modernisierung (zweite Runde)
|
||||
|
||||
Ziel: wie eine durchdachte Alltags-Oberfläche wirken statt wie eine Film-Requisite, das Vorbild
|
||||
bleibt erkennbar. Umsetzung über ein gemeinsames Token-System in `styles.css` (Farbrollen, Radien,
|
||||
Schatten, Fokusring, Schriftrollen für Text/Titel/Beschriftung, Bewegung); jedes Design setzt vor
|
||||
allem diese Werte. Ruhige Fließtext-Schriften, Display-Schrift nur für Seitentitel, weiche
|
||||
Fokusringe, Status als Pillen, Kacheln mit Icon-Feld, Hintergründe zur Mitte hin ausgeblendet,
|
||||
Überblenden beim Design-Wechsel.
|
||||
|
||||
## Regeln, die für alle gelten
|
||||
|
||||
- Farben über Rollen (Hintergrund, Fläche, Text, gedämpft, Akzent, Status), ein Akzent je Design.
|
||||
- Höchstens zwei Schriftfamilien je Design, alle lokal und frei lizenziert.
|
||||
- Kontrast mindestens 4,5:1 (große Schrift 3:1), gemessen auf allen Seiten in allen Designs.
|
||||
- Bewegung nur über `transform`/`opacity` bzw. Canvas; "Animation aus" im Menü und
|
||||
"Bewegung reduzieren" im System stoppen alles. Hintergründe sind `aria-hidden`.
|
||||
- Keine Original-Logos, Abzeichen oder geschützten Schriften; keine erfundenen Kennzahlen.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Quellen und Lizenzen
|
||||
|
||||
## Schriften (alle lokal eingebunden über `@fontsource`)
|
||||
|
||||
| Schrift | Design | Lizenz |
|
||||
|---|---|---|
|
||||
| VT323 | Matrix (Überschriften) | SIL Open Font License 1.1 |
|
||||
| IBM Plex Mono | Matrix (Text) | SIL Open Font License 1.1 |
|
||||
| Chakra Petch | Blackwall (Text, Beschriftungen) | SIL Open Font License 1.1 |
|
||||
| Oxanium | Blackwall (Titel) | SIL Open Font License 1.1 |
|
||||
|
||||
Die Lizenztexte liegen in `frontend/node_modules/@fontsource/<schrift>/LICENSE`.
|
||||
|
||||
## Daten
|
||||
|
||||
| Was | Quelle | Lizenz |
|
||||
|---|---|---|
|
||||
| Weltkarte (`frontend/public/maps/countries-50m.json`) | world-atlas 2.0.2, Grundlage Natural Earth | ISC (world-atlas), gemeinfrei (Natural Earth) |
|
||||
| Länderdaten (`frontend/src/data/laender.json`) | world-countries 5.1.0 | ODbL 1.0 |
|
||||
| Städtesuche | Open-Meteo Geocoding, Daten von GeoNames | CC BY 4.0 |
|
||||
| Infotexte | deutschsprachige Wikipedia | CC BY-SA 4.0 |
|
||||
| Einwohnerzahlen, Hauptstädte | Wikidata | CC0 |
|
||||
|
||||
Die Quellenangaben stehen auch klein unter der Reise-Karte.
|
||||
|
||||
## Designs
|
||||
|
||||
Blackwall ist von Cyberpunk 2077 inspiriert.
|
||||
Es werden keine Original-Logos, Abzeichen, Bilder oder geschützten Schriften verwendet; alle
|
||||
Formen, Farben und Hintergründe sind selbst gezeichnet (CSS, SVG, Canvas).
|
||||
@@ -0,0 +1,34 @@
|
||||
# Bibliotheken und ihre Aufgabe
|
||||
|
||||
Jede Bibliothek hat genau eine Aufgabe.
|
||||
|
||||
## Frontend (`frontend/package.json`)
|
||||
|
||||
| Bibliothek | Aufgabe |
|
||||
|---|---|
|
||||
| React, React DOM | Oberfläche |
|
||||
| React Router | Seiten und Navigation |
|
||||
| TipTap (`@tiptap/*`) | Artikel-Editor |
|
||||
| d3-geo | Weltkarte: Projektion und Pfade |
|
||||
| d3-zoom, d3-selection, d3-transition | Weltkarte: Zoomen, Verschieben, weiche Übergänge |
|
||||
| topojson-client | Kartendaten entpacken |
|
||||
| `@fontsource/*` | Schriften lokal einbinden (keine Anfrage an Google) |
|
||||
| Vite | Entwicklung und Build |
|
||||
|
||||
Bewusst ohne Animationsbibliothek: Jeder Design-Hintergrund ist
|
||||
ein einzelner Canvas mit eigener Schleife (Matrix, Blackwall). Es läuft immer nur der
|
||||
Hintergrund des aktiven Designs, also nie zwei Schleifen gleichzeitig.
|
||||
|
||||
Größen (gzip, gemessen beim Build): Start 120 kB JavaScript + 17 kB CSS (Budget 250 kB).
|
||||
Erst beim Öffnen geladen: Artikel-Editor 131 kB, Weltkarte 44 kB. Schriftdateien lädt der
|
||||
Browser nur für das aktive Design.
|
||||
|
||||
## Backend (`backend/requirements.txt`)
|
||||
|
||||
| Bibliothek | Aufgabe |
|
||||
|---|---|
|
||||
| FastAPI, Uvicorn | API und Auslieferung des Frontends |
|
||||
| httpx | Abrufe nach außen (Sendungsverfolgung, Monitoring, Rezepte, Reise-Infos) |
|
||||
| nh3 | Artikel-HTML bereinigen |
|
||||
|
||||
Datenbank: SQLite (Python-Standardbibliothek).
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#050000" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>P42 Dashboard</title>
|
||||
<script>
|
||||
// Gewähltes Design vor dem ersten Zeichnen setzen (sonst blitzt kurz das Standard-Design auf)
|
||||
(function () {
|
||||
var t = 'matrix';
|
||||
try { t = localStorage.getItem('p42.design') || t; } catch (e) {}
|
||||
if (['matrix', 'blackwall'].indexOf(t) < 0) t = 'matrix';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1690
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "p42-dashboard",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/chakra-petch": "^5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@fontsource/oxanium": "^5.3.0",
|
||||
"@fontsource/vt323": "^5.3.0",
|
||||
"@tiptap/extension-image": "^3.31.4",
|
||||
"@tiptap/extensions": "^3.31.4",
|
||||
"@tiptap/pm": "^3.31.4",
|
||||
"@tiptap/react": "^3.31.4",
|
||||
"@tiptap/starter-kit": "^3.31.4",
|
||||
"d3-geo": "^3.1.1",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-transition": "^3.0.1",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"topojson-client": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"vite": "^8.3.4"
|
||||
},
|
||||
"wmc": {
|
||||
"structure": "existing"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#050000"/><rect x="1" y="1" width="30" height="30" fill="none" stroke="#ff2a2a" stroke-width="2"/><text x="16" y="21" font-family="monospace" font-size="12" font-weight="700" fill="#ff2a2a" text-anchor="middle">>_</text></svg>
|
||||
|
After Width: | Height: | Size: 325 B |
@@ -0,0 +1,2 @@
|
||||
countries-50m.json: world-atlas 2.0.2 (https://github.com/topojson/world-atlas),
|
||||
Copyright 2013-2019 Michael Bostock, ISC-Lizenz. Grundlage: Natural Earth (gemeinfrei).
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"Topology","objects":{"countries":{"type":"GeometryCollection","geometries":[{"type":"Polygon","arcs":[[0,1,2,3]],"id":"716","properties":{"name":"Zimbabwe"}},{"type":"Polygon","arcs":[[-3,4,5,6,7,8,9]],"id":"894","properties":{"name":"Zambia"}},{"type":"MultiPolygon","arcs":[[[10,11,12]],[[13]],[[14]],[[15]],[[16]]],"id":"887","properties":{"name":"Yemen"}},{"type":"MultiPolygon","arcs":[[[17]],[[18,19,20,21]],[[22]],[[23]],[[24]],[[25]],[[26]],[[27]]],"id":"704","properties":{"name":"Vietnam"}},{"type":"MultiPolygon","arcs":[[[28]],[[29]],[[30]],[[31]],[[32,33,34,35]]],"id":"862","properties":{"name":"Venezuela"}},{"type":"Polygon","arcs":[[36]],"id":"336","properties":{"name":"Vatican"}},{"type":"MultiPolygon","arcs":[[[37]],[[38]],[[39]],[[40]],[[41]],[[42]],[[43]],[[44]],[[45]],[[46]],[[47]],[[48]],[[49]],[[50]]],"id":"548","properties":{"name":"Vanuatu"}},{"type":"MultiPolygon","arcs":[[[51,52,53,54,55],[56]],[[57]],[[58]]],"id":"860","properties":{"name":"Uzbekistan"}},{"type":"Polygon","arcs":[[59,60,61]],"id":"858","properties":{"name":"Uruguay"}},{"type":"MultiPolygon","arcs":[[[62]],[[63]],[[64]],[[65]],[[66]]],"id":"583","properties":{"name":"Micronesia"}},{"type":"MultiPolygon","arcs":[[[67]],[[68]],[[69]],[[70]],[[71]]],"id":"584","properties":{"name":"Marshall Is."}},{"type":"MultiPolygon","arcs":[[[72]],[[73]],[[74]],[[75]],[[76]],[[77]]],"id":"580","properties":{"name":"N. Mariana Is."}},{"type":"MultiPolygon","arcs":[[[78]],[[79]],[[80]]],"id":"850","properties":{"name":"U.S. Virgin Is."}},{"type":"Polygon","arcs":[[81]],"id":"316","properties":{"name":"Guam"}},{"type":"Polygon","arcs":[[82]],"id":"016","properties":{"name":"American Samoa"}},{"type":"MultiPolygon","arcs":[[[83]],[[84]],[[85]]],"id":"630","properties":{"name":"Puerto Rico"}},{"type":"MultiPolygon","arcs":[[[86]],[[87]],[[88]],[[89]],[[90]],[[91]],[[92]],[[93]],[[94]],[[95]],[[96]],[[97]],[[98]],[[99]],[[100]],[[101]],[[102,103,104,105]],[[106]],[[107]],[[108]],[[109]],[[110]],[[111]],[[112]],[[113]],[[114]],[[115]],[[116]],[[117]],[[118]],[[119]],[[120]],[[121]],[[122]],[[123]],[[124]],[[125]],[[126]],[[127]],[[128]],[[129]],[[130]],[[131]],[[132]],[[133]],[[134]],[[135]],[[136]],[[137]],[[138]],[[139]],[[140]],[[141]],[[142]],[[143]],[[144]],[[145]],[[146]],[[147]],[[148]],[[149]],[[150]],[[151]],[[152]],[[153]],[[154]],[[155]],[[156]],[[157]],[[158]],[[159]],[[160]],[[161]],[[162]],[[163]],[[164,165,166,167,168]],[[169]],[[170]],[[171]],[[172]],[[173]],[[174]],[[175]],[[176]],[[177]],[[178]],[[179]],[[180]],[[181]],[[182]],[[183]],[[184]],[[185]],[[186]],[[187]],[[188]],[[189]],[[190]],[[191]],[[192]],[[193]],[[194]],[[195]],[[196]],[[197]],[[198]],[[199]],[[200]],[[201]],[[202]],[[203]],[[204]],[[205]],[[206]],[[207]],[[208]],[[209]],[[210]],[[211]],[[212]],[[213]],[[214]],[[215]],[[216]],[[217]],[[218]],[[219]]],"id":"840","properties":{"name":"United States of America"}},{"type":"MultiPolygon","arcs":[[[220]],[[221]]],"id":"239","properties":{"name":"S. Geo. and the Is."}},{"type":"Polygon","arcs":[[222]],"id":"086","properties":{"name":"Br. Indian Ocean Ter."}},{"type":"MultiPolygon","arcs":[[[223]],[[224]]],"id":"654","properties":{"name":"Saint Helena"}},{"type":"Polygon","arcs":[[225]],"id":"612","properties":{"name":"Pitcairn Is."}},{"type":"Polygon","arcs":[[226]],"id":"660","properties":{"name":"Anguilla"}},{"type":"MultiPolygon","arcs":[[[227]],[[228]],[[229]],[[230]],[[231]],[[232]]],"id":"238","properties":{"name":"Falkland Is."}},{"type":"MultiPolygon","arcs":[[[233]],[[234]],[[235]]],"id":"136","properties":{"name":"Cayman Is."}},{"type":"Polygon","arcs":[[236]],"id":"060","properties":{"name":"Bermuda"}},{"type":"MultiPolygon","arcs":[[[237]],[[238]],[[239]]],"id":"092","properties":{"name":"British Virgin Is."}},{"type":"MultiPolygon","arcs":[[[240]],[[241]],[[242]]],"id":"796","properties":{"name":"Turks and Caicos Is."}},{"type":"Polygon","arcs":[[243]],"id":"500","properties":{"name":"Montserrat"}},{"type":"Polygon","arcs":[[244]],"id":"832","properties":{"name":"Jersey"}},{"type":"Polygon","arcs":[[245]],"id":"831","properties":{"name":"Guernsey"}},{"type":"Polygon","arcs":[[246]],"id":"833","properties":{"name":"Isle of Man"}},{"type":"MultiPolygon","arcs":[[[247]],[[248]],[[249]],[[250]],[[251]],[[252]],[[253]],[[254]],[[255]],[[256]],[[257]],[[258]],[[259]],[[260]],[[261]],[[262]],[[263]],[[264]],[[265]],[[266]],[[267]],[[268,269]],[[270]]],"id":"826","properties":{"name":"United Kingdom"}},{"type":"MultiPolygon","arcs":[[[271,272,273,274,275],[276]],[[277]],[[278]],[[279]],[[280]]],"id":"784","properties":{"name":"United Arab Emirates"}},{"type":"MultiPolygon","arcs":[[[281,282,283,284,285,286,287,288,289,290,291]],[[292]]],"id":"804","properties":{"name":"Ukraine"}},{"type":"Polygon","arcs":[[293,294,295,296,297]],"id":"800","properties":{"name":"Uganda"}},{"type":"MultiPolygon","arcs":[[[298]],[[-55,299,300,301,302]]],"id":"795","properties":{"name":"Turkmenistan"}},{"type":"MultiPolygLine truncated
|
||||
@@ -0,0 +1,52 @@
|
||||
// API-Aufrufe ans Python-Backend (Anmeldung per Sitzungs-Cookie)
|
||||
|
||||
export async function api(method, url, body, { raw = false } = {}) {
|
||||
const headers = {};
|
||||
let payload = body;
|
||||
if (body !== undefined && !raw) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
payload = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(url, { method, headers, body: payload });
|
||||
// Sitzung abgelaufen -> zurück zur Anmeldung
|
||||
if (res.status === 401 && !url.startsWith('/api/auth/')) window.dispatchEvent(new Event('p42:unauthorized'));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `Fehler ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Große Fotos (z. B. vom Handy) vor dem Hochladen verkleinern
|
||||
async function prepareImage(file) {
|
||||
if (file.type === 'image/gif' || file.size < 1.5 * 1024 * 1024) return file;
|
||||
try {
|
||||
const bmp = await createImageBitmap(file, { imageOrientation: 'from-image' });
|
||||
const scale = Math.min(1, 2400 / Math.max(bmp.width, bmp.height));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(bmp.width * scale);
|
||||
canvas.height = Math.round(bmp.height * scale);
|
||||
canvas.getContext('2d').drawImage(bmp, 0, 0, canvas.width, canvas.height);
|
||||
const blob = await new Promise((r) => canvas.toBlob(r, 'image/webp', 0.85));
|
||||
return blob || file;
|
||||
} catch {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadImage(file) {
|
||||
const blob = await prepareImage(file);
|
||||
return (await api('POST', '/api/uploads', blob, { raw: true })).file;
|
||||
}
|
||||
|
||||
export const imgUrl = (name) => `/uploads/${encodeURIComponent(name)}`;
|
||||
|
||||
export const fmtDate = (iso) =>
|
||||
new Date(iso).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
export const personName = (person) => person?.name || 'Gelöschtes Konto';
|
||||
|
||||
export const STATUS = {
|
||||
planung: ['In Planung', 'warn'],
|
||||
aktiv: ['Aktiv', 'ok'],
|
||||
pausiert: ['Pausiert', ''],
|
||||
abgeschlossen: ['Abgeschlossen', 'muted'],
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
// Anmeldestatus: lädt beim Start, reagiert auf abgelaufene Sitzungen
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { api } from './api.js';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
// state: 'loading' | 'setup' | 'login' | 'ready'
|
||||
const [state, setState] = useState('loading');
|
||||
const [user, setUser] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const s = await api('GET', '/api/auth/status');
|
||||
setUser(s.user);
|
||||
setState(s.user ? 'ready' : s.setupRequired ? 'setup' : 'login');
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const onUnauthorized = () => { setUser(null); setState('login'); };
|
||||
window.addEventListener('p42:unauthorized', onUnauthorized);
|
||||
return () => window.removeEventListener('p42:unauthorized', onUnauthorized);
|
||||
}, [refresh]);
|
||||
|
||||
const signIn = useCallback(async (endpoint, data) => {
|
||||
const u = await api('POST', endpoint, data);
|
||||
setUser(u);
|
||||
setState('ready');
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await api('POST', '/api/auth/logout').catch(() => {});
|
||||
setUser(null);
|
||||
setState('login');
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
state,
|
||||
user,
|
||||
error,
|
||||
retry: refresh,
|
||||
login: (username, password) => signIn('/api/auth/login', { username, password }),
|
||||
setup: (data) => signIn('/api/auth/setup', data),
|
||||
logout,
|
||||
setUser,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { isCalm, onMotionChange } from '../motion.js';
|
||||
|
||||
// Hintergrund "Blackwall" nach Einzelbildern aus dem Referenzvideo (Cyberpunk 2077):
|
||||
// - Wand-Korridor: senkrechte Lichtlinien, die perspektivisch auf einen Fluchtpunkt am Horizont zulaufen
|
||||
// (am Rand hoch, zur Mitte flach); Daten fließen sichtbar an ihnen herab
|
||||
// - Boden: Feld aus Lichtpunkten in Perspektive, das langsam auf den Betrachter zufließt
|
||||
// - Horizont: gleißendes Band mit wanderndem Hotspot, davor eine zerfaserte Skyline aus Blöcken
|
||||
// - Zeichenregen nur an den Rändern, seltener Glitch (Zeilenversatz, kein Helligkeitsblitz)
|
||||
// Farben: Pink-Magenta mit vereinzelten violett-blauen Linien. Ohne Animation: ein Standbild.
|
||||
const FPS = 24;
|
||||
const GLITCH_EVERY = [4500, 9000];
|
||||
const GLITCH_FOR = 160;
|
||||
const GLYPHS = '0123456789ABCDEF#$%&*+<>/\\|';
|
||||
|
||||
const PINK = [255, 32, 92];
|
||||
const HOT = [255, 110, 160];
|
||||
const VIOLET = [140, 110, 255];
|
||||
|
||||
const hash = (n) => {
|
||||
const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
const rgba = ([r, g, b], a) => `rgba(${r},${g},${b},${Math.max(0, Math.min(1, a))})`;
|
||||
|
||||
export default function BlackwallField() {
|
||||
const ref = useRef(null);
|
||||
const [calm, setCalm] = useState(isCalm);
|
||||
|
||||
useEffect(() => onMotionChange(() => setCalm(isCalm())), []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = ref.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
let dpr = 1;
|
||||
let lines = [];
|
||||
let blocks = [];
|
||||
let columns = [];
|
||||
let frame = 0;
|
||||
let last = 0;
|
||||
let nextGlitch = performance.now() + 3500;
|
||||
let glitchUntil = 0;
|
||||
|
||||
function build() {
|
||||
const cx = w / 2;
|
||||
// Wandlinien: dicht (alle ~3 px), Höhe nach Perspektive
|
||||
lines = Array.from({ length: Math.round(w / 3) }, (_, i) => {
|
||||
const x = hash(i + 1) * w;
|
||||
const persp = Math.min(1, Math.abs(x - cx) / (w / 2)); // 0 Mitte … 1 Rand
|
||||
const tint = hash(i + 7);
|
||||
return {
|
||||
x,
|
||||
width: hash(i + 2) > 0.9 ? 2 : 1,
|
||||
up: h * (0.03 + 0.46 * persp ** 1.25) * (0.55 + 0.45 * hash(i + 3)), // über dem Horizont
|
||||
down: h * (0.01 + 0.16 * persp ** 1.4) * (0.4 + 0.6 * hash(i + 4)), // unter dem Horizont
|
||||
alpha: 0.08 + hash(i + 5) ** 2 * 0.55,
|
||||
flow: 30 + hash(i + 6) * 90, // px/s Datenfluss
|
||||
phase: hash(i + 8) * 10,
|
||||
color: tint > 0.92 ? VIOLET : tint > 0.78 ? HOT : PINK,
|
||||
};
|
||||
});
|
||||
// Zerfaserte Skyline am Horizont, zur Mitte gehäuft
|
||||
blocks = Array.from({ length: Math.round(w / 12) }, (_, i) => {
|
||||
const x = w * (0.5 + (hash(i + 101) - 0.5) * (0.4 + hash(i + 107) * 0.95));
|
||||
return {
|
||||
x,
|
||||
bw: 8 + hash(i + 103) ** 2 * w * 0.08,
|
||||
bh: 6 + hash(i + 104) ** 1.5 * h * 0.12,
|
||||
lift: hash(i + 102) * 0.85, // wie weit der Block über den Horizont ragt
|
||||
alpha: 0.05 + hash(i + 105) * 0.26,
|
||||
drift: (hash(i + 106) - 0.5) * 5,
|
||||
};
|
||||
});
|
||||
// Zeichenspalten nur an den Rändern (nur auf breiten Bildschirmen)
|
||||
columns = [];
|
||||
if (w > 700) {
|
||||
const step = 18;
|
||||
for (let x = 6; x < w; x += step) {
|
||||
if (x > w * 0.14 && x < w * 0.86) continue;
|
||||
const i = columns.length;
|
||||
if (hash(i + 301) < 0.45) continue;
|
||||
columns.push({ x, speed: 40 + hash(i + 302) * 90, offset: hash(i + 303) * h * 2, len: 8 + Math.floor(hash(i + 304) * 14), seed: i });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resize() {
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
w = window.innerWidth;
|
||||
h = window.innerHeight;
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
build();
|
||||
draw(performance.now());
|
||||
}
|
||||
|
||||
function draw(now) {
|
||||
const t = calm ? 7 : now / 1000;
|
||||
const cy = h * 0.52; // Horizont
|
||||
const cx = w * 0.5;
|
||||
const hotX = cx + Math.sin(t * 0.13) * w * 0.16; // wandernder Lichtfleck
|
||||
const breath = 0.86 + 0.1 * Math.sin(t * 0.7) + 0.04 * Math.sin(t * 1.9 + 1.3);
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.fillStyle = '#040002';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
|
||||
// 1) Dunst um den Horizont
|
||||
let g = ctx.createLinearGradient(0, cy - h * 0.4, 0, cy + h * 0.3);
|
||||
g.addColorStop(0, rgba(PINK, 0));
|
||||
g.addColorStop(0.58, rgba(PINK, 0.26 * breath));
|
||||
g.addColorStop(1, rgba(PINK, 0));
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, cy - h * 0.4, w, h * 0.7);
|
||||
|
||||
// 2) Boden: Lichtpunkte in Perspektive, fließen langsam heran
|
||||
const rows = 26;
|
||||
const flow = (t * 0.08) % 1;
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const d = (r + flow) / rows; // 0 Horizont … 1 vorne
|
||||
const y = cy + (h - cy) * d ** 2.1;
|
||||
const spacing = 5 + 46 * d ** 1.6;
|
||||
const size = 0.8 + 2.4 * d;
|
||||
const a = (0.06 + 0.4 * d) * breath;
|
||||
ctx.fillStyle = rgba(PINK, a);
|
||||
const startK = -Math.ceil(cx / spacing);
|
||||
for (let k = startK; k * spacing + cx < w; k++) {
|
||||
const x = cx + k * spacing + ((r % 2) * spacing) / 2;
|
||||
const twinkle = hash(k * 13.1 + r * 7.7);
|
||||
if (twinkle < 0.18) continue;
|
||||
ctx.fillRect(x, y, size, size);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Wandlinien mit herabfließenden Daten
|
||||
for (const l of lines) {
|
||||
const top = cy - l.up;
|
||||
const len = l.up + l.down;
|
||||
const shimmer = 0.7 + 0.3 * Math.sin(t * 1.3 + l.phase);
|
||||
ctx.fillStyle = rgba(l.color, l.alpha * 0.55 * shimmer);
|
||||
ctx.fillRect(l.x, top, l.width, len);
|
||||
// heller Datenpaket-Abschnitt, der nach unten wandert
|
||||
const seg = Math.max(6, len * 0.22);
|
||||
const pos = ((t * l.flow + l.phase * 100) % (len + seg)) - seg;
|
||||
ctx.fillStyle = rgba(l.color === PINK ? HOT : l.color, l.alpha * 1.4);
|
||||
ctx.fillRect(l.x, top + Math.max(0, pos), l.width, Math.min(seg, len - Math.max(0, pos)));
|
||||
}
|
||||
|
||||
// 4) Zerfaserte Skyline (Blöcke ragen aus dem Band, tropfen nach unten)
|
||||
for (const b of blocks) {
|
||||
const x = (b.x + t * b.drift + w) % (w + b.bw) - b.bw * 0.5;
|
||||
const dist = Math.min(1, Math.abs(x + b.bw / 2 - hotX) / (w * 0.5));
|
||||
const hot = (1 - dist) ** 2;
|
||||
const a = b.alpha * (0.4 + 1 * hot) * breath;
|
||||
const y = cy - b.bh * b.lift;
|
||||
const bg = ctx.createLinearGradient(0, y, 0, y + b.bh);
|
||||
bg.addColorStop(0, rgba(PINK, a * 0.25));
|
||||
bg.addColorStop(0.6, rgba([255, 60 + 140 * hot, 120 + 100 * hot], a));
|
||||
bg.addColorStop(1, rgba(PINK, a * 0.1));
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(x, y, b.bw, b.bh);
|
||||
}
|
||||
|
||||
// 5) Lichtband: Bloom, Band, gleißender Kern; Hotspot wandert
|
||||
ctx.save();
|
||||
ctx.translate(hotX, cy);
|
||||
ctx.scale(1, 0.15);
|
||||
let rg = ctx.createRadialGradient(0, 0, 0, 0, 0, w * 0.5);
|
||||
rg.addColorStop(0, rgba(HOT, 0.6 * breath));
|
||||
rg.addColorStop(0.45, rgba(PINK, 0.22 * breath));
|
||||
rg.addColorStop(1, rgba(PINK, 0));
|
||||
ctx.fillStyle = rg;
|
||||
ctx.fillRect(-w * 1.5, -h * 4, w * 3, h * 8);
|
||||
ctx.restore();
|
||||
|
||||
g = ctx.createLinearGradient(0, cy - 10, 0, cy + 10);
|
||||
g.addColorStop(0, rgba(PINK, 0));
|
||||
g.addColorStop(0.5, rgba(PINK, 0.55 * breath));
|
||||
g.addColorStop(1, rgba(PINK, 0));
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, cy - 10, w, 20);
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(hotX, cy);
|
||||
ctx.scale(1, 0.006);
|
||||
rg = ctx.createRadialGradient(0, 0, 0, 0, 0, w * 0.55);
|
||||
rg.addColorStop(0, rgba([255, 245, 250], 1 * breath));
|
||||
rg.addColorStop(0.4, rgba(HOT, 0.8 * breath));
|
||||
rg.addColorStop(1, rgba(PINK, 0.15 * breath));
|
||||
ctx.fillStyle = rg;
|
||||
ctx.fillRect(-w * 1.5, -h * 100, w * 3, h * 200);
|
||||
ctx.restore();
|
||||
|
||||
// 6) Zeichenregen an den Rändern
|
||||
if (columns.length) {
|
||||
ctx.font = '13px ui-monospace, monospace';
|
||||
ctx.textBaseline = 'top';
|
||||
for (const c of columns) {
|
||||
const head = ((c.offset + t * c.speed) % (h + c.len * 16)) - c.len * 16;
|
||||
for (let k = 0; k < c.len; k++) {
|
||||
const y = head - k * 16;
|
||||
if (y < -16 || y > h) continue;
|
||||
const ch = GLYPHS[Math.floor(hash(c.seed * 31 + k * 7 + Math.floor(t * 3) * (k === 0 ? 1 : 0)) * GLYPHS.length)];
|
||||
ctx.fillStyle = k === 0 ? 'rgba(255,200,220,0.55)' : rgba(PINK, 0.32 * (1 - k / c.len));
|
||||
ctx.fillText(ch, c.x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7) Oben und unten ins Schwarz (dort stehen Titel und Fußzeile)
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
g = ctx.createLinearGradient(0, 0, 0, h);
|
||||
g.addColorStop(0, 'rgba(4,0,2,0.95)');
|
||||
g.addColorStop(0.22, 'rgba(4,0,2,0.5)');
|
||||
g.addColorStop(0.42, 'rgba(4,0,2,0.04)');
|
||||
g.addColorStop(0.62, 'rgba(4,0,2,0.04)');
|
||||
g.addColorStop(0.85, 'rgba(4,0,2,0.5)');
|
||||
g.addColorStop(1, 'rgba(4,0,2,0.95)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// 8) Glitch: Streifen seitlich versetzt
|
||||
if (!calm && now < glitchUntil) {
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
for (let b = 0; b < 5; b++) {
|
||||
const sy = Math.floor((0.25 + hash(now * 0.0007 + b) * 0.5) * canvas.height);
|
||||
const sh = Math.floor((4 + hash(b + now * 0.0013) * 30) * dpr);
|
||||
const shift = Math.floor((hash(b * 3 + now * 0.0021) - 0.5) * 120 * dpr);
|
||||
ctx.drawImage(canvas, 0, sy, canvas.width, sh, shift, sy, canvas.width, sh);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loop(now) {
|
||||
frame = requestAnimationFrame(loop);
|
||||
if (document.hidden || now - last < 1000 / FPS) return;
|
||||
last = now;
|
||||
if (now > nextGlitch) {
|
||||
glitchUntil = now + GLITCH_FOR;
|
||||
nextGlitch = now + GLITCH_EVERY[0] + Math.random() * (GLITCH_EVERY[1] - GLITCH_EVERY[0]);
|
||||
}
|
||||
draw(now);
|
||||
}
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
if (!calm) frame = requestAnimationFrame(loop);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [calm]);
|
||||
|
||||
return <canvas ref={ref} className="theme-bg blackwall-field" aria-hidden="true" />;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useImperativeHandle, useRef, useState } from 'react';
|
||||
import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import { Placeholder } from '@tiptap/extensions';
|
||||
import { imgUrl, uploadImage } from '../api.js';
|
||||
|
||||
const imageFiles = (list) => [...(list || [])].filter((f) => f.type.startsWith('image/'));
|
||||
|
||||
/**
|
||||
* Artikel-Editor. Über `ref` lässt sich der aktuelle Inhalt mit `ref.current.getHTML()` abrufen.
|
||||
* onChange: bei jeder Änderung, onUploading(bool): während Bilder hochgeladen werden.
|
||||
*/
|
||||
export default function Editor({ ref, content, onChange, onUploading, onError }) {
|
||||
const [uploading, setUploading] = useState(0);
|
||||
const fileInput = useRef(null);
|
||||
const editorRef = useRef(null);
|
||||
const callbacks = useRef({});
|
||||
callbacks.current = { onChange, onUploading, onError };
|
||||
|
||||
// Bilder hochladen und nacheinander an `pos` (oder an der Cursorposition) einfügen
|
||||
async function insertImages(files, pos) {
|
||||
const images = imageFiles(files);
|
||||
if (!images.length) return;
|
||||
setUploading(images.length);
|
||||
callbacks.current.onUploading?.(true);
|
||||
try {
|
||||
for (const file of images) {
|
||||
const src = imgUrl(await uploadImage(file));
|
||||
const chain = editorRef.current.chain().focus();
|
||||
(pos == null ? chain.setImage({ src }) : chain.insertContentAt(pos, { type: 'image', attrs: { src } })).run();
|
||||
pos = null;
|
||||
setUploading((n) => n - 1);
|
||||
}
|
||||
} catch (err) {
|
||||
callbacks.current.onError?.(err.message);
|
||||
} finally {
|
||||
setUploading(0);
|
||||
callbacks.current.onUploading?.(false);
|
||||
}
|
||||
}
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [2, 3] },
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
link: { openOnClick: false, autolink: true, defaultProtocol: 'https' },
|
||||
}),
|
||||
Image,
|
||||
Placeholder.configure({ placeholder: 'Hier den Artikel schreiben … Bilder per Button, Einfügen (Strg+V) oder Drag & Drop.' }),
|
||||
],
|
||||
content,
|
||||
editorProps: {
|
||||
attributes: { class: 'article-body editor-content', 'aria-label': 'Artikeltext' },
|
||||
handlePaste(view, event) {
|
||||
const files = imageFiles(event.clipboardData?.files);
|
||||
if (!files.length) return false;
|
||||
insertImages(files);
|
||||
return true;
|
||||
},
|
||||
handleDrop(view, event, slice, moved) {
|
||||
const files = imageFiles(event.dataTransfer?.files);
|
||||
if (moved || !files.length) return false;
|
||||
event.preventDefault();
|
||||
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos;
|
||||
insertImages(files, pos);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
onUpdate: () => callbacks.current.onChange?.(),
|
||||
});
|
||||
|
||||
editorRef.current = editor;
|
||||
|
||||
useImperativeHandle(ref, () => ({ getHTML: () => editor.getHTML() }), [editor]);
|
||||
|
||||
return (
|
||||
<div className="editor">
|
||||
<Toolbar editor={editor} onImage={() => fileInput.current.click()} uploading={uploading} />
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => { insertImages(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toolbar({ editor, onImage, uploading }) {
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: e }) => ({
|
||||
paragraph: e.isActive('paragraph'),
|
||||
h2: e.isActive('heading', { level: 2 }),
|
||||
h3: e.isActive('heading', { level: 3 }),
|
||||
bold: e.isActive('bold'),
|
||||
italic: e.isActive('italic'),
|
||||
underline: e.isActive('underline'),
|
||||
bulletList: e.isActive('bulletList'),
|
||||
orderedList: e.isActive('orderedList'),
|
||||
blockquote: e.isActive('blockquote'),
|
||||
link: e.isActive('link'),
|
||||
canUndo: e.can().undo(),
|
||||
canRedo: e.can().redo(),
|
||||
}),
|
||||
});
|
||||
|
||||
const run = (fn) => () => fn(editor.chain().focus()).run();
|
||||
|
||||
function toggleLink() {
|
||||
if (state.link) return editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
if (editor.state.selection.empty) return window.alert('Zuerst den Text markieren, der verlinkt werden soll.');
|
||||
const url = window.prompt('Link-Adresse:', 'https://');
|
||||
if (url && url !== 'https://') editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
}
|
||||
|
||||
const tools = [
|
||||
{ label: 'Text', title: 'Normaler Absatz', active: state.paragraph, onClick: run((c) => c.setParagraph()) },
|
||||
{ label: 'Überschrift', title: 'Überschrift', active: state.h2, onClick: run((c) => c.toggleHeading({ level: 2 })) },
|
||||
{ label: 'Zwischentitel', title: 'Zwischenüberschrift', active: state.h3, onClick: run((c) => c.toggleHeading({ level: 3 })) },
|
||||
null,
|
||||
{ label: <b>B</b>, title: 'Fett (Strg+B)', active: state.bold, onClick: run((c) => c.toggleBold()) },
|
||||
{ label: <i>I</i>, title: 'Kursiv (Strg+I)', active: state.italic, onClick: run((c) => c.toggleItalic()) },
|
||||
{ label: <u>U</u>, title: 'Unterstrichen (Strg+U)', active: state.underline, onClick: run((c) => c.toggleUnderline()) },
|
||||
null,
|
||||
{ label: 'Liste', title: 'Aufzählung', active: state.bulletList, onClick: run((c) => c.toggleBulletList()) },
|
||||
{ label: 'Nummeriert', title: 'Nummerierte Liste', active: state.orderedList, onClick: run((c) => c.toggleOrderedList()) },
|
||||
{ label: 'Zitat', title: 'Zitat', active: state.blockquote, onClick: run((c) => c.toggleBlockquote()) },
|
||||
null,
|
||||
{ label: 'Link', title: state.link ? 'Link entfernen' : 'Link einfügen', active: state.link, onClick: toggleLink },
|
||||
{ label: uploading ? `Lade ${uploading} …` : 'Bild', title: 'Bild einfügen', onClick: onImage, disabled: uploading > 0 },
|
||||
{ label: 'Linie', title: 'Trennlinie', onClick: run((c) => c.setHorizontalRule()) },
|
||||
null,
|
||||
{ label: '↶', title: 'Rückgängig (Strg+Z)', onClick: run((c) => c.undo()), disabled: !state.canUndo },
|
||||
{ label: '↷', title: 'Wiederholen (Strg+Y)', onClick: run((c) => c.redo()), disabled: !state.canRedo },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="toolbar" role="toolbar" aria-label="Formatierung">
|
||||
{tools.map((t, i) =>
|
||||
t ? (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
title={t.title}
|
||||
aria-label={t.title}
|
||||
className={t.active ? 'active' : undefined}
|
||||
disabled={t.disabled}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={t.onClick}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
) : (
|
||||
<span key={i} className="toolbar-sep" />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* Kanban-Projekt anlegen/bearbeiten: Name, Beschreibung und verknüpfte Artikel aus "P42 Projekte".
|
||||
* initial: { name, description, links: [articleId] } oder null (Dialog geschlossen).
|
||||
*/
|
||||
export default function KanbanProjectDialog({ ref, initial, isNew, articles, onSave }) {
|
||||
// Wird pro Öffnen neu erzeugt (key beim Aufrufer), daher direkt aus "initial" vorbelegen
|
||||
const [form, setForm] = useState(() => ({
|
||||
name: initial?.name || '',
|
||||
description: initial?.description || '',
|
||||
links: initial?.links || [],
|
||||
}));
|
||||
const [search, setSearch] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) });
|
||||
const toggle = (id) => setForm((f) => ({ ...f, links: f.links.includes(id) ? f.links.filter((x) => x !== id) : [...f.links, id] }));
|
||||
const q = search.trim().toLocaleLowerCase('de-DE');
|
||||
// Verknüpfte zuerst, dann nach Suche gefiltert
|
||||
const shown = [...articles]
|
||||
.filter((a) => !q || a.title.toLocaleLowerCase('de-DE').includes(q))
|
||||
.sort((a, b) => Number(form.links.includes(b.id)) - Number(form.links.includes(a.id)));
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const err = await onSave({
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
links: form.links,
|
||||
});
|
||||
setBusy(false);
|
||||
if (err) setError(err);
|
||||
else ref.current.close();
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog className="modal task-dialog" ref={ref}>
|
||||
<form className="form" onSubmit={submit}>
|
||||
<h2>{isNew ? 'Neues Kanban-Projekt' : 'Projekt bearbeiten'}</h2>
|
||||
<label>
|
||||
Name
|
||||
<input {...field('name')} maxLength={80} required autoFocus />
|
||||
</label>
|
||||
<label>
|
||||
Beschreibung <small>(optional)</small>
|
||||
<textarea {...field('description')} rows={3} maxLength={5000} placeholder="Worum geht es in diesem Board?" />
|
||||
</label>
|
||||
<fieldset className="link-picker">
|
||||
<legend>Verknüpfte Artikel aus „P42 Projekte“ <small>({form.links.length} gewählt)</small></legend>
|
||||
{articles.length === 0 ? (
|
||||
<p className="hint">Noch keine Artikel vorhanden.</p>
|
||||
) : (
|
||||
<>
|
||||
{articles.length > 6 && (
|
||||
<input type="search" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Artikel suchen …" aria-label="Artikel suchen" />
|
||||
)}
|
||||
<ul>
|
||||
{shown.map((a) => (
|
||||
<li key={a.id}>
|
||||
<label className="checkbox">
|
||||
<input type="checkbox" checked={form.links.includes(a.id)} onChange={() => toggle(a.id)} />
|
||||
{a.title}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
{shown.length === 0 && <li className="hint">Kein Artikel passt zur Suche.</li>}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
|
||||
<button className="btn" type="submit" disabled={busy}>{isNew ? 'Anlegen' : 'Speichern'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../auth.jsx';
|
||||
import { LINK_SERVICES, useLinks } from '../links.jsx';
|
||||
import { isCalm, motionLocked, onMotionChange, setCalm } from '../motion.js';
|
||||
import { getTheme, onThemeChange, setTheme, THEMES } from '../theme.js';
|
||||
|
||||
const PRIMARY = [
|
||||
{ to: '/', label: 'Start', end: true },
|
||||
{ to: '/projekte', label: 'Projekte' },
|
||||
{ to: '/haushalt', label: 'Vorrat' },
|
||||
{ to: '/reisen', label: 'Reisen' },
|
||||
{ to: '/kanban', label: 'Kanban' },
|
||||
{ to: '/monitoring', label: 'Monitoring' },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<>
|
||||
<header className="topbar">
|
||||
<Link className="brand" to="/">P42<span>.</span>Dashboard</Link>
|
||||
<nav className="topnav" aria-label="Hauptnavigation">
|
||||
{PRIMARY.map(({ to, label, end }) => <NavLink key={to} to={to} end={end}>{label}</NavLink>)}
|
||||
</nav>
|
||||
<Menu />
|
||||
</header>
|
||||
<main className="page">
|
||||
<Outlet />
|
||||
</main>
|
||||
<footer className="footer">P42 · F.W.</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Menu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
const location = useLocation();
|
||||
const { links } = useLinks();
|
||||
const { user, logout } = useAuth();
|
||||
const external = LINK_SERVICES.filter(({ key }) => links[key]);
|
||||
const [calm, setCalmState] = useState(isCalm);
|
||||
const [theme, setThemeState] = useState(getTheme);
|
||||
|
||||
useEffect(() => onMotionChange(() => setCalmState(isCalm())), []);
|
||||
useEffect(() => onThemeChange(() => setThemeState(getTheme())), []);
|
||||
|
||||
// Beim Seitenwechsel schließen
|
||||
useEffect(() => setOpen(false), [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e) => !ref.current?.contains(e.target) && setOpen(false);
|
||||
const onKey = (e) => e.key === 'Escape' && setOpen(false);
|
||||
document.addEventListener('click', onClick);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('click', onClick);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="menu" ref={ref}>
|
||||
<button
|
||||
className="menu-btn"
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls="main-menu"
|
||||
aria-label="Menü"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
{open ? <path d="M6 6l12 12M18 6L6 18" /> : <path d="M4 7h16M4 12h16M4 17h16" />}
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<nav className="menu-panel" id="main-menu" aria-label="Menü">
|
||||
<div className="menu-primary">
|
||||
{PRIMARY.map(({ to, label, end }) => <NavLink key={to} to={to} end={end}>{label}</NavLink>)}
|
||||
<hr />
|
||||
</div>
|
||||
{external.map(({ key, name }) => (
|
||||
<a key={key} href={links[key]} target="_blank" rel="noopener noreferrer">{name} ↗</a>
|
||||
))}
|
||||
{external.length > 0 && <hr />}
|
||||
<div className="menu-label" id="design-label">Design</div>
|
||||
<div className="theme-picker" role="radiogroup" aria-labelledby="design-label">
|
||||
{THEMES.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={theme === t.key}
|
||||
className="menu-item theme-option"
|
||||
onClick={() => setTheme(t.key)}
|
||||
>
|
||||
<span className="swatch" aria-hidden="true">
|
||||
{t.swatch.map((c) => <i key={c} style={{ background: c }} />)}
|
||||
</span>
|
||||
<span className="theme-name">{t.name}<small>{t.hint}</small></span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<hr />
|
||||
{!motionLocked() && (
|
||||
<button type="button" className="menu-item" aria-pressed={!calm} onClick={() => setCalm(!calm)}>
|
||||
Animation: {calm ? 'aus' : 'an'}
|
||||
</button>
|
||||
)}
|
||||
<hr />
|
||||
<div className="menu-user">
|
||||
Angemeldet als <strong>{user.displayName}</strong>
|
||||
</div>
|
||||
<NavLink to="/konto">Mein Konto</NavLink>
|
||||
{user.isAdmin && <NavLink to="/benutzer">Benutzer verwalten</NavLink>}
|
||||
<button type="button" className="menu-item" onClick={logout}>Abmelden</button>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
// Vergrößerte Bildansicht; schließt per Klick oder Esc
|
||||
export default function Lightbox({ src, onClose }) {
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
if (src && !dialog.open) dialog.showModal();
|
||||
if (!src && dialog.open) dialog.close();
|
||||
}, [src]);
|
||||
|
||||
return (
|
||||
<dialog className="lightbox" ref={ref} onClick={onClose} onClose={onClose}>
|
||||
{src && <img src={src} alt="" />}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LINK_SERVICES, useLinks } from '../links.jsx';
|
||||
|
||||
// Dialog zum Anpassen der Links hinter den Schnellzugriff-Kacheln
|
||||
export default function LinksDialog({ ref }) {
|
||||
const { links, saveLinks } = useLinks();
|
||||
const [values, setValues] = useState({});
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Gespeicherte Werte übernehmen – beim Laden und nach dem Schließen (verwirft Abgebrochenes)
|
||||
const reset = () => { setValues(links); setError(null); };
|
||||
useEffect(reset, [links]);
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await saveLinks(Object.fromEntries(LINK_SERVICES.map(({ key }) => [key, values[key] || ''])));
|
||||
ref.current.close();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog className="modal" ref={ref} onClose={reset}>
|
||||
<form className="form" onSubmit={submit}>
|
||||
<h2>Links bearbeiten</h2>
|
||||
{LINK_SERVICES.map(({ key, name, placeholder }) => (
|
||||
<label key={key}>
|
||||
{name}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="url"
|
||||
value={values[key] || ''}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [key]: e.target.value }))}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<p className="hint">Leer lassen, wenn es den Dienst (noch) nicht gibt. „http://“ wird bei Bedarf ergänzt.</p>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
|
||||
<button className="btn" type="submit" disabled={busy}>{busy ? 'Speichere …' : 'Speichern'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { isCalm, onMotionChange } from '../motion.js';
|
||||
|
||||
// Dezenter "digitaler Regen" im Hintergrund – Farbe kommt aus dem Stylesheet (--accent-rgb, --bright).
|
||||
// Pausiert in inaktiven Tabs, entfällt bei "Bewegung reduzieren" und lässt sich im Menü abschalten.
|
||||
const CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789P42';
|
||||
const FONT_SIZE = 16;
|
||||
const FPS = 18;
|
||||
|
||||
export default function MatrixRain() {
|
||||
const ref = useRef(null);
|
||||
const [calm, setCalmState] = useState(isCalm);
|
||||
|
||||
useEffect(() => onMotionChange(() => setCalmState(isCalm())), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (calm) return undefined;
|
||||
const canvas = ref.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
const color = `rgb(${css.getPropertyValue('--accent-rgb').trim()})`;
|
||||
const highlight = css.getPropertyValue('--bright').trim();
|
||||
let drops = [];
|
||||
let frame = 0;
|
||||
let last = 0;
|
||||
|
||||
function resize() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
const columns = Math.ceil(canvas.width / FONT_SIZE);
|
||||
drops = Array.from({ length: columns }, () => Math.floor((Math.random() * canvas.height) / FONT_SIZE));
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
function draw(time) {
|
||||
frame = requestAnimationFrame(draw);
|
||||
if (document.hidden || time - last < 1000 / FPS) return;
|
||||
last = time;
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = `${FONT_SIZE}px monospace`;
|
||||
drops.forEach((y, i) => {
|
||||
const char = CHARS[Math.floor(Math.random() * CHARS.length)];
|
||||
// Vorderste Zeichen heller
|
||||
ctx.fillStyle = Math.random() > 0.96 ? highlight : color;
|
||||
ctx.fillText(char, i * FONT_SIZE, y * FONT_SIZE);
|
||||
drops[i] = y * FONT_SIZE > canvas.height && Math.random() > 0.975 ? 0 : y + 1;
|
||||
});
|
||||
}
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
frame = requestAnimationFrame(draw);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', resize);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
};
|
||||
}, [calm]);
|
||||
|
||||
return <canvas ref={ref} className="matrix-rain" aria-hidden="true" />;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
|
||||
const MAX_SUGGESTIONS = 8;
|
||||
const fold = (s) => s.toLocaleLowerCase('de-DE');
|
||||
|
||||
/**
|
||||
* Eingabefeld mit Vorschlägen aus dem Produktkatalog, damit Namen einheitlich bleiben.
|
||||
* products: bekannte Namen (häufig genutzte zuerst), hint(name): Zusatzinfo je Vorschlag.
|
||||
*/
|
||||
export default function ProductInput({ value, onChange, products, hint, inputRef, ...props }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(-1);
|
||||
const listId = useId();
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const q = fold(value.trim());
|
||||
if (!q) return products.slice(0, MAX_SUGGESTIONS);
|
||||
// Treffer am Wortanfang zuerst, dann irgendwo im Namen
|
||||
const starts = [];
|
||||
const contains = [];
|
||||
for (const name of products) {
|
||||
const n = fold(name);
|
||||
if (n === q) continue;
|
||||
if (n.startsWith(q) || n.includes(` ${q}`)) starts.push(name);
|
||||
else if (n.includes(q)) contains.push(name);
|
||||
}
|
||||
return [...starts, ...contains].slice(0, MAX_SUGGESTIONS);
|
||||
}, [value, products]);
|
||||
|
||||
const show = open && suggestions.length > 0;
|
||||
|
||||
function pick(name) {
|
||||
onChange(name);
|
||||
setOpen(false);
|
||||
setActive(-1);
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
setActive((i) => Math.min(i + 1, suggestions.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActive((i) => Math.max(i - 1, -1));
|
||||
} else if (e.key === 'Enter' && show && active >= 0) {
|
||||
e.preventDefault();
|
||||
pick(suggestions[active]);
|
||||
} else if (e.key === 'Escape' && show) {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="combo">
|
||||
<input
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => { onChange(e.target.value); setOpen(true); setActive(-1); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
onKeyDown={onKeyDown}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={show}
|
||||
aria-controls={listId}
|
||||
aria-activedescendant={show && active >= 0 ? `${listId}-${active}` : undefined}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{show && (
|
||||
<ul className="combo-list" id={listId} role="listbox">
|
||||
{suggestions.map((name, i) => (
|
||||
<li
|
||||
key={name}
|
||||
id={`${listId}-${i}`}
|
||||
role="option"
|
||||
aria-selected={i === active}
|
||||
className={i === active ? 'active' : undefined}
|
||||
// mousedown statt click: sonst schließt onBlur die Liste vorher
|
||||
onMouseDown={(e) => { e.preventDefault(); pick(name); }}
|
||||
>
|
||||
<span>{name}</span>
|
||||
{hint?.(name) && <small>{hint(name)}</small>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { ceil2, fmtItems, fmtQty, parseQty } from '../units.js';
|
||||
import ProductInput from './ProductInput.jsx';
|
||||
import UnitSelect from './UnitSelect.jsx';
|
||||
|
||||
const PEOPLE_KEY = 'p42.rezeptPersonen';
|
||||
const UNIT_LABELS = { el: 'EL', tl: 'TL', pck: 'Pck.', msp: 'Msp.', stk: 'Stk.', liter: 'l', gr: 'g' };
|
||||
|
||||
const unitLabel = (u) => UNIT_LABELS[u] || (u.length > 2 ? u[0].toUpperCase() + u.slice(1) : u);
|
||||
// Rezeptmengen, die sich in kg bzw. l umrechnen lassen
|
||||
const CONVERT = {
|
||||
g: ['kg', 0.001], gr: ['kg', 0.001], gramm: ['kg', 0.001], mg: ['kg', 0.000001], kg: ['kg', 1],
|
||||
ml: ['l', 0.001], cl: ['l', 0.01], dl: ['l', 0.1], l: ['l', 1], liter: ['l', 1],
|
||||
};
|
||||
const fmt = (n) => (n >= 10 ? Math.round(n) : Math.round(n * 10) / 10).toLocaleString('de-DE');
|
||||
|
||||
function loadPeople() {
|
||||
try { return Number(localStorage.getItem(PEOPLE_KEY)) || null; } catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rezept abgleichen: Zutaten laden, auf Personenzahl umrechnen, mit dem Vorrat vergleichen
|
||||
* und Fehlendes auf die Einkaufsliste setzen. find(list, name) sucht in den aktuellen Listen.
|
||||
*/
|
||||
export default function RecipeCheck({ products, find, onTakeOver, busy }) {
|
||||
const [mode, setMode] = useState('url'); // url | text
|
||||
const [url, setUrl] = useState('');
|
||||
const [text, setText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [recipe, setRecipe] = useState(null);
|
||||
|
||||
async function load(e) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api('POST', '/api/haushalt/rezept', mode === 'url' ? { url } : { text });
|
||||
// Ohne Portionsangabe gilt die Liste für die gewählte Personenzahl, Mengen bleiben wie eingegeben
|
||||
const people = loadPeople() || data.servings || 4;
|
||||
setRecipe({
|
||||
...data,
|
||||
base: String(data.servings || people),
|
||||
people: String(people),
|
||||
rows: data.ingredients.map((i) => ({ ...i, matched: i.product, take: null, qty: null })),
|
||||
done: null,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!recipe) {
|
||||
return (
|
||||
<section className="card recipe" aria-labelledby="recipe-title">
|
||||
<header className="household-head">
|
||||
<h2 id="recipe-title">Rezept abgleichen</h2>
|
||||
<button className="link-btn" type="button" onClick={() => { setMode(mode === 'url' ? 'text' : 'url'); setError(null); }}>
|
||||
{mode === 'url' ? 'Zutaten selbst einfügen' : 'Rezept-Link verwenden'}
|
||||
</button>
|
||||
</header>
|
||||
<form className="recipe-load" onSubmit={load}>
|
||||
{mode === 'url' ? (
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="Rezept-Link einfügen, z. B. von chefkoch.de"
|
||||
aria-label="Rezept-Link"
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={5}
|
||||
placeholder={'Eine Zutat pro Zeile, z. B.\n300 g Mehl\n2 Eier'}
|
||||
aria-label="Zutatenliste"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<button className="btn" type="submit" disabled={loading}>{loading ? 'Lade …' : 'Zutaten prüfen'}</button>
|
||||
</form>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const base = Number(recipe.base) || 0;
|
||||
const people = Number(recipe.people) || 0;
|
||||
const factor = base > 0 && people > 0 ? people / base : 1;
|
||||
|
||||
// Status je Zutat aus aktuellem Vorrat und gewählter Personenzahl
|
||||
const rows = recipe.rows.map((row) => {
|
||||
const scaled = row.amount != null ? row.amount * factor : null;
|
||||
const stock = find('vorrat', row.product);
|
||||
const listed = find('einkauf', row.product);
|
||||
const convert = row.unit && CONVERT[row.unit];
|
||||
// Vergleichbar sind Gramm/ml mit kg/l im Vorrat und Stückzahlen mit "Anzahl"
|
||||
const measure = scaled == null ? null
|
||||
: convert ? { unit: convert[0], need: scaled * convert[1] }
|
||||
: !row.unit ? { unit: 'stk', need: Math.ceil(scaled - 1e-9) }
|
||||
: null;
|
||||
const same = measure && stock.find((s) => s.unit === measure.unit);
|
||||
let status;
|
||||
let missing;
|
||||
if (same) {
|
||||
missing = Math.max(0, measure.need - same.quantity);
|
||||
status = missing <= 1e-9 ? 'ok' : 'part';
|
||||
} else if (stock.length) {
|
||||
// Vorhanden, aber in anderer Einheit (z. B. 1 Packung Mehl): nur "vorhanden" prüfbar
|
||||
missing = 0;
|
||||
status = 'ok';
|
||||
} else {
|
||||
missing = measure ? measure.need : 1;
|
||||
status = 'missing';
|
||||
}
|
||||
const buyUnit = measure?.unit || 'stk';
|
||||
// Vorhandenes trotzdem ankreuzen: dann die volle benötigte Menge vorschlagen
|
||||
const amount = missing > 1e-9 ? missing : measure ? measure.need : 1;
|
||||
const autoQty = buyUnit === 'stk' ? Math.max(1, Math.ceil(amount)) : ceil2(amount);
|
||||
const autoTake = status !== 'ok' && !listed.length && !row.optional && !row.staple;
|
||||
return {
|
||||
...row,
|
||||
scaled,
|
||||
stock,
|
||||
listed,
|
||||
status,
|
||||
missing,
|
||||
unitBuy: row.unitBuy ?? buyUnit,
|
||||
take: row.take ?? autoTake,
|
||||
qty: row.qty ?? String(autoQty).replace('.', ','),
|
||||
};
|
||||
});
|
||||
const chosen = rows.filter((r) => r.take && r.product.trim());
|
||||
|
||||
const update = (index, patch) =>
|
||||
setRecipe((r) => ({ ...r, done: null, invalid: null, rows: r.rows.map((row, i) => (i === index ? { ...row, ...patch } : row)) }));
|
||||
|
||||
function setPeople(value) {
|
||||
setRecipe((r) => ({ ...r, people: value, done: null, rows: r.rows.map((row) => ({ ...row, qty: null })) }));
|
||||
try { if (Number(value) > 0) localStorage.setItem(PEOPLE_KEY, value); } catch { /* ignorieren */ }
|
||||
}
|
||||
|
||||
async function takeOver() {
|
||||
const items = chosen.map((r) => ({ name: r.product.trim(), quantity: parseQty(r.qty, r.unitBuy), unit: r.unitBuy }));
|
||||
const invalid = chosen.find((r, i) => items[i].quantity == null);
|
||||
if (invalid) {
|
||||
setRecipe((r) => ({ ...r, invalid: invalid.raw }));
|
||||
return;
|
||||
}
|
||||
// Von Hand geänderte Zuordnungen merken, damit sie beim nächsten Rezept passen
|
||||
const aliases = rows
|
||||
.filter((r) => r.product.trim() && r.product.trim() !== r.matched)
|
||||
.map((r) => ({ ingredient: r.name, product: r.product.trim() }));
|
||||
if (await onTakeOver({ items, aliases })) {
|
||||
setRecipe((r) => ({
|
||||
...r,
|
||||
done: items.length,
|
||||
invalid: null,
|
||||
rows: r.rows.map((row) => ({ ...row, matched: row.product.trim() || row.matched, take: null, qty: null, unitBuy: null })),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS = {
|
||||
ok: (r) => [`Vorhanden${r.stock.length ? ` (${fmtItems(r.stock)})` : ''}`, 'ok'],
|
||||
part: (r) => [`Fehlen ${fmtQty(r.unitBuy === 'stk' ? Math.ceil(r.missing) : ceil2(r.missing), r.unitBuy)}`, 'warn'],
|
||||
missing: () => ['Fehlt', 'bad'],
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="card recipe" aria-labelledby="recipe-title">
|
||||
<header className="household-head">
|
||||
<h2 id="recipe-title">
|
||||
{recipe.source
|
||||
? <a href={recipe.source} target="_blank" rel="noopener noreferrer">{recipe.title} ↗</a>
|
||||
: recipe.title}
|
||||
</h2>
|
||||
<button className="link-btn" type="button" onClick={() => setRecipe(null)}>✕ Abgleich schließen</button>
|
||||
</header>
|
||||
|
||||
<div className="recipe-people">
|
||||
<label>
|
||||
Rezept ist für
|
||||
<input type="number" inputMode="numeric" min={1} max={100} value={recipe.base}
|
||||
onChange={(e) => setRecipe((r) => ({ ...r, base: e.target.value, done: null, rows: r.rows.map((row) => ({ ...row, qty: null })) }))} />
|
||||
Portionen
|
||||
</label>
|
||||
<label className="recipe-people-main">
|
||||
Ich koche für
|
||||
<input type="number" inputMode="numeric" min={1} max={100} value={recipe.people} onChange={(e) => setPeople(e.target.value)} />
|
||||
Personen
|
||||
</label>
|
||||
{!recipe.servings && <span className="hint">Das Rezept nennt keine Portionen, bitte prüfen.</span>}
|
||||
</div>
|
||||
|
||||
<ul className="recipe-rows">
|
||||
{rows.map((row, i) => {
|
||||
// Fehlt, steht aber schon auf der Einkaufsliste: nicht rot warnen
|
||||
const [label, tone] = row.status !== 'ok' && row.listed.length
|
||||
? [`Auf Liste (${fmtItems(row.listed)})`, '']
|
||||
: STATUS[row.status](row);
|
||||
return (
|
||||
<li key={row.raw} className={`recipe-row${row.take ? ' chosen' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="household-check recipe-take"
|
||||
checked={row.take}
|
||||
onChange={(e) => update(i, { take: e.target.checked })}
|
||||
aria-label={`${row.product} auf die Einkaufsliste`}
|
||||
/>
|
||||
<div className="recipe-ingredient">
|
||||
<strong>
|
||||
{row.scaled != null && `${fmt(row.scaled)} `}
|
||||
{row.unit && `${unitLabel(row.unit)} `}
|
||||
{row.name}
|
||||
</strong>
|
||||
<small>
|
||||
{row.raw}
|
||||
{row.optional && ' · optional'}
|
||||
{row.listed.length > 0 && ` · steht auf Einkaufsliste (${fmtItems(row.listed)})`}
|
||||
</small>
|
||||
</div>
|
||||
<div className="recipe-product">
|
||||
<ProductInput
|
||||
value={row.product}
|
||||
onChange={(v) => update(i, { product: v, take: null, qty: null, unitBuy: null })}
|
||||
products={products}
|
||||
aria-label={`Produkt für ${row.name}`}
|
||||
maxLength={60}
|
||||
/>
|
||||
</div>
|
||||
<span className={`badge ${tone} recipe-status`}>{label}</span>
|
||||
<div className="recipe-buy qty-unit">
|
||||
<input
|
||||
className="recipe-qty"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={row.qty}
|
||||
disabled={!row.take}
|
||||
onChange={(e) => update(i, { qty: e.target.value })}
|
||||
aria-invalid={(row.take && parseQty(row.qty, row.unitBuy) == null) || undefined}
|
||||
aria-label={`Menge ${row.product} zum Einkaufen`}
|
||||
title="Menge für die Einkaufsliste"
|
||||
/>
|
||||
<UnitSelect
|
||||
value={row.unitBuy}
|
||||
disabled={!row.take}
|
||||
onChange={(unitBuy) => update(i, { unitBuy })}
|
||||
label={`Einheit ${row.product} zum Einkaufen`}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<footer className="recipe-foot">
|
||||
<p className={recipe.invalid ? 'error' : 'hint'} aria-live="polite">
|
||||
{recipe.invalid
|
||||
? `Bitte die Menge bei „${recipe.invalid}“ prüfen (Anzahl: ganze Zahl, kg/l: z. B. 0,5).`
|
||||
: recipe.done != null
|
||||
? `✓ ${recipe.done} ${recipe.done === 1 ? 'Zutat' : 'Zutaten'} auf die Einkaufsliste gesetzt.`
|
||||
: 'Gramm und ml werden mit kg/l im Vorrat verglichen. EL, Packungen usw. nur auf „vorhanden“.'}
|
||||
</p>
|
||||
<button className="btn" type="button" disabled={busy || chosen.length === 0} onClick={takeOver}>
|
||||
Fehlendes auf Einkaufsliste ({chosen.length})
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { STATUS } from '../api.js';
|
||||
|
||||
export default function StatusBadge({ status }) {
|
||||
const [label, tone] = STATUS[status] || [status, ''];
|
||||
return <span className={`badge ${tone}`}>{label}</span>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getTheme, onThemeChange } from '../theme.js';
|
||||
import BlackwallField from './BlackwallField.jsx';
|
||||
import MatrixRain from './MatrixRain.jsx';
|
||||
|
||||
// Dekorativer Hintergrund des aktiven Designs (alles aria-hidden, ohne Mausereignisse)
|
||||
const BACKGROUNDS = {
|
||||
matrix: MatrixRain,
|
||||
blackwall: BlackwallField,
|
||||
};
|
||||
|
||||
export default function ThemeBackground() {
|
||||
const [theme, setTheme] = useState(getTheme);
|
||||
useEffect(() => onThemeChange(() => setTheme(getTheme())), []);
|
||||
const Background = BACKGROUNDS[theme];
|
||||
// key: beim Wechsel neu aufbauen, damit Farben aus den CSS-Variablen frisch gelesen werden
|
||||
return <Background key={theme} />;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const STATUS = {
|
||||
notfound: ['Nicht gefunden', 'warn'],
|
||||
inforeceived: ['Daten übermittelt', ''],
|
||||
intransit: ['Unterwegs', ''],
|
||||
outfordelivery: ['In Zustellung', ''],
|
||||
pickup: ['Abholbereit', 'warn'],
|
||||
delivered: ['Zugestellt', 'ok'],
|
||||
failed: ['Zustellung fehlgeschlagen', 'bad'],
|
||||
exception: ['Problem', 'bad'],
|
||||
expired: ['Abgelaufen', 'warn'],
|
||||
pending: ['Wird abgefragt …', ''],
|
||||
unknown: ['Unbekannt', ''],
|
||||
};
|
||||
|
||||
const VISIBLE_EVENTS = 6;
|
||||
const RECENT_KEY = 'p42.recentTracking';
|
||||
const normalize = (n) => n.replace(/\s+/g, '').toUpperCase();
|
||||
|
||||
function fmtDate(iso, withTime = true) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString('de-DE', {
|
||||
weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
...(withTime && /T\d/.test(iso) ? { hour: '2-digit', minute: '2-digit' } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function fmtEta(eta) {
|
||||
if (!eta) return null;
|
||||
if (eta.from && eta.to && eta.from.slice(0, 10) !== eta.to.slice(0, 10)) {
|
||||
return `${fmtDate(eta.from, false)} – ${fmtDate(eta.to, false)}`;
|
||||
}
|
||||
return fmtDate(eta.from || eta.to);
|
||||
}
|
||||
|
||||
function loadRecent() {
|
||||
try { return JSON.parse(localStorage.getItem(RECENT_KEY)) || []; } catch { return []; }
|
||||
}
|
||||
|
||||
export default function Tracking() {
|
||||
const [params] = useSearchParams();
|
||||
const [input, setInput] = useState(params.get('n') || '');
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [recent, setRecent] = useState(loadRecent);
|
||||
const requestId = useRef(0);
|
||||
const retryTimer = useRef(null);
|
||||
|
||||
function remember(number) {
|
||||
const list = [number, ...loadRecent().filter((n) => n !== number)].slice(0, 5);
|
||||
try { localStorage.setItem(RECENT_KEY, JSON.stringify(list)); } catch { /* ignorieren */ }
|
||||
setRecent(list);
|
||||
}
|
||||
|
||||
async function track(number, attempt = 0) {
|
||||
const req = ++requestId.current;
|
||||
clearTimeout(retryTimer.current);
|
||||
setLoading(true);
|
||||
if (attempt === 0) {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}
|
||||
try {
|
||||
const data = await api('GET', `/api/track?number=${encodeURIComponent(number)}`);
|
||||
if (req !== requestId.current) return;
|
||||
setResult(data);
|
||||
remember(data.number);
|
||||
// Frisch registrierte Nummern brauchen bei 17TRACK ein paar Sekunden
|
||||
if (data.status === 'pending' && attempt < 4) {
|
||||
retryTimer.current = setTimeout(() => track(number, attempt + 1), 4000);
|
||||
}
|
||||
} catch (err) {
|
||||
if (req === requestId.current) setError(err.message);
|
||||
} finally {
|
||||
if (req === requestId.current) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (params.get('n')) track(normalize(params.get('n')));
|
||||
return () => clearTimeout(retryTimer.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function submit(e) {
|
||||
e.preventDefault();
|
||||
const number = normalize(input);
|
||||
if (number) track(number);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="hero">
|
||||
<h1>Sendungsverfolgung</h1>
|
||||
<p className="lead">Sendungsnummer eingeben – der Paketdienst wird automatisch erkannt.</p>
|
||||
|
||||
<form className="track-form" onSubmit={submit} autoComplete="off">
|
||||
<div className="prompt-input">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Sendungsnummer eingeben …"
|
||||
aria-label="Sendungsnummer"
|
||||
spellCheck={false}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button className="btn" type="submit" disabled={loading}>Verfolgen</button>
|
||||
</form>
|
||||
|
||||
{recent.length > 0 && (
|
||||
<div className="recent">
|
||||
{recent.map((n) => (
|
||||
<button key={n} type="button" className="chip" onClick={() => { setInput(n); track(n); }}>{n}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="result" aria-live="polite">
|
||||
{loading && !result && <div className="card"><p className="hint">Suche Sendung …</p></div>}
|
||||
{error && <div className="card"><p className="error">{error}</p></div>}
|
||||
{result && <TrackingResult key={result.number} data={result} />}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CarrierLinks({ data }) {
|
||||
return (
|
||||
<div className="links">
|
||||
{(data.candidates || []).map((c) => (
|
||||
<a key={c.id} href={c.link} target="_blank" rel="noopener noreferrer">{c.name} ↗</a>
|
||||
))}
|
||||
<a href={data.universalLink} target="_blank" rel="noopener noreferrer">17TRACK ↗</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackingResult({ data }) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
// Kein Live-Ergebnis -> Erkennung + Links
|
||||
if (!data.source) {
|
||||
const names = (data.candidates || []).map((c) => c.name);
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="number">{data.number}</div>
|
||||
<p>
|
||||
{names.length
|
||||
? <>Wahrscheinlich: <strong>{names.join(' / ')}</strong></>
|
||||
: 'Paketdienst konnte nicht anhand des Formats erkannt werden.'}
|
||||
</p>
|
||||
<p className="hint">
|
||||
{data.configured
|
||||
? `Live-Daten konnten nicht abgerufen werden.${data.errors?.length ? ` (${data.errors.join('; ')})` : ''}`
|
||||
: <>Für Live-Daten (Status, Ankunft, Verlauf) einen API-Key in <code>config.json</code> eintragen – siehe README.</>}
|
||||
</p>
|
||||
<CarrierLinks data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [label, tone] = STATUS[data.status] || STATUS.unknown;
|
||||
const events = data.events || [];
|
||||
const visible = showAll ? events : events.slice(0, VISIBLE_EVENTS);
|
||||
const facts = [
|
||||
data.deliveredAt
|
||||
? ['Zugestellt am', fmtDate(data.deliveredAt)]
|
||||
: ['Voraussichtliche Ankunft', fmtEta(data.eta) || 'keine Angabe'],
|
||||
events[0]?.location && ['Letzter Ort', events[0].location],
|
||||
data.origin && ['Von', data.origin],
|
||||
data.destination && ['Nach', data.destination],
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="card tracking-result">
|
||||
<div className="tracking-summary">
|
||||
<div className="result-head">
|
||||
<div>
|
||||
<div className="carrier">{data.carrier || 'Unbekannter Paketdienst'}</div>
|
||||
<div className="number">{data.number}</div>
|
||||
</div>
|
||||
<span className={`badge ${tone}`}>{label}</span>
|
||||
</div>
|
||||
{data.statusDetail && data.status !== 'pending' && <p className="hint">{data.statusDetail}</p>}
|
||||
<dl className="facts">
|
||||
{facts.map(([k, v]) => (
|
||||
<div className="fact" key={k}><dt>{k}</dt><dd>{v}</dd></div>
|
||||
))}
|
||||
</dl>
|
||||
<CarrierLinks data={data} />
|
||||
<p className="hint">Quelle: {data.source}</p>
|
||||
</div>
|
||||
|
||||
<div className="tracking-events">
|
||||
{events.length ? (
|
||||
<>
|
||||
<ol className="timeline">
|
||||
{visible.map((e, i) => (
|
||||
<li key={i}>
|
||||
<div className="when">{fmtDate(e.time)}</div>
|
||||
<div className="what">{e.description}</div>
|
||||
{e.location && <div className="where">{e.location}</div>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{events.length > VISIBLE_EVENTS && !showAll && (
|
||||
<button type="button" className="chip more" onClick={() => setShowAll(true)}>
|
||||
Alle {events.length} Schritte anzeigen
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="hint">
|
||||
{data.status === 'pending'
|
||||
? 'Die Sendung wurde gerade registriert, Daten folgen gleich …'
|
||||
: 'Noch keine Sendungsereignisse vorhanden.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { UNITS } from '../units.js';
|
||||
|
||||
// Auswahl Anzahl / kg / Liter; compact für enge Listenzeilen ("Anz.")
|
||||
export default function UnitSelect({ value, onChange, label = 'Einheit', compact = false, ...props }) {
|
||||
return (
|
||||
<select className="unit-select" value={value} onChange={(e) => onChange(e.target.value)} aria-label={label} {...props}>
|
||||
{UNITS.map((u) => <option key={u.key} value={u.key}>{compact ? u.compact : u.label}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||
import { geoArea, geoGraticule10, geoNaturalEarth1, geoPath } from 'd3-geo';
|
||||
import { select } from 'd3-selection';
|
||||
import 'd3-transition';
|
||||
import { zoom as d3zoom, zoomIdentity } from 'd3-zoom';
|
||||
import { isCalm } from '../motion.js';
|
||||
|
||||
const MAX_ZOOM = 60;
|
||||
const CITY_ZOOM = 9;
|
||||
const PAD = 8;
|
||||
|
||||
const duration = () => (isCalm() ? 0 : 750);
|
||||
|
||||
// Bei Ländern mit Überseegebieten (Frankreich, USA …) auf das größte Teilstück zoomen
|
||||
function mainPart(feature) {
|
||||
const g = feature.geometry;
|
||||
if (g?.type !== 'MultiPolygon') return feature;
|
||||
let best = null;
|
||||
let bestArea = -1;
|
||||
for (const coordinates of g.coordinates) {
|
||||
const part = { type: 'Polygon', coordinates };
|
||||
const area = geoArea(part);
|
||||
if (area > bestArea) { best = part; bestArea = area; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weltkarte mit Ländern und Stadt-Punkten. Zoomen/Verschieben per Maus, Touch und Buttons.
|
||||
* countries: [{ key, name, feature }], visited: Set der Länder-Keys, selected: { type, key }.
|
||||
* Über `ref`: focusCountry(key, offset), focusCity(city, offset), zoomBy(f), reset().
|
||||
* offset: Ziel nach rechts oben schieben, weil unten links der Info-Kasten liegt.
|
||||
*/
|
||||
export default function WorldMap({ ref, countries, visited, cities, selected, onSelect }) {
|
||||
const box = useRef(null);
|
||||
const svgRef = useRef(null);
|
||||
const layer = useRef(null);
|
||||
const zoomRef = useRef(null);
|
||||
const [size, setSize] = useState({ w: 960, h: 500 });
|
||||
|
||||
// Kartengröße folgt dem Container; die Welt wird in die Breite eingepasst
|
||||
useEffect(() => {
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry.contentRect;
|
||||
if (width > 0 && height > 0) setSize({ w: Math.round(width), h: Math.round(height) });
|
||||
});
|
||||
ro.observe(box.current);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const { path, projection } = useMemo(() => {
|
||||
const p = geoNaturalEarth1().fitExtent([[PAD, PAD], [size.w - PAD, size.h - PAD]], { type: 'Sphere' });
|
||||
return { projection: p, path: geoPath(p) };
|
||||
}, [size]);
|
||||
|
||||
const shapes = useMemo(
|
||||
() => countries.map((c) => ({ ...c, d: path(c.feature) })).filter((c) => c.d),
|
||||
[countries, path],
|
||||
);
|
||||
const sphere = useMemo(() => path({ type: 'Sphere' }), [path]);
|
||||
const graticule = useMemo(() => path(geoGraticule10()), [path]);
|
||||
|
||||
useEffect(() => {
|
||||
const svg = select(svgRef.current);
|
||||
const z = d3zoom()
|
||||
.scaleExtent([1, MAX_ZOOM])
|
||||
.translateExtent([[0, 0], [size.w, size.h]])
|
||||
.extent([[0, 0], [size.w, size.h]])
|
||||
.on('zoom', (event) => {
|
||||
layer.current.setAttribute('transform', event.transform);
|
||||
// Punkte und Linien behalten beim Zoomen ihre Größe
|
||||
svgRef.current.style.setProperty('--inv', String(1 / event.transform.k));
|
||||
});
|
||||
svg.call(z).on('dblclick.zoom', null);
|
||||
zoomRef.current = z;
|
||||
return () => svg.on('.zoom', null);
|
||||
}, [size]);
|
||||
|
||||
function zoomTo(k, [x, y], offset) {
|
||||
k = Math.max(1, Math.min(MAX_ZOOM, k));
|
||||
const cx = offset ? size.w * 0.64 : size.w / 2;
|
||||
const cy = offset ? size.h * 0.42 : size.h / 2;
|
||||
const t = zoomIdentity.translate(cx - k * x, cy - k * y).scale(k);
|
||||
select(svgRef.current).transition().duration(duration()).call(zoomRef.current.transform, t);
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focusCountry(key, offset = false) {
|
||||
const c = countries.find((x) => x.key === key);
|
||||
if (!c) return;
|
||||
const [[x0, y0], [x1, y1]] = path.bounds(mainPart(c.feature));
|
||||
// Mit Info-Kasten daneben nur gut die Hälfte der Fläche nutzen
|
||||
const fill = offset ? 0.5 : 0.8;
|
||||
const k = fill / Math.max((x1 - x0) / size.w, (y1 - y0) / size.h);
|
||||
zoomTo(Math.min(k, 24), [(x0 + x1) / 2, (y0 + y1) / 2], offset);
|
||||
},
|
||||
focusCity(city, offset = false) {
|
||||
const p = projection([city.lon, city.lat]);
|
||||
if (p) zoomTo(CITY_ZOOM, p, offset);
|
||||
},
|
||||
zoomBy(factor) {
|
||||
select(svgRef.current).transition().duration(duration() / 2).call(zoomRef.current.scaleBy, factor);
|
||||
},
|
||||
reset() {
|
||||
select(svgRef.current).transition().duration(duration()).call(zoomRef.current.transform, zoomIdentity);
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="worldmap" ref={box}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${size.w} ${size.h}`}
|
||||
role="img"
|
||||
aria-label={`Weltkarte: ${visited.size} Länder und ${cities.length} Städte besucht`}
|
||||
onClick={() => onSelect(null)}
|
||||
>
|
||||
<g ref={layer}>
|
||||
<path className="map-sphere" d={sphere} />
|
||||
<path className="map-graticule" d={graticule} />
|
||||
{shapes.map((c) => (
|
||||
<path
|
||||
key={c.key}
|
||||
d={c.d}
|
||||
className={[
|
||||
'map-country',
|
||||
visited.has(c.key) && 'visited',
|
||||
selected?.type === 'country' && selected.key === c.key && 'selected',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect({ type: 'country', key: c.key }); }}
|
||||
>
|
||||
<title>{c.name}</title>
|
||||
</path>
|
||||
))}
|
||||
{cities.map((city) => {
|
||||
const p = projection([city.lon, city.lat]);
|
||||
if (!p) return null;
|
||||
const isSelected = selected?.type === 'city' && selected.key === city.id;
|
||||
return (
|
||||
<g key={city.id} transform={`translate(${p[0]},${p[1]})`}>
|
||||
<g
|
||||
className={`map-city${isSelected ? ' selected' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect({ type: 'city', key: city.id }); }}
|
||||
>
|
||||
<title>{city.name}</title>
|
||||
<circle className="map-city-hit" r="11" />
|
||||
{isSelected && <circle className="map-city-ring" r="9" />}
|
||||
<circle className="map-city-dot" r="4" />
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Bearbeitbare Schnellzugriff-Links (Jellyfin, Audiobookshelf, Homer), einmal geladen und überall verfügbar
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { api } from './api.js';
|
||||
|
||||
export const LINK_SERVICES = [
|
||||
{ key: 'jellyfin', name: 'Jellyfin', description: 'Filme & Serien', placeholder: 'http://jellyfin.local:8096' },
|
||||
{ key: 'audiobookshelf', name: 'Audiobookshelf', description: 'Hörbücher & Podcasts', placeholder: 'http://audiobookshelf.local:13378' },
|
||||
{ key: 'homer', name: 'Homer', description: 'Server-Startseite', placeholder: 'http://homer.local:8080' },
|
||||
];
|
||||
|
||||
const LinksContext = createContext({ links: {}, saveLinks: async () => {} });
|
||||
|
||||
export function LinksProvider({ children }) {
|
||||
const [links, setLinks] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
api('GET', '/api/links').then(setLinks).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const saveLinks = useCallback(async (values) => {
|
||||
const updated = await api('PUT', '/api/links', values);
|
||||
setLinks(updated);
|
||||
return updated;
|
||||
}, []);
|
||||
|
||||
return <LinksContext.Provider value={{ links, saveLinks }}>{children}</LinksContext.Provider>;
|
||||
}
|
||||
|
||||
export const useLinks = () => useContext(LinksContext);
|
||||
@@ -0,0 +1,92 @@
|
||||
import { lazy, StrictMode, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
import '@fontsource/vt323/400.css';
|
||||
import '@fontsource/ibm-plex-mono/400.css';
|
||||
import '@fontsource/ibm-plex-mono/600.css';
|
||||
// Schriften der übrigen Designs (nur @font-face; geladen wird eine Datei erst, wenn das Design sie nutzt)
|
||||
import '@fontsource/chakra-petch/latin-400.css';
|
||||
import '@fontsource/chakra-petch/latin-500.css';
|
||||
import '@fontsource/chakra-petch/latin-600.css';
|
||||
import '@fontsource/oxanium/latin-700.css';
|
||||
import '@fontsource/oxanium/latin-800.css';
|
||||
import { AuthProvider, useAuth } from './auth.jsx';
|
||||
import Layout from './components/Layout.jsx';
|
||||
import ThemeBackground from './components/ThemeBackground.jsx';
|
||||
import { LinksProvider } from './links.jsx';
|
||||
import Account from './pages/Account.jsx';
|
||||
import Haushalt from './pages/Haushalt.jsx';
|
||||
import Kanban from './pages/Kanban.jsx';
|
||||
import KanbanBoard from './pages/KanbanBoard.jsx';
|
||||
import Home from './pages/Home.jsx';
|
||||
import Login from './pages/Login.jsx';
|
||||
import Monitoring from './pages/Monitoring.jsx';
|
||||
import NotFound from './pages/NotFound.jsx';
|
||||
import Projects from './pages/Projects.jsx';
|
||||
import Users from './pages/Users.jsx';
|
||||
import './theme.js';
|
||||
import './styles.css';
|
||||
import './themes.css';
|
||||
|
||||
// Artikelseite inkl. Editor erst bei Bedarf laden, damit die Startseite schnell bleibt
|
||||
const Project = lazy(() => import('./pages/Project.jsx'));
|
||||
// Weltkarte (d3, Kartendaten) ebenfalls nur bei Bedarf
|
||||
const Reisen = lazy(() => import('./pages/Reisen.jsx'));
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{ index: true, element: <Home /> },
|
||||
{ path: 'projekte', element: <Projects /> },
|
||||
{
|
||||
path: 'projekte/:id',
|
||||
element: <Suspense fallback={<p className="hint">Lade …</p>}><Project /></Suspense>,
|
||||
},
|
||||
{ path: 'haushalt', element: <Haushalt /> },
|
||||
{ path: 'reisen', element: <Suspense fallback={<p className="hint">Lade …</p>}><Reisen /></Suspense> },
|
||||
{ path: 'kanban', element: <Kanban /> },
|
||||
{ path: 'kanban/:id', element: <KanbanBoard /> },
|
||||
{ path: 'monitoring', element: <Monitoring /> },
|
||||
{ path: 'konto', element: <Account /> },
|
||||
{ path: 'benutzer', element: <Users /> },
|
||||
{ path: '*', element: <NotFound /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// Ohne Anmeldung gibt es nur die Login- bzw. Einrichtungsseite
|
||||
function App() {
|
||||
const { state, user, error, retry } = useAuth();
|
||||
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
{error ? (
|
||||
<div className="card auth-card">
|
||||
<p className="error">Server nicht erreichbar: {error}</p>
|
||||
<button className="btn" type="button" onClick={retry}>Erneut versuchen</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="hint">Lade …</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state !== 'ready') return <Login />;
|
||||
|
||||
return (
|
||||
<LinksProvider key={user.id}>
|
||||
<RouterProvider router={router} />
|
||||
</LinksProvider>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<ThemeBackground />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
// Schalter für Hintergrund-Animationen (Matrix-Regen, blinkender Cursor), pro Browser gemerkt.
|
||||
// Bei "Bewegung reduzieren" im System ist die Animation ohnehin aus.
|
||||
const KEY = 'p42.calm';
|
||||
const EVENT = 'p42:motion';
|
||||
|
||||
const systemReduced = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
export function isCalm() {
|
||||
if (systemReduced()) return true;
|
||||
try { return localStorage.getItem(KEY) === '1'; } catch { return false; }
|
||||
}
|
||||
|
||||
export function setCalm(calm) {
|
||||
try { localStorage.setItem(KEY, calm ? '1' : '0'); } catch { /* ignorieren */ }
|
||||
apply();
|
||||
}
|
||||
|
||||
export function onMotionChange(fn) {
|
||||
window.addEventListener(EVENT, fn);
|
||||
return () => window.removeEventListener(EVENT, fn);
|
||||
}
|
||||
|
||||
export const motionLocked = systemReduced;
|
||||
|
||||
function apply() {
|
||||
document.documentElement.classList.toggle('calm', isCalm());
|
||||
window.dispatchEvent(new Event(EVENT));
|
||||
}
|
||||
|
||||
apply();
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useAuth } from '../auth.jsx';
|
||||
|
||||
export default function Account() {
|
||||
const { user, setUser } = useAuth();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Mein Konto</h1>
|
||||
<p className="lead">
|
||||
Angemeldet als <strong>{user.username}</strong>{user.isAdmin && ' · Administrator'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-grid">
|
||||
<ProfileForm user={user} onSaved={setUser} />
|
||||
<PasswordForm />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileForm({ user, onSaved }) {
|
||||
const [name, setName] = useState(user.displayName);
|
||||
const [msg, setMsg] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
onSaved(await api('PATCH', '/api/auth/me', { displayName: name }));
|
||||
setMsg({ ok: true, text: 'Gespeichert.' });
|
||||
} catch (err) {
|
||||
setMsg({ ok: false, text: err.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="card form" onSubmit={submit}>
|
||||
<h2>Anzeigename</h2>
|
||||
<p className="hint">So erscheinst du als Autor bzw. letzter Bearbeiter bei Artikeln.</p>
|
||||
<label>
|
||||
Anzeigename
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} maxLength={60} required />
|
||||
</label>
|
||||
{msg && <p className={msg.ok ? 'success' : 'error'}>{msg.text}</p>}
|
||||
<div className="form-actions">
|
||||
<button className="btn" type="submit" disabled={busy}>Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordForm() {
|
||||
const [form, setForm] = useState({ current: '', next: '', repeat: '' });
|
||||
const [msg, setMsg] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) });
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
setMsg(null);
|
||||
if (form.next !== form.repeat) return setMsg({ ok: false, text: 'Die neuen Passwörter stimmen nicht überein.' });
|
||||
setBusy(true);
|
||||
try {
|
||||
await api('PATCH', '/api/auth/me', { currentPassword: form.current, newPassword: form.next });
|
||||
setForm({ current: '', next: '', repeat: '' });
|
||||
setMsg({ ok: true, text: 'Passwort geändert. Andere Geräte wurden abgemeldet.' });
|
||||
} catch (err) {
|
||||
setMsg({ ok: false, text: err.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="card form" onSubmit={submit}>
|
||||
<h2>Passwort ändern</h2>
|
||||
<label>
|
||||
Aktuelles Passwort
|
||||
<input {...field('current')} type="password" autoComplete="current-password" required />
|
||||
</label>
|
||||
<label>
|
||||
Neues Passwort <small>(mind. 8 Zeichen)</small>
|
||||
<input {...field('next')} type="password" autoComplete="new-password" minLength={8} required />
|
||||
</label>
|
||||
<label>
|
||||
Neues Passwort wiederholen
|
||||
<input {...field('repeat')} type="password" autoComplete="new-password" required />
|
||||
</label>
|
||||
{msg && <p className={msg.ok ? 'success' : 'error'}>{msg.text}</p>}
|
||||
<div className="form-actions">
|
||||
<button className="btn" type="submit" disabled={busy}>Passwort ändern</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api, personName } from '../api.js';
|
||||
import ProductInput from '../components/ProductInput.jsx';
|
||||
import RecipeCheck from '../components/RecipeCheck.jsx';
|
||||
import UnitSelect from '../components/UnitSelect.jsx';
|
||||
import { fmtItems, parseQty, round2, STEP } from '../units.js';
|
||||
|
||||
// Geteilte Liste: regelmäßig nachladen, damit Änderungen der anderen ankommen
|
||||
const REFRESH_SECONDS = 15;
|
||||
const TAB_KEY = 'p42.haushaltTab';
|
||||
|
||||
const LISTS = {
|
||||
vorrat: { title: 'Vorrat', lead: 'Was zu Hause ist', empty: 'Noch nichts im Vorrat eingetragen.' },
|
||||
einkauf: { title: 'Einkaufen', lead: 'Was besorgt werden muss', empty: 'Nichts zu besorgen.' },
|
||||
};
|
||||
|
||||
function loadTab() {
|
||||
try { return localStorage.getItem(TAB_KEY) === 'einkauf' ? 'einkauf' : 'vorrat'; } catch { return 'vorrat'; }
|
||||
}
|
||||
|
||||
export default function Haushalt() {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [tab, setTab] = useState(loadTab);
|
||||
const dialog = useRef(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setData(await api('GET', '/api/haushalt'));
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = setInterval(() => !document.hidden && load(), REFRESH_SECONDS * 1000);
|
||||
const onVisible = () => !document.hidden && load();
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
// Jede Änderung liefert den aktuellen Stand beider Listen zurück
|
||||
async function run(method, url, body) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setData(await api(method, url, body));
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
// Bei Konflikten (z. B. gerade von jemand anderem gelöscht) frisch laden
|
||||
load();
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function chooseTab(next) {
|
||||
setTab(next);
|
||||
try { localStorage.setItem(TAB_KEY, next); } catch { /* ignorieren */ }
|
||||
}
|
||||
|
||||
const items = data?.items || [];
|
||||
const byList = {
|
||||
vorrat: items.filter((i) => i.list === 'vorrat'),
|
||||
einkauf: items.filter((i) => i.list === 'einkauf'),
|
||||
};
|
||||
// Alle Zeilen eines Produkts (ein Produkt kann in mehreren Einheiten auf einer Liste stehen)
|
||||
const find = (list, name) => byList[list].filter((i) => i.name.toLocaleLowerCase('de-DE') === name.toLocaleLowerCase('de-DE'));
|
||||
|
||||
const actions = {
|
||||
add: (list, name, quantity, unit) => run('POST', '/api/haushalt/items', { list, name, quantity, unit }),
|
||||
update: (item, patch) => run('PATCH', `/api/haushalt/items/${item.id}`, patch),
|
||||
move: (item) => run('POST', `/api/haushalt/items/${item.id}/move`),
|
||||
remove: (item) => run('DELETE', `/api/haushalt/items/${item.id}`),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Vorrat & Einkauf</h1>
|
||||
<p className="lead">Gemeinsame Liste für alle Konten. Wird alle {REFRESH_SECONDS} Sekunden aktualisiert.</p>
|
||||
</div>
|
||||
{data?.products.length > 0 && (
|
||||
<button className="link-btn" type="button" onClick={() => dialog.current.showModal()}>✎ Vorschläge verwalten</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="error household-error" role="alert">{error}</p>}
|
||||
{!data && !error && <p className="hint">Lade …</p>}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<RecipeCheck
|
||||
products={data.products}
|
||||
find={find}
|
||||
busy={busy}
|
||||
onTakeOver={(body) => run('POST', '/api/haushalt/rezept/uebernehmen', body)}
|
||||
/>
|
||||
|
||||
<div className="segmented" role="tablist" aria-label="Liste wählen">
|
||||
{Object.entries(LISTS).map(([key, { title }]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`tab-${key}`}
|
||||
aria-selected={tab === key}
|
||||
aria-controls={`panel-${key}`}
|
||||
onClick={() => chooseTab(key)}
|
||||
>
|
||||
{title} <span className="count">{byList[key].length}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="household">
|
||||
{Object.keys(LISTS).map((list) => (
|
||||
<ListPanel
|
||||
key={list}
|
||||
list={list}
|
||||
items={byList[list]}
|
||||
products={data.products}
|
||||
find={find}
|
||||
actions={actions}
|
||||
busy={busy}
|
||||
hidden={tab !== list}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ProductsDialog ref={dialog} products={data?.products || []} onDelete={(name) => run('DELETE', `/api/haushalt/produkte?name=${encodeURIComponent(name)}`)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ListPanel({ list, items, products, find, actions, busy, hidden }) {
|
||||
const { title, lead, empty } = LISTS[list];
|
||||
const other = list === 'vorrat' ? 'einkauf' : 'vorrat';
|
||||
|
||||
// Zusatzinfo in den Vorschlägen: steht das Produkt schon irgendwo?
|
||||
function hint(name) {
|
||||
const here = find(list, name);
|
||||
if (here.length) return `schon da: ${fmtItems(here)}`;
|
||||
const there = find(other, name);
|
||||
if (there.length) return `${list === 'vorrat' ? 'auf Einkaufsliste' : 'im Vorrat'}: ${fmtItems(there)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`card household-panel ${list}`}
|
||||
id={`panel-${list}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`title-${list}`}
|
||||
data-hidden={hidden || undefined}
|
||||
>
|
||||
<header className="household-head">
|
||||
<h2 id={`title-${list}`}>{title}</h2>
|
||||
<span className="hint">{lead}</span>
|
||||
</header>
|
||||
|
||||
<AddForm list={list} products={products} hint={hint} onAdd={actions.add} busy={busy} />
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="hint household-empty">{empty}</p>
|
||||
) : (
|
||||
<ul className="household-list">
|
||||
{items.map((item) => (
|
||||
<ItemRow key={item.id} item={item} list={list} other={find(other, item.name)} actions={actions} busy={busy} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AddForm({ list, products, hint, onAdd, busy }) {
|
||||
const [name, setName] = useState('');
|
||||
const [quantity, setQuantity] = useState('1');
|
||||
const [unit, setUnit] = useState('stk');
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const nameInput = useRef(null);
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
const n = name.trim();
|
||||
const q = parseQty(quantity, unit);
|
||||
setInvalid(q == null);
|
||||
if (!n || q == null) return;
|
||||
if (await onAdd(list, n, q, unit)) {
|
||||
setName('');
|
||||
setQuantity('1');
|
||||
nameInput.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="household-add" onSubmit={submit} noValidate>
|
||||
<label className="sr-only" htmlFor={`add-${list}`}>Produkt</label>
|
||||
<ProductInput
|
||||
id={`add-${list}`}
|
||||
inputRef={nameInput}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
products={products}
|
||||
hint={hint}
|
||||
placeholder={list === 'vorrat' ? 'Was ist da? z. B. Milch' : 'Was fehlt? z. B. Brot'}
|
||||
maxLength={60}
|
||||
required
|
||||
/>
|
||||
<div className="qty-unit">
|
||||
<label className="sr-only" htmlFor={`qty-${list}`}>Menge</label>
|
||||
<input
|
||||
id={`qty-${list}`}
|
||||
className="household-qty-input"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={quantity}
|
||||
onChange={(e) => { setQuantity(e.target.value); setInvalid(false); }}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={invalid ? `qty-error-${list}` : undefined}
|
||||
/>
|
||||
<UnitSelect id={`unit-${list}`} value={unit} onChange={setUnit} />
|
||||
</div>
|
||||
<button className="btn" type="submit" disabled={busy}>Hinzufügen</button>
|
||||
{invalid && (
|
||||
<p className="error household-add-error" id={`qty-error-${list}`}>
|
||||
{unit === 'stk' ? 'Anzahl als ganze Zahl von 1 bis 999 eingeben.' : 'Menge von 0,01 bis 999 eingeben, z. B. 1,5.'}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// Menge direkt eintippen; gespeichert wird beim Verlassen des Felds oder mit Enter
|
||||
function QuantityInput({ item, label, onCommit, disabled }) {
|
||||
const shown = String(item.quantity).replace('.', ',');
|
||||
const [draft, setDraft] = useState(null);
|
||||
|
||||
function commit() {
|
||||
if (draft == null) return;
|
||||
const q = parseQty(draft, item.unit);
|
||||
setDraft(null);
|
||||
if (q != null && q !== item.quantity) onCommit(q);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
className="stepper-value"
|
||||
type="text"
|
||||
inputMode={item.unit === 'stk' ? 'numeric' : 'decimal'}
|
||||
value={draft ?? shown}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
if (e.key === 'Escape') setDraft(null);
|
||||
}}
|
||||
aria-label={`Menge ${label}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemRow({ item, list, other, actions, busy }) {
|
||||
const isShopping = list === 'einkauf';
|
||||
const step = STEP[item.unit];
|
||||
const last = item.quantity - step <= 0;
|
||||
// Bei gleichem Produkt in mehreren Einheiten die Einheit mit ansagen
|
||||
const label = item.unit === 'stk' ? item.name : `${item.name} (${item.unit})`;
|
||||
|
||||
return (
|
||||
<li className="household-item">
|
||||
{isShopping && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="household-check"
|
||||
checked={false}
|
||||
disabled={busy}
|
||||
onChange={() => actions.move(item)}
|
||||
aria-label={`${label} gekauft – in den Vorrat übernehmen`}
|
||||
title="Gekauft: in den Vorrat übernehmen"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="household-name">
|
||||
<strong>{item.name}</strong>
|
||||
<small>
|
||||
{other.length > 0 && `${isShopping ? 'im Vorrat' : 'auf Einkaufsliste'}: ${fmtItems(other)} · `}
|
||||
✎ {personName(item.updatedBy)}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="stepper" role="group" aria-label={`Menge ${label}`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || (isShopping && last)}
|
||||
onClick={() => (last ? actions.move(item) : actions.update(item, { quantity: round2(item.quantity - step) }))}
|
||||
aria-label={last && !isShopping ? `${label} aufgebraucht – auf die Einkaufsliste` : `${label} weniger`}
|
||||
title={last && !isShopping ? 'Aufgebraucht: auf die Einkaufsliste' : 'Weniger'}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<QuantityInput key={`${item.quantity}-${item.unit}`} item={item} label={label} disabled={busy} onCommit={(quantity) => actions.update(item, { quantity })} />
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || item.quantity + step > 999}
|
||||
onClick={() => actions.update(item, { quantity: round2(item.quantity + step) })}
|
||||
aria-label={`${label} mehr`}
|
||||
title="Mehr"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<UnitSelect
|
||||
value={item.unit}
|
||||
onChange={(unit) => actions.update(item, { unit })}
|
||||
disabled={busy}
|
||||
compact
|
||||
label={`Einheit ${item.name}`}
|
||||
/>
|
||||
|
||||
<div className="household-actions">
|
||||
{!isShopping && (
|
||||
<button
|
||||
className="link-btn"
|
||||
type="button"
|
||||
disabled={busy || other.length > 0}
|
||||
onClick={() => actions.add('einkauf', item.name, 1, item.unit)}
|
||||
aria-label={other.length ? `${label} steht schon auf der Einkaufsliste` : `${label} nachkaufen`}
|
||||
title={other.length ? 'Steht schon auf der Einkaufsliste' : 'Auf die Einkaufsliste setzen, Vorrat bleibt'}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M3 4h2l2.4 11.2a2 2 0 0 0 2 1.6h7.7a2 2 0 0 0 2-1.5L21 8H6.2" /><circle cx="10" cy="20" r="1.2" /><circle cx="17" cy="20" r="1.2" />
|
||||
</svg>
|
||||
<span className="label">Nachkaufen</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="link-btn danger"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => actions.remove(item)}
|
||||
aria-label={`${label} entfernen`}
|
||||
title="Entfernen"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductsDialog({ ref, products, onDelete }) {
|
||||
const sorted = [...products].sort((a, b) => a.localeCompare(b, 'de-DE'));
|
||||
return (
|
||||
<dialog className="modal" ref={ref}>
|
||||
<div className="form">
|
||||
<h2>Vorschläge verwalten</h2>
|
||||
<p className="hint">
|
||||
Tippfehler oder doppelte Schreibweisen hier löschen. Einträge auf den Listen bleiben erhalten.
|
||||
</p>
|
||||
<ul className="product-list">
|
||||
{sorted.map((name) => (
|
||||
<li key={name}>
|
||||
<span>{name}</span>
|
||||
<button className="link-btn danger" type="button" onClick={() => onDelete(name)} aria-label={`Vorschlag ${name} löschen`}>
|
||||
Löschen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="form-actions">
|
||||
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import LinksDialog from '../components/LinksDialog.jsx';
|
||||
import Tracking from '../components/Tracking.jsx';
|
||||
import { LINK_SERVICES, useLinks } from '../links.jsx';
|
||||
|
||||
const svg = {
|
||||
fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', viewBox: '0 0 24 24',
|
||||
};
|
||||
|
||||
const ICONS = {
|
||||
jellyfin: <svg {...svg}><rect x="2" y="5" width="20" height="14" rx="2" /><path d="m10 9 5 3-5 3z" /></svg>,
|
||||
audiobookshelf: <svg {...svg}><path d="M3 18v-6a9 9 0 0 1 18 0v6" /><path d="M21 19a2 2 0 0 1-2 2h-1v-6h3zM3 19a2 2 0 0 0 2 2h1v-6H3z" /></svg>,
|
||||
homer: <svg {...svg}><rect x="3" y="3" width="7" height="7" rx="1.5" /><rect x="14" y="3" width="7" height="7" rx="1.5" /><rect x="3" y="14" width="7" height="7" rx="1.5" /><rect x="14" y="14" width="7" height="7" rx="1.5" /></svg>,
|
||||
projekte: <svg {...svg}><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /></svg>,
|
||||
haushalt: <svg {...svg}><path d="M3 4h2l2.4 11.2a2 2 0 0 0 2 1.6h7.7a2 2 0 0 0 2-1.5L21 8H6.2" /><circle cx="10" cy="20" r="1.2" /><circle cx="17" cy="20" r="1.2" /></svg>,
|
||||
reisen: <svg {...svg}><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></svg>,
|
||||
kanban: <svg {...svg}><rect x="3" y="4" width="5" height="16" rx="1" /><rect x="10" y="4" width="5" height="10" rx="1" /><rect x="17" y="4" width="4" height="7" rx="1" /></svg>,
|
||||
monitoring: <svg {...svg}><rect x="3" y="4" width="18" height="6" rx="1.5" /><rect x="3" y="14" width="18" height="6" rx="1.5" /><path d="M7 7h.01M7 17h.01" /></svg>,
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const { links } = useLinks();
|
||||
const dialog = useRef(null);
|
||||
const editLinks = () => dialog.current.showModal();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tracking />
|
||||
|
||||
<section className="tiles-section" aria-labelledby="tiles-title">
|
||||
<div className="tiles-head">
|
||||
<h2 id="tiles-title" className="section-title">Schnellzugriff</h2>
|
||||
<button className="link-btn" type="button" onClick={editLinks}>✎ Links bearbeiten</button>
|
||||
</div>
|
||||
<div className="tiles">
|
||||
{LINK_SERVICES.map(({ key, name, description }) =>
|
||||
links[key] ? (
|
||||
<a key={key} className="tile" href={links[key]} target="_blank" rel="noopener noreferrer">
|
||||
{ICONS[key]}
|
||||
<strong>{name}</strong>
|
||||
<small>{description}</small>
|
||||
</a>
|
||||
) : (
|
||||
// Ohne Link öffnet die Kachel direkt den Bearbeiten-Dialog
|
||||
<button key={key} className="tile tile-empty" type="button" onClick={editLinks}>
|
||||
{ICONS[key]}
|
||||
<strong>{name}</strong>
|
||||
<small>Link festlegen …</small>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<Link className="tile" to="/projekte">
|
||||
{ICONS.projekte}
|
||||
<strong>P42 Projekte</strong>
|
||||
<small>Übersicht aller Projekte</small>
|
||||
</Link>
|
||||
<Link className="tile" to="/haushalt">
|
||||
{ICONS.haushalt}
|
||||
<strong>Vorrat & Einkauf</strong>
|
||||
<small>Gemeinsame Einkaufsliste</small>
|
||||
</Link>
|
||||
<Link className="tile" to="/reisen">
|
||||
{ICONS.reisen}
|
||||
<strong>Reisen</strong>
|
||||
<small>Meine Weltkarte</small>
|
||||
</Link>
|
||||
<Link className="tile" to="/kanban">
|
||||
{ICONS.kanban}
|
||||
<strong>Kanban</strong>
|
||||
<small>Gemeinsame Aufgaben</small>
|
||||
</Link>
|
||||
<Link className="tile" to="/monitoring">
|
||||
{ICONS.monitoring}
|
||||
<strong>Server-Monitoring</strong>
|
||||
<small>Status meiner Server</small>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LinksDialog ref={dialog} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { api, fmtDate } from '../api.js';
|
||||
import { useAuth } from '../auth.jsx';
|
||||
import KanbanProjectDialog from '../components/KanbanProjectDialog.jsx';
|
||||
import { COLUMNS } from './KanbanBoard.jsx';
|
||||
|
||||
const DONE_OPEN_KEY = 'p42.kanbanAbgeschlossenOffen';
|
||||
|
||||
// Übersicht: aktive Kanban-Projekte (je ein Board), darunter die abgeschlossenen
|
||||
export default function Kanban() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [projectForm, setProjectForm] = useState(null);
|
||||
const [doneOpen, setDoneOpen] = useState(() => {
|
||||
try { return localStorage.getItem(DONE_OPEN_KEY) === '1'; } catch { return false; }
|
||||
});
|
||||
const projectDialog = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
api('GET', '/api/kanban').then(setData).catch((e) => setError(e.message));
|
||||
}, []);
|
||||
|
||||
// Von der Artikelseite: /kanban?artikel=<id> öffnet "Neues Projekt" mit dieser Verknüpfung
|
||||
useEffect(() => {
|
||||
const article = params.get('artikel');
|
||||
if (!data || !article) return;
|
||||
newProject([article]);
|
||||
setParams({}, { replace: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
|
||||
function newProject(links = []) {
|
||||
setProjectForm({ links, opened: Date.now() });
|
||||
}
|
||||
useEffect(() => {
|
||||
if (projectForm) projectDialog.current?.showModal();
|
||||
}, [projectForm]);
|
||||
|
||||
async function createProject(body) {
|
||||
try {
|
||||
const res = await api('POST', '/api/kanban/projekte', body);
|
||||
navigate(`/kanban/${res.id}`);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function reopen(project) {
|
||||
try {
|
||||
await api('PATCH', `/api/kanban/projekte/${project.id}`, { completed: false });
|
||||
setData(await api('GET', '/api/kanban'));
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(project) {
|
||||
const n = Object.values(project.counts).reduce((a, b) => a + b, 0);
|
||||
if (!window.confirm(`Kanban-Projekt „${project.name}“${n ? ` mit ${n} ${n === 1 ? 'Aufgabe' : 'Aufgaben'}` : ''} endgültig löschen?`)) return;
|
||||
try {
|
||||
setData(await api('DELETE', `/api/kanban/projekte/${project.id}`));
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Selbst steuern statt auf das (verzögerte) toggle-Ereignis zu warten: Zustand ist sofort gespeichert
|
||||
function toggleDone(e) {
|
||||
e.preventDefault();
|
||||
const next = !doneOpen;
|
||||
setDoneOpen(next);
|
||||
try { localStorage.setItem(DONE_OPEN_KEY, next ? '1' : '0'); } catch { /* ignorieren */ }
|
||||
}
|
||||
|
||||
const projects = data?.projects || [];
|
||||
const active = projects.filter((p) => !p.completedAt);
|
||||
const done = projects.filter((p) => p.completedAt).sort((a, b) => b.completedAt.localeCompare(a.completedAt));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Kanban</h1>
|
||||
<p className="lead">
|
||||
{data
|
||||
? `${active.length} ${active.length === 1 ? 'aktives Projekt' : 'aktive Projekte'}${done.length ? `, ${done.length} abgeschlossen` : ''} · gemeinsam für alle Konten`
|
||||
: 'Lade …'}
|
||||
</p>
|
||||
</div>
|
||||
{data && <button className="btn" type="button" onClick={() => newProject()}>+ Projekt</button>}
|
||||
</div>
|
||||
|
||||
{error && <p className="error kanban-error" role="alert">{error}</p>}
|
||||
{data && projects.length === 0 && (
|
||||
<div className="notice">Noch keine Projekte. Leg mit „+ Projekt“ das erste an. Jedes Projekt bekommt ein eigenes Board.</div>
|
||||
)}
|
||||
{data && projects.length > 0 && active.length === 0 && (
|
||||
<p className="hint">Alle Projekte sind abgeschlossen.</p>
|
||||
)}
|
||||
|
||||
{active.length > 0 && (
|
||||
<div className="grid kanban-projects">
|
||||
{active.map((p) => <ProjectCard key={p.id} project={p} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{done.length > 0 && (
|
||||
<details className="kanban-done" open={doneOpen}>
|
||||
<summary onClick={toggleDone}><h2 className="section-title">Abgeschlossen <span className="count">{done.length}</span></h2></summary>
|
||||
<div className="grid kanban-projects">
|
||||
{done.map((p) => (
|
||||
<ProjectCard key={p.id} project={p}>
|
||||
<div className="kanban-project-actions">
|
||||
<button className="link-btn" type="button" onClick={() => reopen(p)}>Wieder öffnen</button>
|
||||
{(user.isAdmin || p.createdBy?.id === user.id) && (
|
||||
<button className="link-btn danger" type="button" onClick={() => remove(p)}>Löschen</button>
|
||||
)}
|
||||
</div>
|
||||
</ProjectCard>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<KanbanProjectDialog
|
||||
key={projectForm?.opened ?? 'zu'}
|
||||
ref={projectDialog}
|
||||
initial={projectForm}
|
||||
isNew
|
||||
articles={data?.articles || []}
|
||||
onSave={createProject}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCard({ project, children }) {
|
||||
const total = Object.values(project.counts).reduce((a, b) => a + b, 0);
|
||||
return (
|
||||
<article className={`card kanban-project-card${project.completedAt ? ' completed' : ''}`}>
|
||||
{/* Der Titel-Link deckt die ganze Karte ab; Buttons darunter bleiben klickbar */}
|
||||
<h3><Link className="kanban-project-link" to={`/kanban/${project.id}`}>{project.name}</Link></h3>
|
||||
{project.completedAt && <span className="badge ok kanban-done-badge">Abgeschlossen {fmtDate(project.completedAt)}</span>}
|
||||
{project.description && <p className="kanban-project-desc">{project.description}</p>}
|
||||
{total > 0 ? (
|
||||
<>
|
||||
{/* Anteil je Status als Balken; die genauen Zahlen stehen darunter */}
|
||||
<div className="status-bar" aria-hidden="true">
|
||||
{COLUMNS.map((c) => project.counts[c.key] > 0 && (
|
||||
<span key={c.key} className={c.key} style={{ flexGrow: project.counts[c.key] }} />
|
||||
))}
|
||||
</div>
|
||||
<p className="kanban-project-counts">
|
||||
{COLUMNS.filter((c) => project.counts[c.key]).map((c) => `${project.counts[c.key]} ${c.title}`).join(' · ')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="kanban-project-counts">Noch keine Aufgaben</p>
|
||||
)}
|
||||
{project.links.length > 0 && (
|
||||
<p className="kanban-project-links">▤ {project.links.map((l) => l.title).join(', ')}</p>
|
||||
)}
|
||||
{children}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { api, fmtDate, personName } from '../api.js';
|
||||
import { useAuth } from '../auth.jsx';
|
||||
import KanbanProjectDialog from '../components/KanbanProjectDialog.jsx';
|
||||
|
||||
// Board eines Kanban-Projekts. Gemeinsam: regelmäßig nachladen, damit Änderungen der anderen ankommen
|
||||
const REFRESH_SECONDS = 15;
|
||||
const FILTER_KEY = 'p42.kanbanFilter';
|
||||
|
||||
export const PRIORITIES = [
|
||||
{ key: 'hoch', title: 'Hoch', rank: 1 },
|
||||
{ key: 'mittel', title: 'Mittel', rank: 2 },
|
||||
{ key: 'niedrig', title: 'Niedrig', rank: 3 },
|
||||
];
|
||||
const RANK = Object.fromEntries(PRIORITIES.map((p) => [p.key, p.rank]));
|
||||
// Innerhalb einer Spalte: zuerst nach Priorität, dann eigene Reihenfolge
|
||||
const byPriority = (a, b) => (RANK[a.priority] ?? 2) - (RANK[b.priority] ?? 2) || a.position - b.position;
|
||||
|
||||
export const COLUMNS = [
|
||||
{ key: 'offen', title: 'Offen' },
|
||||
{ key: 'arbeit', title: 'In Arbeit' },
|
||||
{ key: 'wartet', title: 'Wartet' },
|
||||
{ key: 'erledigt', title: 'Erledigt' },
|
||||
];
|
||||
|
||||
const initials = (name) => name.split(/\s+/).map((w) => w[0]).join('').slice(0, 2).toUpperCase();
|
||||
|
||||
function loadFilter() {
|
||||
try { return localStorage.getItem(FILTER_KEY) || 'alle'; } catch { return 'alle'; }
|
||||
}
|
||||
|
||||
// Position zwischen den Nachbarn an der Einfügestelle
|
||||
function positionAt(cards, index) {
|
||||
const before = cards[index - 1]?.position;
|
||||
const after = cards[index]?.position;
|
||||
if (before == null && after == null) return 1;
|
||||
if (before == null) return after - 1;
|
||||
if (after == null) return before + 1;
|
||||
return (before + after) / 2;
|
||||
}
|
||||
|
||||
export default function KanbanBoard() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [filter, setFilter] = useState(loadFilter);
|
||||
const [editing, setEditing] = useState(null); // null | { task } | { status } (neu)
|
||||
const [drag, setDrag] = useState(null); // { id, status, index } während Drag & Drop
|
||||
const dialog = useRef(null);
|
||||
const projectDialog = useRef(null);
|
||||
const [projectForm, setProjectForm] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setData(await api('GET', `/api/kanban/projekte/${encodeURIComponent(id)}`));
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.project) document.title = `${data.project.name} – Kanban`;
|
||||
return () => { document.title = 'P42 Dashboard'; };
|
||||
}, [data?.project?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = setInterval(() => !document.hidden && load(), REFRESH_SECONDS * 1000);
|
||||
const onVisible = () => !document.hidden && load();
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
async function run(method, url, body) {
|
||||
setError(null);
|
||||
try {
|
||||
setData(await api(method, url, body));
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
load();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function chooseFilter(value) {
|
||||
setFilter(value);
|
||||
try { localStorage.setItem(FILTER_KEY, value); } catch { /* ignorieren */ }
|
||||
}
|
||||
|
||||
// Erst rendern (Formular ist dann schon befüllt), dann öffnen – sonst ist das Feld kurz leer
|
||||
function open(target) {
|
||||
setEditing({ ...target, opened: Date.now() });
|
||||
}
|
||||
useEffect(() => {
|
||||
if (editing) dialog.current?.showModal();
|
||||
}, [editing]);
|
||||
|
||||
const users = data?.users || [];
|
||||
const project = data?.project;
|
||||
|
||||
function editProject() {
|
||||
setProjectForm({ name: project.name, description: project.description, links: project.links.map((l) => l.id), opened: Date.now() });
|
||||
}
|
||||
useEffect(() => {
|
||||
if (projectForm) projectDialog.current?.showModal();
|
||||
}, [projectForm]);
|
||||
|
||||
async function saveProject(body) {
|
||||
try {
|
||||
setData(await api('PATCH', `/api/kanban/projekte/${project.id}`, body));
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
|
||||
const mayDelete = user.isAdmin || project?.createdBy?.id === user.id;
|
||||
|
||||
async function setCompleted(completed) {
|
||||
await saveProject({ completed }).then((err) => err && setError(err));
|
||||
}
|
||||
|
||||
async function deleteProject() {
|
||||
const n = data.tasks.length;
|
||||
if (!window.confirm(`Kanban-Projekt „${project.name}“${n ? ` mit ${n} ${n === 1 ? 'Aufgabe' : 'Aufgaben'}` : ''} endgültig löschen?`)) return;
|
||||
try {
|
||||
await api('DELETE', `/api/kanban/projekte/${project.id}`);
|
||||
navigate('/kanban');
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <><BackLink />{error ? <p className="error">{error}</p> : <p className="hint">Lade …</p>}</>;
|
||||
}
|
||||
const visible = (data?.tasks || []).filter((t) =>
|
||||
filter === 'alle' ? true
|
||||
: filter === 'meine' ? t.assignee === user.id
|
||||
: filter === 'niemand' ? t.assignee == null
|
||||
: t.assignee === Number(filter));
|
||||
const byColumn = Object.fromEntries(COLUMNS.map((c) => [c.key, visible.filter((t) => t.status === c.key).sort(byPriority)]));
|
||||
|
||||
// ---------- Drag & Drop (Desktop) ----------
|
||||
function onDragOver(e, status) {
|
||||
if (!drag) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const cards = [...e.currentTarget.querySelectorAll('.task-card:not(.dragging)')];
|
||||
let index = cards.findIndex((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return e.clientY < r.top + r.height / 2;
|
||||
});
|
||||
if (index < 0) index = cards.length;
|
||||
if (drag.status !== status || drag.index !== index) setDrag((d) => ({ ...d, status, index }));
|
||||
}
|
||||
|
||||
function onDrop(e, status) {
|
||||
e.preventDefault();
|
||||
if (!drag) return;
|
||||
// Die Karte behält ihre Priorität: einsortiert wird nur zwischen Karten derselben Stufe
|
||||
const moved = data.tasks.find((t) => t.id === drag.id);
|
||||
const cards = byColumn[status].filter((t) => t.id !== drag.id);
|
||||
const index = drag.index ?? cards.length;
|
||||
const same = cards.filter((t) => t.priority === moved.priority);
|
||||
const sameIndex = cards.slice(0, index).filter((t) => t.priority === moved.priority).length;
|
||||
const position = positionAt(same, sameIndex);
|
||||
const id = drag.id;
|
||||
setDrag(null);
|
||||
// Sofort anzeigen, der Server bestätigt mit dem neuen Stand
|
||||
setData((d) => ({ ...d, tasks: d.tasks.map((t) => (t.id === id ? { ...t, status, position } : t)).sort((a, b) => a.position - b.position) }));
|
||||
run('PATCH', `/api/kanban/aufgaben/${id}`, { status, position });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BackLink />
|
||||
{project.completedAt && (
|
||||
<div className="notice board-completed">
|
||||
Abgeschlossen am {fmtDate(project.completedAt)}. Aufgaben lassen sich weiter bearbeiten.
|
||||
</div>
|
||||
)}
|
||||
<div className="page-head board-page-head">
|
||||
<div>
|
||||
<h1>{project.name}</h1>
|
||||
{project.description && <p className="lead board-description">{project.description}</p>}
|
||||
{project.links.length > 0 && (
|
||||
<ul className="chip-list board-links" aria-label="Verknüpfte Artikel">
|
||||
{project.links.map((a) => (
|
||||
<li key={a.id}><Link className="chip" to={`/projekte/${a.id}`}>▤ {a.title}</Link></li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button className="btn ghost" type="button" onClick={editProject}>Bearbeiten</button>
|
||||
<button className="btn ghost" type="button" onClick={() => setCompleted(!project.completedAt)}>
|
||||
{project.completedAt ? 'Wieder öffnen' : 'Abschließen'}
|
||||
</button>
|
||||
{mayDelete && <button className="btn ghost danger-btn" type="button" onClick={deleteProject}>Löschen</button>}
|
||||
<label className="kanban-filter">
|
||||
<span>Bearbeiter</span>
|
||||
<select value={filter} onChange={(e) => chooseFilter(e.target.value)}>
|
||||
<option value="alle">Alle</option>
|
||||
<option value="meine">Mir zugeteilt</option>
|
||||
<option value="niemand">Niemandem zugeteilt</option>
|
||||
{users.filter((u) => u.id !== user.id).map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button className="btn" type="button" onClick={() => open({ status: 'offen' })}>+ Neue Aufgabe</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="error kanban-error" role="alert">{error}</p>}
|
||||
|
||||
<div className="board">
|
||||
{COLUMNS.map((col) => {
|
||||
const cards = byColumn[col.key];
|
||||
const target = drag?.status === col.key;
|
||||
return (
|
||||
<section
|
||||
key={col.key}
|
||||
className={`board-column ${col.key}${target ? ' drop-target' : ''}`}
|
||||
aria-labelledby={`col-${col.key}`}
|
||||
onDragOver={(e) => onDragOver(e, col.key)}
|
||||
onDrop={(e) => onDrop(e, col.key)}
|
||||
>
|
||||
<header className="board-head">
|
||||
<h2 id={`col-${col.key}`}>{col.title} <span className="count">{cards.length}</span></h2>
|
||||
<button className="link-btn" type="button" onClick={() => open({ status: col.key })} aria-label={`Neue Aufgabe in ${col.title}`}>+</button>
|
||||
</header>
|
||||
<ul className="board-cards">
|
||||
{cards.map((task) => {
|
||||
// Gezogene Karte bleibt im DOM (sonst bricht der Browser das Ziehen ab), zählt aber nicht mit
|
||||
const index = cards.filter((t) => t.id !== drag?.id).indexOf(task);
|
||||
return (
|
||||
<li key={task.id}>
|
||||
{target && index >= 0 && drag.index === index && <div className="drop-line" aria-hidden="true" />}
|
||||
<TaskCard
|
||||
task={task}
|
||||
users={users}
|
||||
dragging={drag?.id === task.id}
|
||||
onOpen={() => open({ task })}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', String(task.id));
|
||||
// Erst im nächsten Frame markieren, sonst fehlt die Karte im Drag-Bild
|
||||
requestAnimationFrame(() => setDrag({ id: task.id, status: null, index: null }));
|
||||
}}
|
||||
onDragEnd={() => setDrag(null)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{target && drag.index >= cards.filter((t) => t.id !== drag.id).length && <li className="drop-line" aria-hidden="true" />}
|
||||
{cards.length === 0 && !target && <li className="board-empty">Keine Aufgaben</li>}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<TaskDialog
|
||||
key={`aufgabe-${editing?.opened ?? 'zu'}`}
|
||||
ref={dialog}
|
||||
target={editing}
|
||||
users={users}
|
||||
me={user}
|
||||
onSave={(taskId, body) => run(taskId ? 'PATCH' : 'POST', taskId ? `/api/kanban/aufgaben/${taskId}` : `/api/kanban/projekte/${project.id}/aufgaben`, body)}
|
||||
onDelete={(taskId) => run('DELETE', `/api/kanban/aufgaben/${taskId}`)}
|
||||
/>
|
||||
<KanbanProjectDialog
|
||||
key={`projekt-${projectForm?.opened ?? 'zu'}`}
|
||||
ref={projectDialog}
|
||||
initial={projectForm}
|
||||
articles={data.articles}
|
||||
onSave={saveProject}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BackLink() {
|
||||
return <Link className="back" to="/kanban">← Alle Projekte</Link>;
|
||||
}
|
||||
|
||||
function Assignee({ id, users }) {
|
||||
const person = users.find((u) => u.id === id);
|
||||
if (!person) return <span className="assignee none">Niemand</span>;
|
||||
return (
|
||||
<span className="assignee">
|
||||
<span className="avatar" aria-hidden="true">{initials(person.name)}</span>
|
||||
{person.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskCard({ task, users, dragging, onOpen, onDragStart, onDragEnd }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`task-card prio-card-${task.priority}${dragging ? ' dragging' : ''}`}
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<span className="task-top">
|
||||
<strong className="task-title">{task.title}</strong>
|
||||
{task.priority !== 'mittel' && (
|
||||
<span className={`prio prio-${task.priority}`} title={`Priorität: ${PRIORITIES.find((p) => p.key === task.priority)?.title}`}>
|
||||
{PRIORITIES.find((p) => p.key === task.priority)?.title}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{task.description && <span className="task-desc">{task.description}</span>}
|
||||
<span className="task-meta">
|
||||
<Assignee id={task.assignee} users={users} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDialog({ ref, target, users, me, onSave, onDelete }) {
|
||||
const task = target?.task;
|
||||
// Wird pro Öffnen neu erzeugt (key), daher direkt aus den Daten vorbelegen
|
||||
const [form, setForm] = useState(() => (task
|
||||
? { title: task.title, description: task.description, status: task.status, assignee: task.assignee ?? '', priority: task.priority || 'mittel' }
|
||||
: { title: '', description: '', status: target?.status || 'offen', assignee: '', priority: 'mittel' }));
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) });
|
||||
const canDelete = task && (me.isAdmin || task.createdBy?.id === me.id);
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const ok = await onSave(task?.id, {
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
status: form.status,
|
||||
assignee: form.assignee === '' ? null : Number(form.assignee),
|
||||
priority: form.priority,
|
||||
});
|
||||
setBusy(false);
|
||||
if (ok) ref.current.close();
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!window.confirm(`Aufgabe „${task.title}“ löschen?`)) return;
|
||||
setBusy(true);
|
||||
const ok = await onDelete(task.id);
|
||||
setBusy(false);
|
||||
if (ok) ref.current.close();
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog className="modal task-dialog" ref={ref}>
|
||||
<form className="form" onSubmit={submit}>
|
||||
<h2>{task ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'}</h2>
|
||||
<label>
|
||||
Titel
|
||||
<input {...field('title')} maxLength={120} required autoFocus />
|
||||
</label>
|
||||
<label>
|
||||
Beschreibung
|
||||
<textarea {...field('description')} rows={6} maxLength={5000} placeholder="Was ist zu tun? Details, Links, Notizen …" />
|
||||
</label>
|
||||
<div className="task-dialog-row">
|
||||
<label>
|
||||
Status
|
||||
<select {...field('status')}>
|
||||
{COLUMNS.map((c) => <option key={c.key} value={c.key}>{c.title}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Priorität
|
||||
<select {...field('priority')}>
|
||||
{PRIORITIES.map((p) => <option key={p.key} value={p.key}>{p.title}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Bearbeiter
|
||||
<select {...field('assignee')}>
|
||||
<option value="">Niemand</option>
|
||||
{users.map((u) => <option key={u.id} value={u.id}>{u.name}{u.id === me.id ? ' (ich)' : ''}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{task && (
|
||||
<p className="hint task-history">
|
||||
Angelegt von {personName(task.createdBy)} am {fmtDate(task.createdAt)}
|
||||
{task.updatedAt !== task.createdAt && <> · zuletzt geändert von {personName(task.updatedBy)} am {fmtDate(task.updatedAt)}</>}
|
||||
</p>
|
||||
)}
|
||||
<div className="form-actions">
|
||||
{canDelete && <button className="link-btn danger task-delete" type="button" disabled={busy} onClick={remove}>Löschen</button>}
|
||||
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
|
||||
<button className="btn" type="submit" disabled={busy}>{task ? 'Speichern' : 'Anlegen'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../auth.jsx';
|
||||
|
||||
// Anmeldung bzw. – beim allerersten Start – Anlegen des ersten Kontos (Administrator)
|
||||
export default function Login() {
|
||||
const { state, login, setup } = useAuth();
|
||||
const isSetup = state === 'setup';
|
||||
const [form, setForm] = useState({ username: '', displayName: '', password: '', repeat: '' });
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) });
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (isSetup && form.password !== form.repeat) return setError('Die Passwörter stimmen nicht überein.');
|
||||
setBusy(true);
|
||||
try {
|
||||
if (isSetup) await setup({ username: form.username, displayName: form.displayName, password: form.password });
|
||||
else await login(form.username, form.password);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<form className="card auth-card form" onSubmit={submit}>
|
||||
<div className="brand auth-brand">P42<span>.</span>Dashboard</div>
|
||||
<h2>{isSetup ? 'Erstes Konto anlegen' : 'Anmelden'}</h2>
|
||||
{isSetup && (
|
||||
<p className="hint">
|
||||
Dieses Konto wird Administrator und kann weitere Konten anlegen.
|
||||
Bisherige Projekte und Links werden ihm zugeordnet.
|
||||
</p>
|
||||
)}
|
||||
<label>
|
||||
Benutzername
|
||||
<input {...field('username')} autoComplete="username" autoCapitalize="none" required autoFocus />
|
||||
</label>
|
||||
{isSetup && (
|
||||
<label>
|
||||
Anzeigename <small>(wird bei Artikeln angezeigt)</small>
|
||||
<input {...field('displayName')} placeholder="z. B. Elias" maxLength={60} />
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
Passwort
|
||||
<input {...field('password')} type="password" autoComplete={isSetup ? 'new-password' : 'current-password'} required />
|
||||
</label>
|
||||
{isSetup && (
|
||||
<label>
|
||||
Passwort wiederholen
|
||||
<input {...field('repeat')} type="password" autoComplete="new-password" required />
|
||||
</label>
|
||||
)}
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button className="btn full" type="submit" disabled={busy}>
|
||||
{busy ? 'Bitte warten …' : isSetup ? 'Konto anlegen' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const REFRESH_SECONDS = 30;
|
||||
const TYPES = {
|
||||
http: { label: 'Webseite (HTTP)', placeholder: 'https://jellyfin.example.de', hint: 'Online, wenn die Seite antwortet.' },
|
||||
tcp: { label: 'Port (TCP)', placeholder: '192.168.0.10:22', hint: 'Online, wenn der Port Verbindungen annimmt.' },
|
||||
};
|
||||
|
||||
const fmtTime = (iso) => new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
|
||||
export default function Monitoring() {
|
||||
const [monitors, setMonitors] = useState(null);
|
||||
const [status, setStatus] = useState({});
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [editing, setEditing] = useState(null); // null | {} (neu) | monitor
|
||||
const dialog = useRef(null);
|
||||
|
||||
const check = useCallback(async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const results = await api('GET', '/api/monitors/status');
|
||||
setStatus(Object.fromEntries(results.map((r) => [r.id, r])));
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setMonitors(await api('GET', '/api/monitors'));
|
||||
await check();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}, [check]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = setInterval(check, REFRESH_SECONDS * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [load, check]);
|
||||
|
||||
// Erst rendern (Formular ist dann schon befüllt), dann öffnen
|
||||
function openDialog(monitor) {
|
||||
setEditing({ ...(monitor || {}), opened: Date.now() });
|
||||
}
|
||||
useEffect(() => {
|
||||
if (editing) dialog.current?.showModal();
|
||||
}, [editing]);
|
||||
|
||||
async function remove(m) {
|
||||
if (!window.confirm(`„${m.name}“ aus dem Monitoring entfernen?`)) return;
|
||||
try {
|
||||
await api('DELETE', `/api/monitors/${m.id}`);
|
||||
setMonitors((list) => list.filter((x) => x.id !== m.id));
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const online = monitors ? monitors.filter((m) => status[m.id]?.ok).length : 0;
|
||||
const lastCheck = Object.values(status).map((s) => s.checkedAt).sort().at(-1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Server-Monitoring</h1>
|
||||
<p className="lead">
|
||||
Deine Server – nur für dein Konto sichtbar. Wird alle {REFRESH_SECONDS} Sekunden geprüft.
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
{monitors?.length > 0 && (
|
||||
<button className="btn ghost" type="button" onClick={check} disabled={checking}>
|
||||
{checking ? 'Prüfe …' : 'Jetzt prüfen'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn" type="button" onClick={() => openDialog()}>+ Server hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
{!monitors && !error && <p className="hint">Lade …</p>}
|
||||
{monitors?.length === 0 && (
|
||||
<div className="notice">
|
||||
Noch keine Server eingetragen. Über „+ Server hinzufügen“ legst du fest, welche Webseiten oder Ports geprüft werden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{monitors?.length > 0 && (
|
||||
<>
|
||||
<p className="monitor-summary">
|
||||
<span className={`dot ${online === monitors.length ? 'ok' : online === 0 ? 'bad' : 'warn'}`} />
|
||||
{online} von {monitors.length} online
|
||||
{lastCheck && <span className="monitor-summary-time">Stand {fmtTime(lastCheck)}</span>}
|
||||
</p>
|
||||
<div className="grid">
|
||||
{monitors.map((m) => {
|
||||
const s = status[m.id];
|
||||
const state = !s ? 'unknown' : s.ok ? 'ok' : 'bad';
|
||||
return (
|
||||
<article className={`card monitor-card ${state}`} key={m.id}>
|
||||
<div className="monitor-head">
|
||||
<h3>{m.name}</h3>
|
||||
<span className={`badge ${state === 'ok' ? 'ok' : state === 'bad' ? 'bad' : 'muted'}`}>
|
||||
{state === 'ok' ? 'Online' : state === 'bad' ? 'Offline' : 'Wird geprüft …'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="monitor-target">{m.target}</p>
|
||||
<dl className="monitor-facts">
|
||||
<div><dt>Typ</dt><dd>{TYPES[m.type]?.label}</dd></div>
|
||||
<div><dt>Antwortzeit</dt><dd>{s?.ms != null ? `${s.ms} ms` : '—'}</dd></div>
|
||||
<div><dt>Ergebnis</dt><dd>{s?.detail || '—'}</dd></div>
|
||||
</dl>
|
||||
<div className="meta monitor-foot">
|
||||
<span>{s ? `Geprüft ${fmtTime(s.checkedAt)}` : ''}</span>
|
||||
<span className="monitor-actions">
|
||||
<button className="link-btn" type="button" onClick={() => openDialog(m)}>Bearbeiten</button>
|
||||
<button className="link-btn danger" type="button" onClick={() => remove(m)}>Entfernen</button>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<MonitorDialog key={editing?.opened ?? 'zu'} ref={dialog} monitor={editing} onSaved={load} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MonitorDialog({ ref, monitor, onSaved }) {
|
||||
// Wird pro Öffnen neu erzeugt (key beim Aufrufer), daher direkt vorbelegen
|
||||
const [form, setForm] = useState(() => ({ name: monitor?.name || '', type: monitor?.type || 'http', target: monitor?.target || '' }));
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const isNew = !monitor?.id;
|
||||
|
||||
const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) });
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (isNew) await api('POST', '/api/monitors', form);
|
||||
else await api('PUT', `/api/monitors/${monitor.id}`, form);
|
||||
ref.current.close();
|
||||
await onSaved();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const type = TYPES[form.type];
|
||||
|
||||
return (
|
||||
<dialog className="modal" ref={ref}>
|
||||
<form className="form" onSubmit={submit}>
|
||||
<h2>{isNew ? 'Server hinzufügen' : 'Server bearbeiten'}</h2>
|
||||
<label>
|
||||
Name
|
||||
<input {...field('name')} maxLength={60} placeholder="z. B. Jellyfin" required />
|
||||
</label>
|
||||
<label>
|
||||
Prüfung
|
||||
<select {...field('type')}>
|
||||
{Object.entries(TYPES).map(([k, t]) => <option key={k} value={k}>{t.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{form.type === 'http' ? 'Adresse' : 'Host und Port'}
|
||||
<input {...field('target')} placeholder={type.placeholder} autoCapitalize="none" spellCheck={false} required />
|
||||
</label>
|
||||
<p className="hint">{type.hint}</p>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
|
||||
<button className="btn" type="submit" disabled={busy}>Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="center">
|
||||
<h1>404 – Seite nicht gefunden</h1>
|
||||
<p><Link to="/">Zur Startseite</Link></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useBlocker, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { api, fmtDate, imgUrl, personName, STATUS, uploadImage } from '../api.js';
|
||||
import { useAuth } from '../auth.jsx';
|
||||
import Editor from '../components/Editor.jsx';
|
||||
import Lightbox from '../components/Lightbox.jsx';
|
||||
import StatusBadge from '../components/StatusBadge.jsx';
|
||||
|
||||
export default function Project() {
|
||||
const { id } = useParams();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [project, setProject] = useState(null);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [zoom, setZoom] = useState(null);
|
||||
const editor = useRef(null);
|
||||
const coverInput = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setProject(null);
|
||||
setLoadError(null);
|
||||
api('GET', `/api/projects/${encodeURIComponent(id)}`)
|
||||
.then((p) => {
|
||||
setProject(p);
|
||||
if (params.has('bearbeiten')) startEditing(p);
|
||||
})
|
||||
.catch((e) => setLoadError(e.message === 'Nicht gefunden.' ? 'Projekt nicht gefunden.' : e.message));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (project) document.title = `${project.title} – P42 Projekte`;
|
||||
return () => { document.title = 'P42 Dashboard'; };
|
||||
}, [project]);
|
||||
|
||||
// Warnung beim Verlassen mit ungespeicherten Änderungen (innerhalb der App und beim Schließen)
|
||||
const dirtyRef = useRef(false);
|
||||
dirtyRef.current = dirty;
|
||||
const blocker = useBlocker(({ currentLocation, nextLocation }) =>
|
||||
dirtyRef.current && currentLocation.pathname !== nextLocation.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
if (blocker.state !== 'blocked') return;
|
||||
if (window.confirm('Ungespeicherte Änderungen verwerfen?')) blocker.proceed();
|
||||
else blocker.reset();
|
||||
}, [blocker]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const warn = (e) => e.preventDefault();
|
||||
window.addEventListener('beforeunload', warn);
|
||||
return () => window.removeEventListener('beforeunload', warn);
|
||||
}, [dirty]);
|
||||
|
||||
function startEditing(p = project) {
|
||||
setDraft({ title: p.title, description: p.description, status: p.status, cover: p.cover });
|
||||
setEditing(true);
|
||||
setDirty(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function stopEditing() {
|
||||
setEditing(false);
|
||||
setDirty(false);
|
||||
setDraft(null);
|
||||
if (params.has('bearbeiten')) setParams({}, { replace: true });
|
||||
}
|
||||
|
||||
const change = (field) => (e) => {
|
||||
setDraft((d) => ({ ...d, [field]: e.target.value }));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
async function changeCover(e) {
|
||||
const file = e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const cover = await uploadImage(file);
|
||||
setDraft((d) => ({ ...d, cover }));
|
||||
setDirty(true);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await api('PATCH', `/api/projects/${id}`, { ...draft, content: editor.current.getHTML() });
|
||||
setProject(updated);
|
||||
stopEditing();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (dirty && !window.confirm('Änderungen verwerfen?')) return;
|
||||
stopEditing();
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!window.confirm(`„${project.title}“ mit allen Bildern endgültig löschen?`)) return;
|
||||
try {
|
||||
await api('DELETE', `/api/projects/${id}`);
|
||||
dirtyRef.current = false;
|
||||
navigate('/projekte');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Klick auf Bilder im Artikel vergrößert sie
|
||||
function onArticleClick(e) {
|
||||
if (e.target.tagName === 'IMG') setZoom(e.target.src);
|
||||
}
|
||||
|
||||
if (loadError) return <><BackLink /><p className="error">{loadError}</p></>;
|
||||
if (!project) return <><BackLink /><p className="hint">Lade Projekt …</p></>;
|
||||
|
||||
const cover = editing ? draft.cover : project.cover;
|
||||
// Löschen dürfen nur der Autor und Administratoren
|
||||
const canDelete = user.isAdmin || project.createdBy?.id === user.id;
|
||||
|
||||
return (
|
||||
<div className={`article-page${editing ? ' editing' : ''}`}>
|
||||
<BackLink />
|
||||
|
||||
{(cover || editing) && (
|
||||
<figure className={`article-cover${cover ? '' : ' empty'}`}>
|
||||
{cover && <img src={imgUrl(cover)} alt="" onClick={() => !editing && setZoom(imgUrl(cover))} />}
|
||||
{editing && (
|
||||
<div className="cover-actions">
|
||||
<button className="btn ghost small" type="button" disabled={uploading} onClick={() => coverInput.current.click()}>
|
||||
{uploading ? 'Lade …' : cover ? 'Titelbild ändern' : 'Titelbild wählen'}
|
||||
</button>
|
||||
{cover && (
|
||||
<button className="btn ghost small" type="button" onClick={() => { setDraft((d) => ({ ...d, cover: null })); setDirty(true); }}>
|
||||
Entfernen
|
||||
</button>
|
||||
)}
|
||||
<input ref={coverInput} type="file" accept="image/*" hidden onChange={changeCover} />
|
||||
</div>
|
||||
)}
|
||||
</figure>
|
||||
)}
|
||||
|
||||
<div className="article-layout">
|
||||
<article className="article-main">
|
||||
{editing ? (
|
||||
<>
|
||||
<input className="title-input" value={draft.title} onChange={change('title')} maxLength={120} placeholder="Titel" aria-label="Titel" />
|
||||
<textarea
|
||||
className="lead-input"
|
||||
value={draft.description}
|
||||
onChange={change('description')}
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
placeholder="Kurzbeschreibung (erscheint auch in der Übersicht)"
|
||||
aria-label="Kurzbeschreibung"
|
||||
/>
|
||||
<Editor
|
||||
ref={editor}
|
||||
content={project.content}
|
||||
onChange={() => setDirty(true)}
|
||||
onUploading={setUploading}
|
||||
onError={setError}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="article-title">{project.title}</h1>
|
||||
<p className="article-byline">
|
||||
Von <strong>{personName(project.createdBy)}</strong>
|
||||
{project.updatedAt !== project.createdAt && (
|
||||
<> · zuletzt bearbeitet von <strong>{personName(project.updatedBy)}</strong> am {fmtDate(project.updatedAt)}</>
|
||||
)}
|
||||
</p>
|
||||
{project.description && <p className="article-lead">{project.description}</p>}
|
||||
{project.content ? (
|
||||
<ArticleBody html={project.content} onClick={onArticleClick} />
|
||||
) : (
|
||||
<p className="hint article-empty">Noch kein Artikeltext. Über „Bearbeiten“ kannst du ihn schreiben.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<aside className="article-aside">
|
||||
<div className="card aside-card">
|
||||
<dl className="aside-facts">
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>
|
||||
{editing ? (
|
||||
<select value={draft.status} onChange={change('status')} aria-label="Status">
|
||||
{Object.entries(STATUS).map(([k, [label]]) => <option key={k} value={k}>{label}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<StatusBadge status={project.status} />
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Geschrieben von</dt>
|
||||
<dd>{personName(project.createdBy)}<span className="sub">{fmtDate(project.createdAt)}</span></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Zuletzt bearbeitet von</dt>
|
||||
<dd>{personName(project.updatedBy)}<span className="sub">{fmtDate(project.updatedAt)}</span></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Kanban-Boards</dt>
|
||||
<dd className="aside-boards">
|
||||
{project.boards?.map((b) => <Link key={b.id} to={`/kanban/${b.id}`}>▦ {b.name}</Link>)}
|
||||
<Link className="sub" to={`/kanban?artikel=${project.id}`}>+ Board zu diesem Artikel</Link>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{!editing && (
|
||||
<button className="btn full" type="button" onClick={() => startEditing()}>Bearbeiten</button>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<div className="edit-actions">
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="edit-buttons">
|
||||
<button className="btn ghost" type="button" onClick={cancel}>Abbrechen</button>
|
||||
<button className="btn" type="button" onClick={save} disabled={busy || uploading}>
|
||||
{busy ? 'Speichere …' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
{canDelete && <button className="link-btn danger" type="button" onClick={remove}>Projekt löschen</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Lightbox src={zoom} onClose={() => setZoom(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackLink() {
|
||||
return <Link className="back" to="/projekte">← Alle Projekte</Link>;
|
||||
}
|
||||
|
||||
// Artikeltext wird serverseitig bereinigt (nh3) und kann daher direkt eingesetzt werden
|
||||
function ArticleBody({ html, onClick }) {
|
||||
const ref = useRef(null);
|
||||
useEffect(() => {
|
||||
ref.current.querySelectorAll('a[href^="http"]').forEach((a) => { a.target = '_blank'; });
|
||||
}, [html]);
|
||||
return <div ref={ref} className="article-body" onClick={onClick} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { api, fmtDate, imgUrl, personName } from '../api.js';
|
||||
import StatusBadge from '../components/StatusBadge.jsx';
|
||||
|
||||
export default function Projects() {
|
||||
const [projects, setProjects] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const dialog = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
api('GET', '/api/projects').then(setProjects).catch((e) => setError(e.message));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>P42 Projekte</h1>
|
||||
<p className="lead">Übersicht aller laufenden und abgeschlossenen Projekte.</p>
|
||||
</div>
|
||||
<button className="btn" type="button" onClick={() => dialog.current.showModal()}>+ Neues Projekt</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
{!projects && !error && <p className="hint">Lade Projekte …</p>}
|
||||
{projects?.length === 0 && (
|
||||
<div className="notice">Noch keine Projekte. Leg mit „+ Neues Projekt“ das erste an.</div>
|
||||
)}
|
||||
|
||||
{projects?.length > 0 && (
|
||||
<div className="grid projects-grid">
|
||||
{projects.map((p) => (
|
||||
<Link key={p.id} className="card project-card" to={`/projekte/${p.id}`}>
|
||||
<div className="project-cover-wrap">
|
||||
{p.cover
|
||||
? <img className="project-cover" src={imgUrl(p.cover)} alt="" loading="lazy" />
|
||||
: <div className="project-cover empty" />}
|
||||
<StatusBadge status={p.status} />
|
||||
</div>
|
||||
<div className="project-body">
|
||||
<h3>{p.title}</h3>
|
||||
{p.description && <p className="project-teaser">{p.description}</p>}
|
||||
<div className="meta project-meta">
|
||||
<span>Von {personName(p.createdBy)}</span>
|
||||
<span title="Zuletzt bearbeitet">✎ {personName(p.updatedBy)} · {fmtDate(p.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NewProjectDialog ref={dialog} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NewProjectDialog({ ref }) {
|
||||
const navigate = useNavigate();
|
||||
const [title, setTitle] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const project = await api('POST', '/api/projects', { title });
|
||||
navigate(`/projekte/${project.id}?bearbeiten`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog className="modal" ref={ref} onClose={() => { setTitle(''); setError(null); }}>
|
||||
<form className="form" onSubmit={submit}>
|
||||
<h2>Neues Projekt</h2>
|
||||
<label>
|
||||
Titel
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} required maxLength={120} autoFocus />
|
||||
</label>
|
||||
<p className="hint">Beschreibung, Bilder und Text schreibst du danach direkt im Artikel.</p>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
|
||||
<button className="btn" type="submit" disabled={busy}>Projekt anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { feature } from 'topojson-client';
|
||||
import { api } from '../api.js';
|
||||
import WorldMap from '../components/WorldMap.jsx';
|
||||
import LAENDER from '../data/laender.json';
|
||||
|
||||
const BY_CODE = new Map(LAENDER.map((c) => [c.code, c]));
|
||||
const BY_NUM = new Map(LAENDER.filter((c) => c.num).map((c) => [c.num, c]));
|
||||
// Gebiete auf der Karte ohne ISO-Nummer
|
||||
const SPECIAL = {
|
||||
Kosovo: 'XK',
|
||||
Somaliland: 'X-Somaliland',
|
||||
'N. Cyprus': 'X-Nordzypern',
|
||||
'Indian Ocean Ter.': 'X-Indischer Ozean',
|
||||
'Siachen Glacier': 'X-Siachen-Gletscher',
|
||||
};
|
||||
|
||||
const languageName = new Intl.DisplayNames(['de'], { type: 'language' });
|
||||
const currencyName = new Intl.DisplayNames(['de'], { type: 'currency' });
|
||||
const num = (n) => n.toLocaleString('de-DE');
|
||||
const people = (n) => (n >= 1e6 ? `${(n / 1e6).toLocaleString('de-DE', { maximumFractionDigits: 1 })} Mio.` : num(n));
|
||||
const safe = (fn, v) => { try { return fn(v); } catch { return v; } };
|
||||
|
||||
function countryName(key) {
|
||||
return BY_CODE.get(key)?.name || (key.startsWith('X-') ? key.slice(2) : key);
|
||||
}
|
||||
|
||||
function localTime(timezone) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('de-DE', { timeZone: timezone, hour: '2-digit', minute: '2-digit' }).format(new Date());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Reisen() {
|
||||
const [data, setData] = useState(null);
|
||||
const [shapes, setShapes] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [wide, setWide] = useState(() => window.matchMedia('(min-width: 760px)').matches);
|
||||
const map = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
api('GET', '/api/reisen').then(setData).catch((e) => setError(e.message));
|
||||
fetch('/maps/countries-50m.json')
|
||||
.then((r) => r.json())
|
||||
.then((topo) => {
|
||||
// Teile mit derselben Länder-Nummer (z. B. Ashmore- und Cartierinseln → Australien) zusammenführen
|
||||
const byKey = new Map();
|
||||
for (const f of feature(topo, topo.objects.countries).features) {
|
||||
const key = BY_NUM.get(f.id)?.code || SPECIAL[f.properties.name] || `X-${f.properties.name}`;
|
||||
const polygons = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
|
||||
if (byKey.has(key)) byKey.get(key).feature.geometry.coordinates.push(...polygons);
|
||||
else byKey.set(key, { key, name: countryName(key), feature: { ...f, geometry: { type: 'MultiPolygon', coordinates: polygons } } });
|
||||
}
|
||||
setShapes([...byKey.values()]);
|
||||
})
|
||||
.catch(() => setError('Die Weltkarte konnte nicht geladen werden.'));
|
||||
const mq = window.matchMedia('(min-width: 760px)');
|
||||
const onChange = () => setWide(mq.matches);
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => e.key === 'Escape' && setSelected(null);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
const cities = data?.cities || [];
|
||||
// Besucht = von Hand markiert oder mindestens eine Stadt dort
|
||||
const visited = useMemo(
|
||||
() => new Set([...(data?.countries || []), ...cities.map((c) => c.country)]),
|
||||
[data, cities],
|
||||
);
|
||||
const visitedList = [...visited].sort((a, b) => countryName(a).localeCompare(countryName(b), 'de'));
|
||||
|
||||
const select = useCallback((sel, { zoom = true } = {}) => {
|
||||
setSelected(sel);
|
||||
if (!sel || !zoom) return;
|
||||
if (sel.type === 'country') map.current?.focusCountry(sel.key, wide);
|
||||
else {
|
||||
const city = data?.cities.find((c) => c.id === sel.key);
|
||||
if (city) map.current?.focusCity(city, wide);
|
||||
}
|
||||
}, [data, wide]);
|
||||
|
||||
async function run(method, url, body) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await api(method, url, body);
|
||||
setData(next);
|
||||
return next;
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
return null;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function addCity(result) {
|
||||
const next = await run('POST', '/api/reisen/staedte', result);
|
||||
const city = next?.cities.find((c) => c.geonameId === result.geonameId);
|
||||
if (city) {
|
||||
setSelected({ type: 'city', key: city.id });
|
||||
map.current?.focusCity(city, wide);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCity = selected?.type === 'city' ? cities.find((c) => c.id === selected.key) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Reisen</h1>
|
||||
<p className="lead">
|
||||
{data ? `${visited.size} ${visited.size === 1 ? 'Land' : 'Länder'} · ${cities.length} ${cities.length === 1 ? 'Stadt' : 'Städte'}` : 'Lade …'}
|
||||
{' '}· Klick auf ein Land oder einen Punkt zeigt Infos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CitySearch cities={cities} onAdd={addCity} busy={busy} />
|
||||
{error && <p className="error travel-error" role="alert">{error}</p>}
|
||||
|
||||
<div className="travel-map card">
|
||||
{shapes ? (
|
||||
<WorldMap
|
||||
ref={map}
|
||||
countries={shapes}
|
||||
visited={visited}
|
||||
cities={cities}
|
||||
selected={selected}
|
||||
onSelect={(sel) => select(sel)}
|
||||
/>
|
||||
) : (
|
||||
<p className="hint travel-loading">Lade Weltkarte …</p>
|
||||
)}
|
||||
<div className="map-controls" role="group" aria-label="Kartenansicht">
|
||||
<button type="button" onClick={() => map.current?.zoomBy(2)} aria-label="Hineinzoomen">+</button>
|
||||
<button type="button" onClick={() => map.current?.zoomBy(0.5)} aria-label="Herauszoomen">−</button>
|
||||
<button type="button" onClick={() => { setSelected(null); map.current?.reset(); }} aria-label="Ganze Welt zeigen">⌂</button>
|
||||
</div>
|
||||
{selected && (
|
||||
<InfoCard
|
||||
key={`${selected.type}-${selected.key}`}
|
||||
selected={selected}
|
||||
city={selectedCity}
|
||||
cities={cities}
|
||||
manual={data?.countries || []}
|
||||
busy={busy}
|
||||
onSelect={select}
|
||||
onClose={() => setSelected(null)}
|
||||
onMark={(code, mark) => run(mark ? 'PUT' : 'DELETE', `/api/reisen/laender/${encodeURIComponent(code)}`)}
|
||||
onRemoveCity={async (city) => {
|
||||
if (!window.confirm(`„${city.name}“ von der Karte entfernen?`)) return;
|
||||
if (await run('DELETE', `/api/reisen/staedte/${city.id}`)) setSelected({ type: 'country', key: city.country });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<VisitedList
|
||||
visited={visitedList}
|
||||
cities={cities}
|
||||
manual={data.countries}
|
||||
selected={selected}
|
||||
onSelect={select}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="hint travel-credits">
|
||||
Karte: Natural Earth / world-atlas · Länderdaten: world-countries (ODbL) · Orte: GeoNames über Open-Meteo (CC BY 4.0)
|
||||
· Texte: Wikipedia (CC BY-SA 4.0) · Einwohner: Wikidata
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Besuchte Orte: Kontinent → Land (ausklappbar) → Städte ----------
|
||||
|
||||
const CONTINENTS = ['Europa', 'Asien', 'Afrika', 'Nordamerika', 'Südamerika', 'Ozeanien', 'Antarktis'];
|
||||
// Gebiete der Karte ohne Eintrag in den Länderdaten
|
||||
const SPECIAL_CONTINENT = { 'X-Somaliland': 'Afrika', 'X-Nordzypern': 'Europa', 'X-Indischer Ozean': 'Ozeanien', 'X-Siachen-Gletscher': 'Asien' };
|
||||
const continentOf = (code) => BY_CODE.get(code)?.continent || SPECIAL_CONTINENT[code] || 'Sonstige';
|
||||
const OPEN_KEY = 'p42.reisenOffen';
|
||||
const CLOSED_KEY = 'p42.reisenKontinenteZu';
|
||||
|
||||
function loadSet(key) {
|
||||
try { return new Set(JSON.parse(localStorage.getItem(key)) || []); } catch { return new Set(); }
|
||||
}
|
||||
const loadOpen = () => loadSet(OPEN_KEY);
|
||||
|
||||
function VisitedList({ visited, cities, manual, selected, onSelect }) {
|
||||
const [open, setOpen] = useState(loadOpen);
|
||||
|
||||
const save = (next) => {
|
||||
setOpen(next);
|
||||
try { localStorage.setItem(OPEN_KEY, JSON.stringify([...next])); } catch { /* ignorieren */ }
|
||||
};
|
||||
const toggle = (code) => {
|
||||
const next = new Set(open);
|
||||
if (next.has(code)) next.delete(code); else next.add(code);
|
||||
save(next);
|
||||
};
|
||||
|
||||
// Kontinente: standardmäßig offen, zugeklappte werden gemerkt
|
||||
const [closed, setClosed] = useState(() => loadSet(CLOSED_KEY));
|
||||
const saveClosed = (next) => {
|
||||
setClosed(next);
|
||||
try { localStorage.setItem(CLOSED_KEY, JSON.stringify([...next])); } catch { /* ignorieren */ }
|
||||
};
|
||||
const toggleContinent = (name) => {
|
||||
const next = new Set(closed);
|
||||
if (next.has(name)) next.delete(name); else next.add(name);
|
||||
saveClosed(next);
|
||||
};
|
||||
|
||||
// Wird eine Stadt (z. B. auf der Karte) gewählt, ihr Land aufklappen
|
||||
const selectedCountry = selected?.type === 'city' ? cities.find((c) => c.id === selected.key)?.country : null;
|
||||
useEffect(() => {
|
||||
if (selectedCountry && !open.has(selectedCountry)) save(new Set(open).add(selectedCountry));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedCountry]);
|
||||
|
||||
// Ein gewähltes Land oder eine gewählte Stadt macht ihren Kontinent sichtbar
|
||||
const selectedCode = selected?.type === 'country' ? selected.key : selectedCountry;
|
||||
const selectedContinent = selectedCode ? continentOf(selectedCode) : null;
|
||||
useEffect(() => {
|
||||
if (selectedContinent && closed.has(selectedContinent)) {
|
||||
const next = new Set(closed);
|
||||
next.delete(selectedContinent);
|
||||
saveClosed(next);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedContinent, selected]);
|
||||
|
||||
if (!visited.length) {
|
||||
return <p className="hint travel-empty">Noch keine Orte. Füge eine Stadt hinzu oder klick ein Land auf der Karte an und markiere es.</p>;
|
||||
}
|
||||
|
||||
const groups = [...CONTINENTS, 'Sonstige']
|
||||
.map((name) => ({ name, countries: visited.filter((code) => continentOf(code) === name) }))
|
||||
.filter((g) => g.countries.length);
|
||||
|
||||
return (
|
||||
<div className="visited-list">
|
||||
{groups.map((group) => {
|
||||
const cityCount = cities.filter((c) => continentOf(c.country) === group.name).length;
|
||||
const contOpen = !closed.has(group.name);
|
||||
const id = `cont-${group.name}`;
|
||||
return (
|
||||
<section key={group.name} className={`visited-continent${contOpen ? '' : ' closed'}`} aria-labelledby={id}>
|
||||
<h2 className="section-title">
|
||||
<button
|
||||
type="button"
|
||||
id={id}
|
||||
className="continent-toggle"
|
||||
aria-expanded={contOpen}
|
||||
aria-controls={`${id}-list`}
|
||||
onClick={() => toggleContinent(group.name)}
|
||||
>
|
||||
<span className="continent-arrow" aria-hidden="true">▸</span>
|
||||
{group.name}
|
||||
<small className="visited-count">
|
||||
{group.countries.length} {group.countries.length === 1 ? 'Land' : 'Länder'} · {cityCount} {cityCount === 1 ? 'Stadt' : 'Städte'}
|
||||
</small>
|
||||
</button>
|
||||
</h2>
|
||||
{contOpen && (
|
||||
<ul className="visited-countries" id={`${id}-list`}>
|
||||
{group.countries.map((code) => {
|
||||
const list = cities.filter((c) => c.country === code);
|
||||
const isOpen = open.has(code) && list.length > 0;
|
||||
const active = selected?.type === 'country' && selected.key === code;
|
||||
return (
|
||||
<li key={code} className={`visited-country${isOpen ? ' open' : ''}${active ? ' active' : ''}`}>
|
||||
<div className="visited-row">
|
||||
<button
|
||||
type="button"
|
||||
className="visited-toggle"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={`cities-${code}`}
|
||||
disabled={!list.length}
|
||||
onClick={() => toggle(code)}
|
||||
aria-label={list.length ? `Städte in ${countryName(code)} ${isOpen ? 'zuklappen' : 'aufklappen'}` : `Keine Städte in ${countryName(code)}`}
|
||||
>
|
||||
<span aria-hidden="true">{list.length ? '▸' : '·'}</span>
|
||||
</button>
|
||||
<button type="button" className="visited-name" onClick={() => onSelect({ type: 'country', key: code })}>
|
||||
<span className="visited-flag" aria-hidden="true">{BY_CODE.get(code)?.flag}</span>
|
||||
{countryName(code)}
|
||||
</button>
|
||||
<span className="visited-meta">
|
||||
{list.length ? `${list.length} ${list.length === 1 ? 'Stadt' : 'Städte'}` : manual.includes(code) ? 'markiert' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<ul className="chip-list visited-cities" id={`cities-${code}`}>
|
||||
{list.map((c) => (
|
||||
<li key={c.id}>
|
||||
<button type="button" className={`chip${selected?.type === 'city' && selected.key === c.id ? ' active' : ''}`} onClick={() => onSelect({ type: 'city', key: c.id })}>
|
||||
{c.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Stadt suchen und hinzufügen ----------
|
||||
|
||||
function CitySearch({ cities, onAdd, busy }) {
|
||||
const [q, setQ] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(-1);
|
||||
const [state, setState] = useState('idle'); // idle | loading | error
|
||||
const timer = useRef(null);
|
||||
const request = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeout(timer.current);
|
||||
const query = q.trim();
|
||||
if (query.length < 2) { setResults([]); setState('idle'); return undefined; }
|
||||
// Erst suchen, wenn kurz nicht mehr getippt wird
|
||||
timer.current = setTimeout(async () => {
|
||||
const id = ++request.current;
|
||||
setState('loading');
|
||||
try {
|
||||
const list = await api('GET', `/api/reisen/suche?q=${encodeURIComponent(query)}`);
|
||||
if (id !== request.current) return;
|
||||
setResults(list);
|
||||
setActive(-1);
|
||||
setState('idle');
|
||||
} catch {
|
||||
if (id === request.current) setState('error');
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer.current);
|
||||
}, [q]);
|
||||
|
||||
const known = new Set(cities.map((c) => c.geonameId));
|
||||
|
||||
async function pick(result) {
|
||||
if (known.has(result.geonameId)) return;
|
||||
setOpen(false);
|
||||
setQ('');
|
||||
setResults([]);
|
||||
await onAdd(result);
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setOpen(true); setActive((i) => Math.min(i + 1, results.length - 1)); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)); }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); const r = results[active >= 0 ? active : 0]; if (r) pick(r); }
|
||||
else if (e.key === 'Escape') setOpen(false);
|
||||
}
|
||||
|
||||
const show = open && q.trim().length >= 2;
|
||||
|
||||
return (
|
||||
<div className="city-search combo">
|
||||
<label className="sr-only" htmlFor="city-search">Stadt hinzufügen</label>
|
||||
<div className="prompt-input">
|
||||
<input
|
||||
id="city-search"
|
||||
value={q}
|
||||
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Stadt hinzufügen, in der du schon warst …"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={show && results.length > 0}
|
||||
aria-controls="city-results"
|
||||
aria-activedescendant={show && active >= 0 ? `city-result-${active}` : undefined}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
{show && (
|
||||
<ul className="combo-list" id="city-results" role="listbox">
|
||||
{state === 'loading' && results.length === 0 && <li className="combo-info">Suche …</li>}
|
||||
{state === 'error' && <li className="combo-info">Suche gerade nicht erreichbar.</li>}
|
||||
{state === 'idle' && results.length === 0 && <li className="combo-info">Keine Stadt gefunden.</li>}
|
||||
{results.map((r, i) => {
|
||||
const added = known.has(r.geonameId);
|
||||
return (
|
||||
<li
|
||||
key={r.geonameId}
|
||||
id={`city-result-${i}`}
|
||||
role="option"
|
||||
aria-selected={i === active}
|
||||
aria-disabled={added || undefined}
|
||||
className={`${i === active ? 'active' : ''}${added ? ' added' : ''}`}
|
||||
onMouseDown={(e) => { e.preventDefault(); pick(r); }}
|
||||
>
|
||||
<span>
|
||||
{BY_CODE.get(r.country)?.flag} {r.name}
|
||||
<small> {[r.admin1, r.countryName].filter(Boolean).join(', ')}</small>
|
||||
</span>
|
||||
<small>{added ? '✓ schon eingetragen' : r.population ? `${people(r.population)} Einw.` : ''}</small>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Info-Kasten unten links ----------
|
||||
|
||||
const infoCache = new Map();
|
||||
|
||||
function useInfo(params) {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const [info, setInfo] = useState(() => infoCache.get(query) || null);
|
||||
useEffect(() => {
|
||||
if (infoCache.has(query)) { setInfo(infoCache.get(query)); return; }
|
||||
let alive = true;
|
||||
api('GET', `/api/reisen/info?${query}`)
|
||||
.then((d) => { if (!d.offline) infoCache.set(query, d); if (alive) setInfo(d); })
|
||||
.catch(() => alive && setInfo({ offline: true }));
|
||||
return () => { alive = false; };
|
||||
}, [query]);
|
||||
return info;
|
||||
}
|
||||
|
||||
function InfoCard({ selected, city, cities, manual, busy, onSelect, onClose, onMark, onRemoveCity }) {
|
||||
if (selected.type === 'city' && city) {
|
||||
return <CityInfo city={city} onSelect={onSelect} onClose={onClose} onRemove={onRemoveCity} busy={busy} />;
|
||||
}
|
||||
if (selected.type === 'country') {
|
||||
return (
|
||||
<CountryInfo
|
||||
code={selected.key}
|
||||
cities={cities.filter((c) => c.country === selected.key)}
|
||||
manual={manual.includes(selected.key)}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
onMark={onMark}
|
||||
busy={busy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function Extract({ info }) {
|
||||
if (!info) return <p className="hint">Lade Infos …</p>;
|
||||
if (info.offline) return <p className="hint">Infotext gerade nicht erreichbar.</p>;
|
||||
if (!info.extract) return null;
|
||||
return (
|
||||
<>
|
||||
<p className="info-extract">{info.extract}</p>
|
||||
{info.url && <a className="info-more" href={info.url} target="_blank" rel="noopener noreferrer">Mehr bei Wikipedia ↗</a>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CountryInfo({ code, cities, manual, onSelect, onClose, onMark, busy }) {
|
||||
const c = BY_CODE.get(code);
|
||||
const name = countryName(code);
|
||||
const info = useInfo({ kind: 'land', name: c?.name || name, code });
|
||||
const visited = manual || cities.length > 0;
|
||||
const facts = [
|
||||
(info?.capital || c?.capital?.length) && ['Hauptstadt', info?.capital || c.capital.join(', ')],
|
||||
info?.population && ['Einwohner', `${people(info.population)}${info.populationYear ? ` (${info.populationYear})` : ''}`],
|
||||
c?.area && ['Fläche', `${num(Math.round(c.area))} km²`],
|
||||
c?.subregion && ['Region', c.subregion],
|
||||
c?.languages?.length && ['Sprachen', c.languages.map((l) => safe((x) => languageName.of(x), l)).join(', ')],
|
||||
c?.currencies?.length && ['Währung', c.currencies.map((x) => safe((y) => currencyName.of(y), x)).join(', ')],
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<aside className="info-card" aria-label={`Infos zu ${name}`}>
|
||||
<header className="info-head">
|
||||
<h2>{c?.flag} {name}</h2>
|
||||
<button className="link-btn" type="button" onClick={onClose} aria-label="Infos schließen">✕</button>
|
||||
</header>
|
||||
{c?.official && c.official !== name && <p className="info-sub">{c.official}</p>}
|
||||
<dl className="info-facts">
|
||||
{facts.map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}
|
||||
</dl>
|
||||
<Extract info={info} />
|
||||
<div className="info-visit">
|
||||
<span className={`badge ${visited ? 'ok' : 'muted'}`}>{visited ? 'Besucht' : 'Noch nicht besucht'}</span>
|
||||
{cities.length > 0 ? (
|
||||
<span className="info-cities">
|
||||
{cities.map((city) => (
|
||||
<button key={city.id} type="button" className="chip" onClick={() => onSelect({ type: 'city', key: city.id })}>{city.name}</button>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<button className="btn small" type="button" disabled={busy} onClick={() => onMark(code, !manual)}>
|
||||
{manual ? 'Markierung entfernen' : 'Als besucht markieren'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{cities.length > 0 && manual && (
|
||||
<button className="link-btn" type="button" disabled={busy} onClick={() => onMark(code, false)}>Zusätzliche Markierung entfernen</button>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function CityInfo({ city, onSelect, onClose, onRemove, busy }) {
|
||||
const c = BY_CODE.get(city.country);
|
||||
const info = useInfo({ kind: 'stadt', name: city.name, code: String(city.geonameId), lat: city.lat, lon: city.lon });
|
||||
const time = city.timezone && localTime(city.timezone);
|
||||
const facts = [
|
||||
city.admin1 && ['Region', city.admin1],
|
||||
city.population && ['Einwohner', people(city.population)],
|
||||
city.elevation != null && ['Höhe', `${num(Math.round(city.elevation))} m`],
|
||||
time && ['Ortszeit', `${time} Uhr`],
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<aside className="info-card" aria-label={`Infos zu ${city.name}`}>
|
||||
<header className="info-head">
|
||||
<h2>{city.name}</h2>
|
||||
<button className="link-btn" type="button" onClick={onClose} aria-label="Infos schließen">✕</button>
|
||||
</header>
|
||||
<p className="info-sub">
|
||||
<button type="button" className="chip" onClick={() => onSelect({ type: 'country', key: city.country })}>
|
||||
{c?.flag} {countryName(city.country)}
|
||||
</button>
|
||||
</p>
|
||||
<dl className="info-facts">
|
||||
{facts.map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}
|
||||
</dl>
|
||||
<Extract info={info} />
|
||||
<div className="info-visit">
|
||||
<span className="badge ok">Besucht</span>
|
||||
<button className="link-btn danger" type="button" disabled={busy} onClick={() => onRemove(city)}>Stadt entfernen</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api, fmtDate } from '../api.js';
|
||||
import { useAuth } from '../auth.jsx';
|
||||
|
||||
// Benutzerverwaltung (nur für Administratoren)
|
||||
export default function Users() {
|
||||
const { user: me, setUser } = useAuth();
|
||||
const [users, setUsers] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const dialog = useRef(null);
|
||||
const [edit, setEdit] = useState(null); // { id, username, displayName }
|
||||
|
||||
const load = () => api('GET', '/api/users').then(setUsers).catch((e) => setError(e.message));
|
||||
useEffect(() => { if (me.isAdmin) load(); }, [me.isAdmin]);
|
||||
|
||||
if (!me.isAdmin) return <p className="error">Diese Seite ist nur für Administratoren.</p>;
|
||||
|
||||
async function run(action) {
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdit(e) {
|
||||
e.preventDefault();
|
||||
await run(async () => {
|
||||
const updated = await api('PATCH', `/api/users/${edit.id}`, { username: edit.username, displayName: edit.displayName });
|
||||
if (updated.id === me.id) setUser(updated);
|
||||
setEdit(null);
|
||||
});
|
||||
}
|
||||
|
||||
const resetPassword = (u) => {
|
||||
const pw = window.prompt(`Neues Passwort für ${u.displayName} (mind. 8 Zeichen):`);
|
||||
if (pw) run(() => api('PATCH', `/api/users/${u.id}`, { password: pw }));
|
||||
};
|
||||
const toggleAdmin = (u) => run(() => api('PATCH', `/api/users/${u.id}`, { isAdmin: !u.isAdmin }));
|
||||
const remove = (u) => {
|
||||
if (window.confirm(`Konto „${u.displayName}“ löschen? Seine Links und Monitoring-Einträge werden gelöscht, Artikel bleiben erhalten.`)) {
|
||||
run(() => api('DELETE', `/api/users/${u.id}`));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Benutzer verwalten</h1>
|
||||
<p className="lead">Konten anlegen, umbenennen, Passwörter zurücksetzen und Administratoren festlegen.</p>
|
||||
</div>
|
||||
<button className="btn" type="button" onClick={() => dialog.current.showModal()}>+ Neues Konto</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
{!users && !error && <p className="hint">Lade …</p>}
|
||||
|
||||
{users && (
|
||||
<div className="user-list">
|
||||
{users.map((u) => (
|
||||
<article className="card user-row" key={u.id}>
|
||||
{edit?.id === u.id ? (
|
||||
<form className="user-edit" onSubmit={saveEdit}>
|
||||
<input
|
||||
value={edit.username}
|
||||
onChange={(e) => setEdit((x) => ({ ...x, username: e.target.value }))}
|
||||
aria-label="Benutzername"
|
||||
placeholder="Benutzername"
|
||||
autoCapitalize="none"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
value={edit.displayName}
|
||||
onChange={(e) => setEdit((x) => ({ ...x, displayName: e.target.value }))}
|
||||
aria-label="Anzeigename"
|
||||
placeholder="Anzeigename"
|
||||
maxLength={60}
|
||||
required
|
||||
/>
|
||||
<button className="btn small" type="submit">Speichern</button>
|
||||
<button className="btn ghost small" type="button" onClick={() => setEdit(null)}>Abbrechen</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="user-info">
|
||||
<strong>{u.displayName}</strong>
|
||||
<span className="hint">@{u.username} · seit {fmtDate(u.createdAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="user-badges">
|
||||
{u.isAdmin && <span className="badge">Administrator</span>}
|
||||
{u.id === me.id && <span className="badge muted">Du</span>}
|
||||
</div>
|
||||
<div className="user-actions">
|
||||
{edit?.id !== u.id && (
|
||||
<button className="btn ghost small" type="button" onClick={() => setEdit({ id: u.id, username: u.username, displayName: u.displayName })}>
|
||||
Umbenennen
|
||||
</button>
|
||||
)}
|
||||
{u.id !== me.id && (
|
||||
<>
|
||||
<button className="btn ghost small" type="button" onClick={() => resetPassword(u)}>Passwort zurücksetzen</button>
|
||||
<button className="btn ghost small" type="button" onClick={() => toggleAdmin(u)}>
|
||||
{u.isAdmin ? 'Admin entziehen' : 'Zum Admin machen'}
|
||||
</button>
|
||||
<button className="link-btn danger" type="button" onClick={() => remove(u)}>Löschen</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NewUserDialog ref={dialog} onCreated={load} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NewUserDialog({ ref, onCreated }) {
|
||||
const empty = { username: '', displayName: '', password: '', isAdmin: false };
|
||||
const [form, setForm] = useState(empty);
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) });
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api('POST', '/api/users', form);
|
||||
await onCreated();
|
||||
ref.current.close();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog className="modal" ref={ref} onClose={() => { setForm(empty); setError(null); }}>
|
||||
<form className="form" onSubmit={submit}>
|
||||
<h2>Neues Konto</h2>
|
||||
<label>
|
||||
Benutzername
|
||||
<input {...field('username')} autoComplete="off" autoCapitalize="none" required />
|
||||
</label>
|
||||
<label>
|
||||
Anzeigename
|
||||
<input {...field('displayName')} maxLength={60} placeholder="optional, sonst Benutzername" />
|
||||
</label>
|
||||
<label>
|
||||
Start-Passwort <small>(mind. 8 Zeichen, kann danach selbst geändert werden)</small>
|
||||
<input {...field('password')} type="text" autoComplete="off" minLength={8} required />
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input type="checkbox" checked={form.isAdmin} onChange={(e) => setForm((f) => ({ ...f, isAdmin: e.target.checked }))} />
|
||||
Administrator
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
|
||||
<button className="btn" type="submit" disabled={busy}>Konto anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,46 @@
|
||||
// Designs der Oberfläche. Die Auswahl wird pro Browser gespeichert; index.html setzt sie vor dem ersten
|
||||
// Zeichnen (kein Aufblitzen des falschen Designs), dieses Modul hält sie danach aktuell.
|
||||
const KEY = 'p42.design';
|
||||
const EVENT = 'p42:design';
|
||||
|
||||
export const THEMES = [
|
||||
{ key: 'matrix', name: 'Matrix', hint: 'Rotes Retro-Terminal', swatch: ['#ff3a3a', '#1d0505'], color: '#050000' },
|
||||
{ key: 'blackwall', name: 'Blackwall', hint: 'Cyberpunk-Netrunner', swatch: ['#ff2d6f', '#3ff6ff', '#1a0508'], color: '#060104' },
|
||||
];
|
||||
|
||||
const valid = (key) => THEMES.some((t) => t.key === key);
|
||||
|
||||
export function getTheme() {
|
||||
const current = document.documentElement.dataset.theme;
|
||||
return valid(current) ? current : 'matrix';
|
||||
}
|
||||
|
||||
export function setTheme(key) {
|
||||
if (!valid(key)) return;
|
||||
try { localStorage.setItem(KEY, key); } catch { /* ignorieren */ }
|
||||
const still = document.documentElement.classList.contains('calm')
|
||||
|| window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
// Überblenden, wo der Browser View Transitions kann; kurz warten, bis React den neuen Hintergrund gezeichnet hat
|
||||
if (document.startViewTransition && !still) {
|
||||
document.startViewTransition(async () => {
|
||||
apply(key);
|
||||
await new Promise((resolve) => { setTimeout(resolve, 40); });
|
||||
});
|
||||
} else {
|
||||
apply(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function onThemeChange(fn) {
|
||||
window.addEventListener(EVENT, fn);
|
||||
return () => window.removeEventListener(EVENT, fn);
|
||||
}
|
||||
|
||||
function apply(key) {
|
||||
document.documentElement.dataset.theme = key;
|
||||
// Browser-Leiste (Handy) passend einfärben
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', THEMES.find((t) => t.key === key).color);
|
||||
window.dispatchEvent(new Event(EVENT));
|
||||
}
|
||||
|
||||
apply(getTheme());
|
||||
@@ -0,0 +1,361 @@
|
||||
/* ============================================================
|
||||
Designs: Blackwall (Cyberpunk)
|
||||
Das Matrix-Design sind die Standardwerte in styles.css. Jedes Design setzt hier vor allem
|
||||
Tokens (Farbrollen, Schrift, Form) und nur noch seine Erkennungsmerkmale.
|
||||
Inspiriert von den Vorbildern: keine Original-Logos, freie Schriften (OFL), alles lokal.
|
||||
============================================================ */
|
||||
|
||||
/* ---------- Gemeinsam ---------- */
|
||||
.theme-bg { position: fixed; inset: 0; z-index: -1; pointer-events: none; }
|
||||
|
||||
/* Design-Auswahl im Menü */
|
||||
.menu-panel { max-height: calc(100vh - var(--topbar-h) - 16px); overflow-y: auto; }
|
||||
.menu-label { padding: 8px 12px 4px; font-size: var(--label-size); color: var(--muted); text-transform: uppercase; letter-spacing: var(--label-tracking); }
|
||||
.theme-picker { display: flex; flex-direction: column; gap: 2px; }
|
||||
.menu-item.theme-option { display: flex; align-items: center; gap: 12px; }
|
||||
.theme-option .swatch { display: inline-flex; flex: none; border-radius: 4px; overflow: hidden; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.12); }
|
||||
.theme-option .swatch i { width: 9px; height: 22px; }
|
||||
.theme-name { display: flex; flex-direction: column; line-height: 1.25; }
|
||||
.theme-name small { font-size: 0.7rem; text-transform: none; letter-spacing: 0.02em; color: var(--muted); }
|
||||
.theme-option[aria-checked='true'] { color: var(--bright); background: var(--accent-soft); box-shadow: inset 2px 0 0 var(--accent); }
|
||||
.theme-option[aria-checked='true'] .theme-name::after { content: '✓ aktiv'; font-size: 0.68rem; color: var(--accent); }
|
||||
|
||||
/* Matrix-Zierrat in den anderen Designs abschalten (jedes Design setzt eigenen) */
|
||||
html:not([data-theme='matrix']) body::after,
|
||||
html:not([data-theme='matrix']) .page-head h1::after,
|
||||
html:not([data-theme='matrix']) .hero h1::after,
|
||||
html:not([data-theme='matrix']) .brand::before,
|
||||
html:not([data-theme='matrix']) .lead::before,
|
||||
html:not([data-theme='matrix']) .section-title::before,
|
||||
html:not([data-theme='matrix']) .household-head h2::before,
|
||||
html:not([data-theme='matrix']) .article-body h2::before,
|
||||
html:not([data-theme='matrix']) .article-body h3::before,
|
||||
html:not([data-theme='matrix']) .menu-panel a::before,
|
||||
html:not([data-theme='matrix']) .menu-item::before,
|
||||
html:not([data-theme='matrix']) .topnav a.active::before,
|
||||
html:not([data-theme='matrix']) .notice::before,
|
||||
html:not([data-theme='matrix']) .error::before,
|
||||
html:not([data-theme='matrix']) .prompt-input::before,
|
||||
html:not([data-theme='matrix']) .combo-list li::before { content: none; }
|
||||
html:not([data-theme='matrix']) .track-form input { padding-left: 18px; }
|
||||
html:not([data-theme='matrix']) .city-search input { padding-left: 16px; }
|
||||
html:not([data-theme='matrix']) .article-body ul > li::before { content: '●'; }
|
||||
html:not([data-theme='matrix']) .tile strong { overflow-wrap: anywhere; max-width: 100%; }
|
||||
|
||||
@keyframes theme-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes theme-scan { from { transform: translateY(-10vh); } to { transform: translateY(110vh); } }
|
||||
|
||||
|
||||
/* ============================================================
|
||||
BLACKWALL – Cyberpunk / Netrunner
|
||||
Schwarz-Rot wie die Datenwand, Cyan als Gegenfarbe (Fokus, Links, "online").
|
||||
Abgeschrägte Ecken mit nachgezogener Kante, Panels mit Kopfstreifen, chromatisch
|
||||
versetzte Seitentitel, seltener Glitch. Chakra Petch für Text, Oxanium für Titel.
|
||||
============================================================ */
|
||||
html[data-theme='blackwall'] {
|
||||
--bw-red: #ff2d6f;
|
||||
--bw-red-deep: #8a0f3c;
|
||||
--bw-cyan: #3ff6ff;
|
||||
--bw-yellow: #f6e34a;
|
||||
|
||||
--accent-rgb: 255 45 111;
|
||||
--bg: #060104;
|
||||
--surface: rgb(14 2 6 / 0.9);
|
||||
--surface-hover: rgb(30 5 12 / 0.95);
|
||||
--surface-2: #19050b;
|
||||
--border: rgb(255 45 111 / 0.24);
|
||||
--line-strong: rgb(255 45 111 / 0.6);
|
||||
--accent: var(--bw-red);
|
||||
--accent-ink: #12020a;
|
||||
--accent-soft: rgb(255 45 111 / 0.13);
|
||||
--text: #f1d6da;
|
||||
--read: #f6e6e8;
|
||||
--bright: #fff3f4;
|
||||
--muted: #c48a93;
|
||||
--ok: var(--bw-cyan);
|
||||
--warn: var(--bw-yellow);
|
||||
--bad: #ff3b30;
|
||||
--bad-glow: rgb(255 59 48 / 0.55);
|
||||
--topbar-bg: rgb(6 1 4 / 0.72);
|
||||
--glow: 0 0 8px rgb(255 45 111 / 0.55), 0 0 26px rgb(255 45 111 / 0.2);
|
||||
--heading-glow: -2px 0 0 rgb(63 246 255 / 0.55), 2px 0 0 rgb(255 45 111 / 0.75), 0 0 22px rgb(255 45 111 / 0.35);
|
||||
--text-glow: none;
|
||||
--input-bg: rgb(4 1 3 / 0.88);
|
||||
--card-inner: rgb(10 2 5 / 0.94);
|
||||
--panel: rgb(13 2 7 / 0.97);
|
||||
--overlay: rgb(6 1 4 / 0.88);
|
||||
--backdrop: rgb(2 0 1 / 0.7);
|
||||
--editor-bg: rgb(4 1 3 / 0.92);
|
||||
--map-ocean: #060104;
|
||||
--map-land: #1d060c;
|
||||
--map-land-hover: #3a0b17;
|
||||
|
||||
--radius-s: 0px;
|
||||
--radius-m: 0px;
|
||||
--radius-l: 0px;
|
||||
--shadow-1: inset 0 1px 0 rgb(255 45 111 / 0.08), 0 18px 40px -28px #000;
|
||||
--shadow-2: 0 30px 90px -30px #000, 0 0 0 1px rgb(255 45 111 / 0.22), 0 0 50px -16px rgb(255 45 111 / 0.35);
|
||||
--focus-ring: 0 0 0 2px var(--bw-cyan), 0 0 14px rgb(63 246 255 / 0.35);
|
||||
--blur: blur(10px) saturate(1.2);
|
||||
--cut: 14px;
|
||||
|
||||
--font: 'Chakra Petch', 'Segoe UI', system-ui, sans-serif;
|
||||
--font-display: 'Oxanium', 'Chakra Petch', sans-serif;
|
||||
--font-heading: 'Chakra Petch', 'Segoe UI', sans-serif;
|
||||
--font-label: 'Chakra Petch', 'Segoe UI', sans-serif;
|
||||
--body-size: clamp(15px, 0.92rem + 0.15vw, 16.5px);
|
||||
--h1-font: 'Oxanium', 'Chakra Petch', sans-serif;
|
||||
--h1-size: clamp(2rem, 3.4vw, 3.1rem);
|
||||
--h1-weight: 800;
|
||||
--h1-tracking: 0.04em;
|
||||
--h2-size: 1.12rem;
|
||||
--h3-size: 1.02rem;
|
||||
--heading-weight: 600;
|
||||
--heading-tracking: 0.08em;
|
||||
--label-size: 0.7rem;
|
||||
--label-tracking: 0.16em;
|
||||
--tile-font: 'Oxanium', 'Chakra Petch', sans-serif;
|
||||
--tile-size: 1.05rem;
|
||||
|
||||
background:
|
||||
radial-gradient(ellipse 70% 50% at 100% 0%, rgb(138 15 60 / 0.35), transparent 70%),
|
||||
radial-gradient(ellipse 60% 45% at 0% 100%, rgb(138 15 60 / 0.28), transparent 70%),
|
||||
#060104;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
html[data-theme='blackwall'] ::selection { background: var(--bw-cyan); color: #000; }
|
||||
html[data-theme='blackwall'] :focus-visible { outline: 2px solid var(--bw-cyan); outline-offset: 2px; }
|
||||
html[data-theme='blackwall'] * { scrollbar-color: var(--bw-red-deep) transparent; }
|
||||
/* feine Bildschirmzeilen, kaum sichtbar */
|
||||
html[data-theme='blackwall'] body::after {
|
||||
content: '';
|
||||
background: repeating-linear-gradient(to bottom, transparent 0 2px, rgb(0 0 0 / 0.14) 2px 3px);
|
||||
}
|
||||
.blackwall-field { width: 100%; height: 100%; opacity: 0.92; }
|
||||
/* Text, der frei vor dem Lichtband steht: dunkler Halo nur unter der Schrift (statt Vollflächen-Abdunklung) */
|
||||
html[data-theme='blackwall'] .lead,
|
||||
html[data-theme='blackwall'] .hint,
|
||||
html[data-theme='blackwall'] .back,
|
||||
html[data-theme='blackwall'] .section-title,
|
||||
html[data-theme='blackwall'] .tiles-head .link-btn,
|
||||
html[data-theme='blackwall'] .page-head .link-btn,
|
||||
html[data-theme='blackwall'] .kanban-filter,
|
||||
html[data-theme='blackwall'] .footer,
|
||||
html[data-theme='blackwall'] .travel-credits,
|
||||
html[data-theme='blackwall'] .recent .chip { text-shadow: 0 0 3px #000, 0 0 10px #000, 0 0 18px rgb(0 0 0 / 0.8); }
|
||||
html[data-theme='blackwall'] h1 { text-shadow: var(--heading-glow), 0 0 24px rgb(0 0 0 / 0.9); }
|
||||
html[data-theme='blackwall'] body { font-weight: 500; }
|
||||
/* Links allgemein cyan, aber Navigation, Marke und Menü behalten ihre eigenen Farben */
|
||||
html[data-theme='blackwall'] .brand,
|
||||
html[data-theme='blackwall'] .brand:hover { color: var(--bright); }
|
||||
html[data-theme='blackwall'] .topnav a { color: var(--muted); }
|
||||
html[data-theme='blackwall'] .topnav a:hover { color: var(--bright); }
|
||||
html[data-theme='blackwall'] .topnav a.active { color: #12020a; }
|
||||
html[data-theme='blackwall'] .menu-panel a { color: var(--text); }
|
||||
html[data-theme='blackwall'] .menu-panel a:hover { color: var(--bright); }
|
||||
html[data-theme='blackwall'] .menu-panel a.active { color: #12020a; }
|
||||
html[data-theme='blackwall'] .tile, html[data-theme='blackwall'] .tile:hover,
|
||||
html[data-theme='blackwall'] .project-card, html[data-theme='blackwall'] .kanban-project-link { color: var(--text); }
|
||||
|
||||
/* Seitentitel: chromatisch versetzt, mit Kennung davor */
|
||||
html[data-theme='blackwall'] h1 { color: var(--bright); text-transform: uppercase; }
|
||||
html[data-theme='blackwall'] .page-head h1::before {
|
||||
content: '//';
|
||||
margin-right: 0.3em;
|
||||
color: var(--bw-red);
|
||||
text-shadow: none;
|
||||
}
|
||||
html[data-theme='blackwall'] h2, html[data-theme='blackwall'] h3 { color: var(--bright); }
|
||||
html[data-theme='blackwall'] .lead { color: var(--muted); }
|
||||
html[data-theme='blackwall'] .lead::before { content: '> '; color: var(--bw-cyan); }
|
||||
html[data-theme='blackwall'] .section-title::before,
|
||||
html[data-theme='blackwall'] .household-head h2::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-right: 10px;
|
||||
background: var(--bw-red);
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%);
|
||||
vertical-align: 0.05em;
|
||||
}
|
||||
html[data-theme='blackwall'] .error::before { content: '⚠ '; }
|
||||
html[data-theme='blackwall'] .notice::before { content: '!! '; color: var(--bw-yellow); font-weight: 700; }
|
||||
html[data-theme='blackwall'] a { color: var(--bw-cyan); text-decoration-style: solid; text-decoration-color: rgb(63 246 255 / 0.4); }
|
||||
html[data-theme='blackwall'] a:hover { color: var(--bright); }
|
||||
|
||||
/* Kopfzeile: Marke mit Glitch beim Überfahren, roter Kantenstrich mit Lücke */
|
||||
html[data-theme='blackwall'] .topbar { border-bottom: 1px solid rgb(255 45 111 / 0.35); }
|
||||
html[data-theme='blackwall'] .topbar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: var(--gutter);
|
||||
bottom: -2px;
|
||||
width: 180px;
|
||||
height: 3px;
|
||||
background: var(--bw-red);
|
||||
box-shadow: 0 0 12px rgb(255 45 111 / 0.7);
|
||||
clip-path: polygon(0 0, 100% 0, calc(100% - 3px) 100%, 0 100%);
|
||||
}
|
||||
html[data-theme='blackwall'] .brand {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-shadow: -1px 0 0 rgb(63 246 255 / 0.5), 1px 0 0 rgb(255 45 111 / 0.7);
|
||||
}
|
||||
html[data-theme='blackwall'] .brand span { color: var(--bw-red); }
|
||||
html[data-theme='blackwall'] .brand:hover { animation: bw-glitch 0.4s steps(2) 1; }
|
||||
@keyframes bw-glitch {
|
||||
0% { clip-path: inset(0 0 0 0); transform: translateX(0); }
|
||||
20% { clip-path: inset(10% 0 55% 0); transform: translateX(-3px); }
|
||||
40% { clip-path: inset(60% 0 8% 0); transform: translateX(3px); }
|
||||
60% { clip-path: inset(30% 0 35% 0); transform: translateX(-2px); }
|
||||
100% { clip-path: inset(0 0 0 0); transform: translateX(0); }
|
||||
}
|
||||
html[data-theme='blackwall'] .topnav a { font-family: var(--font-label); font-weight: 600; font-size: 0.78rem; letter-spacing: 0.14em; border: 0; border-radius: 0; position: relative; }
|
||||
html[data-theme='blackwall'] .topnav a:hover { color: var(--bright); background: var(--accent-soft); }
|
||||
html[data-theme='blackwall'] .topnav a.active { color: #12020a; background: var(--bw-red); clip-path: polygon(0 0, 100% 0, 100% calc(100% - 7px), calc(100% - 7px) 100%, 0 100%); }
|
||||
html[data-theme='blackwall'] .menu-btn { border-radius: 0; border-color: var(--line-strong); color: var(--bw-red); }
|
||||
html[data-theme='blackwall'] .menu-btn:hover,
|
||||
html[data-theme='blackwall'] .menu-btn[aria-expanded='true'] { background: var(--bw-red); color: #12020a; }
|
||||
html[data-theme='blackwall'] .menu-panel { border-top: 2px solid var(--bw-red); }
|
||||
html[data-theme='blackwall'] .menu-panel a, html[data-theme='blackwall'] .menu-item { font-weight: 500; letter-spacing: 0.08em; }
|
||||
html[data-theme='blackwall'] .menu-panel a.active { background: var(--bw-red); color: #12020a; }
|
||||
html[data-theme='blackwall'] .theme-option[aria-checked='true'] { box-shadow: inset 3px 0 0 var(--bw-cyan); }
|
||||
|
||||
/* Panels: rechts oben abgeschrägt (Kante wird nachgezogen), roter Kopfstreifen links oben */
|
||||
html[data-theme='blackwall'] .info-card { clip-path: polygon(0 0, calc(100% - var(--cut)) 0, 100% var(--cut), 100% 100%, 0 100%); }
|
||||
html[data-theme='blackwall'] .card,
|
||||
html[data-theme='blackwall'] .tile,
|
||||
html[data-theme='blackwall'] .board-column,
|
||||
html[data-theme='blackwall'] .article-page:not(.editing) .article-main {
|
||||
position: relative;
|
||||
}
|
||||
html[data-theme='blackwall'] .card,
|
||||
html[data-theme='blackwall'] .tile,
|
||||
html[data-theme='blackwall'] .board-column,
|
||||
html[data-theme='blackwall'] .modal,
|
||||
html[data-theme='blackwall'] .article-page:not(.editing) .article-main {
|
||||
clip-path: polygon(0 0, calc(100% - var(--cut)) 0, 100% var(--cut), 100% 100%, 0 100%);
|
||||
}
|
||||
html[data-theme='blackwall'] .card::after,
|
||||
html[data-theme='blackwall'] .tile::after,
|
||||
html[data-theme='blackwall'] .board-column::after,
|
||||
html[data-theme='blackwall'] .modal::after,
|
||||
html[data-theme='blackwall'] .info-card::after,
|
||||
html[data-theme='blackwall'] .article-page:not(.editing) .article-main::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: var(--cut);
|
||||
height: var(--cut);
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to top right, transparent calc(50% - 1px), var(--line-strong) calc(50% - 1px) calc(50% + 0.5px), transparent calc(50% + 0.5px));
|
||||
}
|
||||
html[data-theme='blackwall'] .card::before,
|
||||
html[data-theme='blackwall'] .tile::before,
|
||||
html[data-theme='blackwall'] .board-column::before,
|
||||
html[data-theme='blackwall'] .info-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: -1px;
|
||||
width: 54px;
|
||||
height: 3px;
|
||||
background: var(--bw-red);
|
||||
box-shadow: 0 0 10px rgb(255 45 111 / 0.6);
|
||||
pointer-events: none;
|
||||
}
|
||||
html[data-theme='blackwall'] .tile:hover,
|
||||
html[data-theme='blackwall'] .project-card:hover,
|
||||
html[data-theme='blackwall'] .kanban-project-card:hover,
|
||||
html[data-theme='blackwall'] .task-card:hover {
|
||||
border-color: var(--line-strong);
|
||||
background-color: var(--surface-hover);
|
||||
box-shadow: var(--shadow-1), inset 0 0 0 1px rgb(255 45 111 / 0.25), 0 0 30px -10px rgb(255 45 111 / 0.5);
|
||||
}
|
||||
html[data-theme='blackwall'] .tile svg { color: var(--bw-red); background: rgb(255 45 111 / 0.1); box-shadow: inset 0 0 0 1px rgb(255 45 111 / 0.35); }
|
||||
html[data-theme='blackwall'] .tile:hover svg { color: var(--bw-cyan); box-shadow: inset 0 0 0 1px rgb(63 246 255 / 0.5); background: rgb(63 246 255 / 0.08); }
|
||||
html[data-theme='blackwall'] .tile strong { font-weight: 700; letter-spacing: 0.08em; }
|
||||
html[data-theme='blackwall'] .modal { border-color: var(--line-strong); }
|
||||
html[data-theme='blackwall'] .modal h2 { font-family: var(--font-display); font-weight: 700; text-transform: uppercase; }
|
||||
|
||||
/* Buttons: gefüllt rot mit Schräge; umrandet in Cyan */
|
||||
html[data-theme='blackwall'] .btn { font-family: var(--font-label); font-weight: 600; letter-spacing: 0.12em; border-radius: 0; }
|
||||
html[data-theme='blackwall'] .btn:not(.ghost) {
|
||||
background: var(--bw-red);
|
||||
border-color: var(--bw-red);
|
||||
color: #12020a;
|
||||
clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%);
|
||||
box-shadow: 0 0 24px -6px rgb(255 45 111 / 0.8);
|
||||
}
|
||||
html[data-theme='blackwall'] .btn:not(.ghost):hover:not(:disabled) { background: var(--bright); border-color: var(--bright); }
|
||||
html[data-theme='blackwall'] .btn.ghost { color: var(--bw-cyan); border-color: rgb(63 246 255 / 0.45); background: rgb(63 246 255 / 0.04); }
|
||||
html[data-theme='blackwall'] .btn.ghost:hover:not(:disabled) { color: #001417; background: var(--bw-cyan); border-color: var(--bw-cyan); }
|
||||
html[data-theme='blackwall'] .btn.ghost.danger-btn { color: var(--bad); border-color: rgb(255 59 48 / 0.5); background: transparent; }
|
||||
html[data-theme='blackwall'] .btn.ghost.danger-btn:hover:not(:disabled) { background: var(--bad); color: #12020a; }
|
||||
html[data-theme='blackwall'] .link-btn { color: var(--bw-cyan); font-weight: 600; }
|
||||
html[data-theme='blackwall'] .link-btn:hover { color: var(--bright); }
|
||||
html[data-theme='blackwall'] .link-btn.danger { color: var(--bad); }
|
||||
|
||||
/* Status: eckige Tags; "online" in Cyan, Warnung gelb */
|
||||
html[data-theme='blackwall'] .badge { border-radius: 0; font-weight: 700; letter-spacing: 0.12em; clip-path: polygon(0 0, calc(100% - 6px) 0, 100% 6px, 100% 100%, 0 100%); }
|
||||
html[data-theme='blackwall'] .badge::before { border-radius: 0; width: 5px; height: 5px; }
|
||||
html[data-theme='blackwall'] .chip { border-radius: 0; font-weight: 600; }
|
||||
html[data-theme='blackwall'] .chip.active { border-color: var(--bw-cyan); color: var(--bw-cyan); background: rgb(63 246 255 / 0.08); }
|
||||
html[data-theme='blackwall'] .dot { border-radius: 0; }
|
||||
html[data-theme='blackwall'] .monitor-card.ok { border-left-color: var(--bw-cyan); }
|
||||
html[data-theme='blackwall'] .monitor-card.ok h3 { color: var(--bw-cyan); }
|
||||
html[data-theme='blackwall'] .board-column.arbeit { border-top-color: var(--bw-red); }
|
||||
html[data-theme='blackwall'] .board-column.wartet { border-top-color: var(--bw-yellow); }
|
||||
html[data-theme='blackwall'] .board-column.erledigt { border-top-color: var(--bw-cyan); }
|
||||
html[data-theme='blackwall'] .board-head h2 { font-family: var(--font-display); font-weight: 700; letter-spacing: 0.1em; }
|
||||
html[data-theme='blackwall'] .status-bar .arbeit { background: var(--bw-red); }
|
||||
html[data-theme='blackwall'] .status-bar .wartet { background: var(--bw-yellow); }
|
||||
html[data-theme='blackwall'] .status-bar .erledigt { background: var(--bw-cyan); }
|
||||
html[data-theme='blackwall'] .avatar { border-radius: 0; background: var(--bw-red); color: #12020a; }
|
||||
html[data-theme='blackwall'] .task-title, html[data-theme='blackwall'] .household-name strong { font-weight: 600; letter-spacing: 0.02em; }
|
||||
html[data-theme='blackwall'] .drop-line { background: var(--bw-cyan); box-shadow: 0 0 10px rgb(63 246 255 / 0.7); }
|
||||
html[data-theme='blackwall'] .household-check { border-color: rgb(63 246 255 / 0.55); }
|
||||
html[data-theme='blackwall'] .recipe-take:checked { background: var(--bw-cyan); }
|
||||
html[data-theme='blackwall'] .stepper button { color: var(--bw-red); }
|
||||
html[data-theme='blackwall'] .segmented button[aria-selected='true'] { background: var(--bw-red); color: #12020a; }
|
||||
html[data-theme='blackwall'] .stepper-value, html[data-theme='blackwall'] .monitor-facts dd, html[data-theme='blackwall'] .fact dd, html[data-theme='blackwall'] .info-facts dd { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Artikel */
|
||||
html[data-theme='blackwall'] .article-title { font-family: var(--font-display); font-weight: 800; }
|
||||
html[data-theme='blackwall'] .article-body h2 { font-family: var(--font-display); font-size: 1.6em; color: var(--bright); }
|
||||
html[data-theme='blackwall'] .article-body h2::before { content: '// '; color: var(--bw-red); }
|
||||
html[data-theme='blackwall'] .article-body h3 { color: var(--bw-cyan); font-size: 1.2em; }
|
||||
html[data-theme='blackwall'] .article-body ul > li::before { content: '▸'; color: var(--bw-red); }
|
||||
html[data-theme='blackwall'] .article-body blockquote { border-left-color: var(--bw-cyan); }
|
||||
html[data-theme='blackwall'] .article-lead { border-left-color: var(--bw-red); }
|
||||
html[data-theme='blackwall'] .project-cover { filter: saturate(0.75) contrast(1.05); }
|
||||
|
||||
/* Karte */
|
||||
html[data-theme='blackwall'] .map-graticule { stroke: rgb(255 45 111 / 0.08); }
|
||||
html[data-theme='blackwall'] .map-country { stroke: rgb(255 45 111 / 0.4); }
|
||||
html[data-theme='blackwall'] .map-country.visited { fill: rgb(255 45 111 / 0.55); stroke: var(--bw-red); }
|
||||
html[data-theme='blackwall'] .map-country.selected { stroke: var(--bw-cyan); }
|
||||
html[data-theme='blackwall'] .map-city-dot { fill: var(--bw-cyan); stroke: #001417; filter: drop-shadow(0 0 4px rgb(63 246 255 / 0.9)); }
|
||||
html[data-theme='blackwall'] .map-city-ring { stroke: var(--bw-cyan); }
|
||||
|
||||
/* Ruhe: kein Glitch, wenn Bewegung reduziert oder Animation aus */
|
||||
html.calm[data-theme='blackwall'] .brand:hover { animation: none; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html[data-theme='blackwall'] .brand:hover { animation: none; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
html[data-theme='blackwall'] { --h1-size: 1.6rem; --tile-size: 0.86rem; --cut: 10px; }
|
||||
html[data-theme='blackwall'] h1 { overflow-wrap: anywhere; }
|
||||
html[data-theme='blackwall'] .brand { letter-spacing: 0.06em; font-size: 1.05rem; }
|
||||
html[data-theme='blackwall'] .tile strong { letter-spacing: 0.03em; }
|
||||
}
|
||||
/* Leere Kachel nicht durchsichtig: sonst steht ihr Text im hellen Lichtband */
|
||||
html[data-theme='blackwall'] .tile-empty { background: var(--surface); border-style: dashed; }
|
||||
/* Umrandete Buttons mit dunklem Grund, damit sie vor dem Lichtband lesbar bleiben */
|
||||
html[data-theme='blackwall'] .btn.ghost { background-color: rgb(6 1 4 / 0.88); }
|
||||
html[data-theme='blackwall'] .btn.ghost.danger-btn { background: rgb(6 1 4 / 0.88); color: #ff6a60; }
|
||||
html[data-theme='blackwall'] .kanban-filter span { color: var(--text); }
|
||||
@@ -0,0 +1,32 @@
|
||||
// Einheiten für Vorrat & Einkauf: Anzahl (Stück/Packungen), Kilogramm, Liter
|
||||
|
||||
export const UNITS = [
|
||||
{ key: 'stk', label: 'Anzahl', compact: 'Anz.', short: '' },
|
||||
{ key: 'kg', label: 'kg', compact: 'kg', short: 'kg' },
|
||||
{ key: 'l', label: 'Liter', compact: 'l', short: 'l' },
|
||||
];
|
||||
|
||||
// Schrittweite für −/+
|
||||
export const STEP = { stk: 1, kg: 0.25, l: 0.25 };
|
||||
|
||||
export const round2 = (n) => Math.round(n * 100) / 100;
|
||||
|
||||
// Aufrunden auf zwei Nachkommastellen: lieber etwas mehr einkaufen
|
||||
export const ceil2 = (n) => Math.max(0.01, Math.ceil(n * 100 - 1e-9) / 100);
|
||||
|
||||
export function fmtQty(quantity, unit) {
|
||||
const n = quantity.toLocaleString('de-DE', { maximumFractionDigits: 2 });
|
||||
const short = UNITS.find((u) => u.key === unit)?.short;
|
||||
return short ? `${n} ${short}` : n;
|
||||
}
|
||||
|
||||
// Mehrere Zeilen desselben Produkts ("1 kg" und "2") zusammen anzeigen
|
||||
export const fmtItems = (items) => items.map((i) => fmtQty(i.quantity, i.unit)).join(' + ');
|
||||
|
||||
/** Eingabe prüfen: Anzahl als ganze Zahl ab 1, kg/l ab 0,01. Gibt null bei ungültiger Eingabe. */
|
||||
export function parseQty(value, unit) {
|
||||
const n = Number(String(value).replace(',', '.'));
|
||||
if (!Number.isFinite(n) || n <= 0 || n > 999) return null;
|
||||
if (unit === 'stk') return Number.isInteger(n) ? n : null;
|
||||
return round2(n) > 0 ? round2(n) : null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// Im Entwicklungsmodus leitet Vite API- und Bild-Anfragen an das Python-Backend weiter
|
||||
const backend = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': backend,
|
||||
'/uploads': backend,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Startet das P42 Dashboard: richtet beim ersten Mal alles ein,
|
||||
# baut das Frontend neu, wenn sich etwas geändert hat, und startet das Backend.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
[ -f config.json ] || cp config.example.json config.json
|
||||
|
||||
if [ ! -x backend/.venv/bin/python ]; then
|
||||
echo "→ Python-Umgebung einrichten …"
|
||||
python3 -m venv --without-pip backend/.venv
|
||||
python3 -m pip --python backend/.venv/bin/python install -q -r backend/requirements.txt
|
||||
fi
|
||||
|
||||
if [ ! -f frontend/dist/index.html ] || [ -n "$(find frontend/src frontend/index.html -newer frontend/dist/index.html -print -quit)" ]; then
|
||||
echo "→ Frontend bauen …"
|
||||
(cd frontend && { [ -d node_modules ] || npm install; } && npm run build)
|
||||
fi
|
||||
|
||||
cd backend
|
||||
exec .venv/bin/python -m app
|
||||
Reference in new issue
Block a user