- useLiveData: Laden, Nachladen (sichtbarer Tab) und Speichern mit Fehlerbehandlung an einer Stelle; genutzt von Vorrat, Reisen, Kanban-Übersicht und Kanban-Board (Monitoring/Benutzer arbeiten anders) - components/<bereich>/: haushalt, reisen, kanban, monitoring, projekte, start, design (per git mv, Historie bleibt) - Reisen.jsx 783 → 227 Zeilen (CitySearch, VisitedList, InfoCard, ExportDialog, laender.js), Haushalt.jsx 634 → 193 (ListPanel, ListSwitcher, ListDialog, ProductsDialog), KanbanBoard.jsx 452 → 277 (board.js, TaskCard, TaskDialog) - storage.js für Einstellungen pro Browser; Städteliste in Reisen memoisiert - Verhalten unverändert (Browser-Durchlauf aller Seiten und Abläufe) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
182 lines
7.4 KiB
React
182 lines
7.4 KiB
React
import { useEffect, useState } from 'react';
|
|
import { api } from '../../api.js';
|
|
import { BY_CODE, countryName, num, people } from './laender.js';
|
|
|
|
// Info-Kasten unten links auf der Karte: Land oder Stadt mit Fakten und Wikipedia-Text
|
|
|
|
const languageName = new Intl.DisplayNames(['de'], { type: 'language' });
|
|
const currencyName = new Intl.DisplayNames(['de'], { type: 'currency' });
|
|
const safe = (fn, v) => { try { return fn(v); } catch { return v; } };
|
|
|
|
function localTime(timezone) {
|
|
try {
|
|
return new Intl.DateTimeFormat('de-DE', { timeZone: timezone, hour: '2-digit', minute: '2-digit' }).format(new Date());
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const infoCache = new Map();
|
|
|
|
function useInfo(params) {
|
|
const query = new URLSearchParams(params).toString();
|
|
const [info, setInfo] = useState(() => infoCache.get(query) || null);
|
|
useEffect(() => {
|
|
if (infoCache.has(query)) { setInfo(infoCache.get(query)); return; }
|
|
let alive = true;
|
|
api('GET', `/api/reisen/info?${query}`)
|
|
.then((d) => { if (!d.offline) infoCache.set(query, d); if (alive) setInfo(d); })
|
|
.catch(() => alive && setInfo({ offline: true }));
|
|
return () => { alive = false; };
|
|
}, [query]);
|
|
return info;
|
|
}
|
|
|
|
export default function InfoCard({ selected, city, cities, otherCities, manual, busy, onSelect, onClose, onMark, onRemoveCity }) {
|
|
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} />;
|
|
}
|
|
if (selected.type === 'country') {
|
|
return (
|
|
<CountryInfo
|
|
code={selected.key}
|
|
cities={cities.filter((c) => c.country === selected.key)}
|
|
otherCities={otherCities.filter((c) => c.country === selected.key)}
|
|
manual={manual.includes(selected.key)}
|
|
onSelect={onSelect}
|
|
onClose={onClose}
|
|
onMark={onMark}
|
|
busy={busy}
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function Extract({ info }) {
|
|
if (!info) return <p className="hint">Lade Infos …</p>;
|
|
if (info.offline) return <p className="hint">Infotext gerade nicht erreichbar.</p>;
|
|
if (!info.extract) return null;
|
|
return (
|
|
<>
|
|
<p className="info-extract">{info.extract}</p>
|
|
{info.url && <a className="info-more" href={info.url} target="_blank" rel="noopener noreferrer">Mehr bei Wikipedia ↗</a>}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function CountryInfo({ code, cities, otherCities, manual, onSelect, onClose, onMark, busy }) {
|
|
const c = BY_CODE.get(code);
|
|
const name = countryName(code);
|
|
const info = useInfo({ kind: 'land', name: c?.name || name, code });
|
|
const visited = manual || cities.length > 0;
|
|
const facts = [
|
|
(info?.capital || c?.capital?.length) && ['Hauptstadt', info?.capital || c.capital.join(', ')],
|
|
info?.population && ['Einwohner', `${people(info.population)}${info.populationYear ? ` (${info.populationYear})` : ''}`],
|
|
c?.area && ['Fläche', `${num(Math.round(c.area))} km²`],
|
|
c?.subregion && ['Region', c.subregion],
|
|
c?.languages?.length && ['Sprachen', c.languages.map((l) => safe((x) => languageName.of(x), l)).join(', ')],
|
|
c?.currencies?.length && ['Währung', c.currencies.map((x) => safe((y) => currencyName.of(y), x)).join(', ')],
|
|
].filter(Boolean);
|
|
|
|
return (
|
|
<aside className="info-card" aria-label={`Infos zu ${name}`}>
|
|
<header className="info-head">
|
|
<h2>{c?.flag} {name}</h2>
|
|
<button className="link-btn" type="button" onClick={onClose} aria-label="Infos schließen">✕</button>
|
|
</header>
|
|
{c?.official && c.official !== name && <p className="info-sub">{c.official}</p>}
|
|
<dl className="info-facts">
|
|
{facts.map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}
|
|
</dl>
|
|
<Extract info={info} />
|
|
<div className="info-visit">
|
|
<span className={`badge ${visited ? 'ok' : 'muted'}`}>{visited ? 'Besucht' : 'Noch nicht besucht'}</span>
|
|
{cities.length > 0 ? (
|
|
<span className="info-cities">
|
|
{cities.map((city) => (
|
|
<button key={city.id} type="button" className="chip" onClick={() => onSelect({ type: 'city', key: city.id })}>{city.name}</button>
|
|
))}
|
|
</span>
|
|
) : (
|
|
<button className="btn small" type="button" disabled={busy} onClick={() => onMark(code, !manual)}>
|
|
{manual ? 'Markierung entfernen' : 'Als besucht markieren'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{cities.length > 0 && manual && (
|
|
<button className="link-btn" type="button" disabled={busy} onClick={() => onMark(code, false)}>Zusätzliche Markierung entfernen</button>
|
|
)}
|
|
{otherCities.length > 0 && (
|
|
<div className="info-others">
|
|
<span className="hint">Andere waren hier:</span>
|
|
<span className="info-cities">
|
|
{otherCities.map((c) => <OtherChip key={c.id} city={c} onSelect={onSelect} />)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function OtherChip({ city, onSelect, label = city.name }) {
|
|
return (
|
|
<button type="button" className="chip travel-person active" style={{ '--dot': `var(--other-${city.color})` }} onClick={() => onSelect({ type: 'city', key: city.id })}>
|
|
<span className="legend-dot" aria-hidden="true" />
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function CityInfo({ city, same, onSelect, onClose, onRemove, 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);
|
|
const facts = [
|
|
city.admin1 && ['Region', city.admin1],
|
|
city.population && ['Einwohner', people(city.population)],
|
|
city.elevation != null && ['Höhe', `${num(Math.round(city.elevation))} m`],
|
|
time && ['Ortszeit', `${time} Uhr`],
|
|
].filter(Boolean);
|
|
|
|
return (
|
|
<aside className="info-card" aria-label={`Infos zu ${city.name}`}>
|
|
<header className="info-head">
|
|
<h2>{city.name}</h2>
|
|
<button className="link-btn" type="button" onClick={onClose} aria-label="Infos schließen">✕</button>
|
|
</header>
|
|
<p className="info-sub">
|
|
<button type="button" className="chip" onClick={() => onSelect({ type: 'country', key: city.country })}>
|
|
{c?.flag} {countryName(city.country)}
|
|
</button>
|
|
</p>
|
|
<dl className="info-facts">
|
|
{facts.map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}
|
|
</dl>
|
|
<Extract info={info} />
|
|
<div className="info-visit">
|
|
{city.owner ? (
|
|
<span className="badge travel-owner" style={{ '--dot': `var(--other-${city.color})` }}>Besucht von {city.owner.name}</span>
|
|
) : (
|
|
<>
|
|
<span className="badge ok">Besucht</span>
|
|
<button className="link-btn danger" type="button" disabled={busy} onClick={() => onRemove(city)}>Stadt entfernen</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
{same.length > 0 && (
|
|
<div className="info-others">
|
|
<span className="hint">Auch hier:</span>
|
|
<span className="info-cities">
|
|
{same.map((c) => (c.owner
|
|
? <OtherChip key={c.id} city={c} onSelect={onSelect} label={c.owner.name} />
|
|
: <button key={c.id} type="button" className="chip" onClick={() => onSelect({ type: 'city', key: c.id })}>Du</button>))}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</aside>
|
|
);
|
|
}
|