diff --git a/src/App.tsx b/src/App.tsx index 093a1e0..841c026 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { DEFAULT_FILTERS, PlacesView, filterPlaces, type Filters } from './compo import { ProfileView } from './components/ProfileView'; import { RouteView, type RouteForm } from './components/RouteView'; 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'; @@ -26,7 +27,7 @@ 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, newId, useLocalState, useOnline } from './lib/store'; +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'; @@ -62,7 +63,10 @@ export default function App() { const lang: Lang = account ? settings.lang : loginLang; const [view, setView] = useState('places'); - const [pane, setPane] = useState<'list' | 'map'>('list'); + // 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(() => { @@ -152,7 +156,7 @@ export default function App() { 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); - setPane('map'); + setSheet('half'); }; const select = useCallback((id: string | null) => { @@ -199,6 +203,11 @@ export default function App() { ...(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); @@ -224,7 +233,7 @@ export default function App() { setRouteOptions(o); setRouteSel(0); setRouteEnds(from && to ? { from, to } : null); - if (o?.[0] && o[0].result.status !== 'none') setPane('map'); + 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]); @@ -303,12 +312,12 @@ export default function App() { }; const official = account?.role === 'official'; - const nav: { id: View; label: string }[] = [ - { id: 'places', label: t('nav.places') }, - { id: 'route', label: t('nav.route') }, - ...(official ? [{ id: 'dashboard' as View, label: t('nav.dashboard') }] : []), - { id: 'about', label: t('nav.about') }, - { id: 'profile', label: t('nav.profile') }, + const nav: { id: View; label: string; icon: string }[] = [ + { 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('nav.dashboard'), icon: 'assessment' }] : []), + { 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'); @@ -325,10 +334,10 @@ export default function App() { return ( -
+
{t('skip')} -
+

@@ -346,15 +355,16 @@ export default function App() { {t('app.name')}

- {split && ( -
-
-
    - {(['list', 'map'] as const).map((p) => ( -
  • - -
  • - ))} -
-
-
- )} - {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')}
@@ -451,6 +444,8 @@ export default function App() { {split && ready && ( <>
+ {sheetMode && } +
{view === 'places' && hotMode && (
@@ -494,6 +489,7 @@ export default function App() { /> )}

{t('footer.attr')}

+
results.slice(0, shown), [results, shown]); + const activeFilters = (filters.cat !== DEFAULT_FILTERS.cat ? 1 : 0) + (filters.onlySaved ? 1 : 0) + (filters.onlyFit ? 1 : 0); const set = (patch: Partial) => { setShown(PAGE); onFilters({ ...filters, ...patch }); }; return ( @@ -55,6 +56,8 @@ export function PlacesView({ profile, filters, onFilters, results, assessments,
set({ query: e.target.value })} /> +
+ {activeFilters ? t('places.filtersActive', { n: activeFilters }) : t('places.filters')} set({ onlyFit: e.target.checked })} />
+

{t('places.count', { n: results.length })}

diff --git a/src/components/SheetHandle.tsx b/src/components/SheetHandle.tsx new file mode 100644 index 0000000..08c24f2 --- /dev/null +++ b/src/components/SheetHandle.tsx @@ -0,0 +1,33 @@ +import { useRef } from 'react'; +import { useI18n } from '../lib/i18n'; + +export type Sheet = 'peek' | 'half' | 'full'; +const ORDER: Sheet[] = ['peek', 'half', 'full']; +const SWIPE_PX = 40; + +/** Drag handle of the mobile bottom sheet: tap cycles, swipe up/down moves one step. */ +export function SheetHandle({ sheet, onSheet }: { sheet: Sheet; onSheet: (s: Sheet) => void }) { + const { t } = useI18n(); + const startY = useRef(null); + const moved = useRef(false); + const step = (d: 1 | -1) => onSheet(ORDER[Math.min(2, Math.max(0, ORDER.indexOf(sheet) + d))] ?? sheet); + + return ( +