Listen mit eigenem Vorrat und Einkauf, Städte anderer auf der Reise-Karte, Design "Fabrik"

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 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 11:50:46 +02:00
1 parent f16c8c73c6
commit b30acb652a
20 files changed
+1182 -82

No files matched your search

+1 -1
View File
@@ -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).
+30 -8
View File
@@ -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, …
+45 -8
View File
@@ -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")
+157 -32
View File
@@ -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)
+21 -1
View File
@@ -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 ----------
+4 -3
View File
@@ -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)
+3 -1
View File
@@ -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/<schrift>/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).
+1 -1
View File
@@ -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);
})();
</script>
+18
View File
@@ -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",
+2
View File
@@ -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",
+28
View File
@@ -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 (
<div className="theme-bg fabrik-field" aria-hidden="true">
<div className="fabrik-floor" />
<svg className="fabrik-gear big" viewBox="-50 -50 100 100"><path d={BIG} fillRule="evenodd" /></svg>
<svg className="fabrik-gear small" viewBox="-50 -50 100 100"><path d={SMALL} fillRule="evenodd" /></svg>
<div className="fabrik-hud" />
</div>
);
}
+4 -4
View File
@@ -21,9 +21,9 @@ function loadPeople() {
/**
* 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.
* und Fehlendes in den Einkauf der gewählten Liste setzen. find(list, name) sucht in Vorrat bzw. Einkauf dieser Liste.
*/
export default function RecipeCheck({ products, find, onTakeOver, busy }) {
export default function RecipeCheck({ products, find, shoppingList, onTakeOver, busy }) {
const [mode, setMode] = useState('url'); // url | text
const [url, setUrl] = useState('');
const [text, setText] = useState('');
@@ -266,10 +266,10 @@ export default function RecipeCheck({ products, find, onTakeOver, busy }) {
{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.`
? `✓ ${recipe.done} ${recipe.done === 1 ? 'Zutat' : 'Zutaten'} auf die Einkaufsliste „${shoppingList?.name}“ 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}>
<button className="btn" type="button" disabled={busy || chosen.length === 0 || !shoppingList} onClick={takeOver}>
Fehlendes auf Einkaufsliste ({chosen.length})
</button>
</footer>
@@ -1,12 +1,14 @@
import { useEffect, useState } from 'react';
import { getTheme, onThemeChange } from '../theme.js';
import BlackwallField from './BlackwallField.jsx';
import FabrikField from './FabrikField.jsx';
import MatrixRain from './MatrixRain.jsx';
// Dekorativer Hintergrund des aktiven Designs (alles aria-hidden, ohne Mausereignisse)
const BACKGROUNDS = {
matrix: MatrixRain,
blackwall: BlackwallField,
fabrik: FabrikField,
};
export default function ThemeBackground() {
+6 -4
View File
@@ -28,10 +28,11 @@ function mainPart(feature) {
/**
* 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 }.
* otherCities: Städte anderer Konten ({ ...city, owner: { name }, color: 1–6 }), unter den eigenen gezeichnet.
* Ü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 }) {
export default function WorldMap({ ref, countries, visited, cities, otherCities = [], selected, onSelect }) {
const box = useRef(null);
const svgRef = useRef(null);
const layer = useRef(null);
@@ -132,17 +133,18 @@ export default function WorldMap({ ref, countries, visited, cities, selected, on
<title>{c.name}</title>
</path>
))}
{cities.map((city) => {
{[...otherCities, ...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' : ''}`}
className={`map-city${city.owner ? ' other' : ''}${isSelected ? ' selected' : ''}`}
style={city.owner ? { '--dot': `var(--other-${city.color})` } : undefined}
onClick={(e) => { e.stopPropagation(); onSelect({ type: 'city', key: city.id }); }}
>
<title>{city.name}</title>
<title>{city.owner ? `${city.name} (${city.owner.name})` : 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" />
+5
View File
@@ -10,6 +10,11 @@ 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 '@fontsource/roboto/latin-400.css';
import '@fontsource/roboto/latin-500.css';
import '@fontsource/roboto/latin-700.css';
import '@fontsource/roboto-condensed/latin-500.css';
import '@fontsource/roboto-condensed/latin-700.css';
import { AuthProvider, useAuth } from './auth.jsx';
import Layout from './components/Layout.jsx';
import ThemeBackground from './components/ThemeBackground.jsx';
+222 -9
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api, personName } from '../api.js';
import { useAuth } from '../auth.jsx';
import ProductInput from '../components/ProductInput.jsx';
import RecipeCheck from '../components/RecipeCheck.jsx';
import UnitSelect from '../components/UnitSelect.jsx';
@@ -8,9 +9,10 @@ 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 LIST_KEY = 'p42.haushaltListe';
const LISTS = {
vorrat: { title: 'Vorrat', lead: 'Was zu Hause ist', empty: 'Noch nichts im Vorrat eingetragen.' },
vorrat: { title: 'Vorrat', lead: 'Was da ist', empty: 'Noch nichts im Vorrat eingetragen.' },
einkauf: { title: 'Einkaufen', lead: 'Was besorgt werden muss', empty: 'Nichts zu besorgen.' },
};
@@ -18,12 +20,20 @@ function loadTab() {
try { return localStorage.getItem(TAB_KEY) === 'einkauf' ? 'einkauf' : 'vorrat'; } catch { return 'vorrat'; }
}
function loadListId() {
try { return Number(localStorage.getItem(LIST_KEY)) || null; } catch { return null; }
}
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 [listId, setListId] = useState(loadListId);
// Dialog für Listen: null = zu, { list: null } = neue Liste, { list } = bearbeiten
const [listDialog, setListDialog] = useState(null);
const dialog = useRef(null);
const listDialogRef = useRef(null);
const load = useCallback(async () => {
try {
@@ -44,13 +54,14 @@ export default function Haushalt() {
};
}, [load]);
// Jede Änderung liefert den aktuellen Stand beider Listen zurück
// Jede Änderung liefert den aktuellen Stand aller Listen zurück
async function run(method, url, body) {
setBusy(true);
setError(null);
try {
setData(await api(method, url, body));
return true;
const next = await api(method, url, body);
setData(next);
return next;
} catch (e) {
setError(e.message);
// Bei Konflikten (z. B. gerade von jemand anderem gelöscht) frisch laden
@@ -66,16 +77,50 @@ export default function Haushalt() {
try { localStorage.setItem(TAB_KEY, next); } catch { /* ignorieren */ }
}
function chooseList(id) {
setListId(id);
try { localStorage.setItem(LIST_KEY, String(id)); } catch { /* ignorieren */ }
}
function openListDialog(list) {
setListDialog({ list });
// Erst nach dem Rendern öffnen, damit der Dialog mit den neuen Werten startet
requestAnimationFrame(() => listDialogRef.current?.showModal());
}
async function saveList(form) {
const editing = listDialog?.list;
try {
const next = await api(editing ? 'PATCH' : 'POST', editing ? `/api/haushalt/listen/${editing.id}` : '/api/haushalt/listen', form);
setData(next);
if (next.created) chooseList(next.created);
return null;
} catch (e) {
return e.message;
}
}
async function deleteList(list) {
const count = (data?.items || []).filter((i) => i.listId === list.id).length;
const what = count ? ` mit ${count} ${count === 1 ? 'Eintrag' : 'Einträgen'}` : '';
if (!window.confirm(`Liste „${list.name}“ samt Vorrat und Einkauf${what} löschen?`)) return false;
return Boolean(await run('DELETE', `/api/haushalt/listen/${list.id}`));
}
const items = data?.items || [];
const lists = data?.lists || [];
// Gewählte Liste (z. B. Zuhause, Urlaub); gibt es sie nicht (mehr), die erste sichtbare
const current = lists.find((l) => l.id === listId) || lists[0] || null;
const mine = current ? items.filter((i) => i.listId === current.id) : [];
const byList = {
vorrat: items.filter((i) => i.list === 'vorrat'),
einkauf: items.filter((i) => i.list === 'einkauf'),
vorrat: mine.filter((i) => i.list === 'vorrat'),
einkauf: mine.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 }),
add: (list, name, quantity, unit) => run('POST', '/api/haushalt/items', { list, name, quantity, unit, listId: current?.id }),
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}`),
@@ -86,7 +131,7 @@ export default function Haushalt() {
<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>
<p className="lead">Jede Liste hat ihren eigenen Vorrat und Einkauf – z. B. Zuhause und Urlaub. 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>
@@ -97,12 +142,29 @@ export default function Haushalt() {
{!data && !error && <p className="hint">Lade …</p>}
{data && (
<ListSwitcher
lists={lists}
current={current}
users={data.users}
onChoose={chooseList}
onNew={() => openListDialog(null)}
onEdit={openListDialog}
busy={busy}
/>
)}
{data && !current && (
<p className="hint household-empty">Keine Liste, die du sehen kannst. Leg mit „+ Neue Liste“ eine an.</p>
)}
{data && current && (
<>
<RecipeCheck
products={data.products}
find={find}
busy={busy}
onTakeOver={(body) => run('POST', '/api/haushalt/rezept/uebernehmen', body)}
shoppingList={current}
onTakeOver={(body) => run('POST', '/api/haushalt/rezept/uebernehmen', { ...body, listId: current?.id })}
/>
<div className="segmented" role="tablist" aria-label="Liste wählen">
@@ -138,6 +200,18 @@ export default function Haushalt() {
</>
)}
{listDialog && (
<ListDialog
key={listDialog.list?.id ?? 'neu'}
ref={listDialogRef}
list={listDialog.list}
users={data?.users || []}
onSave={saveList}
onDelete={deleteList}
onClosed={() => setListDialog(null)}
/>
)}
<ProductsDialog ref={dialog} products={data?.products || []} onDelete={(name) => run('DELETE', `/api/haushalt/produkte?name=${encodeURIComponent(name)}`)} />
</>
);
@@ -361,6 +435,145 @@ function ItemRow({ item, list, other, actions, busy }) {
);
}
// ---------- Listen (je Vorrat + Einkauf): wählen, anlegen, freigeben ----------
function visibleFor(list, users) {
if (list.everyone) return 'alle Konten';
const ids = new Set([list.createdBy?.id, ...list.members]);
const names = users.filter((u) => ids.has(u.id)).map((u) => u.name);
return names.length ? names.join(', ') : 'niemanden';
}
function ListSwitcher({ lists, current, users, onChoose, onNew, onEdit, busy }) {
return (
<div className="shopping-lists">
<ul className="chip-list" aria-label="Liste wählen">
<li className="shopping-lists-label">Liste:</li>
{lists.map((l) => (
<li key={l.id}>
<button
type="button"
className={`chip${l.id === current?.id ? ' active' : ''}`}
aria-pressed={l.id === current?.id}
onClick={() => onChoose(l.id)}
title={`Sichtbar für ${visibleFor(l, users)}`}
>
{!l.everyone && <span aria-label="nicht für alle sichtbar">🔒 </span>}
{l.name}
</button>
</li>
))}
<li>
<button type="button" className="link-btn" onClick={onNew} disabled={busy}>+ Neue Liste</button>
</li>
</ul>
{current && (
<p className="hint shopping-visibility">
Sichtbar für: {visibleFor(current, users)}
{current.createdBy && ` · angelegt von ${current.createdBy.name}`}
{current.canManage && (
<>
{' · '}
<button type="button" className="link-btn" onClick={() => onEdit(current)} disabled={busy}>✎ Liste bearbeiten</button>
</>
)}
</p>
)}
</div>
);
}
function ListDialog({ ref, list, users, onSave, onDelete, onClosed }) {
const { user } = useAuth();
const ownerId = list ? list.createdBy?.id : user?.id;
const [form, setForm] = useState(() => ({
name: list?.name || '',
everyone: list ? list.everyone : true,
members: list?.members || [],
}));
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
// Der Ersteller sieht seine Liste immer, deshalb steht er nicht zur Auswahl
const choosable = users.filter((u) => u.id !== ownerId);
const toggle = (id) => setForm((f) => ({ ...f, members: f.members.includes(id) ? f.members.filter((x) => x !== id) : [...f.members, id] }));
async function submit(e) {
e.preventDefault();
setBusy(true);
setError(null);
const err = await onSave({ name: form.name, everyone: form.everyone, members: form.everyone ? [] : form.members });
setBusy(false);
if (err) setError(err);
else ref.current.close();
}
async function remove() {
setBusy(true);
const done = await onDelete(list);
setBusy(false);
if (done) ref.current.close();
}
return (
<dialog className="modal task-dialog" ref={ref} onClose={onClosed}>
<form className="form" onSubmit={submit}>
<h2>{list ? 'Liste bearbeiten' : 'Neue Liste'}</h2>
{!list && <p className="hint">Die Liste bekommt einen eigenen Vorrat und eine eigene Einkaufsliste – z. B. für die Ferienwohnung im Urlaub.</p>}
<label>
Name
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} maxLength={40} required autoFocus placeholder="z. B. Zuhause, Urlaub Italien, WG" />
</label>
<fieldset className="link-picker">
<legend>Wer kann die Liste sehen?</legend>
<ul>
<li>
<label className="checkbox">
<input type="radio" name="visibility" checked={form.everyone} onChange={() => setForm((f) => ({ ...f, everyone: true }))} />
Alle Konten
</label>
</li>
<li>
<label className="checkbox">
<input type="radio" name="visibility" checked={!form.everyone} onChange={() => setForm((f) => ({ ...f, everyone: false }))} />
Nur ausgewählte Konten
</label>
</li>
</ul>
{!form.everyone && (
<>
<p className="hint">
{list && list.createdBy?.id !== user?.id ? `${personName(list.createdBy)} (angelegt)` : 'Du'} siehst die Liste immer. Zusätzlich:
</p>
{choosable.length === 0 ? (
<p className="hint">Es gibt keine weiteren Konten.</p>
) : (
<ul>
{choosable.map((u) => (
<li key={u.id}>
<label className="checkbox">
<input type="checkbox" checked={form.members.includes(u.id)} onChange={() => toggle(u.id)} />
{u.name}
</label>
</li>
))}
</ul>
)}
</>
)}
</fieldset>
{error && <p className="error">{error}</p>}
<div className="form-actions">
{list && (
<button className="btn ghost danger-btn list-delete" type="button" disabled={busy} onClick={remove}>Liste löschen</button>
)}
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
<button className="btn" type="submit" disabled={busy}>{list ? 'Speichern' : 'Anlegen'}</button>
</div>
</form>
</dialog>
);
}
function ProductsDialog({ ref, products, onDelete }) {
const sorted = [...products].sort((a, b) => a.localeCompare(b, 'de-DE'));
return (
+117 -9
View File
@@ -33,6 +33,14 @@ function localTime(timezone) {
}
}
const OTHERS_KEY = 'p42.reisenAndere';
const HIDDEN_KEY = 'p42.reisenAndereAus';
const OTHER_COLORS = 6;
function loadFlag(key) {
try { return localStorage.getItem(key) === '1'; } catch { return false; }
}
export default function Reisen() {
const [data, setData] = useState(null);
const [shapes, setShapes] = useState(null);
@@ -40,6 +48,9 @@ export default function Reisen() {
const [busy, setBusy] = useState(false);
const [selected, setSelected] = useState(null);
const [wide, setWide] = useState(() => window.matchMedia('(min-width: 760px)').matches);
// Städte der anderen Konten einblenden; einzelne Personen lassen sich ausblenden
const [showOthers, setShowOthers] = useState(() => loadFlag(OTHERS_KEY));
const [hiddenPeople, setHiddenPeople] = useState(() => loadSet(HIDDEN_KEY));
const map = useRef(null);
useEffect(() => {
@@ -71,6 +82,32 @@ export default function Reisen() {
}, []);
const cities = data?.cities || [];
// Farbe pro Person fest nach Reihenfolge (alphabetisch), damit sie beim Ein-/Ausblenden gleich bleibt
const others = useMemo(
() => (data?.others || []).map((person, i) => ({ ...person, color: (i % OTHER_COLORS) + 1 })),
[data],
);
const otherCities = useMemo(
() => (showOthers
? others.filter((p) => !hiddenPeople.has(p.id)).flatMap((p) => p.cities.map((c) => ({ ...c, owner: p, color: p.color })))
: []),
[others, showOthers, hiddenPeople],
);
const allCities = useMemo(() => [...cities, ...otherCities], [cities, otherCities]);
function toggleOthers() {
const next = !showOthers;
setShowOthers(next);
if (!next && selected?.type === 'city' && !cities.some((c) => c.id === selected.key)) setSelected(null);
try { localStorage.setItem(OTHERS_KEY, next ? '1' : '0'); } catch { /* ignorieren */ }
}
function togglePerson(id) {
const next = new Set(hiddenPeople);
if (next.has(id)) next.delete(id); else next.add(id);
setHiddenPeople(next);
try { localStorage.setItem(HIDDEN_KEY, JSON.stringify([...next])); } catch { /* ignorieren */ }
}
// Besucht = von Hand markiert oder mindestens eine Stadt dort
const visited = useMemo(
() => new Set([...(data?.countries || []), ...cities.map((c) => c.country)]),
@@ -83,10 +120,10 @@ export default function Reisen() {
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);
const city = allCities.find((c) => c.id === sel.key);
if (city) map.current?.focusCity(city, wide);
}
}, [data, wide]);
}, [allCities, wide]);
async function run(method, url, body) {
setBusy(true);
@@ -112,7 +149,7 @@ export default function Reisen() {
}
}
const selectedCity = selected?.type === 'city' ? cities.find((c) => c.id === selected.key) : null;
const selectedCity = selected?.type === 'city' ? allCities.find((c) => c.id === selected.key) : null;
return (
<>
@@ -129,6 +166,39 @@ export default function Reisen() {
<CitySearch cities={cities} onAdd={addCity} busy={busy} />
{error && <p className="error travel-error" role="alert">{error}</p>}
{data && (
<div className="travel-others" role="group" aria-label="Städte anderer Konten">
<button type="button" className={`chip${showOthers ? ' active' : ''}`} aria-pressed={showOthers} onClick={toggleOthers}>
{showOthers ? '✓ ' : ''}Städte anderer zeigen
</button>
{showOthers && others.length === 0 && (
<span className="hint travel-legend">Noch kein anderes Konto hat Städte eingetragen.</span>
)}
{showOthers && others.length > 0 && (
<>
<span className="travel-legend"><span className="legend-dot own" aria-hidden="true" />Du</span>
{others.map((p) => {
const on = !hiddenPeople.has(p.id);
return (
<button
key={p.id}
type="button"
className={`chip travel-person${on ? ' active' : ''}`}
style={{ '--dot': `var(--other-${p.color})` }}
aria-pressed={on}
onClick={() => togglePerson(p.id)}
title={on ? `${p.name} ausblenden` : `${p.name} einblenden`}
>
<span className="legend-dot" aria-hidden="true" />
{p.name} <small>{p.cities.length}</small>
</button>
);
})}
</>
)}
</div>
)}
<div className="travel-map card">
{shapes ? (
<WorldMap
@@ -136,6 +206,7 @@ export default function Reisen() {
countries={shapes}
visited={visited}
cities={cities}
otherCities={otherCities}
selected={selected}
onSelect={(sel) => select(sel)}
/>
@@ -153,6 +224,7 @@ export default function Reisen() {
selected={selected}
city={selectedCity}
cities={cities}
otherCities={otherCities}
manual={data?.countries || []}
busy={busy}
onSelect={select}
@@ -447,15 +519,18 @@ function useInfo(params) {
return info;
}
function InfoCard({ selected, city, cities, manual, busy, onSelect, onClose, onMark, onRemoveCity }) {
function InfoCard({ selected, city, cities, otherCities, manual, busy, onSelect, onClose, onMark, onRemoveCity }) {
if (selected.type === 'city' && city) {
return <CityInfo city={city} onSelect={onSelect} onClose={onClose} onRemove={onRemoveCity} busy={busy} />;
// Wer war (sichtbar) noch in dieser Stadt?
const same = [...cities, ...otherCities].filter((c) => c.geonameId === city.geonameId && c.id !== city.id);
return <CityInfo city={city} same={same} 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)}
otherCities={otherCities.filter((c) => c.country === selected.key)}
manual={manual.includes(selected.key)}
onSelect={onSelect}
onClose={onClose}
@@ -479,7 +554,7 @@ function Extract({ info }) {
);
}
function CountryInfo({ code, cities, manual, onSelect, onClose, onMark, busy }) {
function CountryInfo({ code, cities, otherCities, 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 });
@@ -521,11 +596,28 @@ function CountryInfo({ code, cities, manual, onSelect, onClose, onMark, busy })
{cities.length > 0 && manual && (
<button className="link-btn" type="button" disabled={busy} onClick={() => onMark(code, false)}>Zusätzliche Markierung entfernen</button>
)}
{otherCities.length > 0 && (
<div className="info-others">
<span className="hint">Andere waren hier:</span>
<span className="info-cities">
{otherCities.map((c) => <OtherChip key={c.id} city={c} onSelect={onSelect} />)}
</span>
</div>
)}
</aside>
);
}
function CityInfo({ city, onSelect, onClose, onRemove, busy }) {
function OtherChip({ city, onSelect, label = city.name }) {
return (
<button type="button" className="chip travel-person active" style={{ '--dot': `var(--other-${city.color})` }} onClick={() => onSelect({ type: 'city', key: city.id })}>
<span className="legend-dot" aria-hidden="true" />
{label}
</button>
);
}
function CityInfo({ city, same, 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);
@@ -552,9 +644,25 @@ function CityInfo({ city, onSelect, onClose, onRemove, busy }) {
</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>
{city.owner ? (
<span className="badge travel-owner" style={{ '--dot': `var(--other-${city.color})` }}>Besucht von {city.owner.name}</span>
) : (
<>
<span className="badge ok">Besucht</span>
<button className="link-btn danger" type="button" disabled={busy} onClick={() => onRemove(city)}>Stadt entfernen</button>
</>
)}
</div>
{same.length > 0 && (
<div className="info-others">
<span className="hint">Auch hier:</span>
<span className="info-cities">
{same.map((c) => (c.owner
? <OtherChip key={c.id} city={c} onSelect={onSelect} label={c.owner.name} />
: <button key={c.id} type="button" className="chip" onClick={() => onSelect({ type: 'city', key: c.id })}>Du</button>))}
</span>
</div>
)}
</aside>
);
}
+31
View File
@@ -42,6 +42,13 @@
--map-ocean: #050102;
--map-land: #1a0506;
--map-land-hover: #3a0b0c;
/* Reise-Karte: Punkte anderer Konten, je Person eine Farbe (deutlich anders als die eigenen Punkte) */
--other-1: #9b7fe0;
--other-2: #4fd1a5;
--other-3: #ffd84a;
--other-4: #5fb4ff;
--other-5: #c6ff5c;
--other-6: #ff9f43;
/* Form */
--radius-s: 4px;
@@ -776,6 +783,11 @@ h3 { font-size: var(--h3-size); line-height: 1.3; }
.household-panel { display: flex; flex-direction: column; gap: 16px; }
.household-panel.einkauf { border-color: var(--accent); }
.shopping-lists { display: flex; flex-direction: column; gap: 8px; margin-bottom: 18px; }
.shopping-lists-label { color: var(--muted); font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.08em; margin-right: 2px; }
.shopping-lists .chip-list { align-items: center; }
.shopping-visibility { margin: 0; font-size: 0.8rem; }
.list-delete { margin-right: auto; }
.household-head { display: flex; align-items: baseline; justify-content: space-between; gap: 6px 12px; flex-wrap: wrap; }
.household-head h2 { margin: 0; }
.household-head h2::before { content: '// '; color: var(--accent); }
@@ -1088,6 +1100,22 @@ h3 { font-size: var(--h3-size); line-height: 1.3; }
.info-visit { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding-top: 10px; border-top: 1px dashed var(--border); }
.info-cities { display: flex; flex-wrap: wrap; gap: 6px; }
.info-card .chip { font-size: 0.78rem; }
.info-others { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
.info-others .hint { margin: 0; font-size: 0.78rem; }
/* Städte anderer Konten */
.map-city.other .map-city-dot { fill: var(--dot); stroke: var(--bg); stroke-width: 1.5; filter: drop-shadow(0 0 3px var(--dot)); }
.map-city.other:hover .map-city-dot { fill: var(--dot); stroke: var(--bright); }
.map-city.other .map-city-ring { stroke: var(--dot); }
.travel-others { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; margin: -4px 0 14px; }
.travel-legend { display: inline-flex; align-items: center; gap: 6px; font-size: 0.82rem; color: var(--muted); margin-left: 6px; }
.legend-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--dot); box-shadow: 0 0 4px var(--dot); flex: none; }
.legend-dot.own { background: var(--bright); box-shadow: 0 0 0 2px var(--accent); }
.travel-person { display: inline-flex; align-items: center; gap: 6px; }
.travel-person:not(.active) { opacity: 0.55; }
.travel-person:not(.active) .legend-dot { background: transparent; box-shadow: inset 0 0 0 2px var(--dot); }
.travel-person small { color: var(--muted); }
.badge.travel-owner { color: var(--dot); }
/* Besuchte Orte: Kontinente nebeneinander, Länder ausklappbar */
.visited-list { display: grid; gap: 28px 32px; margin-top: 28px; align-items: start; }
@@ -1697,3 +1725,6 @@ h3 { font-size: var(--h3-size); line-height: 1.3; }
::view-transition-old(root), ::view-transition-new(root) { animation: none; }
}
html.calm .tile:hover, html.calm .project-card:hover, html.calm .kanban-project-card:hover, html.calm .task-card:hover { transform: none; }
/* Auswahlknöpfe (z. B. Sichtbarkeit einer Einkaufsliste) nicht wie Eingabefelder gestalten */
.form label.checkbox input[type='radio'] { width: 18px; height: 18px; min-height: 0; padding: 0; margin: 0; border: 0; background: none; box-shadow: none; accent-color: var(--accent); flex: none; }
+1
View File
@@ -6,6 +6,7 @@ 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' },
{ key: 'fabrik', name: 'Fabrik', hint: 'Industrie-Inventar à la Satisfactory', swatch: ['#ffd84a', '#e8913a', '#3c3c3e'], color: '#18181b' },
];
const valid = (key) => THEMES.some((t) => t.key === key);
+484 -1
View File
@@ -1,5 +1,5 @@
/* ============================================================
Designs: Blackwall (Cyberpunk)
Designs: Blackwall (Cyberpunk), Fabrik (Industrie-UI à la Satisfactory)
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.
@@ -91,6 +91,13 @@ html[data-theme='blackwall'] {
--map-ocean: #060104;
--map-land: #1d060c;
--map-land-hover: #3a0b17;
/* Eigene Punkte sind hier Cyan, darum andere Farben für die übrigen Konten */
--other-1: #f6e34a;
--other-2: #b388ff;
--other-3: #7dff9e;
--other-4: #ff8a3d;
--other-5: #ffffff;
--other-6: #5c7cff;
--radius-s: 0px;
--radius-m: 0px;
@@ -343,6 +350,9 @@ html[data-theme='blackwall'] .map-country.visited { fill: rgb(255 45 111 / 0.55)
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); }
html[data-theme='blackwall'] .map-city.other .map-city-dot { fill: var(--dot); stroke: #000; filter: drop-shadow(0 0 4px var(--dot)); }
html[data-theme='blackwall'] .map-city.other .map-city-ring { stroke: var(--dot); }
html[data-theme='blackwall'] .legend-dot.own { background: var(--bw-cyan); box-shadow: 0 0 4px var(--bw-cyan); }
/* Ruhe: kein Glitch, wenn Bewegung reduziert oder Animation aus */
html.calm[data-theme='blackwall'] .brand:hover { animation: none; }
@@ -361,3 +371,476 @@ html[data-theme='blackwall'] .tile-empty { background: var(--surface); border-st
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); }
/* ============================================================
FABRIK – Industrie-Inventar im Stil von Satisfactory
Fenster aus gebürstetem Stahl mit Schrauben in den Ecken, dunkle Titelleiste mit Fenstersymbol,
graue Inventar-Slots, orange Mengen-Zähler, gelb leuchtende Hauptbuttons (wie "Grab All"/"Craft"),
gewählte Zeilen orange, Warnstreifen unter der Kopfzeile, Lüftungsgitter im Fuß.
Roboto (Text) und Roboto Condensed (Titel, Beschriftungen). Keine Original-Logos.
============================================================ */
html[data-theme='fabrik'] {
--fx-orange: #e8913a; /* Zähler, Linien, Text-Akzent */
--fx-orange-fill: #c86a12; /* Flächen mit weißer Schrift (Kontrast ≥ 3,8:1, nur fett) */
--fx-yellow: #ffd84a;
--fx-plate: #3c3c3f;
--fx-plate-dark: #2b2b2e;
--fx-titlebar: #29292c;
--fx-slot: #505053;
--fx-inset: #1e1e20;
--fx-edge: #141416;
--fx-screw: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cdefs%3E%3CradialGradient id='g' cx='.38' cy='.32' r='.75'%3E%3Cstop offset='0' stop-color='%23e2e2e2'/%3E%3Cstop offset='.5' stop-color='%238c8c8f'/%3E%3Cstop offset='1' stop-color='%23464649'/%3E%3C/radialGradient%3E%3C/defs%3E%3Ccircle cx='6' cy='6' r='5' fill='url(%23g)' stroke='%23121214' stroke-width='1.2'/%3E%3C/svg%3E");
--fx-screws:
var(--fx-screw) left 7px top 7px / 10px 10px no-repeat,
var(--fx-screw) right 7px top 7px / 10px 10px no-repeat,
var(--fx-screw) left 7px bottom 7px / 10px 10px no-repeat,
var(--fx-screw) right 7px bottom 7px / 10px 10px no-repeat;
--fx-brushed: repeating-linear-gradient(90deg, rgb(255 255 255 / 0.014) 0 1px, transparent 1px 3px);
--fx-metal: linear-gradient(180deg, #444447 0%, #39393c 55%, #333336 100%);
--fx-bevel: inset 0 1px 0 rgb(255 255 255 / 0.09), inset 0 -2px 0 rgb(0 0 0 / 0.35);
--fx-glow: linear-gradient(180deg, #fff6b8 0%, #ffd84a 42%, #f2a51c 100%);
--fx-hazard: repeating-linear-gradient(-45deg, #f2a51c 0 9px, #1c1c1e 9px 18px);
--accent-rgb: 232 145 58;
--bg: #18181b;
--surface: #3a3a3d;
--surface-hover: #444448;
--surface-2: var(--fx-plate-dark);
--border: #1a1a1c;
--line-strong: #5c5c60;
--accent: var(--fx-orange);
--accent-ink: #ffffff;
--accent-soft: rgb(232 145 58 / 0.16);
--text: #e6e6e6;
--read: #ececec;
--bright: #ffffff;
--muted: #a8a8ac;
--ok: #8fdc6a;
--warn: var(--fx-yellow);
--bad: #ff5a4a;
--bad-glow: rgb(255 90 74 / 0.45);
--series-1: #e8913a;
--series-2: #5fb4ff;
--topbar-bg: rgb(22 22 24 / 0.9);
--glow: 0 0 0 1px var(--fx-orange), 0 0 14px -2px rgb(232 145 58 / 0.6);
--heading-glow: none;
--text-glow: none;
--input-bg: var(--fx-inset);
--card-inner: var(--fx-plate-dark);
--panel: var(--fx-plate);
--overlay: rgb(30 30 32 / 0.94);
--backdrop: rgb(8 8 10 / 0.72);
--editor-bg: var(--fx-inset);
--map-ocean: #1b1d22;
--map-land: #3b3b3f;
--map-land-hover: #4c4c51;
--other-1: #4fd1c5;
--other-2: #a78bfa;
--other-3: #8fdc6a;
--other-4: #5fb4ff;
--other-5: #f472b6;
--other-6: #ffd84a;
--radius-s: 2px;
--radius-m: 3px;
--radius-l: 4px;
--shadow-1: var(--fx-bevel), 0 14px 30px -18px #000;
--shadow-2: var(--fx-bevel), 0 30px 80px -20px #000, 0 0 0 1px var(--fx-edge);
--focus-ring: 0 0 0 2px var(--fx-orange);
--blur: none;
--font: 'Roboto', 'Segoe UI', system-ui, sans-serif;
--font-display: 'Roboto Condensed', 'Roboto', sans-serif;
--font-heading: 'Roboto', 'Segoe UI', sans-serif;
--font-label: 'Roboto Condensed', 'Roboto', sans-serif;
--body-size: clamp(14.5px, 0.88rem + 0.15vw, 16px);
--h1-font: 'Roboto Condensed', 'Roboto', sans-serif;
--h1-size: clamp(2rem, 3.4vw, 3rem);
--h1-weight: 700;
--h1-tracking: 0.03em;
--h2-size: 1.08rem;
--h3-size: 1rem;
--heading-weight: 500;
--heading-tracking: 0.01em;
--heading-case: none;
--label-size: 0.72rem;
--label-tracking: 0.1em;
--tile-font: 'Roboto Condensed', 'Roboto', sans-serif;
--tile-size: 1.2rem;
background: #18181b;
}
html[data-theme='fabrik'] ::selection { background: var(--fx-orange); color: #fff; }
html[data-theme='fabrik'] :focus-visible { outline: 2px solid var(--fx-orange); outline-offset: 2px; }
html[data-theme='fabrik'] * { scrollbar-color: #5a5a5e transparent; }
/* Keine Bildschirmzeilen, nur eine weiche Vignette wie im Spiel hinter den Fenstern */
html[data-theme='fabrik'] body::after { background: radial-gradient(ellipse at center, transparent 55%, rgb(0 0 0 / 0.45) 100%); }
/* ---------- Hintergrund: Halle, Riffelblech, HUD-Linie, Zahnräder ---------- */
.fabrik-field {
overflow: hidden;
background:
radial-gradient(ellipse 70% 55% at 72% 8%, rgb(120 80 160 / 0.22), transparent 70%),
radial-gradient(ellipse 60% 45% at 8% 100%, rgb(232 145 58 / 0.13), transparent 70%),
linear-gradient(180deg, #1c1b22 0%, #18181b 60%, #141416 100%);
}
.fabrik-floor {
position: absolute;
inset: 55% -20% -10%;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28'%3E%3Cg stroke-linecap='round' stroke-width='2.4' fill='none'%3E%3Cpath d='M4 12 L12 4' stroke='%23000' stroke-opacity='.5' transform='translate(0 1)'/%3E%3Cpath d='M18 18 L26 26' stroke='%23000' stroke-opacity='.5' transform='translate(0 1)'/%3E%3Cpath d='M4 12 L12 4' stroke='%23fff' stroke-opacity='.07'/%3E%3Cpath d='M18 18 L26 26' stroke='%23fff' stroke-opacity='.07'/%3E%3C/g%3E%3C/svg%3E") 0 0 / 28px 28px;
transform: perspective(600px) rotateX(55deg);
transform-origin: 50% 0;
mask-image: linear-gradient(180deg, transparent 0%, #000 35%);
-webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 35%);
}
.fabrik-gear { position: absolute; fill: rgb(255 255 255 / 0.025); stroke: rgb(255 255 255 / 0.06); stroke-width: 0.8; }
.fabrik-gear.big { width: 46vmax; height: 46vmax; left: -14vmax; bottom: -16vmax; animation: theme-spin 140s linear infinite; }
.fabrik-gear.small { width: 26vmax; height: 26vmax; right: -8vmax; top: 8vh; animation: theme-spin 90s linear infinite reverse; }
/* HUD: feine Kompasslinie mit Punkten, Mitte orange markiert */
.fabrik-hud {
position: absolute;
left: 20%;
right: 20%;
top: calc(var(--topbar-h) + 10px);
height: 7px;
background:
radial-gradient(circle, var(--fx-orange) 0 3px, transparent 3.5px) center / 7px 7px no-repeat,
radial-gradient(circle, rgb(255 255 255 / 0.35) 0 1px, transparent 1.5px) 0 50% / 14px 7px repeat-x;
mask-image: linear-gradient(90deg, transparent, #000 20%, #000 80%, transparent);
-webkit-mask-image: linear-gradient(90deg, transparent, #000 20%, #000 80%, transparent);
opacity: 0.6;
}
html.calm .fabrik-gear { animation: none; }
@media (prefers-reduced-motion: reduce) { .fabrik-gear { animation: none; } }
/* ---------- Schrift, Titel ---------- */
html[data-theme='fabrik'] h1 { text-transform: uppercase; color: var(--bright); }
html[data-theme='fabrik'] .page-head h1::after,
html[data-theme='fabrik'] .hero h1::after {
content: '';
display: block;
width: 72px;
height: 4px;
margin-top: 10px;
background: var(--fx-orange);
animation: none;
}
html[data-theme='fabrik'] .hero h1::after { margin-inline: auto; }
html[data-theme='fabrik'] .lead { color: var(--muted); }
html[data-theme='fabrik'] a { color: var(--fx-orange); text-decoration-color: rgb(232 145 58 / 0.45); }
html[data-theme='fabrik'] a:hover { color: #ffb067; }
html[data-theme='fabrik'] .error::before { content: '⚠ '; }
html[data-theme='fabrik'] .notice::before { content: '⚠ '; color: var(--fx-yellow); }
/* Fenstertitel: kleines Fenstersymbol wie in der Titelleiste der Spiel-Fenster */
html[data-theme='fabrik'] .household-head h2::before,
html[data-theme='fabrik'] .board-head h2::before,
html[data-theme='fabrik'] .modal h2::before,
html[data-theme='fabrik'] .info-head h2::before {
content: '';
display: inline-block;
width: 15px;
height: 12px;
margin-right: 10px;
border: 1.5px solid currentColor;
border-top-width: 4px;
border-radius: 2px;
vertical-align: -0.05em;
opacity: 0.85;
}
/* Abschnittstitel als Kategorie-Leiste ("Standard Parts −") */
html[data-theme='fabrik'] .section-title {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 3px 12px 3px 10px;
font-family: var(--font-label);
font-weight: 700;
font-size: 0.86rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #f0f0f0;
background: linear-gradient(180deg, #47474a, #3a3a3d);
border-left: 3px solid var(--fx-orange);
box-shadow: var(--fx-bevel);
}
html[data-theme='fabrik'] .section-title::before { content: none; }
/* ---------- Kopfzeile wie HUD, Warnstreifen ---------- */
html[data-theme='fabrik'] .topbar {
background: linear-gradient(180deg, rgb(34 34 37 / 0.96), rgb(26 26 28 / 0.94));
border-bottom: 0;
box-shadow: 0 10px 30px -18px #000;
}
html[data-theme='fabrik'] .topbar::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -5px;
height: 5px;
background: var(--fx-hazard);
box-shadow: 0 2px 0 rgb(0 0 0 / 0.5);
}
html[data-theme='fabrik'] .brand {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.35rem;
letter-spacing: 0.08em;
color: #fff;
text-shadow: none;
}
html[data-theme='fabrik'] .brand::before { content: '⚙ '; color: var(--fx-orange); }
html[data-theme='fabrik'] .brand span { color: var(--fx-orange); }
html[data-theme='fabrik'] .topnav a {
font-family: var(--font-label);
font-weight: 700;
font-size: 0.82rem;
letter-spacing: 0.08em;
color: #d6d6d8;
border: 0;
border-radius: var(--radius-s);
}
html[data-theme='fabrik'] .topnav a:hover { color: #fff; background: rgb(255 255 255 / 0.08); }
html[data-theme='fabrik'] .topnav a.active { color: #fff; background: var(--fx-orange-fill); box-shadow: var(--fx-bevel); }
html[data-theme='fabrik'] .menu-btn { border-radius: var(--radius-s); border-color: var(--fx-edge); background: linear-gradient(180deg, #5d5d60, #4b4b4e); color: #f0f0f0; box-shadow: var(--fx-bevel); }
html[data-theme='fabrik'] .menu-btn:hover,
html[data-theme='fabrik'] .menu-btn[aria-expanded='true'] { background: var(--fx-orange-fill); border-color: var(--fx-edge); color: #fff; }
html[data-theme='fabrik'] .menu-panel { background: var(--fx-brushed), var(--fx-metal); border: 1px solid var(--fx-edge); box-shadow: var(--shadow-2); }
html[data-theme='fabrik'] .menu-panel a, html[data-theme='fabrik'] .menu-item { color: var(--text); font-weight: 500; }
html[data-theme='fabrik'] .menu-panel a:hover, html[data-theme='fabrik'] .menu-item:hover { background: rgb(255 255 255 / 0.07); color: #fff; }
html[data-theme='fabrik'] .menu-panel a.active { background: var(--fx-orange-fill); color: #fff; font-weight: 700; }
html[data-theme='fabrik'] .theme-option[aria-checked='true'] { background: rgb(232 145 58 / 0.14); box-shadow: inset 3px 0 0 var(--fx-orange); }
/* ---------- Fenster: gebürsteter Stahl mit Schrauben ---------- */
html[data-theme='fabrik'] .card,
html[data-theme='fabrik'] .tile,
html[data-theme='fabrik'] .board-column,
html[data-theme='fabrik'] .modal {
background: var(--fx-screws), var(--fx-brushed), var(--fx-metal);
border: 1px solid var(--fx-edge);
border-radius: var(--radius-l);
box-shadow: var(--shadow-1);
}
html[data-theme='fabrik'] .household-panel.einkauf { border-color: var(--fx-edge); box-shadow: var(--shadow-1), inset 0 3px 0 var(--fx-orange); }
/* Dialog: dunkle Titelleiste über die ganze Breite */
html[data-theme='fabrik'] .modal { --fx-pad: 24px; padding: var(--fx-pad); box-shadow: var(--shadow-2); }
html[data-theme='fabrik'] .modal .form > h2:first-child {
margin: calc(-1 * var(--fx-pad)) calc(-1 * var(--fx-pad)) 6px;
padding: 12px var(--fx-pad);
font-weight: 500;
color: #f2f2f2;
background: var(--fx-titlebar);
border-bottom: 1px solid var(--fx-edge);
border-radius: var(--radius-l) var(--radius-l) 0 0;
}
html[data-theme='fabrik'] .modal::backdrop { backdrop-filter: blur(3px); }
/* Startseite: Kacheln als Fenster, Icon im Inventar-Slot; beim Überfahren leuchtet der Slot gelb */
html[data-theme='fabrik'] .tile strong { font-weight: 700; letter-spacing: 0.06em; color: #f2f2f2; }
html[data-theme='fabrik'] .tile svg {
color: #f0f0f0;
background: linear-gradient(180deg, #5c5c60, #4a4a4d);
border-radius: var(--radius-s);
box-shadow: inset 0 0 0 1px var(--fx-edge), inset 0 2px 4px rgb(0 0 0 / 0.35);
filter: none;
}
html[data-theme='fabrik'] .tile:hover { border-color: var(--fx-edge); box-shadow: var(--shadow-1), 0 0 0 1px var(--fx-orange); }
html[data-theme='fabrik'] .tile:hover svg {
color: #3a2600;
background: var(--fx-glow);
box-shadow: inset 0 0 0 1px #3a2a08, 0 0 16px -2px rgb(255 200 60 / 0.7);
}
html[data-theme='fabrik'] .tile-empty { background: var(--fx-plate-dark); border-style: dashed; border-color: #59595d; }
/* ---------- Buttons: gelb leuchtend (Grab All / Craft), grau (Sort) ---------- */
html[data-theme='fabrik'] .btn {
font-family: var(--font-label);
font-weight: 700;
letter-spacing: 0.08em;
border-radius: var(--radius-l);
}
html[data-theme='fabrik'] .btn:not(.ghost) {
background: var(--fx-glow);
color: #2e2000;
border: 2px solid #19191b;
box-shadow: 0 0 0 1px #5b5b5f, 0 0 18px -3px rgb(255 196 50 / 0.65), inset 0 1px 0 rgb(255 255 255 / 0.65);
}
html[data-theme='fabrik'] .btn:not(.ghost):hover:not(:disabled) {
background: linear-gradient(180deg, #fffbd6 0%, #ffe372 45%, #f7b52c 100%);
border-color: #19191b;
box-shadow: 0 0 0 1px #6a6a6e, 0 0 26px -2px rgb(255 206 70 / 0.85), inset 0 1px 0 rgb(255 255 255 / 0.75);
}
html[data-theme='fabrik'] .btn:disabled { filter: grayscale(0.85) brightness(0.7); box-shadow: none; }
html[data-theme='fabrik'] .btn.ghost {
color: #f0f0f0;
background: linear-gradient(180deg, #5d5d60, #4b4b4e);
border: 1px solid var(--fx-edge);
box-shadow: var(--fx-bevel);
}
html[data-theme='fabrik'] .btn.ghost:hover:not(:disabled) { background: linear-gradient(180deg, #6a6a6e, #57575a); color: #fff; border-color: var(--fx-edge); }
html[data-theme='fabrik'] .btn.ghost.danger-btn { color: #ff8a7e; background: linear-gradient(180deg, #5d5d60, #4b4b4e); border-color: var(--fx-edge); }
html[data-theme='fabrik'] .btn.ghost.danger-btn:hover:not(:disabled) { background: #b3362a; color: #fff; }
html[data-theme='fabrik'] .link-btn { color: var(--fx-orange); font-weight: 700; font-family: var(--font-label); }
html[data-theme='fabrik'] .link-btn:hover { color: #ffb067; }
html[data-theme='fabrik'] .link-btn.danger { color: var(--muted); }
html[data-theme='fabrik'] .link-btn.danger:hover { color: var(--bad); }
/* ---------- Eingabefelder: dunkel eingelassen ---------- */
html[data-theme='fabrik'] .form input:not([type='checkbox']):not([type='radio']),
html[data-theme='fabrik'] .form select,
html[data-theme='fabrik'] .form textarea,
html[data-theme='fabrik'] .household-add input,
html[data-theme='fabrik'] .city-search input,
html[data-theme='fabrik'] .recipe input:not([type='checkbox']),
html[data-theme='fabrik'] .recipe textarea,
html[data-theme='fabrik'] .unit-select,
html[data-theme='fabrik'] .kanban-filter select {
background: var(--fx-inset);
border: 1px solid var(--fx-edge);
border-radius: var(--radius-s);
box-shadow: inset 0 2px 4px rgb(0 0 0 / 0.45);
color: #f2f2f2;
}
html[data-theme='fabrik'] .form input:focus,
html[data-theme='fabrik'] .household-add input:focus,
html[data-theme='fabrik'] .city-search input:focus,
html[data-theme='fabrik'] .unit-select:focus { border-color: var(--fx-orange); box-shadow: inset 0 2px 4px rgb(0 0 0 / 0.45), 0 0 0 1px var(--fx-orange); }
html[data-theme='fabrik'] .combo-list { background: var(--fx-plate-dark); border: 1px solid var(--fx-edge); box-shadow: var(--shadow-2); }
html[data-theme='fabrik'] .combo-list li:hover,
html[data-theme='fabrik'] .combo-list li.active { background: var(--fx-orange-fill); color: #fff; }
html[data-theme='fabrik'] .combo-list li.active small, html[data-theme='fabrik'] .combo-list li:hover small { color: #ffe6cc; }
/* ---------- Vorrat & Einkauf: Zeilen als Inventar-Slots, Mengen als orange Zähler ---------- */
html[data-theme='fabrik'] .household-item {
margin: 0 0 5px;
padding: 8px 10px;
border: 1px solid var(--fx-edge);
border-radius: var(--radius-s);
background: linear-gradient(180deg, #333336, #2c2c2f);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.05);
}
html[data-theme='fabrik'] .household-item:hover { background: linear-gradient(180deg, #3a3a3e, #323235); }
html[data-theme='fabrik'] .household-name strong { font-weight: 500; color: #f4f4f4; }
html[data-theme='fabrik'] .stepper { border: 1px solid var(--fx-edge); border-radius: var(--radius-s); background: var(--fx-slot); }
html[data-theme='fabrik'] .stepper button { color: #f0f0f0; background: linear-gradient(180deg, #5d5d60, #4b4b4e); }
html[data-theme='fabrik'] .stepper button:hover:not(:disabled) { background: var(--fx-orange-fill); color: #fff; }
html[data-theme='fabrik'] .stepper button:disabled { color: #77777b; background: #3f3f42; }
html[data-theme='fabrik'] .stepper-value {
color: #fff;
font-weight: 700;
background: var(--fx-orange-fill);
border-inline: 1px solid var(--fx-edge);
text-shadow: 0 1px 0 rgb(0 0 0 / 0.35);
}
html[data-theme='fabrik'] .stepper-value:focus { background: #a95a10; box-shadow: inset 0 0 0 1px #fff; }
html[data-theme='fabrik'] .household-check {
border: 1px solid var(--fx-edge);
border-radius: var(--radius-s);
background: var(--fx-slot);
box-shadow: inset 0 2px 4px rgb(0 0 0 / 0.4);
}
html[data-theme='fabrik'] .household-check::after { color: var(--fx-yellow); }
html[data-theme='fabrik'] .segmented { background: var(--fx-plate-dark); border: 1px solid var(--fx-edge); border-radius: var(--radius-m); }
html[data-theme='fabrik'] .segmented button { font-family: var(--font-label); color: #d8d8da; }
html[data-theme='fabrik'] .segmented button[aria-selected='true'] { background: var(--fx-orange-fill); color: #fff; box-shadow: var(--fx-bevel); }
/* Zähler wie im Rezept-Menü: [ 8 ] */
html[data-theme='fabrik'] .segmented .count::before { content: '[ '; }
html[data-theme='fabrik'] .segmented .count::after { content: ' ]'; }
html[data-theme='fabrik'] .recipe-take:checked { background: var(--fx-orange-fill); }
/* ---------- Chips, Status, Balken ---------- */
html[data-theme='fabrik'] .chip {
font-family: var(--font-label);
font-weight: 500;
border: 1px solid var(--fx-edge);
border-radius: var(--radius-s);
color: #e8e8ea;
background: linear-gradient(180deg, #535356, #46464a);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.07);
}
html[data-theme='fabrik'] .chip:hover { color: #fff; background: linear-gradient(180deg, #5f5f63, #515155); border-color: var(--fx-edge); }
html[data-theme='fabrik'] .chip.active { color: #fff; background: var(--fx-orange-fill); border-color: var(--fx-edge); font-weight: 700; }
html[data-theme='fabrik'] .badge { border-radius: var(--radius-s); font-family: var(--font-label); font-weight: 700; background: var(--fx-inset); box-shadow: inset 0 0 0 1px var(--fx-edge); }
html[data-theme='fabrik'] .meter-bar { height: 8px; border-radius: 0; background: var(--fx-slot); box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.5); }
html[data-theme='fabrik'] .meter-bar i { border-radius: 0; background: var(--fx-orange); }
html[data-theme='fabrik'] .avatar { background: var(--fx-orange-fill); color: #fff; border-radius: var(--radius-s); }
html[data-theme='fabrik'] .board-column.arbeit { border-top-color: var(--fx-orange); }
html[data-theme='fabrik'] .board-column.wartet { border-top-color: var(--fx-yellow); }
html[data-theme='fabrik'] .board-column.erledigt { border-top-color: var(--ok); }
html[data-theme='fabrik'] .status-bar .arbeit { background: var(--fx-orange); }
html[data-theme='fabrik'] .status-bar .wartet { background: var(--fx-yellow); }
html[data-theme='fabrik'] .status-bar .erledigt { background: var(--ok); }
html[data-theme='fabrik'] .task-card,
html[data-theme='fabrik'] .project-card,
html[data-theme='fabrik'] .kanban-project-card { background: linear-gradient(180deg, #333336, #2c2c2f); border-color: var(--fx-edge); }
html[data-theme='fabrik'] .monitor-card.ok { border-left-color: var(--ok); }
html[data-theme='fabrik'] .drop-line { background: var(--fx-orange); }
/* ---------- Info-Karte der Reise-Karte: weiße Karte wie "Limestone – 40 per minute" ---------- */
html[data-theme='fabrik'] .info-card {
--text: #2c2c2e;
--read: #2c2c2e;
--bright: #111113;
--muted: #6e6e73;
--border: rgb(0 0 0 / 0.14);
--line-strong: rgb(0 0 0 / 0.3);
color: var(--text);
background: #f3f3f3;
border: 4px solid #2a2a2d;
border-radius: 6px;
box-shadow: 0 0 0 1px #4a4a4e, 0 24px 60px -20px #000;
backdrop-filter: none;
}
html[data-theme='fabrik'] .info-card .info-facts dd { color: #c4620c; font-weight: 700; }
html[data-theme='fabrik'] .info-card a, html[data-theme='fabrik'] .info-card .link-btn { color: #b85a0a; }
html[data-theme='fabrik'] .info-card .link-btn.danger { color: #8a8a8f; }
html[data-theme='fabrik'] .info-card .link-btn.danger:hover { color: #c0392b; }
html[data-theme='fabrik'] .info-card .chip { color: #2c2c2e; background: #e2e2e4; border-color: #c4c4c8; box-shadow: none; }
html[data-theme='fabrik'] .info-card .chip:hover { background: #d4d4d8; }
html[data-theme='fabrik'] .info-card .chip.active { color: #fff; background: var(--fx-orange-fill); }
html[data-theme='fabrik'] .info-card .badge { background: #e6e6e8; box-shadow: none; }
html[data-theme='fabrik'] .info-card .badge.ok { color: #2f7d16; }
html[data-theme='fabrik'] .info-card .btn.ghost, html[data-theme='fabrik'] .info-card .btn:not(.ghost) { color: #2e2000; }
/* ---------- Karte ---------- */
html[data-theme='fabrik'] .map-graticule { stroke: rgb(255 255 255 / 0.05); }
html[data-theme='fabrik'] .map-country { stroke: #232326; }
html[data-theme='fabrik'] .map-country.visited { fill: rgb(232 145 58 / 0.62); stroke: #f0a050; }
html[data-theme='fabrik'] .map-country.visited:hover { fill: rgb(232 145 58 / 0.78); }
html[data-theme='fabrik'] .map-country.selected { stroke: #fff; }
html[data-theme='fabrik'] .map-city-dot { fill: #fff; stroke: var(--fx-orange-fill); filter: drop-shadow(0 0 3px rgb(0 0 0 / 0.8)); }
html[data-theme='fabrik'] .map-city.other .map-city-dot { fill: var(--dot); stroke: #111; filter: drop-shadow(0 0 3px rgb(0 0 0 / 0.8)); }
html[data-theme='fabrik'] .map-controls { background: var(--fx-plate); border: 1px solid var(--fx-edge); }
html[data-theme='fabrik'] .map-controls button { color: #f0f0f0; background: linear-gradient(180deg, #5d5d60, #4b4b4e); }
html[data-theme='fabrik'] .map-controls button:hover { background: var(--fx-orange-fill); color: #fff; }
html[data-theme='fabrik'] .legend-dot.own { background: #fff; box-shadow: 0 0 0 2px var(--fx-orange-fill); }
/* ---------- Artikel ---------- */
html[data-theme='fabrik'] .article-title { font-family: var(--font-display); font-weight: 700; text-transform: uppercase; }
html[data-theme='fabrik'] .article-body h2 { font-family: var(--font-display); font-weight: 700; color: #fff; border-left: 4px solid var(--fx-orange); padding-left: 12px; }
html[data-theme='fabrik'] .article-body h3 { color: var(--fx-orange); }
html[data-theme='fabrik'] .article-body ul > li::before { content: '■'; color: var(--fx-orange); font-size: 0.7em; }
html[data-theme='fabrik'] .article-body blockquote { border-left-color: var(--fx-orange); background: var(--fx-plate-dark); }
html[data-theme='fabrik'] .article-lead { border-left-color: var(--fx-orange); }
/* ---------- Fuß: Lüftungsgitter, Text wie Tasten-Hinweis ---------- */
html[data-theme='fabrik'] .footer { color: var(--muted); font-family: var(--font-label); }
html[data-theme='fabrik'] .footer::before {
content: '';
display: block;
width: 120px;
height: 30px;
margin: 0 auto 14px;
border-radius: 2px;
background: repeating-linear-gradient(180deg, #0c0c0d 0 3px, #3a3a3d 3px 4px, transparent 4px 6px);
opacity: 0.85;
}
@media (max-width: 640px) {
html[data-theme='fabrik'] { --h1-size: 1.7rem; --tile-size: 1rem; }
html[data-theme='fabrik'] h1 { overflow-wrap: anywhere; }
html[data-theme='fabrik'] .brand { font-size: 1.1rem; letter-spacing: 0.04em; }
html[data-theme='fabrik'] .modal { --fx-pad: 18px; }
}