Files
pansy/web/src/router.tsx
T
steveandClaude Opus 4.8 14af9502d4
Build image / build-and-push (push) Successful in 17s
Gadfly review (reusable) / review (pull_request) Successful in 9m34s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m34s
Resume the last garden on this device (#97)
`/` always dumped you on the gardens list; a phone user who lives in one
garden had to open the list and tap in every time. Now the device
remembers the garden it was last in and `/` resumes there.

- lib/lastGarden.ts: per-device localStorage (pansy:last-garden), same
  swallow-failures rationale as the seed tray / recents. getLastGardenId
  guards against a non-positive/garbage stored value.
- The `/` route redirects to the stored garden when present, else /gardens.
- The editor records the garden on successful load, and — if it 404s
  (deleted or access revoked) — forgets it (only if it's the stored one,
  so a bad direct link can't wipe a good resume target) and bounces to the
  list, so a stale id can't trap the user on an error screen. Transient
  errors still show the retryable message.
- ApiError.isNotFound getter (mirrors isConflict/isUnauthorized).

Verified live at 390px: resume into the last garden; a stale id bounces to
/gardens and clears itself. tsc + vitest (incl. new lastGarden test) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 00:44:58 -04:00

174 lines
5.6 KiB
TypeScript

import {
createRootRouteWithContext,
createRoute,
createRouter,
redirect,
} from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
import { AppShell } from '@/components/layout/AppShell'
import { NotFound } from '@/components/NotFound'
import { RouteError } from '@/components/RouteError'
import { LoginPage } from '@/pages/LoginPage'
import { RegisterPage } from '@/pages/RegisterPage'
import { GardensPage } from '@/pages/GardensPage'
import { GardenEditorPage } from '@/pages/GardenEditorPage'
import { PublicGardenPage } from '@/pages/PublicGardenPage'
import { PlantsPage } from '@/pages/PlantsPage'
import { SettingsPage } from '@/pages/SettingsPage'
import { meQueryOptions } from '@/lib/auth'
import { queryClient } from '@/lib/queryClient'
import { safeRedirectPath } from '@/lib/redirect'
import { getLastGardenId } from '@/lib/lastGarden'
interface RouterContext {
queryClient: QueryClient
}
const rootRoute = createRootRouteWithContext<RouterContext>()({
component: AppShell,
// A beforeLoad/loader failure that isn't a redirect (e.g. /auth/me errors with
// a 500 or the network drops) lands here instead of a blank screen.
errorComponent: RouteError,
// Unknown paths render inside the app shell rather than a blank screen.
notFoundComponent: NotFound,
})
// requireAuth: resolve the current user (shared cache with useMe); send anyone
// unauthenticated to /login, remembering the path they were headed to.
async function requireAuth(context: RouterContext, path: string) {
const me = await context.queryClient.ensureQueryData(meQueryOptions)
if (!me) {
throw redirect({ to: '/login', search: { redirect: path } })
}
}
// requireGuest: keep already-authenticated users off /login and /register.
async function requireGuest(context: RouterContext, redirectTo: string) {
const me = await context.queryClient.ensureQueryData(meQueryOptions)
if (me) {
throw redirect({ to: redirectTo })
}
}
// requireAdmin: authenticated AND admin. A non-admin who navigates to /settings
// is sent to /gardens rather than shown a page whose API calls would 403. This
// is convenience routing, not the security boundary — the server's requireAdmin
// is authoritative.
async function requireAdmin(context: RouterContext, path: string) {
const me = await context.queryClient.ensureQueryData(meQueryOptions)
if (!me) {
throw redirect({ to: '/login', search: { redirect: path } })
}
if (!me.isAdmin) {
throw redirect({ to: '/gardens' })
}
}
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
// Resume the garden this device was last in, so a returning user lands back in
// their plot rather than on the list every time. A stored id that no longer
// loads is cleared by the editor and bounces here to /gardens on the next
// visit, so this can't trap anyone on a dead garden.
beforeLoad: () => {
const last = getLastGardenId()
if (last != null) {
throw redirect({ to: '/gardens/$gardenId', params: { gardenId: String(last) } })
}
throw redirect({ to: '/gardens' })
},
})
interface LoginSearch {
redirect?: string
error?: string
}
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'login',
// Optional properties (not `key: undefined`) so links to /login don't have to
// pass a search object.
validateSearch: (search: Record<string, unknown>): LoginSearch => {
const out: LoginSearch = {}
if (typeof search.redirect === 'string') out.redirect = search.redirect
if (typeof search.error === 'string') out.error = search.error
return out
},
beforeLoad: ({ context, search }) => requireGuest(context, safeRedirectPath(search.redirect)),
component: LoginPage,
})
const registerRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'register',
beforeLoad: ({ context }) => requireGuest(context, '/gardens'),
component: RegisterPage,
})
const gardensRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'gardens',
beforeLoad: ({ context, location }) => requireAuth(context, location.href),
component: GardensPage,
})
const gardenEditorRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'gardens/$gardenId',
// ?focus=<objectId> frames that object on load.
validateSearch: (search: Record<string, unknown>): { focus?: number } => {
const f = Number(search.focus)
return Number.isInteger(f) && f > 0 ? { focus: f } : {}
},
beforeLoad: ({ context, location }) => requireAuth(context, location.href),
component: GardenEditorPage,
})
const plantsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'plants',
beforeLoad: ({ context, location }) => requireAuth(context, location.href),
component: PlantsPage,
})
const settingsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'settings',
beforeLoad: ({ context, location }) => requireAdmin(context, location.href),
component: SettingsPage,
})
// Public read-only garden by share token. Deliberately has NO beforeLoad auth
// guard, so a logged-out visitor viewing a shared link is never redirected to
// /login or OIDC.
const publicGardenRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'g/$token',
component: PublicGardenPage,
})
const routeTree = rootRoute.addChildren([
indexRoute,
loginRoute,
registerRoute,
gardensRoute,
gardenEditorRoute,
plantsRoute,
settingsRoute,
publicGardenRoute,
])
export const router = createRouter({
routeTree,
defaultPreload: 'intent',
context: { queryClient },
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}