From bbf740603e3bc094cc2119603385d573d7a775b8 Mon Sep 17 00:00:00 2001 From: guyg1474 <340572510+guyg1474@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:25:07 +0200 Subject: [PATCH] Reisen: Wunschliste, Besuche mit Fotos, Zeitleiste und Statistik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wunschliste (Spalte travel_cities.wish): eigene Punktform auf der Karte, zählt nicht als besucht, für andere Konten unsichtbar; "War ich da ✓" - Besuche je Stadt (Tabellen travel_visits, travel_visit_photos): Zeitraum, Begleitung, Notizen, Fotos (Upload ohne Metadaten); nur für das eigene Konto; ein Besuch macht aus einem Wunsch einen Besuch - Fotos von Besuchen zählen beim Aufräumen verwaister Bilder als benutzt; beim Entfernen eines Fotos, Besuchs oder einer Stadt werden die Dateien gelöscht - Zeitleiste "Wann war ich wo?" nach Jahren, Fotos mit Lightbox - Statistik: Länder (Welt, je Kontinent), Landfläche, äußerste Orte, Kilometer entlang der Besuche - Ansichten unter der Karte als Tabs: Besuchte Orte, Zeitleiste, Statistik, Wunschliste - Migration Schritt 4, Tests für Wunschliste, Besuche, Fotos und Aufräumen Co-Authored-By: Claude Opus 5.5 --- README.md | 10 ++ backend/app/db.py | 19 +++ backend/app/migrations.py | 8 + backend/app/projects.py | 8 +- backend/app/reisen.py | 159 +++++++++++++++++- backend/tests/test_reisen.py | 80 +++++++++ frontend/src/components/reisen/CitySearch.jsx | 11 +- frontend/src/components/reisen/InfoCard.jsx | 42 ++++- frontend/src/components/reisen/Timeline.jsx | 56 ++++++ .../src/components/reisen/TravelStats.jsx | 79 +++++++++ .../src/components/reisen/VisitDialog.jsx | 99 +++++++++++ .../src/components/reisen/VisitedList.jsx | 6 +- frontend/src/components/reisen/WorldMap.jsx | 4 +- frontend/src/components/reisen/laender.js | 16 ++ frontend/src/pages/Reisen.jsx | 90 ++++++++-- frontend/src/styles/reisen.css | 40 +++++ 16 files changed, 695 insertions(+), 32 deletions(-) create mode 100644 frontend/src/components/reisen/Timeline.jsx create mode 100644 frontend/src/components/reisen/TravelStats.jsx create mode 100644 frontend/src/components/reisen/VisitDialog.jsx 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 ( +
+
+ + +
@@ -64,7 +70,7 @@ export default function CitySearch({ cities, onAdd, busy }) { onFocus={() => setOpen(true)} onBlur={() => setOpen(false)} onKeyDown={onKeyDown} - placeholder="Stadt hinzufügen, in der du schon warst …" + placeholder={wish ? 'Stadt auf die Wunschliste setzen …' : 'Stadt hinzufügen, in der du schon warst …'} role="combobox" aria-autocomplete="list" aria-expanded={show && results.length > 0} @@ -103,5 +109,6 @@ export default function CitySearch({ cities, onAdd, busy }) { )}
+
); } diff --git a/frontend/src/components/reisen/InfoCard.jsx b/frontend/src/components/reisen/InfoCard.jsx index a26685b..b6f76a1 100644 --- a/frontend/src/components/reisen/InfoCard.jsx +++ b/frontend/src/components/reisen/InfoCard.jsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { api } from '../../api.js'; -import { BY_CODE, countryName, num, people } from './laender.js'; +import { BY_CODE, countryName, fmtRange, num, people } from './laender.js'; // Info-Kasten unten links auf der Karte: Land oder Stadt mit Fakten und Wikipedia-Text @@ -32,11 +32,23 @@ function useInfo(params) { return info; } -export default function InfoCard({ selected, city, cities, otherCities, manual, busy, onSelect, onClose, onMark, onRemoveCity }) { +export default function InfoCard({ selected, city, cities, otherCities, visits = [], manual, busy, onSelect, onClose, onMark, onRemoveCity, onVisit, onWishDone }) { if (selected.type === 'city' && city) { // Wer war (sichtbar) noch in dieser Stadt? const same = [...cities, ...otherCities].filter((c) => c.geonameId === city.geonameId && c.id !== city.id); - return ; + return ( + v.cityId === city.id)} + onSelect={onSelect} + onClose={onClose} + onRemove={onRemoveCity} + onVisit={onVisit} + onWishDone={onWishDone} + busy={busy} + /> + ); } if (selected.type === 'country') { return ( @@ -130,7 +142,7 @@ function OtherChip({ city, onSelect, label = city.name }) { ); } -function CityInfo({ city, same, onSelect, onClose, onRemove, busy }) { +function CityInfo({ city, same, visits, onSelect, onClose, onRemove, onVisit, onWishDone, 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); @@ -159,6 +171,12 @@ function CityInfo({ city, same, onSelect, onClose, onRemove, busy }) {
{city.owner ? ( Besucht von {city.owner.name} + ) : city.wish ? ( + <> + ☆ Wunschliste + + + ) : ( <> Besucht @@ -166,6 +184,22 @@ function CityInfo({ city, same, onSelect, onClose, onRemove, busy }) { )}
+ {!city.owner && ( +
+ {visits.length > 0 && ( +
    + {visits.map((v) => ( +
  • + + {v.companions && mit {v.companions}} + {v.photos.length > 0 && · {v.photos.length} {v.photos.length === 1 ? 'Foto' : 'Fotos'}} +
  • + ))} +
+ )} + +
+ )} {same.length > 0 && (
Auch hier: diff --git a/frontend/src/components/reisen/Timeline.jsx b/frontend/src/components/reisen/Timeline.jsx new file mode 100644 index 0000000..d499e08 --- /dev/null +++ b/frontend/src/components/reisen/Timeline.jsx @@ -0,0 +1,56 @@ +import { imgUrl } from '../../api.js'; +import { BY_CODE, countryName, fmtRange } from './laender.js'; + +// Zeitleiste "Wann war ich wo?": alle Besuche, neueste zuerst, nach Jahren gruppiert +export default function Timeline({ visits, cities, onSelectCity, onEdit, onPhoto }) { + if (!visits.length) { + 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 ( +
+ {years.map((y) => ( +
+

{y.year}

+
    + {y.visits.map((v) => { + const city = byId.get(v.cityId); + if (!city) return null; + return ( +
  1. + {fmtRange(v.dateFrom, v.dateTo)} +
    + + {v.companions && mit {v.companions}} + {v.notes &&

    {v.notes}

    } + {v.photos.length > 0 && ( +
      + {v.photos.map((p) => ( +
    • + +
    • + ))} +
    + )} +
    + +
  2. + ); + })} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/reisen/TravelStats.jsx b/frontend/src/components/reisen/TravelStats.jsx new file mode 100644 index 0000000..bb7e4e8 --- /dev/null +++ b/frontend/src/components/reisen/TravelStats.jsx @@ -0,0 +1,79 @@ +import LAENDER from '../../data/laender.json'; +import { BY_CODE, CONTINENTS, continentOf, countryName, distanceKm, num } from './laender.js'; + +const pct = (part, total) => (total ? (part / total) * 100 : 0); +const fmtPct = (p) => `${p.toLocaleString('de-DE', { maximumFractionDigits: p < 10 ? 1 : 0 })} %`; +const TOTAL_AREA = LAENDER.reduce((s, c) => s + (c.area || 0), 0); + +/** + * Statistik: Länder (Welt und je Kontinent), Fläche, äußerste Punkte, Kilometer entlang der Zeitleiste. + * Grundlage für "Welt": alle Länder und Gebiete der Länderdaten (world-countries). + */ +export default function TravelStats({ visited, cities, visits, wishes, onSelectCity }) { + const codes = [...visited].filter((c) => BY_CODE.has(c)); + const area = codes.reduce((s, c) => s + (BY_CODE.get(c).area || 0), 0); + const continents = CONTINENTS.map((name) => { + const total = LAENDER.filter((c) => c.continent === name).length; + const seen = codes.filter((c) => continentOf(c) === name).length; + return { name, total, seen }; + }).filter((c) => c.total); + + // Äußerste Punkte (nur besuchte Städte) + const extreme = (pick) => (cities.length ? cities.reduce((a, b) => (pick(b) > pick(a) ? b : a)) : null); + const points = [ + ['Nördlichster Ort', extreme((c) => c.lat), (c) => `${c.lat.toFixed(1).replace('.', ',')}° N`], + ['Südlichster Ort', extreme((c) => -c.lat), (c) => `${Math.abs(c.lat).toFixed(1).replace('.', ',')}° ${c.lat < 0 ? 'S' : 'N'}`], + ['Östlichster Ort', extreme((c) => c.lon), (c) => `${Math.abs(c.lon).toFixed(1).replace('.', ',')}° ${c.lon < 0 ? 'W' : 'O'}`], + ['Westlichster Ort', extreme((c) => -c.lon), (c) => `${Math.abs(c.lon).toFixed(1).replace('.', ',')}° ${c.lon < 0 ? 'W' : 'O'}`], + ]; + + // Kilometer: Besuche in zeitlicher Reihenfolge, Luftlinie von Ort zu Ort + const byId = new Map(cities.map((c) => [c.id, c])); + const route = [...visits].sort((a, b) => a.dateFrom.localeCompare(b.dateFrom)).map((v) => byId.get(v.cityId)).filter(Boolean); + let km = 0; + for (let i = 1; i < route.length; i += 1) if (route[i].id !== route[i - 1].id) km += distanceKm(route[i - 1], route[i]); + const days = visits.reduce((s, v) => s + 1 + (v.dateTo ? Math.round((new Date(v.dateTo) - new Date(v.dateFrom)) / 864e5) : 0), 0); + + return ( +
+
+
{fmtPct(pct(codes.length, LAENDER.length))}der Welt ({codes.length} von {LAENDER.length} Ländern und Gebieten)
+
{fmtPct(pct(area, TOTAL_AREA))}der Landfläche ({num(Math.round(area))} km²)
+
{cities.length}{cities.length === 1 ? 'Stadt' : 'Städte'} besucht · {wishes} auf der Wunschliste
+
+ {route.length > 1 ? `${num(Math.round(km))} km` : '–'} + {route.length > 1 ? `Luftlinie entlang ${visits.length} Besuchen (${days} Reisetage)` : 'Besuche mit Datum eintragen, um Kilometer zu sehen'} +
+
+ +
+

Kontinente

+ {continents.map((c) => ( +
+
{c.name}{c.seen} / {c.total} · {fmtPct(pct(c.seen, c.total))}
+ +
+ ))} +
+ + {cities.length > 0 && ( +
+

Äußerste Punkte

+
+ {points.map(([label, city, fmt]) => ( +
+
{label}
+
+ + {countryName(city.country)} · {fmt(city)} +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/reisen/VisitDialog.jsx b/frontend/src/components/reisen/VisitDialog.jsx new file mode 100644 index 0000000..59b5250 --- /dev/null +++ b/frontend/src/components/reisen/VisitDialog.jsx @@ -0,0 +1,99 @@ +import { useRef, useState } from 'react'; +import { imgUrl, uploadImage } from '../../api.js'; + +/** + * Besuch einer Stadt eintragen oder bearbeiten: Zeitraum, Begleitung, Notizen, Fotos. + * Fotos werden beim Hochladen auf dem Server von Metadaten (GPS usw.) befreit. + * onSave(body) / onDelete() liefern bei Erfolg den neuen Stand (truthy). + */ +export default function VisitDialog({ ref, city, visit, onSave, onDelete, busy }) { + const today = new Date().toISOString().slice(0, 10); + const [form, setForm] = useState(() => ({ + dateFrom: visit?.dateFrom || today, + dateTo: visit?.dateTo || '', + companions: visit?.companions || '', + notes: visit?.notes || '', + photos: visit?.photos || [], + })); + const [uploading, setUploading] = useState(0); + const [error, setError] = useState(null); + const fileInput = useRef(null); + const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) }); + + async function addPhotos(files) { + setError(null); + for (const file of files) { + setUploading((n) => n + 1); + try { + const name = await uploadImage(file); + setForm((f) => ({ ...f, photos: [...f.photos, name] })); + } catch (e) { + setError(`${file.name}: ${e.message}`); + } finally { + setUploading((n) => n - 1); + } + } + } + + async function submit(e) { + e.preventDefault(); + if (await onSave({ ...form, dateTo: form.dateTo || null })) ref.current.close(); + } + + async function remove() { + if (!window.confirm(`Besuch in ${city.name} löschen?`)) return; + if (await onDelete()) ref.current.close(); + } + + return ( + +
+

{visit ? 'Besuch bearbeiten' : 'Besuch eintragen'} – {city.name}

+
+ + +
+

„Bis“ leer lassen für einen einzelnen Tag.

+ +