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:
1 parent
f16c8c73c6
commit
b30acb652a
20 files changed
+1182
-82
No files matched your search
@@ -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).
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 ----------
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Generated
+18
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 & 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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user