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:
1 parent
9835f5e69e
commit
bbf740603e
16 files changed
+695
-32
No files matched your search
@@ -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 (
|
||||
<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">
|
||||
<label className="sr-only" htmlFor="city-search">Stadt hinzufügen</label>
|
||||
<div className="prompt-input">
|
||||
@@ -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 }) {
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <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') {
|
||||
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 }) {
|
||||
<div className="info-visit">
|
||||
{city.owner ? (
|
||||
<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>
|
||||
@@ -166,6 +184,22 @@ function CityInfo({ city, same, onSelect, onClose, onRemove, busy }) {
|
||||
</>
|
||||
)}
|
||||
</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 && (
|
||||
<div className="info-others">
|
||||
<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 { 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
|
||||
|
||||
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 CLOSED_KEY = 'p42.reisenKontinenteZu';
|
||||
|
||||
|
||||
@@ -140,11 +140,11 @@ export default function WorldMap({ ref, countries, visited, cities, otherCities
|
||||
return (
|
||||
<g key={city.id} transform={`translate(${p[0]},${p[1]})`}>
|
||||
<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}
|
||||
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" />
|
||||
{isSelected && <circle className="map-city-ring" r="9" />}
|
||||
<circle className="map-city-dot" r="4" />
|
||||
|
||||
@@ -10,3 +10,19 @@ export const people = (n) => (n >= 1e6 ? `${(n / 1e6).toLocaleString('de-DE', {
|
||||
export function countryName(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));
|
||||
}
|
||||
@@ -2,8 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { feature } from 'topojson-client';
|
||||
import CitySearch from '../components/reisen/CitySearch.jsx';
|
||||
import ExportDialog from '../components/reisen/ExportDialog.jsx';
|
||||
import Lightbox from '../components/projekte/Lightbox.jsx';
|
||||
import InfoCard from '../components/reisen/InfoCard.jsx';
|
||||
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 WorldMap from '../components/reisen/WorldMap.jsx';
|
||||
import { loadFlag, loadSet, saveFlag, saveSet } from '../storage.js';
|
||||
@@ -18,6 +22,7 @@ const SPECIAL = {
|
||||
'Siachen Glacier': 'X-Siachen-Gletscher',
|
||||
};
|
||||
|
||||
const TAB_KEY = 'p42.reisenAnsicht';
|
||||
const OTHERS_KEY = 'p42.reisenAndere';
|
||||
const HIDDEN_KEY = 'p42.reisenAndereAus';
|
||||
const OTHER_COLORS = 6;
|
||||
@@ -32,6 +37,10 @@ export default function Reisen() {
|
||||
const [hiddenPeople, setHiddenPeople] = useState(() => loadSet(HIDDEN_KEY));
|
||||
const map = 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(() => {
|
||||
fetch('/maps/countries-50m.json')
|
||||
@@ -60,7 +69,11 @@ export default function Reisen() {
|
||||
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
|
||||
const others = useMemo(
|
||||
() => (data?.others || []).map((person, i) => ({ ...person, color: (i % OTHER_COLORS) + 1 })),
|
||||
@@ -72,12 +85,12 @@ export default function Reisen() {
|
||||
: []),
|
||||
[others, showOthers, hiddenPeople],
|
||||
);
|
||||
const allCities = useMemo(() => [...cities, ...otherCities], [cities, otherCities]);
|
||||
const allCities = useMemo(() => [...own, ...otherCities], [own, otherCities]);
|
||||
|
||||
function toggleOthers() {
|
||||
const next = !showOthers;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -104,8 +117,23 @@ export default function Reisen() {
|
||||
}
|
||||
}, [allCities, wide]);
|
||||
|
||||
async function addCity(result) {
|
||||
const next = await run('POST', '/api/reisen/staedte', result);
|
||||
function chooseTab(next) {
|
||||
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);
|
||||
if (city) {
|
||||
setSelected({ type: 'city', key: city.id });
|
||||
@@ -121,7 +149,7 @@ export default function Reisen() {
|
||||
<div>
|
||||
<h1>Reisen</h1>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -133,7 +161,7 @@ export default function Reisen() {
|
||||
<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>}
|
||||
|
||||
{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() {
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<VisitedList
|
||||
visited={visitedList}
|
||||
cities={cities}
|
||||
manual={data.countries}
|
||||
selected={selected}
|
||||
onSelect={select}
|
||||
<div className="segmented travel-tabs" role="tablist" aria-label="Ansicht">
|
||||
{[['orte', 'Besuchte Orte'], ['zeitleiste', 'Zeitleiste'], ['statistik', 'Statistik'], ['wunsch', `Wunschliste (${wishes.length})`]].map(([key, label]) => (
|
||||
<button key={key} type="button" role="tab" aria-selected={tab === key} onClick={() => chooseTab(key)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{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">
|
||||
Karte: Natural Earth / world-atlas · Länderdaten: world-countries (ODbL) · Orte: GeoNames über Open-Meteo (CC BY 4.0)
|
||||
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user