Files
p42.f-w/frontend/src/components/reisen/VisitedList.jsx
T
guyg1474andClaude Opus 5.5 bbf740603e 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>
2026-10-15 19:25:07 +02:00

138 lines
5.9 KiB
React

import { useEffect, useState } from 'react';
import { loadSet, saveSet } from '../../storage.js';
import { BY_CODE, CONTINENTS, continentOf, countryName } from './laender.js';
// Besuchte Orte: Kontinent → Land (ausklappbar) → Städte; auf-/zugeklappt wird pro Browser gemerkt
const OPEN_KEY = 'p42.reisenOffen';
const CLOSED_KEY = 'p42.reisenKontinenteZu';
const loadOpen = () => loadSet(OPEN_KEY);
export default function VisitedList({ visited, cities, manual, selected, onSelect }) {
const [open, setOpen] = useState(loadOpen);
const save = (next) => {
setOpen(next);
saveSet(OPEN_KEY, next);
};
const toggle = (code) => {
const next = new Set(open);
if (next.has(code)) next.delete(code); else next.add(code);
save(next);
};
// Kontinente: standardmäßig offen, zugeklappte werden gemerkt
const [closed, setClosed] = useState(() => loadSet(CLOSED_KEY));
const saveClosed = (next) => {
setClosed(next);
saveSet(CLOSED_KEY, next);
};
const toggleContinent = (name) => {
const next = new Set(closed);
if (next.has(name)) next.delete(name); else next.add(name);
saveClosed(next);
};
// Wird eine Stadt (z. B. auf der Karte) gewählt, ihr Land aufklappen
const selectedCountry = selected?.type === 'city' ? cities.find((c) => c.id === selected.key)?.country : null;
useEffect(() => {
if (selectedCountry && !open.has(selectedCountry)) save(new Set(open).add(selectedCountry));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCountry]);
// Ein gewähltes Land oder eine gewählte Stadt macht ihren Kontinent sichtbar
const selectedCode = selected?.type === 'country' ? selected.key : selectedCountry;
const selectedContinent = selectedCode ? continentOf(selectedCode) : null;
useEffect(() => {
if (selectedContinent && closed.has(selectedContinent)) {
const next = new Set(closed);
next.delete(selectedContinent);
saveClosed(next);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedContinent, selected]);
if (!visited.length) {
return <p className="hint travel-empty">Noch keine Orte. Füge eine Stadt hinzu oder klick ein Land auf der Karte an und markiere es.</p>;
}
const groups = [...CONTINENTS, 'Sonstige']
.map((name) => ({ name, countries: visited.filter((code) => continentOf(code) === name) }))
.filter((g) => g.countries.length);
return (
<div className="visited-list">
{groups.map((group) => {
const cityCount = cities.filter((c) => continentOf(c.country) === group.name).length;
const contOpen = !closed.has(group.name);
const id = `cont-${group.name}`;
return (
<section key={group.name} className={`visited-continent${contOpen ? '' : ' closed'}`} aria-labelledby={id}>
<h2 className="section-title">
<button
type="button"
id={id}
className="continent-toggle"
aria-expanded={contOpen}
aria-controls={`${id}-list`}
onClick={() => toggleContinent(group.name)}
>
<span className="continent-arrow" aria-hidden="true">▸</span>
{group.name}
<small className="visited-count">
{group.countries.length} {group.countries.length === 1 ? 'Land' : 'Länder'} · {cityCount} {cityCount === 1 ? 'Stadt' : 'Städte'}
</small>
</button>
</h2>
{contOpen && (
<ul className="visited-countries" id={`${id}-list`}>
{group.countries.map((code) => {
const list = cities.filter((c) => c.country === code);
const isOpen = open.has(code) && list.length > 0;
const active = selected?.type === 'country' && selected.key === code;
return (
<li key={code} className={`visited-country${isOpen ? ' open' : ''}${active ? ' active' : ''}`}>
<div className="visited-row">
<button
type="button"
className="visited-toggle"
aria-expanded={isOpen}
aria-controls={`cities-${code}`}
disabled={!list.length}
onClick={() => toggle(code)}
aria-label={list.length ? `Städte in ${countryName(code)} ${isOpen ? 'zuklappen' : 'aufklappen'}` : `Keine Städte in ${countryName(code)}`}
>
<span aria-hidden="true">{list.length ? '▸' : '·'}</span>
</button>
<button type="button" className="visited-name" onClick={() => onSelect({ type: 'country', key: code })}>
<span className="visited-flag" aria-hidden="true">{BY_CODE.get(code)?.flag}</span>
{countryName(code)}
</button>
<span className="visited-meta">
{list.length ? `${list.length} ${list.length === 1 ? 'Stadt' : 'Städte'}` : manual.includes(code) ? 'markiert' : ''}
</span>
</div>
{isOpen && (
<ul className="chip-list visited-cities" id={`cities-${code}`}>
{list.map((c) => (
<li key={c.id}>
<button type="button" className={`chip${selected?.type === 'city' && selected.key === c.id ? ' active' : ''}`} onClick={() => onSelect({ type: 'city', key: c.id })}>
{c.name}
</button>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
)}
</section>
);
})}
</div>
);
}