mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
5d334d7ec8
A third scope, home, next to trip and journey: threads that name neither a trip nor a journey (the CHECK now only forbids both), reached under /api/assistant by their owner only. A home turn reads the user's trips through list_trips into a short card (under way, next, later, behind, undated), offers every read (a trip's on the id the model names, checked by the tool with the user's rights) and only the writes that take no trip or journey (atlas, vacation planner, collections, tour library, settings), all held as change cards. open_trip_chat answers with a card that goes on in that trip's own chat with the question waiting in its composer. On the desktop the window opens on the home chat outside a trip; its head lists the trips to switch into and a switched trip chat has the way back. On the phone the home chat opens full screen from the dashboard head and the dock's More list, and the card opens the trip with its chat. Both run on useAssistantHome.
587 lines
28 KiB
TypeScript
587 lines
28 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 { useSaveToCollectionStore } from './store/saveToCollectionStore'
|
|
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 tour keep dialog a day plan row opens; it loads only once a tour is to be kept.
|
|
const TourSaveToCollectionHost = lazyWithRetry(() => import('./components/Tours/collection/TourSaveToCollectionHost'))
|
|
// The desktop assistant window: its own chunk, and nothing at all until a connection is set up.
|
|
const AssistantHost = lazyWithRetry(() => import('./components/Assistant/AssistantHost'))
|
|
const MAssistantHomeScreen = lazyWithRetry(() => import('./mobile/screens/assistant/MAssistantHomeScreen'))
|
|
|
|
// 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 keepingTour = useSaveToCollectionStore(s => s.tour !== null)
|
|
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 />)}
|
|
{!hideAuthedWidgets && keepingTour && <ErrorBoundary boundaryId="widget:keep-tour" fallback={null}><Suspense fallback={null}><TourSaveToCollectionHost /></Suspense></ErrorBoundary>}
|
|
{/* The phone has its own assistant screen inside the trip, and the home chat's outside it. */}
|
|
{!hideAuthedWidgets && isAuthenticated && (
|
|
<ErrorBoundary boundaryId="widget:assistant" fallback={null}>
|
|
<Suspense fallback={null}>{isPhone ? <MAssistantHomeScreen /> : <AssistantHost />}</Suspense>
|
|
</ErrorBoundary>
|
|
)}
|
|
<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>
|
|
)
|
|
}
|