Frontend: gemeinsamer Hook useLiveData, Komponenten nach Bereichen, große Seiten aufgeteilt

- 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>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 18:14:32 +02:00
1 parent 4aba48ce51
commit fa58708e2d
41 files changed
+1264 -1241

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
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>
);
}