import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AboutView } from './components/AboutView'; import { AccessCard } from './components/AccessCard'; import { DashboardView } from './components/DashboardView'; import { LoginView } from './components/LoginView'; import { MapView } from './components/MapView'; import { PlaceDetail, type ReachState, 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 { LangToggle } from './components/LangToggle'; import { SettingsPanel } from './components/SettingsPanel'; import { SheetHandle, type Sheet } from './components/SheetHandle'; import { Alert, Icon } from './components/ui'; import { assessPlace, buildIndex } from './lib/assess'; import { findAccount, userKey, type Account } from './lib/auth'; import { computeHotspots, computeStats, download, toCsv, toGeoJson, type Hotspot } from './lib/civic'; import { DEMO_PLACES, DEMO_REPORTS } from './lib/demo'; import type { LatLon } from './lib/geo'; import { I18nContext, formatDate, makeT, type I18n, type Key } from './lib/i18n'; import { buildLabels } from './lib/labels'; import { refreshPlace } from './lib/live'; import { withCityOffer } from './lib/osmPlace'; import { DEFAULT_PROFILE, isProfile } from './lib/profile'; import { computeReach, placesInReach, type Reach } from './lib/reach'; import { buildGraph, type RouteOption } from './lib/route'; import { deletePhoto, putPhoto, type ProcessedPhoto } from './lib/photo'; import { NoCreditsError, vetReport } from './lib/vet'; import { GuideBanner } from './components/GuideBanner'; import { findAlternatives } from './lib/route'; import { applySettings, defaultSettings, isSettings, type Settings } from './lib/settings'; import { clearLocal, isMetered, newId, useLocalState, useMedia, useOnline } from './lib/store'; import type { Assessment, Lang, Place, Profile, Snapshot, UserReport, Verdict } from './lib/types'; type View = 'places' | 'route' | 'dashboard' | 'about' | 'profile'; type Decision = { status: 'accepted' | 'rejected'; reviewedAt: string; reviewedBy: string }; const isLang = (v: unknown): v is Lang => v === 'pl' || v === 'en'; const detectLang = (): Lang => (navigator.language.toLowerCase().startsWith('pl') ? 'pl' : 'en'); const isStrOrNull = (v: unknown): v is string | null => v === null || typeof v === 'string'; const isStrArray = (v: unknown): v is string[] => Array.isArray(v) && v.every((x) => typeof x === 'string'); const isObj = (v: unknown): v is T => typeof v === 'object' && v !== null && !Array.isArray(v); /** Real place pairs from the snapshot; the result changes with the profile. Resolved by name at runtime. */ const PRESETS: { id: string; from: string; to: string }[] = [ { id: 'wawel', from: 'Sukiennice', to: 'Zamek Królewski na Wawelu' }, { id: 'kazimierz', from: 'Cinema City Kraków - Galeria Kazimierz', to: 'Sukiennice' }, { id: 'remuh', from: 'Synagoga Remuh', to: 'Sukiennice' }, { id: 'manggha', from: 'Narodowy Stary Teatr imienia Heleny Modrzejewskiej', to: 'Muzeum Sztuki i Techniki Japońskiej Manggha' }, { id: 'schindler', from: 'Sukiennice', to: 'Schody z filmu Lista Schindlera' }, ]; export default function App() { // ---- identity (demo) ---- const [loginLang, setLoginLang] = useLocalState('kbb.lang', detectLang(), isLang); const [session, setSession] = useLocalState('kbb.session', null, isStrOrNull); const account: Account | null = findAccount(session); const uid = account?.id ?? 'none'; const [settings, setSettings] = useLocalState(userKey(uid, 'settings'), defaultSettings(loginLang), isSettings); const [profile, setProfile] = useLocalState(userKey(uid, 'profile'), null, (v): v is Profile | null => v === null || isProfile(v)); const [favorites, setFavorites] = useLocalState(userKey(uid, 'favorites'), [], isStrArray); const [userReports, setUserReports] = useLocalState('kbb.reports', [], (v): v is UserReport[] => Array.isArray(v)); const [decisions, setDecisions] = useLocalState>('kbb.decisions', {}, isObj>); const [demand, setDemand] = useLocalState>('kbb.demand', {}, isObj>); const lang: Lang = account ? settings.lang : loginLang; const [view, setView] = useState('places'); // Mobile portrait: the map fills the screen and the panel is a bottom sheet (peek / half / full). const [sheet, setSheet] = useState('half'); const sheetMode = useMedia('(max-width: 63.99em) and (orientation: portrait)'); const setPane = useCallback((p: 'list' | 'map') => setSheet(p === 'map' ? 'peek' : 'half'), []); const online = useOnline(); const i18n = useMemo(() => { const { t, tm } = makeT(lang); return { lang, setLang: (l) => (account ? setSettings((s) => ({ ...s, lang: l })) : setLoginLang(l)), t, tm, date: (iso) => formatDate(iso, lang) ?? t('fact.dateUnknown') }; }, [lang, account, setSettings, setLoginLang]); const { t } = i18n; useEffect(() => { document.title = t('app.name'); }, [t]); useEffect(() => { if (account) { applySettings(settings); setLoginLang(settings.lang); } else { document.documentElement.lang = loginLang; document.documentElement.style.fontSize = ''; document.documentElement.dataset.contrast = 'default'; } }, [account, settings, loginLang, setLoginLang]); // first login: seed the profile from the account's persona useEffect(() => { if (!account || profile) return; const kind = account.preset ?? (account.role === 'official' ? 'wheelchair' : null); if (kind) setProfile(DEFAULT_PROFILE[kind]); }, [account, profile, setProfile]); const signIn = (a: Account) => { setSession(a.id); setView(a.role === 'official' ? 'dashboard' : 'places'); setPane('list'); }; const signOut = () => { setSession(null); setView('places'); setSelectedId(null); }; // ---- 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 realPlaces = useMemo(() => (snap ? snap.places.map((p) => withCityOffer(overrides[p.id] ?? p)) : []), [snap, overrides]); const places = useMemo(() => (settings.showDemoPlaces ? [...DEMO_PLACES, ...realPlaces] : realPlaces), [realPlaces, settings.showDemoPlaces]); const reports = useMemo( () => [...DEMO_REPORTS, ...userReports].map((r) => (decisions[r.id] ? { ...r, ...decisions[r.id] } : r)), [userReports, decisions], ); 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]); const placeById = useMemo(() => new Map([...DEMO_PLACES, ...realPlaces].map((p) => [p.id, p])), [realPlaces]); const previewCounts = useCallback((p: Profile): Record => { const c: Record = { match: 0, partial: 0, conflict: 0, incomplete: 0, fail: 0 }; if (index) for (const pl of realPlaces) c[assessPlace(pl, p, index, reports).verdict]++; return c; }, [index, realPlaces, reports]); // ---- places view ---- const [filters, setFilters] = useState(DEFAULT_FILTERS); const favSet = useMemo(() => new Set(favorites), [favorites]); const results = useMemo(() => filterPlaces(places, assessments, filters, favSet), [places, assessments, filters, favSet]); const catCounts = useMemo(() => { const c: Record = {}; for (const p of filterPlaces(places, assessments, { ...filters, cat: 'all' }, favSet)) { c[p.cat] = (c[p.cat] ?? 0) + 1; c.all = (c.all ?? 0) + 1; } return c; }, [places, assessments, filters, favSet]); 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 ? placeById.get(selectedId) ?? null : null; const [refresh, setRefresh] = useState({ kind: 'idle' }); const [simulateOutage, setSimulateOutage] = useState(false); const detailHeading = useRef(null); // ---- reach ---- const [reachState, setReachState] = useState({ minutes: null, summary: null }); const [reachLines, setReachLines] = useState<[LatLon, LatLon][] | null>(null); const snapCache = useRef(new Map()); const clearReach = useCallback(() => { setReachState({ minutes: null, summary: null }); setReachLines(null); }, []); const doReach = (place: Place, minutes: number) => { if (!graph || !profile) return; const r: Reach | null = computeReach(graph, [place.lat, place.lon], profile, minutes); if (!r) { setReachState({ minutes, summary: 'stuck' }); setReachLines(null); return; } const inReach = placesInReach(graph, r, realPlaces.filter((p) => p.id !== place.id), snapCache.current); const fit = inReach.filter((p) => { const v = assessments.get(p.id)?.verdict; return v === 'match' || v === 'partial'; }).length; setReachState({ minutes, summary: { n: inReach.length, fit } }); setReachLines(r.lines); setSheet('half'); }; const select = useCallback((id: string | null) => { setSelectedId(id); setRefresh({ kind: 'idle' }); clearReach(); if (id) { setPane('list'); if (!id.startsWith('demo-')) setDemand((d) => ({ ...d, [id]: (d[id] ?? 0) + 1 })); } }, [clearReach, setDemand]); useEffect(() => { if (selectedId) detailHeading.current?.focus(); }, [selectedId]); useEffect(() => { clearReach(); }, [profile, clearReach]); 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]: { ...(p.ext ? { ext: p.ext } : {}), ...fresh } })); setRefresh({ kind: 'ok', at: new Date().toISOString() }); } catch { setRefresh({ kind: 'fail' }); } }; const patchReport = (id: string, patch: Partial) => setUserReports((cur) => cur.map((r) => (r.id === id ? { ...r, ...patch } : r))); // a vetting that was running when the tab closed will never finish useEffect(() => { setUserReports((cur) => (cur.some((r) => r.vetting?.state === 'running') ? cur.map((r) => (r.vetting?.state === 'running' ? { ...r, vetting: { state: 'failed', reason: 'error' } } : r)) : cur)); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const addReport = async (r: Omit, photo: ProcessedPhoto | null) => { const id = newId(); const rep: UserReport = { ...r, id, createdAt: new Date().toISOString(), authorId: account?.id ?? 'guest', ...(photo ? { photoId: id } : {}), vetting: { state: 'running' }, }; if (photo) await putPhoto(id, photo.dataUrl); // the on-device model is ~90 MB: ask before downloading it on a metered link if (photo && isMetered() && !window.confirm(t('vet.metered'))) { setUserReports((cur) => [...cur, { ...rep, vetting: { state: 'failed', reason: 'skipped' } }]); return; } setUserReports((cur) => [...cur, rep]); try { const result = await vetReport(rep, photo ? { dataUrl: photo.dataUrl, blur: photo.blur } : null); patchReport(id, { vetting: { state: 'done', result } }); } catch (e) { patchReport(id, { vetting: { state: 'failed', reason: e instanceof NoCreditsError ? 'no-credits' : 'error' } }); } }; const [cardId, setCardId] = useState(null); // ---- route ---- const [routeForm, setRouteForm] = useState({ fromText: '', toText: '', fromPoint: null }); const [routeOptions, setRouteOptions] = useState(null); const [routeSel, setRouteSel] = useState(0); const [guiding, setGuiding] = useState(false); const [userPos, setUserPos] = useState(null); const [basemap, setBasemap] = useLocalState<'map' | 'satellite'>('kbb.basemap', 'map', (v): v is 'map' | 'satellite' => v === 'map' || v === 'satellite'); const route = routeOptions?.[routeSel]?.result ?? null; const altRoutes = useMemo(() => (routeOptions ?? []).filter((_, i) => i !== routeSel).map((o) => o.result.coords), [routeOptions, routeSel]); const [routeEnds, setRouteEnds] = useState<{ from: LatLon; to: LatLon } | null>(null); const [focus, setFocus] = useState<{ at: LatLon; n: number } | null>(null); const onResult = (o: RouteOption[] | null, from: LatLon | null, to: LatLon | null) => { setRouteOptions(o); setRouteSel(0); setRouteEnds(from && to ? { from, to } : null); if (o?.[0] && o[0].result.status !== 'none') setSheet('half'); }; useEffect(() => { setRouteOptions(null); setRouteEnds(null); setGuiding(false); }, [profile]); useEffect(() => { if (view !== 'route' || !route || route.status === 'none') setGuiding(false); }, [view, route]); const reroute = (from: LatLon) => { if (!graph || !profile || !routeEnds) return; const o = findAlternatives(graph, from, routeEnds.to, profile); if (!o.length || o[0]!.result.status === 'none') return; setRouteOptions(o); setRouteSel(Math.max(0, o.findIndex((x) => x.recommended))); setRouteEnds({ from, to: routeEnds.to }); }; const routeTo = (p: Place) => { setRouteForm((f) => ({ ...f, toText: labels.labelOf.get(p.id) ?? p.name ?? '' })); setRouteOptions(null); setRouteEnds(null); setView('route'); setPane('list'); }; const presets = useMemo(() => { const byName = (n: string) => realPlaces.find((p) => p.name === n)?.id; return PRESETS.flatMap((p) => { const a = byName(p.from), b = byName(p.to); return a && b ? [{ id: p.id, fromId: a, toId: b }] : []; }); }, [realPlaces]); // ---- city panel ---- const stats = useMemo(() => computeStats(places, assessments), [places, assessments]); const hotspots = useMemo(() => (snap && index ? computeHotspots(snap, index, realPlaces) : []), [snap, index, realPlaces]); const [hotMode, setHotMode] = useState(false); const [hotFocus, setHotFocus] = useState(null); const [exportMsg, setExportMsg] = useState(null); const demandList = useMemo( () => Object.entries(demand) .flatMap(([id, views]) => { const p = placeById.get(id); return p && assessments.get(id)?.verdict === 'incomplete' ? [{ place: p, views }] : []; }) .sort((a, b) => b.views - a.views).slice(0, 5), [demand, placeById, assessments], ); const decide = (ids: string[], status: 'accepted' | 'rejected') => { const stamp = { status, reviewedAt: new Date().toISOString(), reviewedBy: account ? t(`auth.${account.id}` as Key) : '' }; setDecisions((d) => ({ ...d, ...Object.fromEntries(ids.map((id) => [id, stamp])) })); }; const undoDecisions = (ids: string[]) => setDecisions((d) => Object.fromEntries(Object.entries(d).filter(([id]) => !ids.includes(id)))); const pendingCount = useMemo(() => reports.filter((r) => !r.status || r.status === 'pending').length, [reports]); const doExport = (kind: 'csv' | 'geojson') => { const stamp = new Date().toISOString().slice(0, 10); const name = `krakow-bez-barier-${stamp}.${kind === 'csv' ? 'csv' : 'geojson'}`; if (kind === 'csv') download(name, 'text/csv', toCsv(realPlaces, assessments)); else download(name, 'application/geo+json', JSON.stringify(toGeoJson(realPlaces, assessments))); setExportMsg(t('dash.export.done', { name })); }; // ---- settings export / import ---- const [settingsMsg, setSettingsMsg] = useState<{ kind: 'success' | 'error'; text: string } | null>(null); const exportSettings = () => download('kbb-settings.json', 'application/json', JSON.stringify({ app: 'kbb', v: 1, profile, settings, favorites }, null, 2)); const importSettings = async (file: File) => { try { const j: unknown = JSON.parse(await file.text()); if (!isObj>(j) || j.app !== 'kbb' || !isSettings(j.settings) || !(j.profile === null || isProfile(j.profile)) || !isStrArray(j.favorites)) throw new Error('shape'); setSettings(j.settings); setProfile(j.profile); setFavorites(j.favorites); setSettingsMsg({ kind: 'success', text: t('set.import.ok') }); } catch { setSettingsMsg({ kind: 'error', text: t('set.import.bad') }); } }; // ---- 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 = () => { const keys = Object.keys(localStorage).filter((k) => k.startsWith('kbb.') && k !== 'kbb.lang'); clearLocal(keys); setResetDone(true); window.setTimeout(() => window.location.reload(), 600); }; const official = account?.role === 'official'; const nav: { id: View; label: string; icon: string; badge?: number }[] = [ { id: 'places', label: t('nav.places'), icon: 'location_on' }, { id: 'route', label: t('nav.route'), icon: 'directions_walk' }, ...(official ? [{ id: 'dashboard' as View, label: t(sheetMode ? 'nav.dashboard.short' : 'nav.dashboard'), icon: 'assessment', badge: pendingCount }] : []), { id: 'about', label: t('nav.about'), icon: 'info' }, { id: 'profile', label: t('nav.profile'), icon: 'account_circle' }, ]; const needsProfile = !!account && !profile; const split = !!account && !needsProfile && (view === 'places' || view === 'route'); const ready = snap && index && graph && profile; const card = cardId ? placeById.get(cardId) : null; if (!account) { return (
); } return (
{t('skip')}

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

{t('app.name')}

{account.id === 'guest' ? t('auth.role.guest') : t(`auth.${account.id}` as Key)}
{guiding && view === 'route' && route && route.status !== 'none' && ( setGuiding(false)} onPos={setUserPos} onReroute={reroute} /> )}
{loadError &&
{t('common.loadError')}
} {!loadError && !ready && !needsProfile && view !== 'profile' && (
{t('common.loading')}
)} {(view === 'profile' || needsProfile) && ( setView('places')} onSignOut={signOut} preview={previewCounts} > {!needsProfile && ( void importSettings(f)} message={settingsMsg} /> )} )} {view === 'about' && !needsProfile && snap && ( )} {view === 'dashboard' && !needsProfile && ( official ? ( ready && ( { setView('places'); select(id); }} onExport={doExport} exportMsg={exportMsg} onShowHotspot={(h) => { setHotMode(true); setHotFocus(h); setSelectedId(null); setView('places'); setPane('map'); }} /> ) ) : (
{t('dash.role')}
) )} {split && ready && ( <>
{sheetMode && }
{view === 'places' && hotMode && (
{t('dash.hot.mapBanner', { n: hotspots.length })}{' '}
)} {view === 'places' && (selected ? ( setFavorites((f) => (f.includes(selected.id) ? f.filter((x) => x !== selected.id) : [...f, selected.id]))} onCard={() => setCardId(selected.id)} reach={reachState} onReach={(m) => doReach(selected, m)} onClearReach={clearReach} onBack={back} onRouteTo={() => routeTo(selected)} onRefresh={() => void doRefresh(selected)} onAddReport={(r, photo) => void addReport(r, photo)} onRemoveReport={(id) => { void deletePhoto(id); setUserReports((cur) => cur.filter((r) => r.id !== id)); }} /> ) : ( ))} {view === 'route' && ( { setGuiding(true); setPane('map'); }} selected={routeSel} onSelect={setRouteSel} onResult={onResult} presets={presets} onFocus={(at) => { setFocus({ at, n: Date.now() }); setPane('map'); }} /> )}

{t('footer.attr')}

)}
{card && profile && ( setCardId(null)} /> )}
); }