300 lines
13 KiB
TypeScript
300 lines
13 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|