GdzieWjade/src/App.tsx

300 lines
13 KiB
TypeScript
Raw Normal View History

2026-10-03 13:33:50 +02:00
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AboutView } from './components/AboutView';
import { MapView } from './components/MapView';
import { PlaceDetail, type RefreshState } from './components/PlaceDetail';
import { DEFAULT_FILTERS, PlacesView, filterPlaces, type Filters } from './components/PlacesView';
import { ProfileView } from './components/ProfileView';
import { RouteView, type RouteForm } from './components/RouteView';
import { Alert, Icon } from './components/ui';
import { assessPlace, buildIndex } from './lib/assess';
import { DEMO_PLACES, DEMO_REPORTS } from './lib/demo';
import type { LatLon } from './lib/geo';
import { I18nContext, formatDate, makeT, type I18n } from './lib/i18n';
import { buildLabels } from './lib/labels';
import { refreshPlace } from './lib/live';
import { isProfile } from './lib/profile';
import { buildGraph, type RouteResult } from './lib/route';
import { clearLocal, newId, useLocalState, useOnline } from './lib/store';
import type { Lang, Place, Profile, Snapshot, UserReport } from './lib/types';
type View = 'places' | 'route' | 'about' | 'profile';
const isLang = (v: unknown): v is Lang => v === 'pl' || v === 'en';
const detectLang = (): Lang => (navigator.language.toLowerCase().startsWith('pl') ? 'pl' : 'en');
const KEYS = ['kbb.profile', 'kbb.reports'];
/** Verified against the snapshot: see README "Demo script". */
const PRESETS: { id: string; fromId: string; toId: string }[] = [];
export default function App() {
const [lang, setLang] = useLocalState<Lang>('kbb.lang', detectLang(), isLang);
const [profile, setProfile] = useLocalState<Profile | null>('kbb.profile', null, (v): v is Profile | null => v === null || isProfile(v));
const [userReports, setUserReports] = useLocalState<UserReport[]>('kbb.reports', [], (v): v is UserReport[] => Array.isArray(v));
const [view, setView] = useState<View>(profile ? 'places' : 'profile');
const [pane, setPane] = useState<'list' | 'map'>('list');
const online = useOnline();
const i18n = useMemo<I18n>(() => {
const { t, tm } = makeT(lang);
return { lang, setLang, t, tm, date: (iso) => formatDate(iso, lang) ?? t('fact.dateUnknown') };
}, [lang, setLang]);
const { t } = i18n;
useEffect(() => { document.documentElement.lang = lang; document.title = t('app.name'); }, [lang, t]);
// ---- data ----
const [snap, setSnap] = useState<Snapshot | null>(null);
const [loadError, setLoadError] = useState(false);
useEffect(() => {
const ctrl = new AbortController();
fetch(`${import.meta.env.BASE_URL}data/kk-snapshot.json`, { signal: ctrl.signal })
.then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json() as Promise<Snapshot>; })
.then(setSnap)
.catch((e: unknown) => { if (!(e instanceof DOMException && e.name === 'AbortError')) setLoadError(true); });
return () => ctrl.abort();
}, []);
const index = useMemo(() => (snap ? buildIndex(snap) : null), [snap]);
const graph = useMemo(() => (snap ? buildGraph(snap) : null), [snap]);
const [overrides, setOverrides] = useState<Record<string, Place>>({});
const places = useMemo<Place[]>(
() => (snap ? [...DEMO_PLACES, ...snap.places.map((p) => overrides[p.id] ?? p)] : []),
[snap, overrides],
);
const reports = useMemo(() => [...DEMO_REPORTS, ...userReports], [userReports]);
const assessments = useMemo(() => {
const m = new Map<string, ReturnType<typeof assessPlace>>();
if (index && profile) for (const p of places) m.set(p.id, assessPlace(p, profile, index, reports));
return m;
}, [places, profile, index, reports]);
const labels = useMemo(() => buildLabels(places), [places]);
// ---- places view state ----
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
const results = useMemo(() => filterPlaces(places, assessments, filters), [places, assessments, filters]);
const mapPlaces = useMemo(
() => results.flatMap((place) => { const a = assessments.get(place.id); return a ? [{ place, verdict: a.verdict }] : []; }),
[results, assessments],
);
const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = selectedId ? places.find((p) => p.id === selectedId) ?? null : null;
const [refresh, setRefresh] = useState<RefreshState>({ kind: 'idle' });
const [simulateOutage, setSimulateOutage] = useState(false);
const detailHeading = useRef<HTMLHeadingElement>(null);
const select = useCallback((id: string | null) => {
setSelectedId(id);
setRefresh({ kind: 'idle' });
if (id) setPane('list');
}, []);
useEffect(() => { if (selectedId) detailHeading.current?.focus(); }, [selectedId]);
const back = () => {
const id = selectedId;
select(null);
if (id) setTimeout(() => document.getElementById(`place-${id}`)?.focus(), 0);
};
const doRefresh = async (p: Place) => {
setRefresh({ kind: 'loading' });
try {
if (simulateOutage) throw new Error('simulated outage');
const fresh = await refreshPlace(p.id);
setOverrides((o) => ({ ...o, [p.id]: fresh }));
setRefresh({ kind: 'ok', at: new Date().toISOString() });
} catch {
setRefresh({ kind: 'fail' });
}
};
const addReport = (r: Omit<UserReport, 'id' | 'createdAt'>) =>
setUserReports((cur) => [...cur, { ...r, id: newId(), createdAt: new Date().toISOString() }]);
// ---- route state ----
const [routeForm, setRouteForm] = useState<RouteForm>({ fromText: '', toText: '', fromPoint: null });
const [route, setRoute] = useState<RouteResult | null>(null);
const [routeEnds, setRouteEnds] = useState<{ from: LatLon; to: LatLon } | null>(null);
const [focus, setFocus] = useState<{ at: LatLon; n: number } | null>(null);
const onResult = (r: RouteResult | null, from: LatLon | null, to: LatLon | null) => {
setRoute(r);
setRouteEnds(from && to ? { from, to } : null);
if (r && r.status !== 'none') setPane('map');
};
useEffect(() => { setRoute(null); setRouteEnds(null); }, [profile]);
const routeTo = (p: Place) => {
setRouteForm((f) => ({ ...f, toText: labels.labelOf.get(p.id) ?? p.name ?? '' }));
setRoute(null);
setRouteEnds(null);
setView('route');
setPane('list');
};
// ---- shell ----
const mainRef = useRef<HTMLElement>(null);
const first = useRef(true);
useEffect(() => {
if (first.current) { first.current = false; return; }
mainRef.current?.focus();
}, [view]);
const [resetDone, setResetDone] = useState(false);
const reset = () => {
clearLocal(KEYS);
setProfile(null);
setUserReports([]);
setResetDone(true);
setView('profile');
};
const nav: { id: View; label: string }[] = [
{ id: 'places', label: t('nav.places') },
{ id: 'route', label: t('nav.route') },
{ id: 'about', label: t('nav.about') },
{ id: 'profile', label: t('nav.profile') },
];
const needsProfile = !profile;
const split = (view === 'places' || view === 'route') && !needsProfile;
const ready = snap && index && graph && profile;
return (
<I18nContext.Provider value={i18n}>
<div className="kbb-app">
<a className="usa-skipnav" href="#main">{t('skip')}</a>
<section className="usa-site-alert usa-site-alert--info usa-site-alert--slim" aria-label={t('banner.proto')}>
<div className="usa-alert">
<div className="usa-alert__body">
<p className="usa-alert__text">
<strong>{t('banner.proto')}</strong>{' '}
{snap ? t('banner.data', { date: i18n.date(snap.meta.fetchedAt.slice(0, 10)) }) : ''}
</p>
</div>
</div>
</section>
<header className="bg-white border-bottom-1px border-base-lighter">
<div className="grid-container padding-y-1 display-flex flex-justify flex-align-center flex-wrap">
<p className="margin-0 display-flex flex-align-center text-bold font-sans-lg text-primary-darker">
<Icon name="accessible_forward" size={4} className="margin-right-1" />
{t('app.name')}
</p>
<div role="group" aria-label={t('lang.label')} className="display-flex">
{(['pl', 'en'] as const).map((l) => (
<button
key={l} type="button" lang={l} aria-pressed={lang === l}
className={`usa-button usa-button--small margin-right-0 ${lang === l ? '' : 'usa-button--outline'}`}
onClick={() => setLang(l)}
>
{l.toUpperCase()}
</button>
))}
</div>
</div>
<nav aria-label={t('nav.label')} className="grid-container padding-bottom-1">
<ul className="usa-button-group usa-button-group--segmented margin-0 padding-0">
{nav.map((n) => (
<li className="usa-button-group__item" key={n.id}>
<button
type="button" aria-current={view === n.id ? 'page' : undefined}
className={`usa-button usa-button--small ${view === n.id ? '' : 'usa-button--outline'}`}
onClick={() => setView(n.id)}
>
{n.label}
</button>
</li>
))}
</ul>
</nav>
</header>
{split && (
<div className="desktop:display-none bg-base-lightest border-bottom-1px border-base-lighter padding-1">
<div role="group" aria-label={t('view.label')} className="grid-container padding-0">
<ul className="usa-button-group usa-button-group--segmented margin-0 padding-0">
{(['list', 'map'] as const).map((p) => (
<li className="usa-button-group__item" key={p}>
<button type="button" aria-pressed={pane === p} className={`usa-button usa-button--small ${pane === p ? '' : 'usa-button--outline'}`} onClick={() => setPane(p)}>
<Icon name={p === 'list' ? 'list' : 'map'} size={3} className="margin-right-05" />
{t(p === 'list' ? 'view.list' : 'view.map')}
</button>
</li>
))}
</ul>
</div>
</div>
)}
<main id="main" ref={mainRef} tabIndex={-1} className={`kbb-main ${split ? `kbb-main--split is-${pane}` : ''}`}>
{loadError && <div className="grid-container padding-y-4"><Alert kind="error" role="alert">{t('common.loadError')}</Alert></div>}
{!loadError && !ready && view !== 'profile' && !needsProfile && (
<div className="grid-container padding-y-4" role="status">{t('common.loading')}</div>
)}
{(view === 'profile' || needsProfile) && (
<ProfileView
key={profile ? 'edit' : 'new'}
initial={profile}
onSave={(p) => { setProfile(p); setView('places'); setResetDone(false); }}
/>
)}
{view === 'about' && !needsProfile && snap && (
<AboutView
snapshotDate={snap.meta.fetchedAt.slice(0, 10)} counts={snap.meta.counts}
simulateOutage={simulateOutage} onSimulate={setSimulateOutage} onReset={reset} resetDone={resetDone}
/>
)}
{split && ready && (
<>
<div className="kbb-panel">
{view === 'places' && (selected ? (
<PlaceDetail
ref={detailHeading}
place={selected}
assessment={assessments.get(selected.id) ?? assessPlace(selected, profile, index)}
reports={reports}
snapshotDate={snap.meta.fetchedAt.slice(0, 10)}
refresh={refresh}
onBack={back}
onRouteTo={() => routeTo(selected)}
onShowOnMap={() => setPane('map')}
onRefresh={() => void doRefresh(selected)}
onAddReport={addReport}
onRemoveReport={(id) => setUserReports((cur) => cur.filter((r) => r.id !== id))}
/>
) : (
<PlacesView
profile={profile} filters={filters} onFilters={setFilters} results={results}
assessments={assessments} onSelect={select}
/>
))}
{view === 'route' && (
<RouteView
graph={graph} labels={labels} profile={profile} bbox={snap.meta.bbox}
form={routeForm} onForm={setRouteForm} result={route} onResult={onResult}
presets={PRESETS} onFocus={(at) => { setFocus({ at, n: Date.now() }); setPane('map'); }}
/>
)}
<p className="font-sans-3xs text-base-dark margin-top-4">{t('footer.attr')}</p>
</div>
<MapView
key={lang}
places={mapPlaces}
showPlaces={view === 'places'}
selectedId={view === 'places' ? selectedId : null}
onSelect={select}
route={view === 'route' ? route : null}
routeEnds={view === 'route' ? routeEnds : null}
focus={focus}
visible={pane === 'map'}
bbox={snap.meta.bbox}
online={online}
/>
</>
)}
</main>
</div>
</I18nContext.Provider>
);
}