diff --git a/README.md b/README.md index d43dd73..5bc3726 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,16 @@ 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). +- **Wunschliste:** Bei der Suche „☆ Wunschliste“ wählen – die Stadt erscheint als gestrichelter Ring + und zählt nicht als besucht. „War ich da ✓“ oder ein eingetragener Besuch machen daraus einen Besuch. + Andere Konten sehen die Wunschliste nicht. +- **Besuche:** In der Info-Karte einer Stadt „+ Besuch eintragen“: Zeitraum, Begleitung, Notizen, Fotos. + Besuche sieht nur das eigene Konto; Fotos werden ohne Metadaten gespeichert. +- **Zeitleiste:** „Wann war ich wo?“ – alle Besuche nach Jahren, mit Fotos (Klick vergrößert). +- **Statistik:** Anteil der Welt (Länder und Gebiete der Länderdaten) und der Landfläche, je Kontinent, + nördlichster/südlichster/östlichster/westlichster Ort und die Kilometer (Luftlinie) entlang der + Besuche in zeitlicher Reihenfolge. + 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. diff --git a/backend/app/db.py b/backend/app/db.py index c89baf7..0bb5011 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -198,10 +198,29 @@ CREATE TABLE IF NOT EXISTS travel_cities ( elevation REAL, timezone TEXT, capital INTEGER, -- Hauptstadt (GeoNames PPLC); NULL = noch nicht abgefragt + wish INTEGER NOT NULL DEFAULT 0, -- 1 = Wunschliste (noch nicht besucht) created_at TEXT NOT NULL, UNIQUE (user_id, geoname_id) ); +-- Besuche einer Stadt (nur für das eigene Konto sichtbar): Zeitraum, Begleitung, Notizen, Fotos +CREATE TABLE IF NOT EXISTS travel_visits ( + id INTEGER PRIMARY KEY, + city_id INTEGER NOT NULL REFERENCES travel_cities(id) ON DELETE CASCADE, + date_from TEXT NOT NULL, -- JJJJ-MM-TT + date_to TEXT, + companions TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS travel_visit_photos ( + visit_id INTEGER NOT NULL REFERENCES travel_visits(id) ON DELETE CASCADE, + file TEXT NOT NULL, -- Datei in data/uploads (ohne Metadaten gespeichert) + position INTEGER NOT NULL, + PRIMARY KEY (visit_id, file) +); + CREATE TABLE IF NOT EXISTS travel_countries ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, code TEXT NOT NULL, diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 57eabcc..6258fc0 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -249,9 +249,17 @@ def _v3_kategorien(conn) -> None: conn.execute("UPDATE products SET category = ? WHERE key = ?", (guess(row["name"]), row["key"])) +# ---------- Schritt 4: Reise-Wunschliste ---------- + + +def _v4_wunschliste(conn) -> None: + _add_column(conn, "travel_cities", "wish", "INTEGER NOT NULL DEFAULT 0") + + # Reihenfolge = Versionsnummer (Index + 1). Nur anhängen, nie umsortieren oder entfernen. STEPS = [ _v1_altbestand, _v2_datenschutz, _v3_kategorien, + _v4_wunschliste, ] diff --git a/backend/app/projects.py b/backend/app/projects.py index f3ecbf0..21b3e45 100644 --- a/backend/app/projects.py +++ b/backend/app/projects.py @@ -72,7 +72,8 @@ def _detect_image_type(data: bytes) -> str | None: def _referenced_images(conn) -> set[str]: - used = set() + """Alle Bilder, die noch gebraucht werden: Artikel (Titelbild, Text) und Fotos von Reise-Besuchen.""" + used = {r["file"] for r in conn.execute("SELECT file FROM travel_visit_photos")} for row in conn.execute("SELECT cover, content FROM projects"): if row["cover"]: used.add(row["cover"]) @@ -80,6 +81,11 @@ def _referenced_images(conn) -> set[str]: return used +def remove_unused(conn, names) -> None: + """Bilder löschen, die nirgends mehr verwendet werden (z. B. nach dem Löschen eines Besuchs).""" + _remove_unused(conn, names) + + def _remove_unused(conn, names) -> None: used = _referenced_images(conn) for n in names: diff --git a/backend/app/reisen.py b/backend/app/reisen.py index d7169cb..9fbdd38 100644 --- a/backend/app/reisen.py +++ b/backend/app/reisen.py @@ -7,6 +7,7 @@ Infotexte kommen aus der deutschen Wikipedia, Einwohnerzahlen von Ländern aus W Alle Abrufe laufen über den Server und werden zwischengespeichert; der Browser fragt keine Fremdserver. """ import asyncio +import datetime import json import logging import math @@ -21,6 +22,7 @@ from .auth import current_user from .config import WIKIPEDIA_CONTACT from .common import ValidationError, clean_text, now_iso from .db import transaction +from .projects import UPLOAD_DIR, UPLOAD_NAME, remove_unused router = APIRouter() @@ -45,6 +47,7 @@ def _city(row) -> dict: "country": row["country"], "lat": row["lat"], "lon": row["lon"], "population": row["population"], "elevation": row["elevation"], "timezone": row["timezone"], "createdAt": row["created_at"], "capital": None if row["capital"] is None else bool(row["capital"]), + "wish": bool(row["wish"]), } @@ -52,7 +55,7 @@ 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""", + WHERE c.user_id != ? AND c.wish = 0 ORDER BY u.display_name COLLATE NOCASE, u.id, c.name""", (user_id,), ).fetchall() people = {} @@ -62,12 +65,35 @@ def _others(conn, user_id: int) -> list: return list(people.values()) +def _visits(conn, user_id: int) -> list: + rows = conn.execute( + """SELECT v.* FROM travel_visits v JOIN travel_cities c ON c.id = v.city_id + WHERE c.user_id = ? ORDER BY v.date_from DESC, v.id DESC""", + (user_id,), + ).fetchall() + photos = {} + for p in conn.execute( + """SELECT p.* FROM travel_visit_photos p JOIN travel_visits v ON v.id = p.visit_id + JOIN travel_cities c ON c.id = v.city_id WHERE c.user_id = ? ORDER BY p.position""", + (user_id,), + ): + photos.setdefault(p["visit_id"], []).append(p["file"]) + return [ + { + "id": v["id"], "cityId": v["city_id"], "dateFrom": v["date_from"], "dateTo": v["date_to"], + "companions": v["companions"], "notes": v["notes"], "photos": photos.get(v["id"], []), + } + for v in rows + ] + + 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], + "visits": _visits(conn, user_id), "others": _others(conn, user_id), } @@ -106,18 +132,19 @@ def add_city(data: dict = Body(...), user: dict = Depends(current_user)): timezone = None # Hauptstadt kommt aus der Suche; fehlt die Angabe, trägt fill_capitals() sie nach capital = int(data["capital"]) if isinstance(data.get("capital"), bool) else None + wish = data.get("wish") is True values = ( user["id"], geoname_id, clean_text(data.get("name"), "Name", 100), clean_text(data.get("admin1"), "Region", 100, required=False), country, _number(data.get("lat"), "Breitengrad", -90, 90), _number(data.get("lon"), "Längengrad", -180, 180), - population, elevation, timezone, capital, now_iso(), + population, elevation, timezone, capital, int(wish), now_iso(), ) with transaction() as conn: if conn.execute("SELECT COUNT(*) FROM travel_cities WHERE user_id = ?", (user["id"],)).fetchone()[0] >= MAX_CITIES: raise ValidationError(f"Maximal {MAX_CITIES} Städte.") conn.execute( """INSERT INTO travel_cities - (user_id, geoname_id, name, admin1, country, lat, lon, population, elevation, timezone, capital, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, geoname_id) DO NOTHING""", + (user_id, geoname_id, name, admin1, country, lat, lon, population, elevation, timezone, capital, wish, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, geoname_id) DO NOTHING""", values, ) return _state(conn, user["id"]) @@ -126,9 +153,133 @@ def add_city(data: dict = Body(...), user: dict = Depends(current_user)): @router.delete("/api/reisen/staedte/{city_id}") def delete_city(city_id: int, user: dict = Depends(current_user)): with transaction() as conn: + photos = [r["file"] for r in conn.execute( + """SELECT p.file FROM travel_visit_photos p JOIN travel_visits v ON v.id = p.visit_id + JOIN travel_cities c ON c.id = v.city_id WHERE c.id = ? AND c.user_id = ?""", + (city_id, user["id"]), + )] cur = conn.execute("DELETE FROM travel_cities WHERE id = ? AND user_id = ?", (city_id, user["id"])) if not cur.rowcount: raise HTTPException(404, "Stadt nicht gefunden.") + remove_unused(conn, photos) + return _state(conn, user["id"]) + + +@router.patch("/api/reisen/staedte/{city_id}") +def update_city(city_id: int, data: dict = Body(...), user: dict = Depends(current_user)): + """Wunschliste ↔ besucht ({wish: bool}).""" + if not isinstance(data.get("wish"), bool): + raise ValidationError("Ungültige Angabe.") + with transaction() as conn: + _own_city(conn, city_id, user) + if data["wish"] and conn.execute("SELECT 1 FROM travel_visits WHERE city_id = ?", (city_id,)).fetchone(): + raise ValidationError("Für diese Stadt sind schon Besuche eingetragen.") + conn.execute("UPDATE travel_cities SET wish = ? WHERE id = ?", (int(data["wish"]), city_id)) + return _state(conn, user["id"]) + + +# ---------- Besuche: Zeitraum, Begleitung, Notizen, Fotos ---------- + +DATE = re.compile(r"\d{4}-\d{2}-\d{2}") +MAX_PHOTOS = 30 +MAX_VISITS = 5000 + + +def _own_city(conn, city_id: int, user: dict): + row = conn.execute("SELECT * FROM travel_cities WHERE id = ? AND user_id = ?", (city_id, user["id"])).fetchone() + if not row: + raise HTTPException(404, "Stadt nicht gefunden.") + return row + + +def _date(value, field: str, required: bool): + if value in (None, "") and not required: + return None + if not isinstance(value, str) or not DATE.fullmatch(value): + raise ValidationError(f"{field}: bitte ein Datum angeben.") + try: + datetime.date.fromisoformat(value) + except ValueError: + raise ValidationError(f"{field}: ungültiges Datum.") + return value + + +def _visit_fields(data: dict) -> tuple: + date_from = _date(data.get("dateFrom"), "Von", True) + date_to = _date(data.get("dateTo"), "Bis", False) + if date_to and date_to < date_from: + raise ValidationError("„Bis“ liegt vor „Von“.") + companions = clean_text(data.get("companions"), "Begleitung", 200, required=False) + notes = clean_text(data.get("notes"), "Notizen", 5000, required=False, collapse=False) + photos = data.get("photos", []) + if not isinstance(photos, list) or len(photos) > MAX_PHOTOS or not all(isinstance(p, str) and UPLOAD_NAME.fullmatch(p) for p in photos): + raise ValidationError(f"Ungültige Fotos (max. {MAX_PHOTOS}).") + photos = list(dict.fromkeys(photos)) + if any(not (UPLOAD_DIR / p).exists() for p in photos): + raise ValidationError("Ein Foto wurde nicht gefunden – bitte erneut hochladen.") + return date_from, date_to, companions, notes, photos + + +def _save_photos(conn, visit_id: int, photos: list) -> list: + """Fotos speichern; Rückgabe: Dateien, die dabei weggefallen sind.""" + old = [r["file"] for r in conn.execute("SELECT file FROM travel_visit_photos WHERE visit_id = ?", (visit_id,))] + conn.execute("DELETE FROM travel_visit_photos WHERE visit_id = ?", (visit_id,)) + conn.executemany( + "INSERT INTO travel_visit_photos (visit_id, file, position) VALUES (?, ?, ?)", + [(visit_id, f, i) for i, f in enumerate(photos)], + ) + return [f for f in old if f not in photos] + + +def _own_visit(conn, visit_id: int, user: dict): + row = conn.execute( + "SELECT v.* FROM travel_visits v JOIN travel_cities c ON c.id = v.city_id WHERE v.id = ? AND c.user_id = ?", + (visit_id, user["id"]), + ).fetchone() + if not row: + raise HTTPException(404, "Besuch nicht gefunden.") + return row + + +@router.post("/api/reisen/staedte/{city_id}/besuche", status_code=201) +def add_visit(city_id: int, data: dict = Body(...), user: dict = Depends(current_user)): + """Besuch eintragen; eine Stadt von der Wunschliste gilt damit als besucht.""" + date_from, date_to, companions, notes, photos = _visit_fields(data) + with transaction() as conn: + _own_city(conn, city_id, user) + if conn.execute( + "SELECT COUNT(*) FROM travel_visits v JOIN travel_cities c ON c.id = v.city_id WHERE c.user_id = ?", (user["id"],) + ).fetchone()[0] >= MAX_VISITS: + raise ValidationError(f"Maximal {MAX_VISITS} Besuche.") + visit_id = conn.execute( + "INSERT INTO travel_visits (city_id, date_from, date_to, companions, notes, created_at) VALUES (?, ?, ?, ?, ?, ?)", + (city_id, date_from, date_to, companions, notes, now_iso()), + ).lastrowid + _save_photos(conn, visit_id, photos) + conn.execute("UPDATE travel_cities SET wish = 0 WHERE id = ?", (city_id,)) + return _state(conn, user["id"]) + + +@router.put("/api/reisen/besuche/{visit_id}") +def update_visit(visit_id: int, data: dict = Body(...), user: dict = Depends(current_user)): + date_from, date_to, companions, notes, photos = _visit_fields(data) + with transaction() as conn: + _own_visit(conn, visit_id, user) + conn.execute( + "UPDATE travel_visits SET date_from = ?, date_to = ?, companions = ?, notes = ? WHERE id = ?", + (date_from, date_to, companions, notes, visit_id), + ) + remove_unused(conn, _save_photos(conn, visit_id, photos)) + return _state(conn, user["id"]) + + +@router.delete("/api/reisen/besuche/{visit_id}") +def delete_visit(visit_id: int, user: dict = Depends(current_user)): + with transaction() as conn: + _own_visit(conn, visit_id, user) + photos = [r["file"] for r in conn.execute("SELECT file FROM travel_visit_photos WHERE visit_id = ?", (visit_id,))] + conn.execute("DELETE FROM travel_visits WHERE id = ?", (visit_id,)) + remove_unused(conn, photos) return _state(conn, user["id"]) diff --git a/backend/tests/test_reisen.py b/backend/tests/test_reisen.py index b01412d..5be9365 100644 --- a/backend/tests/test_reisen.py +++ b/backend/tests/test_reisen.py @@ -32,3 +32,83 @@ def test_land_markieren(login): assert anna.put("/api/reisen/laender/IS").json()["countries"] == ["IS"] assert anna.delete("/api/reisen/laender/IS").json()["countries"] == [] assert anna.put("/api/reisen/laender/xx").status_code == 400 + + +# ---------- Wunschliste und Besuche ---------- + +import io # noqa: E402 + +from PIL import Image # noqa: E402 + +from app.config import DATA_DIR # noqa: E402 + +PRAG = {"geonameId": 3067696, "name": "Prag", "country": "CZ", "lat": 50.08, "lon": 14.42, "capital": True} + + +def foto(client) -> str: + buf = io.BytesIO() + Image.new("RGB", (8, 8), "blue").save(buf, "JPEG") + return client.post("/api/uploads", content=buf.getvalue()).json()["file"] + + +def test_wunschliste(login): + anna, ben = login("anna"), login("ben") + data = anna.post("/api/reisen/staedte", json={**PRAG, "wish": True}).json() + prag = data["cities"][0] + assert prag["wish"] is True + assert ben.get("/api/reisen").json()["others"] == [] # Wünsche sehen andere nicht + data = anna.patch(f"/api/reisen/staedte/{prag['id']}", json={"wish": False}).json() + assert data["cities"][0]["wish"] is False + assert [p["name"] for p in ben.get("/api/reisen").json()["others"]] == ["Anna"] + + +def test_besuch_mit_fotos(login): + anna, ben = login("anna"), login("ben") + prag = anna.post("/api/reisen/staedte", json={**PRAG, "wish": True}).json()["cities"][0] + f1, f2 = foto(anna), foto(anna) + data = anna.post(f"/api/reisen/staedte/{prag['id']}/besuche", json={ + "dateFrom": "2024-03-01", "dateTo": "2024-03-04", "companions": "Ben", "notes": "Karlsbrücke", "photos": [f1, f2], + }).json() + assert data["cities"][0]["wish"] is False # Besuch macht aus dem Wunsch einen Besuch + visit = data["visits"][0] + assert (visit["dateFrom"], visit["dateTo"], visit["companions"], visit["photos"]) == ("2024-03-01", "2024-03-04", "Ben", [f1, f2]) + assert ben.get("/api/reisen").json().get("visits") == [] # Besuche sind privat + # Foto entfernt → Datei gelöscht; Besuch gelöscht → alle Fotos weg + anna.put(f"/api/reisen/besuche/{visit['id']}", json={**visit, "photos": [f1]}) + assert not (DATA_DIR / "uploads" / f2).exists() + anna.delete(f"/api/reisen/besuche/{visit['id']}") + assert not (DATA_DIR / "uploads" / f1).exists() + + +def test_stadt_loeschen_entfernt_fotos(login): + anna = login("anna") + prag = anna.post("/api/reisen/staedte", json=PRAG).json()["cities"][0] + f = foto(anna) + anna.post(f"/api/reisen/staedte/{prag['id']}/besuche", json={"dateFrom": "2023-05-01", "photos": [f]}) + anna.delete(f"/api/reisen/staedte/{prag['id']}") + assert not (DATA_DIR / "uploads" / f).exists() + + +def test_besuch_pruefungen(login): + anna, ben = login("anna"), login("ben") + prag = anna.post("/api/reisen/staedte", json=PRAG).json()["cities"][0] + url = f"/api/reisen/staedte/{prag['id']}/besuche" + assert anna.post(url, json={"dateFrom": "2024-13-01"}).status_code == 400 + assert anna.post(url, json={"dateFrom": "2024-05-02", "dateTo": "2024-05-01"}).status_code == 400 + assert anna.post(url, json={"dateFrom": "2024-05-02", "photos": ["../../etc/passwd"]}).status_code == 400 + assert anna.post(url, json={"dateFrom": "2024-05-02", "photos": ["9e" * 12 + ".jpg"]}).status_code == 400 # gibt es nicht + assert ben.post(url, json={"dateFrom": "2024-05-02"}).status_code == 404 # fremde Stadt + + +def test_reisefotos_ueberleben_aufraeumen(login): + """Das Aufräumen verwaister Bilder darf Reisefotos nicht löschen.""" + from app import projects + anna = login("anna") + prag = anna.post("/api/reisen/staedte", json=PRAG).json()["cities"][0] + f = foto(anna) + anna.post(f"/api/reisen/staedte/{prag['id']}/besuche", json={"dateFrom": "2023-05-01", "photos": [f]}) + import os, time + old = time.time() - 3 * 24 * 3600 + os.utime(DATA_DIR / "uploads" / f, (old, old)) + projects._cleanup_orphans() + assert (DATA_DIR / "uploads" / f).exists() diff --git a/frontend/src/components/reisen/CitySearch.jsx b/frontend/src/components/reisen/CitySearch.jsx index cf01018..aedcbb2 100644 --- a/frontend/src/components/reisen/CitySearch.jsx +++ b/frontend/src/components/reisen/CitySearch.jsx @@ -5,6 +5,7 @@ import { BY_CODE, people } from './laender.js'; // Stadt suchen (Open-Meteo über den Server) und hinzufügen; schon eingetragene sind markiert export default function CitySearch({ cities, onAdd, busy }) { + const [wish, setWish] = useState(false); // Besucht oder Wunschliste const [q, setQ] = useState(''); const [results, setResults] = useState([]); const [open, setOpen] = useState(false); @@ -41,7 +42,7 @@ export default function CitySearch({ cities, onAdd, busy }) { setOpen(false); setQ(''); setResults([]); - await onAdd(result); + await onAdd(result, wish); } function onKeyDown(e) { @@ -54,6 +55,11 @@ export default function CitySearch({ cities, onAdd, busy }) { const show = open && q.trim().length >= 2; return ( +
Noch keine Besuche. Klick auf eine Stadt und dann auf „+ Besuch eintragen“.
; + } + const byId = new Map(cities.map((c) => [c.id, c])); + const years = []; + for (const v of visits) { + const year = v.dateFrom.slice(0, 4); + if (years.at(-1)?.year !== year) years.push({ year, visits: [] }); + years.at(-1).visits.push(v); + } + return ( +{v.notes}
} + {v.photos.length > 0 && ( +- {data ? `${visited.size} ${visited.size === 1 ? 'Land' : 'Länder'} · ${cities.length} ${cities.length === 1 ? 'Stadt' : 'Städte'}` : 'Lade …'} + {data ? `${visited.size} ${visited.size === 1 ? 'Land' : 'Länder'} · ${cities.length} ${cities.length === 1 ? 'Stadt' : 'Städte'}${wishes.length ? ` · ${wishes.length} auf der Wunschliste` : ''}` : 'Lade …'} {' '}· Klick auf ein Land oder einen Punkt zeigt Infos.
{error}
} {data && ( @@ -175,7 +203,7 @@ export default function Reisen() { ref={map} countries={shapes} visited={visited} - cities={cities} + cities={own} otherCities={otherCities} selected={selected} onSelect={(sel) => select(sel)} @@ -195,8 +223,11 @@ export default function Reisen() { city={selectedCity} cities={cities} otherCities={otherCities} + visits={visits} manual={data?.countries || []} busy={busy} + onVisit={openVisit} + onWishDone={(city) => run('PATCH', `/api/reisen/staedte/${city.id}`, { wish: false })} onSelect={select} onClose={() => setSelected(null)} onMark={(code, mark) => run(mark ? 'PUT' : 'DELETE', `/api/reisen/laender/${encodeURIComponent(code)}`)} @@ -209,14 +240,45 @@ export default function Reisen() {Noch keine Wünsche. Oben bei der Suche „Wunschliste“ wählen und eine Stadt hinzufügen.
+ )} + + {visitDialog && ( +Karte: Natural Earth / world-atlas · Länderdaten: world-countries (ODbL) · Orte: GeoNames über Open-Meteo (CC BY 4.0) diff --git a/frontend/src/styles/reisen.css b/frontend/src/styles/reisen.css index 2f52be2..b021df3 100644 --- a/frontend/src/styles/reisen.css +++ b/frontend/src/styles/reisen.css @@ -196,3 +196,43 @@ .info-card { position: relative; left: auto; bottom: auto; width: auto; max-height: none; border-width: 1px 0 0; box-shadow: none; } .info-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); } } + +/* Wunschliste: Ring statt gefülltem Punkt */ +.map-city.wish .map-city-dot { fill: var(--bg); stroke: var(--warn); stroke-width: 2.5; stroke-dasharray: 3 2; } +.city-search-row { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; margin-bottom: 14px; } +.city-search-row .city-search { flex: 1 1 320px; margin: 0; } + +/* Besuche, Zeitleiste, Statistik */ +.info-visits { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; padding-top: 8px; border-top: 1px dashed var(--border); } +.info-visits ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; } +.info-visits small { color: var(--muted); } +.travel-tabs { display: flex; margin-top: 24px; } +.visit-photos { list-style: none; margin: 0 0 8px; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; } +.visit-photos li { position: relative; } +.visit-photos img { display: block; width: 84px; height: 84px; object-fit: cover; border-radius: var(--radius-s); border: 1px solid var(--border); } +.visit-photos li > .link-btn { position: absolute; top: 2px; right: 2px; background: var(--overlay); padding: 0 5px; border-radius: var(--radius-s); } +.visit-photos.small img { width: 56px; height: 56px; } +.visit-photos button { padding: 0; border: 0; background: none; cursor: zoom-in; } +.timeline-travel { margin-top: 24px; display: grid; gap: 22px; } +.timeline-travel ol { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 10px; } +.timeline-entry { display: grid; grid-template-columns: minmax(9em, 14em) 1fr auto; gap: 4px 16px; align-items: start; padding: 10px 0; border-top: 1px dashed var(--border); } +.timeline-date { color: var(--muted); font-size: 0.85rem; font-variant-numeric: tabular-nums; } +.timeline-body { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.timeline-body .hint { margin: 0; } +.timeline-city { font: inherit; font-weight: 600; color: var(--bright); background: none; border: 0; padding: 0; text-align: left; cursor: pointer; } +.timeline-city small { font-weight: 400; color: var(--muted); } +.timeline-city:hover { color: var(--accent); } +.timeline-notes { margin: 0; white-space: pre-line; color: var(--read); } +@media (max-width: 640px) { .timeline-entry { grid-template-columns: 1fr auto; } .timeline-date { grid-column: 1 / -1; } } +.travel-stats { margin-top: 24px; display: grid; gap: 24px; } +.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; } +.stat-tile { display: flex; flex-direction: column; gap: 4px; padding: 14px 16px; border: 1px solid var(--border); border-radius: var(--radius-m); background: var(--surface); } +.stat-tile strong { font-family: var(--font-display); font-size: 1.8rem; line-height: 1.1; color: var(--bright); } +.stat-tile span { color: var(--muted); font-size: 0.82rem; } +.stat-continents { display: grid; gap: 10px; max-width: 640px; } +.stat-points { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } +.stat-points small { color: var(--muted); } +.travel-wishes { margin-top: 24px; } +.link-picker ul.visit-photos { display: flex; flex-direction: row; flex-wrap: wrap; gap: 8px; max-height: none; } +.link-picker ul.visit-photos li { width: auto; padding: 0; border: 0; } +.visit-hint { margin: -8px 0 0; }