mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
0126ef3401
Three gaps in the notice from the previous commit, found in review. A page served from the network and claimed later by its own build's worker counted as already taken over, so Reload went to that worker, got the old build again and spent the session's reload: no notice and no new build until the next start. Only a worker that replaced the one that served the page skips the handover now, as at launch. A reload that came before the new worker could take over (a slow precache, a failed update, an install gone redundant) also spent that guard, so the launch behind it could no longer finish the handover with its own reload. That reload leaves the guard alone now. An install whose server never reports the bundle's version (a hand-set APP_VERSION) showed the notice in every session, and Reload could not clear it. The store keeps the first version a page hears of, from the app config at launch, and the notice only asks when a later report differs: a different build at launch is the launch handover's to fix. The server's wire-envelope test pins the welcome frame's fields and now expects the version beside the socket id. The wiki's update page says what an open tab shows after an update.
573 lines
27 KiB
TypeScript
573 lines
27 KiB
TypeScript
import React, { useEffect, useState, useRef, ReactNode, Suspense } from 'react'
|
|
import { Routes, Route, Navigate, useLocation } from 'react-router'
|
|
import { useAuthStore } from './store/authStore'
|
|
import { useSettingsStore } from './store/settingsStore'
|
|
import { applyAppearance } from './theme/applyAppearance'
|
|
import { useAddonStore } from './store/addonStore'
|
|
import { usePluginStore } from './store/pluginStore'
|
|
// The one page that stays in the entry chunk. Anyone logged out lands here, and
|
|
// every other route redirects here first — a chunk round trip in front of the login
|
|
// form would slow down the single screen that has to be there immediately.
|
|
import LoginPage from './pages/LoginPage'
|
|
import { ToastContainer } from './components/shared/Toast'
|
|
import SaveToCollectionModal from './components/Collections/SaveToCollectionModal'
|
|
import MSaveToCollectionSheet from './components/Collections/MSaveToCollectionSheet'
|
|
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'
|
|
import MobileShell from './mobile/MobileShell'
|
|
import MRouteFallback from './mobile/components/MRouteFallback'
|
|
import ErrorBoundary from './components/shared/ErrorBoundary'
|
|
import { lazyWithRetry } from './utils/lazyWithRetry'
|
|
import { reconcileAppVersion } from './utils/versionHandover'
|
|
import { useServerVersionStore } from './store/serverVersionStore'
|
|
import { useIsPhone } from './mobile/useIsPhone'
|
|
import { TranslationProvider, useTranslation } from './i18n'
|
|
import { authApi, isAuthPublicPath } from './api/client'
|
|
import { tripRepo } from './repo/tripRepo'
|
|
import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination'
|
|
import { takeResumeRoute, useRememberRoute } from './utils/resumeRoute'
|
|
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
|
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
|
import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'
|
|
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
|
|
import OfflineBanner from './components/Layout/OfflineBanner'
|
|
import NewVersionNotice from './components/Layout/NewVersionNotice'
|
|
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
|
import HelpPanel from './components/Help/HelpPanel'
|
|
// Notice action registrations (side-effect imports):
|
|
import './pages/Trips/noticeActions.js'
|
|
import { managedRoutes } from './managed'
|
|
|
|
// Every page below loads on demand. The entry chunk used to carry all twenty of
|
|
// them eagerly, so opening /dashboard also paid for the planner, the journal, the
|
|
// atlas and the vacation planner. lazyWithRetry rather than lazy: a chunk that
|
|
// fails once gets a second, cache-busted attempt before the route boundary reaches
|
|
// for a reload.
|
|
const PluginPage = lazyWithRetry(() => import('./pages/PluginPage'))
|
|
const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage'))
|
|
const ResetPasswordPage = lazyWithRetry(() => import('./pages/ResetPasswordPage'))
|
|
const DashboardPage = lazyWithRetry(() => import('./pages/DashboardPage'))
|
|
const TripPlannerPage = lazyWithRetry(() => import('./pages/TripPlannerPage'))
|
|
const FilesPage = lazyWithRetry(() => import('./pages/FilesPage'))
|
|
const AdminPage = lazyWithRetry(() => import('./pages/AdminPage'))
|
|
const SettingsPage = lazyWithRetry(() => import('./pages/SettingsPage'))
|
|
const VacayPage = lazyWithRetry(() => import('./pages/VacayPage'))
|
|
const HelpPage = lazyWithRetry(() => import('./pages/HelpPage'))
|
|
const AtlasPage = lazyWithRetry(() => import('./pages/AtlasPage'))
|
|
const JourneyPage = lazyWithRetry(() => import('./pages/JourneyPage'))
|
|
const JourneyDetailPage = lazyWithRetry(() => import('./pages/JourneyDetailPage'))
|
|
const JourneyStudioPage = lazyWithRetry(() => import('./pages/JourneyStudioPage'))
|
|
const CollectionsPage = lazyWithRetry(() => import('./pages/CollectionsPage'))
|
|
const JourneyPublicPage = lazyWithRetry(() => import('./pages/JourneyPublicPage'))
|
|
const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage'))
|
|
const JoinTripPage = lazyWithRetry(() => import('./pages/JoinTripPage'))
|
|
const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotificationsPage.tsx'))
|
|
const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage'))
|
|
|
|
// The ten phone screens are chunks of their own, alongside the desktop pages
|
|
// rather than inside them. Each page used to import its M screen statically and
|
|
// decide while rendering, so the route chunk always carried both trees and the
|
|
// viewport only picked which half stayed dark. Here the branch decides the
|
|
// chunk: a phone never loads the desktop planner, a desktop never the mobile
|
|
// shell.
|
|
const MDashboardScreen = lazyWithRetry(() => import('./mobile/screens/dashboard/MDashboard'))
|
|
const MTripScreen = lazyWithRetry(() => import('./mobile/screens/trip/MTripShell'))
|
|
const MAdminScreen = lazyWithRetry(() => import('./mobile/screens/admin/MAdmin'))
|
|
const MSettingsScreen = lazyWithRetry(() => import('./mobile/screens/settings/MSettings'))
|
|
const MVacayScreen = lazyWithRetry(() => import('./mobile/screens/vacay/MVacay'))
|
|
const MAtlasScreen = lazyWithRetry(() => import('./mobile/screens/atlas/MAtlas'))
|
|
const MJourneyScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourney'))
|
|
const MJourneyDetailScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourneyDetail'))
|
|
const MCollectionsScreen = lazyWithRetry(() => import('./mobile/screens/collections/MCollections'))
|
|
const MNotificationsScreen = lazyWithRetry(() => import('./mobile/screens/notifications/MNotifications'))
|
|
|
|
interface ProtectedRouteProps {
|
|
children: ReactNode
|
|
adminRequired?: boolean
|
|
addonId?: string
|
|
}
|
|
|
|
function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedRouteProps) {
|
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
|
const user = useAuthStore((s) => s.user)
|
|
const isLoading = useAuthStore((s) => s.isLoading)
|
|
const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
|
|
const loggingOut = useAuthStore((s) => s.loggingOut)
|
|
const addonStore = useAddonStore()
|
|
const { t } = useTranslation()
|
|
const location = useLocation()
|
|
const isPhone = useIsPhone()
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
|
|
<p className="text-slate-500 text-sm">{t('common.loading')}</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!isAuthenticated) {
|
|
// A session that ended on its own should come back to where it left off; a
|
|
// deliberate sign-out is a fresh start and gets no return ticket, so the
|
|
// startup destination decides where the next login lands.
|
|
if (loggingOut) return <Navigate to="/login" replace state={{ noRedirect: true }} />
|
|
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
|
|
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
|
}
|
|
|
|
if (
|
|
appRequireMfa &&
|
|
user &&
|
|
!user.mfa_enabled &&
|
|
location.pathname !== '/settings'
|
|
) {
|
|
return <Navigate to="/settings?mfa=required" replace />
|
|
}
|
|
|
|
if (adminRequired && user && user.role !== 'admin') {
|
|
return <Navigate to="/dashboard" replace />
|
|
}
|
|
|
|
if (addonId && addonStore.loaded && !addonStore.isEnabled(addonId)) {
|
|
return <Navigate to="/dashboard" replace />
|
|
}
|
|
|
|
// Below the md breakpoint the new mobile shell owns chrome (tokens, dock,
|
|
// sheets, toasts); from 768px up the legacy wrapper stays untouched. The
|
|
// shell branches internally so pages keep their state when the viewport
|
|
// crosses the breakpoint.
|
|
// The boundary sits inside the shell so a broken page keeps the navigation the
|
|
// user needs to leave it — outside, the route would be a dead end, and the
|
|
// mobile --m-* tokens live on the shell's .m-root anyway.
|
|
//
|
|
// key, not resetKeys: ProtectedRoute is the same component at the same position
|
|
// for all 16 protected routes, so without it React could keep the failed
|
|
// instance across a navigation and the error would follow the user around.
|
|
return (
|
|
<MobileShell isPhone={isPhone}>
|
|
<RouteMemory />
|
|
<ErrorBoundary
|
|
key={location.pathname}
|
|
boundaryId="route"
|
|
level="route"
|
|
variant={isPhone ? 'mobile' : 'desktop'}
|
|
>
|
|
{children}
|
|
</ErrorBoundary>
|
|
</MobileShell>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The public routes render outside ProtectedRoute, so the route boundary above
|
|
* never sees them — including /login, where someone lands when everything else
|
|
* failed, and the two anonymous share pages.
|
|
*/
|
|
function PublicRoute({ children, redirectAuthed = false }: { children: React.ReactNode; redirectAuthed?: boolean }) {
|
|
const location = useLocation()
|
|
// redirectAuthed (only /login and /register) bounces a visitor who is already
|
|
// authenticated when they land here — manual URL, browser back button (#1810).
|
|
// Only on a bare URL, because every parameter this page takes is a flow that
|
|
// has to reach useLogin intact: ?redirect= carries the OAuth consent handoff
|
|
// (bouncing drops it, or loops if the server still reports login_required),
|
|
// ?invite= puts the form into register mode against a validated token, and
|
|
// ?oidc_code= / ?oidc_error= complete an external login. A visitor with any of
|
|
// those in the URL asked for this page on purpose.
|
|
// Capture the flag at mount so a fresh login is left alone: it flips
|
|
// isAuthenticated to true while the takeoff animation still plays here, before
|
|
// useLogin navigates away.
|
|
// The target is START_DESTINATION_ROUTE, not /dashboard, so the bounce honours
|
|
// the start-page preference the same way useLogin does.
|
|
const wasAuthenticated = useRef(useAuthStore.getState().isAuthenticated)
|
|
if (redirectAuthed && wasAuthenticated.current && !location.search) {
|
|
return <Navigate to={START_DESTINATION_ROUTE} replace />
|
|
}
|
|
return (
|
|
<ErrorBoundary key={location.pathname} boundaryId="public-route" level="route">
|
|
{children}
|
|
</ErrorBoundary>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Picks the chunk, not just the branch. Both sides come through lazyWithRetry,
|
|
* so exactly one of them is fetched for a given viewport — which is the whole
|
|
* point: the ternary that used to sit in each page only ran once the browser
|
|
* had already paid for both trees.
|
|
*/
|
|
function ViewportRoute({ phone: Phone, desktop: Desktop }: {
|
|
phone: React.ComponentType
|
|
desktop: React.ComponentType
|
|
}): React.ReactElement {
|
|
const isPhone = useIsPhone()
|
|
return isPhone ? <Phone /> : <Desktop />
|
|
}
|
|
|
|
/**
|
|
* The entry point every shortcut, bookmark and the installed PWA share
|
|
* (manifest start_url is '/'), which is why the startup destination is decided
|
|
* here rather than on /dashboard — landing on the dashboard directly has to keep
|
|
* working, or there would be no way back to it.
|
|
*
|
|
* Once this device has seen the preference it comes from the localStorage
|
|
* mirror, so the common launch waits for nothing. A device that has never seen
|
|
* it waits for the settings instead of assuming the default — the preference
|
|
* lives on the server, and guessing here would ignore it on every browser that
|
|
* didn't set it. Only 'active_trip' costs the one lookup that finds the trip,
|
|
* and any failure along the way lands on the dashboard.
|
|
*/
|
|
/** Remembers the protected route for the installed app's relaunch (#1024). Renders nothing. */
|
|
function RouteMemory() {
|
|
useRememberRoute()
|
|
return null
|
|
}
|
|
|
|
function RootRedirect() {
|
|
const { isAuthenticated, isLoading } = useAuthStore()
|
|
const settingsLoaded = useSettingsStore((s) => s.isLoaded)
|
|
const settings = useSettingsStore((s) => s.settings)
|
|
const loadSettings = useSettingsStore((s) => s.loadSettings)
|
|
const [target, setTarget] = useState<string | null>(null)
|
|
// Bounds the wait below: loadSettings deliberately leaves isLoaded false on a
|
|
// failed request so it can retry, which would otherwise strand a launch that
|
|
// started offline on the spinner.
|
|
const [settingsGaveUp, setSettingsGaveUp] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (settingsLoaded) return
|
|
const timer = setTimeout(() => setSettingsGaveUp(true), SETTINGS_WAIT_MS)
|
|
return () => clearTimeout(timer)
|
|
}, [settingsLoaded])
|
|
|
|
useEffect(() => {
|
|
if (isLoading || !isAuthenticated || target) return
|
|
// The installed app coming back after the system threw it away (#1024):
|
|
// back to the trip and tab it was in, ahead of the start page.
|
|
const resume = takeResumeRoute()
|
|
if (resume) { setTarget(resume); return }
|
|
const mirrored = readStartDestination()
|
|
if (!mirrored && !settingsLoaded && !settingsGaveUp) {
|
|
// Ask for the settings instead of waiting for whoever else might. The
|
|
// store de-dupes concurrent loads, so this is free when one is already in
|
|
// flight — and it stops the decision from riding on where that request
|
|
// happens to land in the queue behind everything else a launch fires off.
|
|
loadSettings()
|
|
return
|
|
}
|
|
// Loaded settings are the truth; the mirror is what we knew last time.
|
|
const { page, tab } = settingsLoaded
|
|
? { page: settings.start_page ?? DEFAULT_START_PAGE, tab: settings.start_trip_tab ?? DEFAULT_START_TRIP_TAB }
|
|
: (mirrored ?? { page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB })
|
|
if (page !== 'active_trip') { setTarget('/dashboard'); return }
|
|
let cancelled = false
|
|
// Through the repo, not the api: offline this answers from Dexie instead of
|
|
// bouncing a cached trip to the dashboard.
|
|
tripRepo.active()
|
|
.then(({ trip }) => { if (!cancelled) setTarget(trip ? tripStartPath(trip.id, tab) : '/dashboard') })
|
|
.catch(() => { if (!cancelled) setTarget('/dashboard') })
|
|
return () => { cancelled = true }
|
|
}, [isLoading, isAuthenticated, settingsLoaded, settingsGaveUp, settings, target])
|
|
|
|
if (isLoading || (isAuthenticated && !target)) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
|
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return <Navigate to={isAuthenticated ? (target ?? '/dashboard') : '/login'} replace />
|
|
}
|
|
|
|
/**
|
|
* Shown while a route chunk is in flight. Same geometry as the auth spinner above,
|
|
* but on theme tokens — that one predates the styleguide and a new surface does not
|
|
* get to inherit its raw slate.
|
|
*/
|
|
function RouteFallback() {
|
|
// The fallback renders above MobileShell, so it has to pick its own palette —
|
|
// on a phone the desktop spinner on bg-surface would be a foreign white sheet
|
|
// in front of the mobile screen.
|
|
const isPhone = useIsPhone()
|
|
if (isPhone) return <MRouteFallback />
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-surface">
|
|
<div className="w-10 h-10 border-4 border-edge border-t-content rounded-full animate-spin"></div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function App() {
|
|
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
|
|
const { loadSettings } = useSettingsStore()
|
|
const { loadAddons } = useAddonStore()
|
|
const { loadPlugins } = usePluginStore()
|
|
|
|
useEffect(() => {
|
|
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
|
|
// If the persist snapshot already has an authenticated user, validate
|
|
// silently so the PWA shell renders immediately without a spinner.
|
|
const alreadyAuthenticated = useAuthStore.getState().isAuthenticated
|
|
if (alreadyAuthenticated) {
|
|
useAuthStore.setState({ isLoading: false })
|
|
loadUser({ silent: true })
|
|
} else {
|
|
loadUser()
|
|
}
|
|
}
|
|
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; max_upload_mb?: number; permissions?: Record<string, PermissionLevel> }) => {
|
|
setManaged(!!config?.managed)
|
|
setDemoMode(!!config?.demo_mode)
|
|
if (config?.dev_mode) setDevMode(true)
|
|
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
|
|
if (config?.version) setAppVersion(config.version)
|
|
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
|
if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key)
|
|
if (config?.places_provider) setPlacesProvider(config.places_provider)
|
|
if (config?.timezone) setServerTimezone(config.timezone)
|
|
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
|
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
|
|
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled)
|
|
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
|
|
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
|
|
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
|
|
if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled)
|
|
if (typeof config?.max_upload_mb === 'number' && config.max_upload_mb > 0) useAuthStore.getState().setMaxUploadMb(config.max_upload_mb)
|
|
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
|
// The version this page starts out with, before the realtime socket reports.
|
|
useServerVersionStore.getState().note(config?.version)
|
|
// Last, since a new release reloads the page from here.
|
|
await reconcileAppVersion(config?.version)
|
|
}).catch(() => {})
|
|
}, [])
|
|
|
|
const { settings } = useSettingsStore()
|
|
|
|
useInAppNotificationListener()
|
|
useRoadtripPreferencesSync()
|
|
|
|
useEffect(() => {
|
|
if (isAuthenticated) {
|
|
loadSettings()
|
|
loadAddons()
|
|
loadPlugins()
|
|
}
|
|
}, [isAuthenticated])
|
|
|
|
useEffect(() => {
|
|
registerSyncTriggers()
|
|
return () => unregisterSyncTriggers()
|
|
}, [])
|
|
|
|
const location = useLocation()
|
|
const isSharedPage = location.pathname.startsWith('/shared/')
|
|
|
|
useEffect(() => {
|
|
const run = () =>
|
|
applyAppearance({
|
|
darkMode: settings.dark_mode,
|
|
appearance: settings.appearance,
|
|
isSharedPage,
|
|
})
|
|
run()
|
|
// Re-resolve on OS theme change while in auto mode.
|
|
if (!isSharedPage && settings.dark_mode === 'auto') {
|
|
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
|
const handler = () => run()
|
|
mq.addEventListener('change', handler)
|
|
return () => mq.removeEventListener('change', handler)
|
|
}
|
|
}, [settings.dark_mode, settings.appearance, isSharedPage])
|
|
|
|
const isPhone = useIsPhone()
|
|
const isAuthPage = location.pathname.startsWith('/login')
|
|
|| location.pathname.startsWith('/register')
|
|
|| location.pathname.startsWith('/forgot-password')
|
|
|| location.pathname.startsWith('/reset-password')
|
|
// No session on these, so authenticated-only widgets (system notices,
|
|
// background tasks, save-to-collection) have nothing to do and would only fire
|
|
// a doomed authenticated request on mount.
|
|
//
|
|
// Off isAuthPublicPath rather than a second hand-kept list: the two had already
|
|
// drifted, this one naming only /public/journey/ while the response
|
|
// interceptor's covers all of /public/. A route that is public to one and not
|
|
// to the other is exactly the seam that puts a 401 back.
|
|
const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname)
|
|
|
|
return (
|
|
<TranslationProvider>
|
|
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
|
|
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:help-panel" fallback={null}><HelpPanel /></ErrorBoundary>}
|
|
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
|
|
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
|
|
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
|
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
|
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
|
|
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
|
across navigations. react-router runs location updates inside a transition,
|
|
so a mounted boundary keeps the current page on screen instead of flashing
|
|
a spinner on every jump — the spinner is for the first paint of a deep link. */}
|
|
<Suspense fallback={<RouteFallback />}>
|
|
<Routes>
|
|
<Route path="/" element={<RootRedirect />} />
|
|
<Route path="/login" element={<PublicRoute redirectAuthed><LoginPage /></PublicRoute>} />
|
|
<Route path="/shared/:token" element={<PublicRoute><SharedTripPage /></PublicRoute>} />
|
|
<Route path="/public/journey/:token" element={<PublicRoute><JourneyPublicPage /></PublicRoute>} />
|
|
<Route path="/register" element={<PublicRoute redirectAuthed><LoginPage /></PublicRoute>} />
|
|
<Route path="/forgot-password" element={<PublicRoute><ForgotPasswordPage /></PublicRoute>} />
|
|
<Route path="/reset-password" element={<PublicRoute><ResetPasswordPage /></PublicRoute>} />
|
|
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
|
<Route path="/oauth/consent" element={<PublicRoute><OAuthAuthorizePage /></PublicRoute>} />
|
|
<Route
|
|
path="/dashboard"
|
|
element={
|
|
<ProtectedRoute>
|
|
<ViewportRoute phone={MDashboardScreen} desktop={DashboardPage} />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
{/* Trip invite link (#1143) — behind ProtectedRoute so an anonymous
|
|
visitor is redirected to /login (never registration) and returns here. */}
|
|
<Route
|
|
path="/join/:token"
|
|
element={
|
|
<ProtectedRoute>
|
|
<JoinTripPage />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/help"
|
|
element={
|
|
<ProtectedRoute>
|
|
<HelpPage />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/help/:slug"
|
|
element={
|
|
<ProtectedRoute>
|
|
<HelpPage />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/trips/:id"
|
|
element={
|
|
<ProtectedRoute>
|
|
<ViewportRoute phone={MTripScreen} desktop={TripPlannerPage} />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/trips/:id/files"
|
|
element={
|
|
<ProtectedRoute>
|
|
<FilesPage />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/admin"
|
|
element={
|
|
<ProtectedRoute adminRequired>
|
|
<ViewportRoute phone={MAdminScreen} desktop={AdminPage} />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/settings"
|
|
element={
|
|
<ProtectedRoute>
|
|
<ViewportRoute phone={MSettingsScreen} desktop={SettingsPage} />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/plugins/:pluginId"
|
|
element={
|
|
<ProtectedRoute>
|
|
<PluginPage />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
{/* Empty in this repository, and read unconditionally so the public
|
|
build walks the same path as any other. See client/src/managed. */}
|
|
{managedRoutes.map(r => (
|
|
<Route
|
|
key={r.path}
|
|
path={r.path}
|
|
element={<ProtectedRoute adminRequired={r.adminOnly}>{r.element}</ProtectedRoute>}
|
|
/>
|
|
))}
|
|
<Route
|
|
path="/vacay"
|
|
element={
|
|
<ProtectedRoute>
|
|
<ViewportRoute phone={MVacayScreen} desktop={VacayPage} />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/atlas"
|
|
element={
|
|
<ProtectedRoute>
|
|
<ViewportRoute phone={MAtlasScreen} desktop={AtlasPage} />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/journey"
|
|
element={
|
|
<ProtectedRoute addonId="journey">
|
|
<ViewportRoute phone={MJourneyScreen} desktop={JourneyPage} />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/journey/:id"
|
|
element={
|
|
<ProtectedRoute addonId="journey">
|
|
<ViewportRoute phone={MJourneyDetailScreen} desktop={JourneyDetailPage} />
|
|
</ProtectedRoute>
|
|
}
|
|
>
|
|
{/* Studio is nested so the journey stays mounted underneath it and
|
|
shows through the panel's margin — it is opened on top of the
|
|
journey, not navigated away to. */}
|
|
<Route path="studio" element={<JourneyStudioPage />} />
|
|
</Route>
|
|
<Route
|
|
path="/collections"
|
|
element={
|
|
<ProtectedRoute addonId="collections">
|
|
<ViewportRoute phone={MCollectionsScreen} desktop={CollectionsPage} />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/collections/:id"
|
|
element={
|
|
<ProtectedRoute addonId="collections">
|
|
<ViewportRoute phone={MCollectionsScreen} desktop={CollectionsPage} />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/notifications"
|
|
element={
|
|
<ProtectedRoute>
|
|
<ViewportRoute phone={MNotificationsScreen} desktop={InAppNotificationsPage} />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
</Routes>
|
|
</Suspense>
|
|
</TranslationProvider>
|
|
)
|
|
}
|