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('kbb.lang', detectLang(), isLang); const [profile, setProfile] = useLocalState('kbb.profile', null, (v): v is Profile | null => v === null || isProfile(v)); const [userReports, setUserReports] = useLocalState('kbb.reports', [], (v): v is UserReport[] => Array.isArray(v)); const [view, setView] = useState(profile ? 'places' : 'profile'); const [pane, setPane] = useState<'list' | 'map'>('list'); const online = useOnline(); const i18n = useMemo(() => { 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(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; }) .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>({}); const places = useMemo( () => (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>(); 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(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(null); const selected = selectedId ? places.find((p) => p.id === selectedId) ?? null : null; const [refresh, setRefresh] = useState({ kind: 'idle' }); const [simulateOutage, setSimulateOutage] = useState(false); const detailHeading = useRef(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) => setUserReports((cur) => [...cur, { ...r, id: newId(), createdAt: new Date().toISOString() }]); // ---- route state ---- const [routeForm, setRouteForm] = useState({ fromText: '', toText: '', fromPoint: null }); const [route, setRoute] = useState(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(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 (
{t('skip')}

{t('banner.proto')}{' '} {snap ? t('banner.data', { date: i18n.date(snap.meta.fetchedAt.slice(0, 10)) }) : ''}

{t('app.name')}

{(['pl', 'en'] as const).map((l) => ( ))}
{split && (
    {(['list', 'map'] as const).map((p) => (
  • ))}
)}
{loadError &&
{t('common.loadError')}
} {!loadError && !ready && view !== 'profile' && !needsProfile && (
{t('common.loading')}
)} {(view === 'profile' || needsProfile) && ( { setProfile(p); setView('places'); setResetDone(false); }} /> )} {view === 'about' && !needsProfile && snap && ( )} {split && ready && ( <>
{view === 'places' && (selected ? ( routeTo(selected)} onShowOnMap={() => setPane('map')} onRefresh={() => void doRefresh(selected)} onAddReport={addReport} onRemoveReport={(id) => setUserReports((cur) => cur.filter((r) => r.id !== id))} /> ) : ( ))} {view === 'route' && ( { setFocus({ at, n: Date.now() }); setPane('map'); }} /> )}

{t('footer.attr')}

)}
); }