Reisen: Wunschliste, Besuche mit Fotos, Zeitleiste und Statistik

- 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 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 19:25:07 +02:00
1 parent 9835f5e69e
commit bbf740603e
16 files changed
+695 -32

No files matched your search

+10
View File
@@ -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, 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). 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, 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 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. Städte kann man nur ansehen, nicht ändern. Alle angemeldeten Konten können die Städte der anderen sehen.
+19
View File
@@ -198,10 +198,29 @@ CREATE TABLE IF NOT EXISTS travel_cities (
elevation REAL, elevation REAL,
timezone TEXT, timezone TEXT,
capital INTEGER, -- Hauptstadt (GeoNames PPLC); NULL = noch nicht abgefragt 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, created_at TEXT NOT NULL,
UNIQUE (user_id, geoname_id) 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 ( CREATE TABLE IF NOT EXISTS travel_countries (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code TEXT NOT NULL, code TEXT NOT NULL,
+8
View File
@@ -249,9 +249,17 @@ def _v3_kategorien(conn) -> None:
conn.execute("UPDATE products SET category = ? WHERE key = ?", (guess(row["name"]), row["key"])) 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. # Reihenfolge = Versionsnummer (Index + 1). Nur anhängen, nie umsortieren oder entfernen.
STEPS = [ STEPS = [
_v1_altbestand, _v1_altbestand,
_v2_datenschutz, _v2_datenschutz,
_v3_kategorien, _v3_kategorien,
_v4_wunschliste,
] ]
+7 -1
View File
@@ -72,7 +72,8 @@ def _detect_image_type(data: bytes) -> str | None:
def _referenced_images(conn) -> set[str]: 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"): for row in conn.execute("SELECT cover, content FROM projects"):
if row["cover"]: if row["cover"]:
used.add(row["cover"]) used.add(row["cover"])
@@ -80,6 +81,11 @@ def _referenced_images(conn) -> set[str]:
return used 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: def _remove_unused(conn, names) -> None:
used = _referenced_images(conn) used = _referenced_images(conn)
for n in names: for n in names:
+155 -4
View File
@@ -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. Alle Abrufe laufen über den Server und werden zwischengespeichert; der Browser fragt keine Fremdserver.
""" """
import asyncio import asyncio
import datetime
import json import json
import logging import logging
import math import math
@@ -21,6 +22,7 @@ from .auth import current_user
from .config import WIKIPEDIA_CONTACT from .config import WIKIPEDIA_CONTACT
from .common import ValidationError, clean_text, now_iso from .common import ValidationError, clean_text, now_iso
from .db import transaction from .db import transaction
from .projects import UPLOAD_DIR, UPLOAD_NAME, remove_unused
router = APIRouter() router = APIRouter()
@@ -45,6 +47,7 @@ def _city(row) -> dict:
"country": row["country"], "lat": row["lat"], "lon": row["lon"], "population": row["population"], "country": row["country"], "lat": row["lat"], "lon": row["lon"], "population": row["population"],
"elevation": row["elevation"], "timezone": row["timezone"], "createdAt": row["created_at"], "elevation": row["elevation"], "timezone": row["timezone"], "createdAt": row["created_at"],
"capital": None if row["capital"] is None else bool(row["capital"]), "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.""" """Städte der anderen Konten (nur zum Anzeigen auf der Karte), gruppiert nach Konto."""
rows = conn.execute( rows = conn.execute(
"""SELECT c.*, u.display_name FROM travel_cities c JOIN users u ON u.id = c.user_id """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,), (user_id,),
).fetchall() ).fetchall()
people = {} people = {}
@@ -62,12 +65,35 @@ def _others(conn, user_id: int) -> list:
return list(people.values()) 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: def _state(conn, user_id: int) -> dict:
cities = conn.execute("SELECT * FROM travel_cities WHERE user_id = ? ORDER BY name", (user_id,)).fetchall() 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() countries = conn.execute("SELECT code FROM travel_countries WHERE user_id = ? ORDER BY code", (user_id,)).fetchall()
return { return {
"cities": [_city(r) for r in cities], "cities": [_city(r) for r in cities],
"countries": [r["code"] for r in countries], "countries": [r["code"] for r in countries],
"visits": _visits(conn, user_id),
"others": _others(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 timezone = None
# Hauptstadt kommt aus der Suche; fehlt die Angabe, trägt fill_capitals() sie nach # 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 capital = int(data["capital"]) if isinstance(data.get("capital"), bool) else None
wish = data.get("wish") is True
values = ( values = (
user["id"], geoname_id, clean_text(data.get("name"), "Name", 100), clean_text(data.get("admin1"), "Region", 100, required=False), 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), 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: with transaction() as conn:
if conn.execute("SELECT COUNT(*) FROM travel_cities WHERE user_id = ?", (user["id"],)).fetchone()[0] >= MAX_CITIES: 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.") raise ValidationError(f"Maximal {MAX_CITIES} Städte.")
conn.execute( conn.execute(
"""INSERT INTO travel_cities """INSERT INTO travel_cities
(user_id, geoname_id, name, admin1, country, lat, lon, population, elevation, timezone, capital, created_at) (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, geoname_id) DO NOTHING""",
values, values,
) )
return _state(conn, user["id"]) 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}") @router.delete("/api/reisen/staedte/{city_id}")
def delete_city(city_id: int, user: dict = Depends(current_user)): def delete_city(city_id: int, user: dict = Depends(current_user)):
with transaction() as conn: 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"])) cur = conn.execute("DELETE FROM travel_cities WHERE id = ? AND user_id = ?", (city_id, user["id"]))
if not cur.rowcount: if not cur.rowcount:
raise HTTPException(404, "Stadt nicht gefunden.") 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"]) return _state(conn, user["id"])
+80
View File
@@ -32,3 +32,83 @@ def test_land_markieren(login):
assert anna.put("/api/reisen/laender/IS").json()["countries"] == ["IS"] assert anna.put("/api/reisen/laender/IS").json()["countries"] == ["IS"]
assert anna.delete("/api/reisen/laender/IS").json()["countries"] == [] assert anna.delete("/api/reisen/laender/IS").json()["countries"] == []
assert anna.put("/api/reisen/laender/xx").status_code == 400 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()
@@ -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 // Stadt suchen (Open-Meteo über den Server) und hinzufügen; schon eingetragene sind markiert
export default function CitySearch({ cities, onAdd, busy }) { export default function CitySearch({ cities, onAdd, busy }) {
const [wish, setWish] = useState(false); // Besucht oder Wunschliste
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [results, setResults] = useState([]); const [results, setResults] = useState([]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -41,7 +42,7 @@ export default function CitySearch({ cities, onAdd, busy }) {
setOpen(false); setOpen(false);
setQ(''); setQ('');
setResults([]); setResults([]);
await onAdd(result); await onAdd(result, wish);
} }
function onKeyDown(e) { function onKeyDown(e) {
@@ -54,6 +55,11 @@ export default function CitySearch({ cities, onAdd, busy }) {
const show = open && q.trim().length >= 2; const show = open && q.trim().length >= 2;
return ( return (
<div className="city-search-row">
<div className="sort-toggle" role="group" aria-label="Hinzufügen als">
<button type="button" aria-pressed={!wish} onClick={() => setWish(false)}>Besucht</button>
<button type="button" aria-pressed={wish} onClick={() => setWish(true)}>☆ Wunschliste</button>
</div>
<div className="city-search combo"> <div className="city-search combo">
<label className="sr-only" htmlFor="city-search">Stadt hinzufügen</label> <label className="sr-only" htmlFor="city-search">Stadt hinzufügen</label>
<div className="prompt-input"> <div className="prompt-input">
@@ -64,7 +70,7 @@ export default function CitySearch({ cities, onAdd, busy }) {
onFocus={() => setOpen(true)} onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)} onBlur={() => setOpen(false)}
onKeyDown={onKeyDown} 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" role="combobox"
aria-autocomplete="list" aria-autocomplete="list"
aria-expanded={show && results.length > 0} aria-expanded={show && results.length > 0}
@@ -103,5 +109,6 @@ export default function CitySearch({ cities, onAdd, busy }) {
</ul> </ul>
)} )}
</div> </div>
</div>
); );
} }
+38 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api } from '../../api.js'; 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 // Info-Kasten unten links auf der Karte: Land oder Stadt mit Fakten und Wikipedia-Text
@@ -32,11 +32,23 @@ function useInfo(params) {
return info; 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) { if (selected.type === 'city' && city) {
// Wer war (sichtbar) noch in dieser Stadt? // Wer war (sichtbar) noch in dieser Stadt?
const same = [...cities, ...otherCities].filter((c) => c.geonameId === city.geonameId && c.id !== city.id); 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} />; return (
<CityInfo
city={city}
same={same}
visits={visits.filter((v) => v.cityId === city.id)}
onSelect={onSelect}
onClose={onClose}
onRemove={onRemoveCity}
onVisit={onVisit}
onWishDone={onWishDone}
busy={busy}
/>
);
} }
if (selected.type === 'country') { if (selected.type === 'country') {
return ( 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 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 info = useInfo({ kind: 'stadt', name: city.name, code: String(city.geonameId), lat: city.lat, lon: city.lon });
const time = city.timezone && localTime(city.timezone); const time = city.timezone && localTime(city.timezone);
@@ -159,6 +171,12 @@ function CityInfo({ city, same, onSelect, onClose, onRemove, busy }) {
<div className="info-visit"> <div className="info-visit">
{city.owner ? ( {city.owner ? (
<span className="badge travel-owner" style={{ '--dot': `var(--other-${city.color})` }}>Besucht von {city.owner.name}</span> <span className="badge travel-owner" style={{ '--dot': `var(--other-${city.color})` }}>Besucht von {city.owner.name}</span>
) : city.wish ? (
<>
<span className="badge warn">☆ Wunschliste</span>
<button className="btn small" type="button" disabled={busy} onClick={() => onWishDone(city)}>War ich da ✓</button>
<button className="link-btn danger" type="button" disabled={busy} onClick={() => onRemove(city)}>Entfernen</button>
</>
) : ( ) : (
<> <>
<span className="badge ok">Besucht</span> <span className="badge ok">Besucht</span>
@@ -166,6 +184,22 @@ function CityInfo({ city, same, onSelect, onClose, onRemove, busy }) {
</> </>
)} )}
</div> </div>
{!city.owner && (
<div className="info-visits">
{visits.length > 0 && (
<ul>
{visits.map((v) => (
<li key={v.id}>
<button type="button" className="link-btn" onClick={() => onVisit(city, v)}>{fmtRange(v.dateFrom, v.dateTo)}</button>
{v.companions && <small> mit {v.companions}</small>}
{v.photos.length > 0 && <small> · {v.photos.length} {v.photos.length === 1 ? 'Foto' : 'Fotos'}</small>}
</li>
))}
</ul>
)}
<button className="link-btn" type="button" onClick={() => onVisit(city, null)}>+ Besuch eintragen</button>
</div>
)}
{same.length > 0 && ( {same.length > 0 && (
<div className="info-others"> <div className="info-others">
<span className="hint">Auch hier:</span> <span className="hint">Auch hier:</span>
@@ -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 <p className="hint travel-empty">Noch keine Besuche. Klick auf eine Stadt und dann auf „+ Besuch eintragen“.</p>;
}
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 (
<div className="timeline-travel">
{years.map((y) => (
<section key={y.year} aria-labelledby={`jahr-${y.year}`}>
<h2 className="section-title" id={`jahr-${y.year}`}>{y.year}</h2>
<ol>
{y.visits.map((v) => {
const city = byId.get(v.cityId);
if (!city) return null;
return (
<li key={v.id} className="timeline-entry">
<span className="timeline-date">{fmtRange(v.dateFrom, v.dateTo)}</span>
<div className="timeline-body">
<button type="button" className="timeline-city" onClick={() => onSelectCity(city)}>
{BY_CODE.get(city.country)?.flag} {city.name}
<small> {countryName(city.country)}</small>
</button>
{v.companions && <span className="hint">mit {v.companions}</span>}
{v.notes && <p className="timeline-notes">{v.notes}</p>}
{v.photos.length > 0 && (
<ul className="visit-photos small">
{v.photos.map((p) => (
<li key={p}>
<button type="button" onClick={() => onPhoto(imgUrl(p))} aria-label="Foto vergrößern">
<img src={imgUrl(p)} alt="" loading="lazy" />
</button>
</li>
))}
</ul>
)}
</div>
<button className="link-btn" type="button" onClick={() => onEdit(city, v)} aria-label={`Besuch in ${city.name} bearbeiten`}>✎</button>
</li>
);
})}
</ol>
</section>
))}
</div>
);
}
@@ -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 (
<div className="travel-stats">
<div className="stat-tiles">
<div className="stat-tile"><strong>{fmtPct(pct(codes.length, LAENDER.length))}</strong><span>der Welt ({codes.length} von {LAENDER.length} Ländern und Gebieten)</span></div>
<div className="stat-tile"><strong>{fmtPct(pct(area, TOTAL_AREA))}</strong><span>der Landfläche ({num(Math.round(area))} km²)</span></div>
<div className="stat-tile"><strong>{cities.length}</strong><span>{cities.length === 1 ? 'Stadt' : 'Städte'} besucht · {wishes} auf der Wunschliste</span></div>
<div className="stat-tile">
<strong>{route.length > 1 ? `${num(Math.round(km))} km` : '–'}</strong>
<span>{route.length > 1 ? `Luftlinie entlang ${visits.length} Besuchen (${days} Reisetage)` : 'Besuche mit Datum eintragen, um Kilometer zu sehen'}</span>
</div>
</div>
<section className="stat-continents" aria-labelledby="stat-kontinente">
<h2 className="section-title" id="stat-kontinente">Kontinente</h2>
{continents.map((c) => (
<div key={c.name} className="meter">
<div className="meter-label"><span>{c.name}</span><strong>{c.seen} / {c.total} · {fmtPct(pct(c.seen, c.total))}</strong></div>
<span className="meter-bar"><i style={{ width: `${pct(c.seen, c.total)}%` }} /></span>
</div>
))}
</section>
{cities.length > 0 && (
<section aria-labelledby="stat-punkte">
<h2 className="section-title" id="stat-punkte">Äußerste Punkte</h2>
<dl className="info-facts stat-points">
{points.map(([label, city, fmt]) => (
<div key={label}>
<dt>{label}</dt>
<dd>
<button type="button" className="link-btn" onClick={() => onSelectCity(city)}>
{BY_CODE.get(city.country)?.flag} {city.name}
</button>
<small> {countryName(city.country)} · {fmt(city)}</small>
</dd>
</div>
))}
</dl>
</section>
)}
</div>
);
}
@@ -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 (
<dialog className="modal task-dialog" ref={ref}>
<form className="form" onSubmit={submit}>
<h2>{visit ? 'Besuch bearbeiten' : 'Besuch eintragen'} – {city.name}</h2>
<div className="task-dialog-row">
<label>
Von
<input type="date" {...field('dateFrom')} required max={today} />
</label>
<label>
Bis
<input type="date" {...field('dateTo')} min={form.dateFrom} aria-describedby="bis-hint" />
</label>
</div>
<p className="hint visit-hint" id="bis-hint">„Bis“ leer lassen für einen einzelnen Tag.</p>
<label>
Begleitung <small>(optional)</small>
<input {...field('companions')} maxLength={200} placeholder="z. B. Anna, Ben" />
</label>
<label>
Notizen <small>(optional)</small>
<textarea {...field('notes')} rows={4} maxLength={5000} placeholder="Was war schön? Tipps fürs nächste Mal …" />
</label>
<fieldset className="link-picker">
<legend>Fotos <small>({form.photos.length})</small></legend>
{form.photos.length > 0 && (
<ul className="visit-photos">
{form.photos.map((p) => (
<li key={p}>
<img src={imgUrl(p)} alt="" loading="lazy" />
<button type="button" className="link-btn danger" onClick={() => setForm((f) => ({ ...f, photos: f.photos.filter((x) => x !== p) }))} aria-label="Foto entfernen">✕</button>
</li>
))}
</ul>
)}
<input ref={fileInput} type="file" accept="image/jpeg,image/png,image/webp,image/gif" multiple hidden
onChange={(e) => { addPhotos([...e.target.files]); e.target.value = ''; }} />
<button className="btn ghost" type="button" onClick={() => fileInput.current.click()} disabled={uploading > 0}>
{uploading ? `Lade ${uploading} Foto${uploading === 1 ? '' : 's'} hoch …` : '+ Fotos hinzufügen'}
</button>
<p className="hint">Standort, Kamera und Aufnahmezeit werden beim Hochladen aus den Fotos entfernt.</p>
</fieldset>
{error && <p className="error">{error}</p>}
<div className="form-actions">
{visit && <button className="link-btn danger task-delete" type="button" disabled={busy} onClick={remove}>Löschen</button>}
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
<button className="btn" type="submit" disabled={busy || uploading > 0}>{visit ? 'Speichern' : 'Eintragen'}</button>
</div>
</form>
</dialog>
);
}
@@ -1,13 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { loadSet, saveSet } from '../../storage.js'; import { loadSet, saveSet } from '../../storage.js';
import { BY_CODE, countryName } from './laender.js'; import { BY_CODE, CONTINENTS, continentOf, countryName } from './laender.js';
// Besuchte Orte: Kontinent → Land (ausklappbar) → Städte; auf-/zugeklappt wird pro Browser gemerkt // Besuchte Orte: Kontinent → Land (ausklappbar) → Städte; auf-/zugeklappt wird pro Browser gemerkt
const CONTINENTS = ['Europa', 'Asien', 'Afrika', 'Nordamerika', 'Südamerika', 'Ozeanien', 'Antarktis'];
// Gebiete der Karte ohne Eintrag in den Länderdaten
const SPECIAL_CONTINENT = { 'X-Somaliland': 'Afrika', 'X-Nordzypern': 'Europa', 'X-Indischer Ozean': 'Ozeanien', 'X-Siachen-Gletscher': 'Asien' };
const continentOf = (code) => BY_CODE.get(code)?.continent || SPECIAL_CONTINENT[code] || 'Sonstige';
const OPEN_KEY = 'p42.reisenOffen'; const OPEN_KEY = 'p42.reisenOffen';
const CLOSED_KEY = 'p42.reisenKontinenteZu'; const CLOSED_KEY = 'p42.reisenKontinenteZu';
+2 -2
View File
@@ -140,11 +140,11 @@ export default function WorldMap({ ref, countries, visited, cities, otherCities
return ( return (
<g key={city.id} transform={`translate(${p[0]},${p[1]})`}> <g key={city.id} transform={`translate(${p[0]},${p[1]})`}>
<g <g
className={`map-city${city.owner ? ' other' : ''}${isSelected ? ' selected' : ''}`} className={`map-city${city.owner ? ' other' : ''}${city.wish ? ' wish' : ''}${isSelected ? ' selected' : ''}`}
style={city.owner ? { '--dot': `var(--other-${city.color})` } : undefined} style={city.owner ? { '--dot': `var(--other-${city.color})` } : undefined}
onClick={(e) => { e.stopPropagation(); onSelect({ type: 'city', key: city.id }); }} onClick={(e) => { e.stopPropagation(); onSelect({ type: 'city', key: city.id }); }}
> >
<title>{city.owner ? `${city.name} (${city.owner.name})` : city.name}</title> <title>{city.owner ? `${city.name} (${city.owner.name})` : city.wish ? `${city.name} (Wunschliste)` : city.name}</title>
<circle className="map-city-hit" r="11" /> <circle className="map-city-hit" r="11" />
{isSelected && <circle className="map-city-ring" r="9" />} {isSelected && <circle className="map-city-ring" r="9" />}
<circle className="map-city-dot" r="4" /> <circle className="map-city-dot" r="4" />
+16
View File
@@ -10,3 +10,19 @@ export const people = (n) => (n >= 1e6 ? `${(n / 1e6).toLocaleString('de-DE', {
export function countryName(key) { export function countryName(key) {
return BY_CODE.get(key)?.name || (key.startsWith('X-') ? key.slice(2) : key); return BY_CODE.get(key)?.name || (key.startsWith('X-') ? key.slice(2) : key);
} }
export const CONTINENTS = ['Europa', 'Asien', 'Afrika', 'Nordamerika', 'Südamerika', 'Ozeanien', 'Antarktis'];
// Gebiete der Karte ohne Eintrag in den Länderdaten
const SPECIAL_CONTINENT = { 'X-Somaliland': 'Afrika', 'X-Nordzypern': 'Europa', 'X-Indischer Ozean': 'Ozeanien', 'X-Siachen-Gletscher': 'Asien' };
export const continentOf = (code) => BY_CODE.get(code)?.continent || SPECIAL_CONTINENT[code] || 'Sonstige';
// Datum JJJJ-MM-TT → "4. März 2024"; Zeitraum mit "–"
export const fmtDay = (d) => new Date(`${d}T12:00:00`).toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' });
export const fmtRange = (from, to) => (to && to !== from ? `${fmtDay(from)} – ${fmtDay(to)}` : fmtDay(from));
// Entfernung zweier Orte auf der Erdkugel in km
export function distanceKm(a, b) {
const rad = (x) => (x * Math.PI) / 180;
const h = Math.sin(rad(b.lat - a.lat) / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(rad(b.lon - a.lon) / 2) ** 2;
return 6371 * 2 * Math.asin(Math.sqrt(h));
}
+76 -14
View File
@@ -2,8 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { feature } from 'topojson-client'; import { feature } from 'topojson-client';
import CitySearch from '../components/reisen/CitySearch.jsx'; import CitySearch from '../components/reisen/CitySearch.jsx';
import ExportDialog from '../components/reisen/ExportDialog.jsx'; import ExportDialog from '../components/reisen/ExportDialog.jsx';
import Lightbox from '../components/projekte/Lightbox.jsx';
import InfoCard from '../components/reisen/InfoCard.jsx'; import InfoCard from '../components/reisen/InfoCard.jsx';
import { BY_NUM, countryName } from '../components/reisen/laender.js'; import { BY_NUM, countryName } from '../components/reisen/laender.js';
import Timeline from '../components/reisen/Timeline.jsx';
import TravelStats from '../components/reisen/TravelStats.jsx';
import VisitDialog from '../components/reisen/VisitDialog.jsx';
import VisitedList from '../components/reisen/VisitedList.jsx'; import VisitedList from '../components/reisen/VisitedList.jsx';
import WorldMap from '../components/reisen/WorldMap.jsx'; import WorldMap from '../components/reisen/WorldMap.jsx';
import { loadFlag, loadSet, saveFlag, saveSet } from '../storage.js'; import { loadFlag, loadSet, saveFlag, saveSet } from '../storage.js';
@@ -18,6 +22,7 @@ const SPECIAL = {
'Siachen Glacier': 'X-Siachen-Gletscher', 'Siachen Glacier': 'X-Siachen-Gletscher',
}; };
const TAB_KEY = 'p42.reisenAnsicht';
const OTHERS_KEY = 'p42.reisenAndere'; const OTHERS_KEY = 'p42.reisenAndere';
const HIDDEN_KEY = 'p42.reisenAndereAus'; const HIDDEN_KEY = 'p42.reisenAndereAus';
const OTHER_COLORS = 6; const OTHER_COLORS = 6;
@@ -32,6 +37,10 @@ export default function Reisen() {
const [hiddenPeople, setHiddenPeople] = useState(() => loadSet(HIDDEN_KEY)); const [hiddenPeople, setHiddenPeople] = useState(() => loadSet(HIDDEN_KEY));
const map = useRef(null); const map = useRef(null);
const exportDialog = useRef(null); const exportDialog = useRef(null);
const visitRef = useRef(null);
const [visitDialog, setVisitDialog] = useState(null); // { city, visit, opened }
const [tab, setTab] = useState(() => { try { return localStorage.getItem(TAB_KEY) || 'orte'; } catch { return 'orte'; } });
const [zoom, setZoom] = useState(null);
useEffect(() => { useEffect(() => {
fetch('/maps/countries-50m.json') fetch('/maps/countries-50m.json')
@@ -60,7 +69,11 @@ export default function Reisen() {
return () => document.removeEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey);
}, []); }, []);
const cities = useMemo(() => data?.cities || [], [data]); // Eigene Städte: besucht und Wunschliste; "cities" meint überall die besuchten
const own = useMemo(() => data?.cities || [], [data]);
const cities = useMemo(() => own.filter((c) => !c.wish), [own]);
const wishes = useMemo(() => own.filter((c) => c.wish), [own]);
const visits = useMemo(() => data?.visits || [], [data]);
// Farbe pro Person fest nach Reihenfolge (alphabetisch), damit sie beim Ein-/Ausblenden gleich bleibt // Farbe pro Person fest nach Reihenfolge (alphabetisch), damit sie beim Ein-/Ausblenden gleich bleibt
const others = useMemo( const others = useMemo(
() => (data?.others || []).map((person, i) => ({ ...person, color: (i % OTHER_COLORS) + 1 })), () => (data?.others || []).map((person, i) => ({ ...person, color: (i % OTHER_COLORS) + 1 })),
@@ -72,12 +85,12 @@ export default function Reisen() {
: []), : []),
[others, showOthers, hiddenPeople], [others, showOthers, hiddenPeople],
); );
const allCities = useMemo(() => [...cities, ...otherCities], [cities, otherCities]); const allCities = useMemo(() => [...own, ...otherCities], [own, otherCities]);
function toggleOthers() { function toggleOthers() {
const next = !showOthers; const next = !showOthers;
setShowOthers(next); setShowOthers(next);
if (!next && selected?.type === 'city' && !cities.some((c) => c.id === selected.key)) setSelected(null); if (!next && selected?.type === 'city' && !own.some((c) => c.id === selected.key)) setSelected(null);
saveFlag(OTHERS_KEY, next); saveFlag(OTHERS_KEY, next);
} }
@@ -104,8 +117,23 @@ export default function Reisen() {
} }
}, [allCities, wide]); }, [allCities, wide]);
async function addCity(result) { function chooseTab(next) {
const next = await run('POST', '/api/reisen/staedte', result); setTab(next);
try { localStorage.setItem(TAB_KEY, next); } catch { /* ignorieren */ }
}
function openVisit(city, visit = null) {
setVisitDialog({ city, visit, opened: Date.now() });
requestAnimationFrame(() => visitRef.current?.showModal());
}
function showCity(city) {
select({ type: 'city', key: city.id });
document.querySelector('.travel-map')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
async function addCity(result, wish = false) {
const next = await run('POST', '/api/reisen/staedte', { ...result, wish });
const city = next?.cities.find((c) => c.geonameId === result.geonameId); const city = next?.cities.find((c) => c.geonameId === result.geonameId);
if (city) { if (city) {
setSelected({ type: 'city', key: city.id }); setSelected({ type: 'city', key: city.id });
@@ -121,7 +149,7 @@ export default function Reisen() {
<div> <div>
<h1>Reisen</h1> <h1>Reisen</h1>
<p className="lead"> <p className="lead">
{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. {' '}· Klick auf ein Land oder einen Punkt zeigt Infos.
</p> </p>
</div> </div>
@@ -133,7 +161,7 @@ export default function Reisen() {
<ExportDialog ref={exportDialog} shapes={shapes} visited={visited} cities={cities} /> <ExportDialog ref={exportDialog} shapes={shapes} visited={visited} cities={cities} />
)} )}
<CitySearch cities={cities} onAdd={addCity} busy={busy} /> <CitySearch cities={own} onAdd={addCity} busy={busy} />
{error && <p className="error travel-error" role="alert">{error}</p>} {error && <p className="error travel-error" role="alert">{error}</p>}
{data && ( {data && (
@@ -175,7 +203,7 @@ export default function Reisen() {
ref={map} ref={map}
countries={shapes} countries={shapes}
visited={visited} visited={visited}
cities={cities} cities={own}
otherCities={otherCities} otherCities={otherCities}
selected={selected} selected={selected}
onSelect={(sel) => select(sel)} onSelect={(sel) => select(sel)}
@@ -195,8 +223,11 @@ export default function Reisen() {
city={selectedCity} city={selectedCity}
cities={cities} cities={cities}
otherCities={otherCities} otherCities={otherCities}
visits={visits}
manual={data?.countries || []} manual={data?.countries || []}
busy={busy} busy={busy}
onVisit={openVisit}
onWishDone={(city) => run('PATCH', `/api/reisen/staedte/${city.id}`, { wish: false })}
onSelect={select} onSelect={select}
onClose={() => setSelected(null)} onClose={() => setSelected(null)}
onMark={(code, mark) => run(mark ? 'PUT' : 'DELETE', `/api/reisen/laender/${encodeURIComponent(code)}`)} onMark={(code, mark) => run(mark ? 'PUT' : 'DELETE', `/api/reisen/laender/${encodeURIComponent(code)}`)}
@@ -209,14 +240,45 @@ export default function Reisen() {
</div> </div>
{data && ( {data && (
<VisitedList <div className="segmented travel-tabs" role="tablist" aria-label="Ansicht">
visited={visitedList} {[['orte', 'Besuchte Orte'], ['zeitleiste', 'Zeitleiste'], ['statistik', 'Statistik'], ['wunsch', `Wunschliste (${wishes.length})`]].map(([key, label]) => (
cities={cities} <button key={key} type="button" role="tab" aria-selected={tab === key} onClick={() => chooseTab(key)}>{label}</button>
manual={data.countries} ))}
selected={selected} </div>
onSelect={select} )}
{data && tab === 'orte' && (
<VisitedList visited={visitedList} cities={cities} manual={data.countries} selected={selected} onSelect={select} />
)}
{data && tab === 'zeitleiste' && (
<Timeline visits={visits} cities={own} onSelectCity={showCity} onEdit={openVisit} onPhoto={setZoom} />
)}
{data && tab === 'statistik' && (
<TravelStats visited={visited} cities={cities} visits={visits} wishes={wishes.length} onSelectCity={showCity} />
)}
{data && tab === 'wunsch' && (
wishes.length ? (
<ul className="chip-list travel-wishes">
{wishes.map((c) => (
<li key={c.id}><button type="button" className="chip" onClick={() => showCity(c)}>☆ {c.name} <small>{countryName(c.country)}</small></button></li>
))}
</ul>
) : <p className="hint travel-empty">Noch keine Wünsche. Oben bei der Suche „Wunschliste“ wählen und eine Stadt hinzufügen.</p>
)}
{visitDialog && (
<VisitDialog
key={visitDialog.opened}
ref={visitRef}
city={visitDialog.city}
visit={visitDialog.visit}
busy={busy}
onSave={(body) => (visitDialog.visit
? run('PUT', `/api/reisen/besuche/${visitDialog.visit.id}`, body)
: run('POST', `/api/reisen/staedte/${visitDialog.city.id}/besuche`, body))}
onDelete={() => run('DELETE', `/api/reisen/besuche/${visitDialog.visit.id}`)}
/> />
)} )}
<Lightbox src={zoom} onClose={() => setZoom(null)} />
<p className="hint travel-credits"> <p className="hint travel-credits">
Karte: Natural Earth / world-atlas · Länderdaten: world-countries (ODbL) · Orte: GeoNames über Open-Meteo (CC BY 4.0) Karte: Natural Earth / world-atlas · Länderdaten: world-countries (ODbL) · Orte: GeoNames über Open-Meteo (CC BY 4.0)
+40
View File
@@ -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-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)); } .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; }