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:
guyg1474andClaude Opus 5.5 committed 2026-10-14 14:08:11 +02:00
commit dd58b3d1f8
75 files changed
+11418

No files matched your search

+19
View File
@@ -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
+13
View File
@@ -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=*
+6
View File
@@ -0,0 +1,6 @@
config.json
data/
backend/.venv/
__pycache__/
.env
_alt-node/
+100
View File
@@ -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
View File
@@ -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"]
+204
View File
@@ -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.
View File
Whitespace-only changes.
+14
View File
@@ -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",
)
+288
View File
@@ -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}
+24
View File
@@ -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()
+337
View File
@@ -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()
+205
View File
@@ -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)
+368
View File
@@ -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]
+64
View File
@@ -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"])
+72
View File
@@ -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"})
+131
View File
@@ -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)]
+270
View File
@@ -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
+305
View File
@@ -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
+30
View File
@@ -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()
+357
View File
@@ -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)
+50
View File
@@ -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
+37
View File
@@ -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
+64
View File
@@ -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,
}
+80
View File
@@ -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,
}
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.143.0
uvicorn[standard]==0.54.0
httpx==0.28.1
nh3==0.3.7
+15
View File
@@ -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": ""
}
}
+29
View File
@@ -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
+20
View File
@@ -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
+35
View File
@@ -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.
+30
View File
@@ -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).
+34
View File
@@ -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).
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+23
View File
@@ -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>
+1690
View File
File diff suppressed because it is too large. Load diff
+37
View File
@@ -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"
}
}
+1
View File
@@ -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">&gt;_</text></svg>

After

Width:  |  Height:  |  Size: 325 B

+2
View File
@@ -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).
+1
View File
@@ -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
+52
View File
@@ -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'],
};
+57
View File
@@ -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);
+259
View File
@@ -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" />;
}
+166
View File
@@ -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>
);
}
+122
View File
@@ -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>
);
}
+18
View File
@@ -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>
);
}
+56
View File
@@ -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>
);
}
+63
View File
@@ -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" />;
}
+93
View File
@@ -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>
);
}
+278
View File
@@ -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>
);
}
+6
View File
@@ -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} />;
}
+228
View File
@@ -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>
);
}
+10
View File
@@ -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>
);
}
+157
View File
@@ -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>
);
}
+29
View File
@@ -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);
+92
View File
@@ -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>,
);
+30
View File
@@ -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();
+104
View File
@@ -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>
);
}
+389
View File
@@ -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 &amp; 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>
);
}
+84
View File
@@ -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 &amp; 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} />
</>
);
}
+171
View File
@@ -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>
);
}
+411
View File
@@ -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>
);
}
+66
View File
@@ -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>
);
}
+193
View File
@@ -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>
);
}
+10
View File
@@ -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>
);
}
+272
View File
@@ -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 }} />;
}
+95
View File
@@ -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>
);
}
+560
View File
@@ -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>
);
}
+172
View File
@@ -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
+46
View File
@@ -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());
+361
View File
@@ -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); }
+32
View File
@@ -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;
}
+15
View File
@@ -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,
},
},
});
Executable
+21
View File
@@ -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