From b30acb652a4bfab56bb4aa20733bdc4c7e85c446 Mon Sep 17 00:00:00 2001 From: guyg1474 <340572510+guyg1474@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:50:46 +0200 Subject: [PATCH] =?UTF-8?q?Listen=20mit=20eigenem=20Vorrat=20und=20Einkauf?= =?UTF-8?q?,=20St=C3=A4dte=20anderer=20auf=20der=20Reise-Karte,=20Design?= =?UTF-8?q?=20"Fabrik"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vorrat & Einkauf: - Mehrere Listen (z. B. "Zuhause", "Urlaub"), jede mit eigenem Vorrat und eigener Einkaufsliste; Auswahl oben auf der Seite, gemerkt pro Browser - Beim Anlegen festlegen, wer die Liste sieht: alle Konten oder nur ausgewählte (Ersteller sieht sie immer); ändern/löschen nur Ersteller und Administratoren - Gekauft, aufgebraucht, Nachkaufen und Rezeptabgleich wirken innerhalb der gewählten Liste - Migration: bisheriger Vorrat und Einkauf werden zur Liste "Zuhause" (für alle sichtbar) Reisen: - Schalter "Städte anderer zeigen": Städte der übrigen Konten in je einer eigenen Farbe, einzelne Personen ein-/ausblendbar, nur lesend - Info-Karte zeigt, wer noch in einer Stadt bzw. einem Land war Design "Fabrik" (Industrie-Inventar à la Satisfactory): - Stahlplatten mit Schrauben, Titelleisten mit Fenstersymbol, gelb leuchtende Hauptbuttons, orange Mengen-Zähler, Warnstreifen, Riffelblech und drehende Zahnräder im Hintergrund - Schriften Roboto / Roboto Condensed (OFL), lokal eingebunden Doku: README verlinkt das zugehörige Repository P42-S.M. Co-Authored-By: Claude Opus 5.5 --- DEPLOY.md | 2 +- README.md | 38 +- backend/app/db.py | 53 ++- backend/app/haushalt.py | 189 ++++++-- backend/app/reisen.py | 22 +- backend/app/rezept.py | 7 +- docs/CREDITS.md | 4 +- frontend/index.html | 2 +- frontend/package-lock.json | 18 + frontend/package.json | 2 + frontend/src/components/FabrikField.jsx | 28 ++ frontend/src/components/RecipeCheck.jsx | 8 +- frontend/src/components/ThemeBackground.jsx | 2 + frontend/src/components/WorldMap.jsx | 10 +- frontend/src/main.jsx | 5 + frontend/src/pages/Haushalt.jsx | 231 +++++++++- frontend/src/pages/Reisen.jsx | 126 ++++- frontend/src/styles.css | 31 ++ frontend/src/theme.js | 1 + frontend/src/themes.css | 485 +++++++++++++++++++- 20 files changed, 1182 insertions(+), 82 deletions(-) create mode 100644 frontend/src/components/FabrikField.jsx diff --git a/DEPLOY.md b/DEPLOY.md index af13fe2..a481417 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -107,6 +107,6 @@ docker compose exec p42 python -m app.reset_password BENUTZERNAME # Passwort z - **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`). - **Server-Auslastung:** Das Dashboard hängt zusätzlich im Docker-Netz `p42` (legt `update-docker.sh` an). - Läuft dort der Agent P42-S.M., im Monitoring „Server-Auslastung (P42-S.M.)“ mit der Adresse + Läuft dort der Agent [P42-S.M.](https://github.com/guyg1474/p42.s-m), im Monitoring „Server-Auslastung (P42-S.M.)“ mit der Adresse `http://p42-sm:9142` und dessen Token eintragen. - **Zeitzone** ist `Europe/Berlin` (für Uhrzeiten in Monitoring und Logs). diff --git a/README.md b/README.md index 09f21ba..f94d48f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,12 @@ Zwei umschaltbare Designs: Matrix und Blackwall. - **Backend:** Python (FastAPI) – `backend/` - **Frontend:** React (Vite, React Router, TipTap-Editor) – `frontend/` +## Zugehöriges Projekt: P42-S.M. + +Die Server-Auslastung im Monitoring liefert der Agent **P42-S.M.** – ein eigenes Repository, das auf +dem zu überwachenden Server läuft: **https://github.com/guyg1474/p42.s-m** +Einrichtung dort in der README; danach hier im Monitoring eintragen (siehe [Monitoring](#monitoring)). + ## Starten ```bash @@ -63,8 +69,11 @@ Die ganze Seite ist nur nach Anmeldung erreichbar. - 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**. +- Pro Konto eigenständig: **Links** auf der Startseite, **Monitoring** und die **Reise-Karte** + (die Städte der anderen lassen sich auf der Karte einblenden). +- Gemeinsam für alle Konten: **Kanban**. +- **Vorrat & Einkauf** in Listen (z. B. Zuhause, Urlaub): wer eine Liste anlegt, bestimmt, ob alle + Konten sie sehen oder nur ausgewählte. - **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. @@ -86,7 +95,7 @@ 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`. -- **Server-Auslastung (P42-S.M.):** CPU, RAM, Platten, Load und Temperatur direkt auf der Karte. +- **Server-Auslastung ([P42-S.M.](https://github.com/guyg1474/p42.s-m)):** CPU, RAM, Platten, Load und Temperatur direkt auf der Karte. Klick auf die Karte öffnet die große Ansicht mit allen Werten (alle 5 s aktualisiert) und dem Verlauf der letzten 6 h, 24 h oder 2 Tage. Adresse `http://p42-sm:9142` (Agent auf demselben Server), dazu der Token aus der `.env` von P42-S.M. Der Token wird nur im Backend gespeichert und nie an den Browser gegeben. @@ -121,10 +130,18 @@ 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. +Oben wählst du die **Liste**, z. B. „Zuhause“ oder „Urlaub“. Jede Liste hat zwei eigene Bereiche: +**Vorrat** (was da ist) und **Einkaufen** (was fehlt) – im Urlaub gibt es also einen eigenen Bestand +und einen eigenen Einkaufszettel. +- **Neue Liste:** „+ Neue Liste“ legt Vorrat und Einkauf zusammen neu an. Dabei festlegen, wer sie + sieht: **alle Konten** oder **nur ausgewählte**. Der Ersteller sieht seine Liste immer. + Name, Sichtbarkeit und Löschen („✎ Liste bearbeiten“) nur für den Ersteller und Administratoren – + Administratoren aber nur bei Listen, die sie sehen. Löschen entfernt Vorrat und Einkauf der Liste. +- Beim Update werden der bisherige Vorrat und die bisherige Einkaufsliste zur Liste „Zuhause“ + (für alle sichtbar). +- Welche Liste gewählt ist, merkt sich der Browser. Gekauft, aufgebraucht, Nachkaufen und der + Rezeptabgleich wirken immer innerhalb der gewählten Liste. - 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 @@ -139,6 +156,10 @@ Weltkarte pro Konto: Städte, in denen man war, als Punkte; besuchte Länder aus 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). +Der Schalter **Städte anderer zeigen** (über der Karte) blendet die Städte der übrigen Konten ein – je Person in einer eigenen Farbe, +einzelne Personen lassen sich über ihre Schaltfläche aus- und einblenden (gemerkt pro Browser). Fremde +Städte kann man nur ansehen, nicht ändern. Alle angemeldeten Konten können die Städte der anderen sehen. + ### Kanban Beliebig viele Projekte mit je einem Board (Offen, In Arbeit, Wartet, Erledigt). Aufgaben @@ -154,6 +175,7 @@ Im Menü unter **Design** umschaltbar, gespeichert pro Browser: |---|---| | Matrix | rotes Retro-Terminal mit Matrix-Regen (Standard) | | Blackwall | Cyberpunk-Netrunner: Pink-Rot mit Cyan, leuchtende Datenwand, abgeschrägte Panels | +| Fabrik | Industrie-Inventar à la Satisfactory: Stahlplatten mit Schrauben, gelb leuchtende Buttons, orange Mengen-Zähler, Warnstreifen, drehende Zahnräder | „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, @@ -186,7 +208,7 @@ backend/ 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, P42-S.M.-Auslastung + Verlauf) - app/haushalt.py Vorrat & Einkauf, Produktkatalog, Einheiten + app/haushalt.py Listen (je Vorrat + Einkauf) mit Sichtbarkeit, 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 @@ -197,7 +219,7 @@ frontend/ 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/themes.css Blackwall und Fabrik (überschreiben 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, … diff --git a/backend/app/db.py b/backend/app/db.py index 4402034..93afc37 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -10,18 +10,20 @@ 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). +# Vorrat und Einkauf; key = Name in casefold() für den Vergleich. +# Jeder Eintrag gehört zu einer Liste (shopping_lists, z. B. "Zuhause", "Urlaub") mit eigenem Vorrat und Einkauf. +# Pro Liste, Produkt, Bereich 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')), + list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, 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) + UNIQUE (list_id, key, list, unit) )""" # Kanban-Projekte (je ein Board), gemeinsam für alle Konten @@ -115,6 +117,23 @@ CREATE TABLE IF NOT EXISTS monitor_samples ( PRIMARY KEY (monitor_id, ts) ) WITHOUT ROWID; +-- Listen mit je eigenem Vorrat und Einkauf (z. B. "Zuhause", "Urlaub"); +-- everyone = 1: alle Konten sehen sie, sonst nur Ersteller und Mitglieder +CREATE TABLE IF NOT EXISTS shopping_lists ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + everyone INTEGER NOT NULL DEFAULT 1, + position REAL NOT NULL, + created_at TEXT NOT NULL, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS shopping_list_members ( + list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (list_id, user_id) +); + CREATE TABLE IF NOT EXISTS household_items {HOUSEHOLD_TABLE}; -- Produktkatalog für die Vorschläge @@ -244,15 +263,33 @@ def _import_legacy(conn) -> None: 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: + """Vorrat/Einkauf aus früheren Versionen (ohne Einheiten, ohne Listen oder Vorrat ohne Liste) neu aufbauen. + + Alles, was noch keiner Liste gehört, kommt in die erste Liste bzw. in eine neue Liste "Zuhause", + die alle Konten sehen. + """ + info = {r["name"]: r for r in conn.execute("PRAGMA table_info(household_items)")} + if "list_id" in info and info["list_id"]["notnull"]: return + row = conn.execute("SELECT id FROM shopping_lists ORDER BY position, id LIMIT 1").fetchone() + if row: + default = row["id"] + # Die Zwischenversion nannte die gemeinsame Liste "Einkaufsliste"; jetzt enthält sie auch den Vorrat + conn.execute("UPDATE shopping_lists SET name = 'Zuhause' WHERE id = ? AND name = 'Einkaufsliste' AND created_by IS NULL", (default,)) + else: + default = conn.execute( + "INSERT INTO shopping_lists (name, everyone, position, created_at) VALUES ('Zuhause', 1, 1, ?)", (now_iso(),) + ).lastrowid + unit = "unit" if "unit" in info else "'stk'" + list_id = "COALESCE(list_id, ?)" if "list_id" in info else "?" + conn.execute("DROP INDEX IF EXISTS household_vorrat") + conn.execute("DROP INDEX IF EXISTS household_einkauf") 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""" + f"""INSERT INTO household_items (id, key, name, list, list_id, quantity, unit, updated_at, updated_by) + SELECT id, key, name, list, {list_id}, quantity, {unit}, updated_at, updated_by FROM household_items_alt""", + (default,), ) conn.execute("DROP TABLE household_items_alt") diff --git a/backend/app/haushalt.py b/backend/app/haushalt.py index 19f4137..fef5946 100644 --- a/backend/app/haushalt.py +++ b/backend/app/haushalt.py @@ -1,6 +1,8 @@ -"""Geteilte Vorrats- und Einkaufsliste mit Produktkatalog für einheitliche Namen. +"""Vorrat und Einkauf in mehreren Listen sowie Produktkatalog für einheitliche Namen. -Alle Konten sehen und bearbeiten dieselben Listen. Jedes Produkt steht pro Liste und Einheit +Jede Liste (z. B. "Zuhause", "Urlaub") hat ihren eigenen Vorrat und ihre eigene Einkaufsliste. +Wer eine Liste anlegt, legt fest, ob alle Konten sie sehen oder nur ausgewählte. Ändern +(Name, Sichtbarkeit) und löschen dürfen der Ersteller und Administratoren. 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". """ @@ -20,6 +22,8 @@ UNITS = ("stk", "kg", "l") MAX_NAME = 60 MAX_QUANTITY = 999 MAX_PRODUCTS = 2000 +MAX_LIST_NAME = 40 +MAX_LISTS = 50 def _item(row) -> dict: @@ -27,6 +31,7 @@ def _item(row) -> dict: "id": row["id"], "name": row["name"], "list": row["list"], + "listId": row["list_id"], "quantity": _out_quantity(row["quantity"], row["unit"]), "unit": row["unit"], "updatedAt": row["updated_at"], @@ -96,43 +101,124 @@ def _canonical_name(conn, name: str) -> str: 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.""" +def _add(conn, name: str, list_: str, list_id: int, quantity, unit: str, user_id: int) -> None: + """Produkt in den Vorrat bzw. Einkauf einer 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 + """INSERT INTO household_items (key, name, list, list_id, quantity, unit, updated_at, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (list_id, 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), + (_key(name), name, list_, list_id, 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() +# ---------- Listen und wer sie sieht ---------- + +# Sichtbar: für alle freigegeben, selbst angelegt oder als Mitglied eingetragen. +# Listen eines gelöschten Kontos ohne Mitglieder sehen Administratoren, damit sie nicht verwaist bleiben. +_VISIBLE = """(l.everyone = 1 OR l.created_by = :uid + OR EXISTS (SELECT 1 FROM shopping_list_members m WHERE m.list_id = l.id AND m.user_id = :uid) + OR (:admin AND l.created_by IS NULL))""" + + +def _who(user: dict) -> dict: + return {"uid": user["id"], "admin": int(user["isAdmin"])} + + +def _visible_lists(conn, user: dict) -> list: + return conn.execute( + f"""SELECT l.*, u.display_name AS created_by_name FROM shopping_lists l + LEFT JOIN users u ON u.id = l.created_by + WHERE {_VISIBLE} ORDER BY l.position, l.id""", + _who(user), + ).fetchall() + + +def _get_list(conn, list_id, user: dict): + if isinstance(list_id, bool) or not isinstance(list_id, int): + raise ValidationError("Bitte eine Liste wählen.") + row = conn.execute(f"SELECT l.* FROM shopping_lists l WHERE l.id = :id AND {_VISIBLE}", {"id": list_id, **_who(user)}).fetchone() if not row: - raise HTTPException(404, "Eintrag nicht gefunden – vielleicht hat ihn gerade jemand anderes geändert.") + raise HTTPException(404, "Liste nicht gefunden – vielleicht wurde sie gelöscht oder nicht mit dir geteilt.") return row -def _state(conn) -> dict: +def _can_manage(row, user: dict) -> bool: + return row["created_by"] == user["id"] or user["isAdmin"] + + +def _shopping_list(conn, row, user: dict) -> dict: + members = [r["user_id"] for r in conn.execute("SELECT user_id FROM shopping_list_members WHERE list_id = ?", (row["id"],))] + return { + "id": row["id"], + "name": row["name"], + "everyone": bool(row["everyone"]), + "members": members, + "createdBy": {"id": row["created_by"], "name": row["created_by_name"]} if row["created_by"] else None, + "canManage": _can_manage(row, user), + } + + +def _list_fields(conn, data: dict, user: dict, owner_id) -> tuple: + """Name, "für alle" und Mitglieder prüfen. Der Ersteller sieht seine Liste immer und steht nicht bei den Mitgliedern.""" + name = re.sub(r"\s+", " ", data.get("name")).strip() if isinstance(data.get("name"), str) else "" + if not name: + raise ValidationError("Name der Liste fehlt.") + if len(name) > MAX_LIST_NAME: + raise ValidationError(f"Name ist zu lang (max. {MAX_LIST_NAME} Zeichen).") + everyone = data.get("everyone", True) + if not isinstance(everyone, bool): + raise ValidationError("Ungültige Sichtbarkeit.") + members = data.get("members", []) + if not isinstance(members, list) or any(isinstance(m, bool) or not isinstance(m, int) for m in members): + raise ValidationError("Ungültige Auswahl der Konten.") + members = {m for m in members if m != owner_id} + if members: + known = {r["id"] for r in conn.execute(f"SELECT id FROM users WHERE id IN ({','.join('?' * len(members))})", list(members))} + if known != members: + raise ValidationError("Ein ausgewähltes Konto gibt es nicht mehr.") + return name, everyone, sorted(members) if not everyone else [] + + +def _set_members(conn, list_id: int, members: list) -> None: + conn.execute("DELETE FROM shopping_list_members WHERE list_id = ?", (list_id,)) + conn.executemany("INSERT INTO shopping_list_members (list_id, user_id) VALUES (?, ?)", [(list_id, m) for m in members]) + + +def _get_item(conn, item_id: int, user: dict): + 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.") + _get_list(conn, row["list_id"], user) + return row + + +def _state(conn, user: dict) -> dict: + lists = _visible_lists(conn, user) + ids = [r["id"] for r in lists] 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""" + f"""SELECT i.*, u.display_name AS updated_by_name FROM household_items i + LEFT JOIN users u ON u.id = i.updated_by + WHERE i.list_id IN ({','.join('?' * len(ids))})""", + ids, ).fetchall() # Häufig genutzte Produkte zuerst vorschlagen products = conn.execute("SELECT name, uses FROM products").fetchall() + users = conn.execute("SELECT id, display_name FROM users ORDER BY display_name COLLATE NOCASE").fetchall() return { "items": [_item(r) for r in sorted(items, key=lambda r: _sort_key(r["name"]))], + "lists": [_shopping_list(conn, r, user) for r in lists], + "users": [{"id": u["id"], "name": u["display_name"]} for u in users], "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)): +def get_household(user: dict = Depends(current_user)): with transaction() as conn: - return _state(conn) + return _state(conn, user) @router.post("/api/haushalt/items", status_code=201) @@ -144,15 +230,16 @@ def add_item(data: dict = Body(...), user: dict = Depends(current_user)): 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) + list_id = _get_list(conn, data.get("listId"), user)["id"] + _add(conn, _canonical_name(conn, name), list_, list_id, quantity, unit, user["id"]) + return _state(conn, user) @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) + row = _get_item(conn, item_id, user) unit = _unit(data.get("unit", row["unit"])) if "quantity" in data: quantity = _quantity(data["quantity"], unit) @@ -160,46 +247,84 @@ def update_item(item_id: int, data: dict = Body(...), user: dict = Depends(curre # 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), + "SELECT id FROM household_items WHERE list_id = ? AND key = ? AND list = ? AND unit = ? AND id != ?", + (row["list_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"]) + _add(conn, row["name"], row["list"], row["list_id"], 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) + return _state(conn, user) @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.""" + """Innerhalb einer Liste: Einkauf → Vorrat ("gekauft") bzw. Vorrat → Einkauf ("aufgebraucht"), mit Zusammenführen.""" with transaction() as conn: - row = _get_item(conn, item_id) + row = _get_item(conn, item_id, user) 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) + _add(conn, row["name"], target, row["list_id"], quantity, row["unit"], user["id"]) + return _state(conn, user) @router.delete("/api/haushalt/items/{item_id}") -def delete_item(item_id: int, _: dict = Depends(current_user)): +def delete_item(item_id: int, user: dict = Depends(current_user)): with transaction() as conn: - _get_item(conn, item_id) + _get_item(conn, item_id, user) conn.execute("DELETE FROM household_items WHERE id = ?", (item_id,)) - return _state(conn) + return _state(conn, user) + + +@router.post("/api/haushalt/listen", status_code=201) +def create_list(data: dict = Body(...), user: dict = Depends(current_user)): + with transaction() as conn: + name, everyone, members = _list_fields(conn, data, user, user["id"]) + if conn.execute("SELECT COUNT(*) FROM shopping_lists").fetchone()[0] >= MAX_LISTS: + raise ValidationError(f"Es gibt schon {MAX_LISTS} Listen. Bitte erst alte löschen.") + position = conn.execute("SELECT COALESCE(MAX(position), 0) + 1 FROM shopping_lists").fetchone()[0] + list_id = conn.execute( + "INSERT INTO shopping_lists (name, everyone, position, created_at, created_by) VALUES (?, ?, ?, ?, ?)", + (name, int(everyone), position, now_iso(), user["id"]), + ).lastrowid + _set_members(conn, list_id, members) + return {**_state(conn, user), "created": list_id} + + +@router.patch("/api/haushalt/listen/{list_id}") +def update_list(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)): + with transaction() as conn: + row = _get_list(conn, list_id, user) + if not _can_manage(row, user): + raise HTTPException(403, "Nur wer die Liste angelegt hat (oder ein Administrator) kann sie ändern.") + name, everyone, members = _list_fields(conn, data, user, row["created_by"]) + conn.execute("UPDATE shopping_lists SET name = ?, everyone = ? WHERE id = ?", (name, int(everyone), list_id)) + _set_members(conn, list_id, members) + return _state(conn, user) + + +@router.delete("/api/haushalt/listen/{list_id}") +def delete_list(list_id: int, user: dict = Depends(current_user)): + """Liste samt Vorrat und Einkauf löschen (ON DELETE CASCADE).""" + with transaction() as conn: + row = _get_list(conn, list_id, user) + if not _can_manage(row, user): + raise HTTPException(403, "Nur wer die Liste angelegt hat (oder ein Administrator) kann sie löschen.") + conn.execute("DELETE FROM shopping_lists WHERE id = ?", (list_id,)) + return _state(conn, user) @router.delete("/api/haushalt/produkte") -def delete_product(name: str, _: dict = Depends(current_user)): +def delete_product(name: str, user: 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) + return _state(conn, user) diff --git a/backend/app/reisen.py b/backend/app/reisen.py index e6a8170..f4a3292 100644 --- a/backend/app/reisen.py +++ b/backend/app/reisen.py @@ -1,5 +1,7 @@ """Reise-Karte pro Konto: besuchte Städte und Länder sowie kurze Infos dazu. +Die Städte der anderen Konten werden mitgeliefert und lassen sich auf der Karte einblenden (nur lesen). + 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. @@ -40,10 +42,28 @@ def _city(row) -> dict: } +def _others(conn, user_id: int) -> list: + """Städte der anderen Konten (nur zum Anzeigen auf der Karte), gruppiert nach Konto.""" + rows = conn.execute( + """SELECT c.*, u.display_name FROM travel_cities c JOIN users u ON u.id = c.user_id + WHERE c.user_id != ? ORDER BY u.display_name COLLATE NOCASE, u.id, c.name""", + (user_id,), + ).fetchall() + people = {} + for r in rows: + person = people.setdefault(r["user_id"], {"id": r["user_id"], "name": r["display_name"], "cities": []}) + person["cities"].append(_city(r)) + return list(people.values()) + + 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]} + return { + "cities": [_city(r) for r in cities], + "countries": [r["code"] for r in countries], + "others": _others(conn, user_id), + } # ---------- Verwaltung ---------- diff --git a/backend/app/rezept.py b/backend/app/rezept.py index c9df328..c8f25cc 100644 --- a/backend/app/rezept.py +++ b/backend/app/rezept.py @@ -16,7 +16,7 @@ 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 .haushalt import _add, _canonical_name, _clean_name, _get_list, _key, _quantity, _state, _unit from .links import ValidationError router = APIRouter() @@ -342,6 +342,7 @@ def take_over(data: dict = Body(...), user: dict = Depends(current_user)): 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: + list_id = _get_list(conn, data.get("listId"), user)["id"] if checked else None # Einkauf dieser Liste for ingredient, product in learned: product = _canonical_name(conn, product) if _key(ingredient) == _key(product): @@ -353,5 +354,5 @@ def take_over(data: dict = Body(...), user: dict = Depends(current_user)): (_key(ingredient), product), ) for name, quantity, unit in checked: - _add(conn, _canonical_name(conn, name), "einkauf", quantity, unit, user["id"]) - return _state(conn) + _add(conn, _canonical_name(conn, name), "einkauf", list_id, quantity, unit, user["id"]) + return _state(conn, user) diff --git a/docs/CREDITS.md b/docs/CREDITS.md index 7c060c2..7009044 100644 --- a/docs/CREDITS.md +++ b/docs/CREDITS.md @@ -8,6 +8,8 @@ | 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 | +| Roboto | Fabrik (Text) | SIL Open Font License 1.1 | +| Roboto Condensed | Fabrik (Titel, Beschriftungen) | SIL Open Font License 1.1 | Die Lizenztexte liegen in `frontend/node_modules/@fontsource//LICENSE`. @@ -25,6 +27,6 @@ Die Quellenangaben stehen auch klein unter der Reise-Karte. ## Designs -Blackwall ist von Cyberpunk 2077 inspiriert. +Blackwall ist von Cyberpunk 2077 inspiriert, Fabrik von den Inventar- und Crafting-Menüs aus Satisfactory. Es werden keine Original-Logos, Abzeichen, Bilder oder geschützten Schriften verwendet; alle Formen, Farben und Hintergründe sind selbst gezeichnet (CSS, SVG, Canvas). diff --git a/frontend/index.html b/frontend/index.html index 5188f4b..5cc5dd6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -11,7 +11,7 @@ (function () { var t = 'matrix'; try { t = localStorage.getItem('p42.design') || t; } catch (e) {} - if (['matrix', 'blackwall'].indexOf(t) < 0) t = 'matrix'; + if (['matrix', 'blackwall', 'fabrik'].indexOf(t) < 0) t = 'matrix'; document.documentElement.setAttribute('data-theme', t); })(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4a3ca9b..c67860c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,8 @@ "@fontsource/chakra-petch": "^5.3.0", "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/oxanium": "^5.3.0", + "@fontsource/roboto": "^5.3.0", + "@fontsource/roboto-condensed": "^5.3.0", "@fontsource/vt323": "^5.3.0", "@tiptap/extension-image": "^3.31.4", "@tiptap/extensions": "^3.31.4", @@ -80,6 +82,22 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/roboto": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.3.0.tgz", + "integrity": "sha512-BapRJOWYP+LZ21zp+wBQjfpPYKRoxc4LspJ/RLuI+HSMBD5u/X4O+ESDrSvEqDSy0rAl7GwBJ+09mdc16cVQ1Q==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/roboto-condensed": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/roboto-condensed/-/roboto-condensed-5.3.0.tgz", + "integrity": "sha512-Vbh5pesre/M/Jspd10btdKRqLSTe3yn4l6B2tXHGElCeASfA7Pgdpu1mb8wTG8E72J4Xk6FUfqnSe46UN2QAog==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/vt323": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/vt323/-/vt323-5.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7afa777..9860751 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,8 @@ "@fontsource/chakra-petch": "^5.3.0", "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/oxanium": "^5.3.0", + "@fontsource/roboto": "^5.3.0", + "@fontsource/roboto-condensed": "^5.3.0", "@fontsource/vt323": "^5.3.0", "@tiptap/extension-image": "^3.31.4", "@tiptap/extensions": "^3.31.4", diff --git a/frontend/src/components/FabrikField.jsx b/frontend/src/components/FabrikField.jsx new file mode 100644 index 0000000..7895812 --- /dev/null +++ b/frontend/src/components/FabrikField.jsx @@ -0,0 +1,28 @@ +// Hintergrund "Fabrik" (Industrie-UI im Stil von Satisfactory): Hallenwand mit Riffelblech-Boden, +// HUD-Linie oben und zwei langsam drehende Zahnräder. Nur CSS/SVG, ohne Animation steht alles still. + +// Zahnrad als Umriss mit Loch (evenodd): Zähne trapezförmig zwischen Fuß- und Kopfkreis +function gearPath(teeth, outer, inner, hole) { + const step = (Math.PI * 2) / teeth; + const pts = []; + for (let i = 0; i < teeth; i += 1) { + const a = i * step; + pts.push([inner, a - step * 0.32], [outer, a - step * 0.18], [outer, a + step * 0.18], [inner, a + step * 0.32]); + } + const ring = pts.map(([r, a], i) => `${i ? 'L' : 'M'}${(r * Math.cos(a)).toFixed(2)} ${(r * Math.sin(a)).toFixed(2)}`).join(''); + return `${ring}Z M${hole} 0A${hole} ${hole} 0 1 0 ${-hole} 0A${hole} ${hole} 0 1 0 ${hole} 0Z`; +} + +const BIG = gearPath(14, 48, 40, 15); +const SMALL = gearPath(10, 48, 38, 18); + +export default function FabrikField() { + return ( +