diff --git a/server.ts b/server.ts index e351769..469a37f 100644 --- a/server.ts +++ b/server.ts @@ -24,6 +24,7 @@ import * as _schemas from '~/api/schemas' import * as _uploads from '~/api/uploads' import * as _versions from '~/api/versions' import { auth } from '~/lib/auth' +import { getSessionUser } from '~/lib/auth.server' import { getMirrorConfig } from '~/lib/mirror-config' const isProd = process.env.NODE_ENV === 'production' @@ -230,6 +231,18 @@ app.get('/api/blog/:slug', (c) => { return c.html(typeof html === 'string' ? html : '') }) +// --- App context (consumed by root route loader) --- +app.get('/api/context', async (c) => { + const user = await getSessionUser(c.req.raw) + const config = getMirrorConfig() + return c.json({ + currentUser: user, + mirrorConfig: config, + kfAccountUrl: process.env.OIDC_ACCOUNT_URL ?? 'http://localhost:3001', + kfAuthUrl: process.env.OIDC_ISSUER_URL ?? 'http://localhost:3000', + }) +}) + // API 404 catch-all app.all('/api/*', (c) => { return c.json({ error: 'API route not found', statusCode: 404 }, 404) @@ -256,17 +269,19 @@ if (isProd) { const { render } = await import(ssrBundle as string) app.get('*', async (c) => { - const { html, ssrData, redirect, statusCode, title, description } = await render(c.req.raw) + const { html, hydrationData, redirect, statusCode, title, description } = await render( + c.req.raw, + ) if (redirect) { - return c.redirect(redirect, 302) + return c.redirect(redirect, statusCode ?? 302) } let page = template .replace('', html) .replace( '', - ``, + ``, ) if (title) { @@ -305,17 +320,19 @@ if (isProd) { template = await vite!.transformIndexHtml(url, template) const { render } = await vite!.ssrLoadModule('/src/entry-server.tsx') - const { html, ssrData, redirect, statusCode, title, description } = await render(c.req.raw) + const { html, hydrationData, redirect, statusCode, title, description } = await render( + c.req.raw, + ) if (redirect) { - return c.redirect(redirect, 302) + return c.redirect(redirect, statusCode ?? 302) } let page = template .replace('', html) .replace( '', - ``, + ``, ) if (title) { diff --git a/src/App.tsx b/src/App.tsx index db229fd..ae8c840 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,27 +1,46 @@ -import { lazy, Suspense } from 'react' -import { Route, Routes } from 'react-router' +import type { RouteObject } from 'react-router' -import { AppErrorBoundary } from '~/components/NotFound' -import { buildRoutes } from '~/route-gen' +import Root from '~/components/Root' +import type { AppContext } from '~/lib/app-context' +import { buildDataRoutes } from '~/route-gen' -const modules = import.meta.glob<{ default: React.ComponentType }>('./routes/**/[!_]*.tsx') -const routes = buildRoutes(modules) +const modules = import.meta.glob<{ default: React.ComponentType; handle?: unknown }>( + './routes/**/[!_]*.tsx', +) -const componentMap = new Map(routes.map((r) => [r.path, lazy(modules[r.filePath]!)])) - -export { routes } +const EMPTY_CONTEXT: AppContext = { + currentUser: null, + mirrorConfig: { enabled: false, upstream: '', nodeName: '', syncSchedule: '', apiKey: '' }, + kfAccountUrl: '', + kfAuthUrl: '', +} -export default function App() { - return ( - - - - {routes.map((r) => { - const Page = componentMap.get(r.path) - return Page ? } /> : null - })} - - - - ) +async function rootLoader({ + request, + context, +}: { + request: Request + context?: { loadAppContext: (req: Request) => Promise } +}): Promise { + // SSR: server passes loadAppContext via requestContext — direct DB access, no HTTP + if (context?.loadAppContext) { + return context.loadAppContext(request) + } + // Client navigation: fetch from server + const res = await fetch(new URL('/api/context', request.url), { + headers: { Cookie: request.headers.get('Cookie') ?? '' }, + }) + if (!res.ok) return EMPTY_CONTEXT + return res.json() } + +const NotFound = () => import('~/routes/404').then((m) => ({ Component: m.default })) + +export const routes: RouteObject[] = [ + { + id: 'root', + Component: Root, + loader: rootLoader, + children: [...buildDataRoutes(modules), { path: '*', lazy: NotFound }], + }, +] diff --git a/src/components/BaseLayout.tsx b/src/components/BaseLayout.tsx index 72f8157..c44dd01 100644 --- a/src/components/BaseLayout.tsx +++ b/src/components/BaseLayout.tsx @@ -1,17 +1,10 @@ import { Link } from 'react-router' import UserMenu from '~/components/UserMenu' -import { useSSRData } from '~/lib/ssr-data' - -interface MirrorConfig { - enabled: boolean - nodeName: string - upstream: string -} +import { useAppContext } from '~/lib/app-context' export default function BaseLayout({ children }: { children: React.ReactNode }) { - const currentUser = useSSRData('currentUser') - const mirrorConfig = useSSRData('mirrorConfig') + const { currentUser, mirrorConfig } = useAppContext() return ( <> diff --git a/src/components/Root.tsx b/src/components/Root.tsx new file mode 100644 index 0000000..073c0ec --- /dev/null +++ b/src/components/Root.tsx @@ -0,0 +1,11 @@ +import { Outlet } from 'react-router' + +import { AppErrorBoundary } from '~/components/NotFound' + +export default function Root() { + return ( + + + + ) +} diff --git a/src/entry-client.tsx b/src/entry-client.tsx index 5a992e0..eaf0b7c 100644 --- a/src/entry-client.tsx +++ b/src/entry-client.tsx @@ -1,18 +1,38 @@ import { hydrateRoot } from 'react-dom/client' -import { BrowserRouter } from 'react-router' +import { createBrowserRouter, matchRoutes, RouterProvider } from 'react-router' -import App from '~/App' -import { getClientSSRData, SSRDataProvider } from '~/lib/ssr-data' +import { routes } from '~/App' +import { extractRouteMeta } from '~/lib/route-meta' import '~/global.css' -const ssrData = getClientSSRData() +// Resolve matched lazy routes before hydrating to prevent server/client mismatch. +// On the server, createStaticHandler.query() resolves lazy routes before rendering. +// The client must do the same before hydrateRoot, otherwise RouterProvider renders +// null while lazy modules load, causing React to see a mismatch and double-render. +const matched = matchRoutes(routes, window.location) +if (matched) { + await Promise.all( + matched.map(async (m) => { + if (m.route.lazy && typeof m.route.lazy === 'function') { + const resolved = await (m.route.lazy as () => Promise>)() + Object.assign(m.route, resolved) + delete m.route.lazy + } + }), + ) +} -hydrateRoot( - document.getElementById('root')!, - - - - - , -) +const hydrationData = (window as any).__staticRouterHydrationData + +const router = createBrowserRouter(routes, { hydrationData }) + +router.subscribe((state) => { + const { title } = extractRouteMeta( + state.matches as Array<{ params: Record; route: { handle?: unknown } }>, + (state as any).loaderData, + ) + if (title) document.title = title +}) + +hydrateRoot(document.getElementById('root')!, ) diff --git a/src/entry-server.tsx b/src/entry-server.tsx index 00f1654..373b0a9 100644 --- a/src/entry-server.tsx +++ b/src/entry-server.tsx @@ -1,105 +1,99 @@ import { PassThrough } from 'node:stream' import { renderToPipeableStream } from 'react-dom/server' -import { StaticRouter } from 'react-router' +import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router' -import App, { routes } from '~/App' -import { SSRDataProvider } from '~/lib/ssr-data' -import { runLoaders } from '~/loaders.server' +import { routes } from '~/App' +import type { AppContext } from '~/lib/app-context' +import { getSessionUser } from '~/lib/auth.server' +import { getMirrorConfig } from '~/lib/mirror-config' +import { extractRouteMeta } from '~/lib/route-meta' -function matchPath(pattern: string, pathname: string): Record | null { - const patternParts = pattern.split('/').filter(Boolean) - const pathParts = pathname.split('/').filter(Boolean) +const handler = createStaticHandler(routes) - if (patternParts.length !== pathParts.length) return null - - const params: Record = {} - for (let i = 0; i < patternParts.length; i++) { - const pat = patternParts[i]! - const val = pathParts[i]! - if (pat.startsWith(':')) { - params[pat.slice(1)] = val - } else if (pat !== val) { - return null - } - } - return params -} - -function matchRoutes(url: string) { - const pathname = new URL(url, 'http://localhost').pathname - const matched: { path: string; params: Record }[] = [] - - for (const route of routes) { - const params = matchPath(route.path, pathname) - if (params !== null) { - matched.push({ path: route.path, params }) - break // first match wins - } +async function loadAppContext(request: Request): Promise { + const user = await getSessionUser(request) + const config = getMirrorConfig() + return { + currentUser: user, + mirrorConfig: config, + kfAccountUrl: process.env.OIDC_ACCOUNT_URL ?? 'http://localhost:3001', + kfAuthUrl: process.env.OIDC_ISSUER_URL ?? 'http://localhost:3000', } - return matched } export async function render(request: Request): Promise<{ html: string - ssrData: Record + hydrationData: string redirect?: string statusCode?: number title?: string description?: string }> { - const url = request.url - const pathname = new URL(url, 'http://localhost').pathname - const matchedRoutes = matchRoutes(url) - - let ssrData: Record - let redirect: string | undefined - let statusCode: number | undefined - let title: string | undefined - let description: string | undefined + const context = await handler.query(request, { + requestContext: { loadAppContext }, + }) - try { - const result = await runLoaders(matchedRoutes, request) - ssrData = result.data - redirect = result.redirect - statusCode = result.statusCode - title = result.title - description = result.description - } catch (err) { - console.error('Loader error:', err) - ssrData = {} - statusCode = 500 + if (context instanceof Response) { + const location = context.headers.get('Location') + return { + html: '', + hydrationData: '{}', + redirect: location ?? '/', + statusCode: context.status, + } } - if (redirect) { - return { html: '', ssrData: {}, redirect, statusCode: statusCode ?? 302 } + // Check for auth redirects via route handle metadata + for (const match of context.matches) { + const handle = match.route.handle as { requireAuth?: boolean } | undefined + if (handle?.requireAuth) { + const rootData = context.loaderData?.root as { currentUser: unknown } | undefined + if (!rootData?.currentUser) { + return { + html: '', + hydrationData: '{}', + redirect: '/login', + statusCode: 302, + } + } + } } + const { title, description } = extractRouteMeta( + context.matches as Array<{ params: Record; route: { handle?: unknown } }>, + context.loaderData, + ) + + const router = createStaticRouter(handler.dataRoutes, context) + return new Promise((resolve, reject) => { let html = '' const passthrough = new PassThrough() - passthrough.on('data', (chunk) => { + passthrough.on('data', (chunk: Buffer) => { html += chunk.toString() }) const { pipe } = renderToPipeableStream( - - - - - , + , { onAllReady() { pipe(passthrough) - passthrough.on('end', () => + passthrough.on('end', () => { + const hydrationData = JSON.stringify({ + loaderData: context.loaderData, + actionData: context.actionData ?? null, + errors: context.errors ?? null, + }).replace(/ + route: { handle?: unknown } +} + +interface HandleMeta { + title?: string | ((params: Record, loaderData: unknown) => string) + description?: string | ((params: Record, loaderData: unknown) => string) +} + +export function extractRouteMeta( + matches: RouteMatch[], + loaderData?: unknown, +): { title: string | undefined; description: string | undefined } { + let title: string | undefined + let description: string | undefined + + for (let i = matches.length - 1; i >= 0; i--) { + const match = matches[i]! + const handle = match.route.handle as HandleMeta | undefined + + if (handle?.title && !title) { + title = + typeof handle.title === 'function' ? handle.title(match.params, loaderData) : handle.title + } + if (handle?.description && !description) { + description = + typeof handle.description === 'function' + ? handle.description(match.params, loaderData) + : handle.description + } + if (title && description) break + } + + return { title, description } +} diff --git a/src/lib/ssr-data.tsx b/src/lib/ssr-data.tsx deleted file mode 100644 index e337bff..0000000 --- a/src/lib/ssr-data.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createContext, useContext } from 'react' - -type SSRData = Record - -const SSRDataContext = createContext({}) - -export function SSRDataProvider({ data, children }: { data: SSRData; children: React.ReactNode }) { - return {children} -} - -export function useSSRData(key: string): T { - const data = useContext(SSRDataContext) - return data[key] as T -} - -export function getClientSSRData(): SSRData { - if (typeof window !== 'undefined' && (window as any).__SSR_DATA__) { - return (window as any).__SSR_DATA__ as SSRData - } - return {} -} diff --git a/src/loaders.server.ts b/src/loaders.server.ts deleted file mode 100644 index f4cdf7c..0000000 --- a/src/loaders.server.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { getSessionUser } from '~/lib/auth.server' -import { getMirrorConfig } from '~/lib/mirror-config' - -type LoaderContext = { - params: Record - request: Request -} - -type LoaderResult = { - data: Record - redirect?: string - statusCode?: number - title?: string - description?: string -} - -type LoaderFn = (ctx: LoaderContext) => LoaderResult | Promise - -const mirrorConfig = getMirrorConfig() - -// Shared helper: require auth or redirect to login -async function requireUser(request: Request): Promise<{ user: any; redirect?: string }> { - const user = await getSessionUser(request) - if (!user) { - return { user: null, redirect: '/login' } - } - return { user } -} - -const loaders: Record = { - // --- Public pages --- - '/': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: mirrorConfig.enabled - ? `Underlay · ${mirrorConfig.nodeName}` - : 'Underlay — A public registry for structured knowledge', - } - }, - - '/explore': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Explore — Underlay', - } - }, - - '/query': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Query Explorer — Underlay', - } - }, - - '/schemas': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Schemas — Underlay', - } - }, - - '/schemas/:id': async ({ params, request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig, schemaId: params['id'] }, - title: 'Schema — Underlay', - } - }, - - // --- Auth pages --- - '/login': async ({ request }) => { - const user = await getSessionUser(request) - if (user) return { data: {}, redirect: '/dashboard' } - return { - data: { currentUser: null, mirrorConfig }, - title: 'Log in — Underlay', - } - }, - - '/signup': async ({ request }) => { - const user = await getSessionUser(request) - if (user) return { data: {}, redirect: '/dashboard' } - return { - data: { currentUser: null, mirrorConfig }, - title: 'Sign up — Underlay', - } - }, - - '/logout': async () => { - return { - data: { - kfAuthUrl: process.env.OIDC_ISSUER_URL ?? 'http://localhost:3000', - }, - } - }, - - '/forgot-password': async () => { - return { - data: { currentUser: null, mirrorConfig }, - title: 'Forgot password — Underlay', - } - }, - - '/reset-password': async () => { - return { - data: { currentUser: null, mirrorConfig }, - title: 'Reset password — Underlay', - } - }, - - // --- Dashboard/Settings --- - '/dashboard': async ({ request }) => { - const { user, redirect } = await requireUser(request) - if (redirect) return { data: {}, redirect } - return { - data: { currentUser: user, mirrorConfig }, - title: 'Dashboard — Underlay', - } - }, - - '/settings': async ({ request }) => { - const { user, redirect } = await requireUser(request) - if (redirect) return { data: {}, redirect } - return { - data: { currentUser: user, mirrorConfig }, - title: 'Settings — Underlay', - } - }, - - '/settings/keys': async ({ request }) => { - const { user, redirect } = await requireUser(request) - if (redirect) return { data: {}, redirect } - return { - data: { currentUser: user, mirrorConfig }, - title: 'API Keys — Underlay', - } - }, - - '/settings/sessions': async ({ request }) => { - const { user, redirect } = await requireUser(request) - if (redirect) return { data: {}, redirect } - return { - data: { currentUser: user, mirrorConfig }, - title: 'Sessions — Underlay', - } - }, - - '/settings/avatar': async ({ request }) => { - const { user, redirect } = await requireUser(request) - if (redirect) return { data: {}, redirect } - return { - data: { currentUser: user, mirrorConfig }, - title: 'Avatar — Underlay', - } - }, - - '/invitations/accept': async ({ request }) => { - const { user, redirect } = await requireUser(request) - if (redirect) return { data: {}, redirect } - return { - data: { currentUser: user, mirrorConfig }, - title: 'Accept Invitation — Underlay', - } - }, - - '/admin/mirror': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Mirror Admin — Underlay', - } - }, - - // --- Blog --- - '/blog': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Blog — Underlay', - } - }, - - '/blog/:slug': async ({ params, request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig, slug: params['slug'] }, - title: 'Blog — Underlay', - } - }, - - // --- Docs --- - '/docs': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Documentation — Underlay', - } - }, - - '/docs/concepts': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Core Concepts — Underlay Docs', - } - }, - - '/docs/quickstart': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Quickstart — Underlay Docs', - } - }, - - '/docs/integration': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Integration — Underlay Docs', - } - }, - - '/docs/self-host': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Self-hosting — Underlay Docs', - } - }, - - '/docs/api': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'API Reference — Underlay Docs', - } - }, - - '/docs/api/accounts': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Accounts API — Underlay Docs', - } - }, - - '/docs/api/collections': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Collections API — Underlay Docs', - } - }, - - '/docs/api/versions': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Versions API — Underlay Docs', - } - }, - - '/docs/api/files': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Files API — Underlay Docs', - } - }, - - // --- Dynamic owner/collection --- - '/:owner': async ({ params, request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig, owner: params['owner'] }, - title: `${params['owner']} — Underlay`, - } - }, - - '/:owner/settings': async ({ params, request }) => { - const { user, redirect } = await requireUser(request) - if (redirect) return { data: {}, redirect } - return { - data: { currentUser: user, mirrorConfig, owner: params['owner'] }, - title: `Settings — ${params['owner']} — Underlay`, - } - }, - - '/:owner/settings/keys': async ({ params, request }) => { - const { user, redirect } = await requireUser(request) - if (redirect) return { data: {}, redirect } - return { - data: { currentUser: user, mirrorConfig, owner: params['owner'] }, - title: `API Keys — ${params['owner']} — Underlay`, - } - }, - - '/:owner/settings/members': async ({ params, request }) => { - const { user, redirect } = await requireUser(request) - if (redirect) return { data: {}, redirect } - return { - data: { currentUser: user, mirrorConfig, owner: params['owner'] }, - title: `Members — ${params['owner']} — Underlay`, - } - }, - - '/:owner/:collection': async ({ params, request }) => { - const user = await getSessionUser(request) - return { - data: { - currentUser: user, - mirrorConfig, - owner: params['owner'], - collection: params['collection'], - }, - title: `${params['owner']}/${params['collection']} — Underlay`, - } - }, - - '/:owner/:collection/versions': async ({ params, request }) => { - const user = await getSessionUser(request) - return { - data: { - currentUser: user, - mirrorConfig, - owner: params['owner'], - collection: params['collection'], - }, - title: `Versions — ${params['owner']}/${params['collection']} — Underlay`, - } - }, - - '/:owner/:collection/v/:n': async ({ params, request }) => { - const user = await getSessionUser(request) - return { - data: { - currentUser: user, - mirrorConfig, - owner: params['owner'], - collection: params['collection'], - versionNumber: params['n'], - }, - title: `v${params['n']} — ${params['owner']}/${params['collection']} — Underlay`, - } - }, - - '/:owner/:collection/schemas': async ({ params, request }) => { - const user = await getSessionUser(request) - return { - data: { - currentUser: user, - mirrorConfig, - owner: params['owner'], - collection: params['collection'], - }, - title: `Schemas — ${params['owner']}/${params['collection']} — Underlay`, - } - }, - - '/:owner/:collection/diff': async ({ params, request }) => { - const user = await getSessionUser(request) - return { - data: { - currentUser: user, - mirrorConfig, - owner: params['owner'], - collection: params['collection'], - }, - title: `Diff — ${params['owner']}/${params['collection']} — Underlay`, - } - }, - - '/:owner/:collection/settings': async ({ params, request }) => { - const { user, redirect } = await requireUser(request) - if (redirect) return { data: {}, redirect } - return { - data: { - currentUser: user, - mirrorConfig, - owner: params['owner'], - collection: params['collection'], - }, - title: `Settings — ${params['owner']}/${params['collection']} — Underlay`, - } - }, -} - -const kfAccountUrl = process.env.OIDC_ACCOUNT_URL ?? 'http://localhost:3001' - -export async function runLoaders( - matchedRoutes: { path: string; params: Record }[], - request: Request, -): Promise { - for (const { path, params } of matchedRoutes) { - const loader = loaders[path] - if (loader) { - const result = await loader({ params, request }) - result.data.kfAccountUrl = kfAccountUrl - return result - } - } - - // No loader found — return empty data - return { data: { kfAccountUrl } } -} diff --git a/src/route-gen.ts b/src/route-gen.ts index 0cb07f1..432b9e7 100644 --- a/src/route-gen.ts +++ b/src/route-gen.ts @@ -1,64 +1,38 @@ -/** - * Filesystem-based route generation. - * - * Converts file paths from import.meta.glob into React Router route patterns. - * Follows Next.js/Astro conventions: - * - index.tsx → parent path - * - [param].tsx → :param dynamic segment - * - static segments sort before dynamic ones - */ +import type { ComponentType } from 'react' +import type { RouteObject } from 'react-router' export interface RouteEntry { path: string filePath: string } -/** - * Convert a glob key like "./routes/docs/api/[id].tsx" - * into a route path like "/docs/api/:id". - */ function fileToRoutePath(file: string): string { - // Strip prefix and extension: "./routes/foo/bar.tsx" → "foo/bar" let route = file.replace(/^\.\/routes\//, '').replace(/\.tsx$/, '') - - // index files map to parent: "docs/index" → "docs", "index" → "" route = route.replace(/(^|\/)+index$/, '') - - // Convert [param] segments to :param route = route.replace(/\[([^\]]+)\]/g, ':$1') - return '/' + route } -/** - * Sort routes so static segments come before dynamic ones (Next.js/Astro convention). - * More specific routes first, catch-all dynamic routes last. - */ function sortRoutes(routes: RouteEntry[]): RouteEntry[] { return routes.sort((a, b) => { const aParts = a.path.split('/').filter(Boolean) const bParts = b.path.split('/').filter(Boolean) - // Compare segment by segment const len = Math.max(aParts.length, bParts.length) for (let i = 0; i < len; i++) { const aP = aParts[i] const bP = bParts[i] - // Missing segment = shorter path, comes later for catch-all scenarios - // But "/" (root) should come first if (!aP && !bP) continue - if (!aP) return -1 // a is shorter → a first - if (!bP) return 1 // b is shorter → b first + if (!aP) return -1 + if (!bP) return 1 const aDynamic = aP.startsWith(':') const bDynamic = bP.startsWith(':') - // Static before dynamic at the same level if (!aDynamic && bDynamic) return -1 if (aDynamic && !bDynamic) return 1 - // Both static or both dynamic: alphabetical if (aP < bP) return -1 if (aP > bP) return 1 } @@ -67,10 +41,6 @@ function sortRoutes(routes: RouteEntry[]): RouteEntry[] { }) } -/** - * Build sorted route entries from a glob result. - * Usage: buildRoutes(import.meta.glob('./routes/...*.tsx')) - */ export function buildRoutes(globResult: Record Promise>): RouteEntry[] { const entries: RouteEntry[] = Object.keys(globResult).map((filePath) => ({ path: fileToRoutePath(filePath), @@ -79,3 +49,18 @@ export function buildRoutes(globResult: Record Promise>): return sortRoutes(entries) } + +export function buildDataRoutes(globResult: Record Promise>): RouteObject[] { + const entries = buildRoutes(globResult) + + return entries.map((entry) => ({ + path: entry.path, + lazy: async () => { + const mod = await globResult[entry.filePath]!() + return { + Component: mod.default as ComponentType, + ...(mod.handle && { handle: mod.handle }), + } + }, + })) +} diff --git a/src/routes/404.tsx b/src/routes/404.tsx index c724776..0eec04e 100644 --- a/src/routes/404.tsx +++ b/src/routes/404.tsx @@ -1,6 +1,8 @@ import BaseLayout from '~/components/BaseLayout' import NotFound from '~/components/NotFound' +export const handle = { title: 'Page not found — Underlay' } + export default function NotFoundPage() { return ( diff --git a/src/routes/[owner]/[collection]/diff.tsx b/src/routes/[owner]/[collection]/diff.tsx index d8ab3ff..b2130e8 100644 --- a/src/routes/[owner]/[collection]/diff.tsx +++ b/src/routes/[owner]/[collection]/diff.tsx @@ -3,7 +3,7 @@ import { useParams, useSearchParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' import { NotFoundError } from '~/components/NotFound' -import { useSSRData } from '~/lib/ssr-data' +import { useAppContext } from '~/lib/app-context' import { CollectionNav } from '.' @@ -16,10 +16,15 @@ function groupByType(records: any[]) { return groups } +export const handle = { + title: (params: Record) => + 'Diff — ' + params.owner + '/' + params.collection + ' — Underlay', +} + export default function CollectionDiffPage() { const { owner, collection } = useParams() const [searchParams, setSearchParams] = useSearchParams() - const currentUser = useSSRData('currentUser') + const { currentUser } = useAppContext() const [data, setData] = useState(null) const [versions, setVersions] = useState([]) diff --git a/src/routes/[owner]/[collection]/index.tsx b/src/routes/[owner]/[collection]/index.tsx index b3aa337..0690d21 100644 --- a/src/routes/[owner]/[collection]/index.tsx +++ b/src/routes/[owner]/[collection]/index.tsx @@ -3,7 +3,7 @@ import { Link, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' import { NotFoundError } from '~/components/NotFound' -import { useSSRData } from '~/lib/ssr-data' +import { useAppContext } from '~/lib/app-context' function CollectionNav({ owner, @@ -83,10 +83,13 @@ function formatBytes(bytes: number): string { return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] } +export const handle = { + title: (params: Record) => params.owner + '/' + params.collection + ' — Underlay', +} + export default function CollectionPage() { const { owner, collection } = useParams() - const currentUser = useSSRData('currentUser') - const mirrorConfig = useSSRData('mirrorConfig') + const { currentUser, mirrorConfig } = useAppContext() const [data, setData] = useState(null) const [totalVersions, setTotalVersions] = useState(0) diff --git a/src/routes/[owner]/[collection]/schemas.tsx b/src/routes/[owner]/[collection]/schemas.tsx index 4a07b4b..a9ca6e3 100644 --- a/src/routes/[owner]/[collection]/schemas.tsx +++ b/src/routes/[owner]/[collection]/schemas.tsx @@ -3,13 +3,18 @@ import { Link, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' import { NotFoundError } from '~/components/NotFound' -import { useSSRData } from '~/lib/ssr-data' +import { useAppContext } from '~/lib/app-context' import { CollectionNav } from '.' +export const handle = { + title: (params: Record) => + 'Schemas — ' + params.owner + '/' + params.collection + ' — Underlay', +} + export default function CollectionSchemasPage() { const { owner, collection } = useParams() - const currentUser = useSSRData('currentUser') + const { currentUser } = useAppContext() const [data, setData] = useState(null) const [isOwner, setIsOwner] = useState(false) diff --git a/src/routes/[owner]/[collection]/settings.tsx b/src/routes/[owner]/[collection]/settings.tsx index 77af5df..28da18b 100644 --- a/src/routes/[owner]/[collection]/settings.tsx +++ b/src/routes/[owner]/[collection]/settings.tsx @@ -1,15 +1,21 @@ import { type FormEvent, useEffect, useState } from 'react' -import { Link, useParams } from 'react-router' +import { Link, Navigate, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' import { NotFoundError } from '~/components/NotFound' -import { useSSRData } from '~/lib/ssr-data' +import { useAppContext } from '~/lib/app-context' import { CollectionNav } from '.' +export const handle = { + title: (params: Record) => + 'Settings — ' + params.owner + '/' + params.collection + ' — Underlay', + requireAuth: true, +} + export default function CollectionSettingsPage() { const { owner, collection } = useParams() - const currentUser = useSSRData('currentUser') + const { currentUser } = useAppContext() const [data, setData] = useState(null) const [arkSettings, setArkSettings] = useState({ @@ -73,10 +79,7 @@ export default function CollectionSettingsPage() { }) }, [owner, collection, currentUser]) - if (!currentUser) { - window.location.href = '/login' - return null - } + if (!currentUser) return function clearMessages() { setSuccess('') diff --git a/src/routes/[owner]/[collection]/v/[n].tsx b/src/routes/[owner]/[collection]/v/[n].tsx index f52a86c..310431b 100644 --- a/src/routes/[owner]/[collection]/v/[n].tsx +++ b/src/routes/[owner]/[collection]/v/[n].tsx @@ -3,14 +3,19 @@ import { Link, useParams, useSearchParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' import { NotFoundError } from '~/components/NotFound' -import { useSSRData } from '~/lib/ssr-data' +import { useAppContext } from '~/lib/app-context' import { CollectionNav, formatBytes } from '..' +export const handle = { + title: (params: Record) => + 'v' + params.n + ' — ' + params.owner + '/' + params.collection + ' — Underlay', +} + export default function CollectionVersionPage() { const { owner, collection, n } = useParams() const [searchParams, setSearchParams] = useSearchParams() - const currentUser = useSSRData('currentUser') + const { currentUser } = useAppContext() const [version, setVersion] = useState(null) const [collectionData, setCollectionData] = useState(null) diff --git a/src/routes/[owner]/[collection]/versions.tsx b/src/routes/[owner]/[collection]/versions.tsx index af65b11..94b512c 100644 --- a/src/routes/[owner]/[collection]/versions.tsx +++ b/src/routes/[owner]/[collection]/versions.tsx @@ -3,13 +3,18 @@ import { Link, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' import { NotFoundError } from '~/components/NotFound' -import { useSSRData } from '~/lib/ssr-data' +import { useAppContext } from '~/lib/app-context' import { CollectionNav, formatBytes } from '.' +export const handle = { + title: (params: Record) => + 'Versions — ' + params.owner + '/' + params.collection + ' — Underlay', +} + export default function CollectionVersionsPage() { const { owner, collection } = useParams() - const currentUser = useSSRData('currentUser') + const { currentUser } = useAppContext() const [data, setData] = useState(null) const [versions, setVersions] = useState([]) diff --git a/src/routes/[owner]/index.tsx b/src/routes/[owner]/index.tsx index 7ef1ddb..078532f 100644 --- a/src/routes/[owner]/index.tsx +++ b/src/routes/[owner]/index.tsx @@ -3,11 +3,13 @@ import { Link, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' import { NotFoundError } from '~/components/NotFound' -import { useSSRData } from '~/lib/ssr-data' +import { useAppContext } from '~/lib/app-context' + +export const handle = { title: (params: Record) => params.owner + ' — Underlay' } export default function OwnerPage() { const { owner } = useParams() - const currentUser = useSSRData('currentUser') + const { currentUser } = useAppContext() const [account, setAccount] = useState(null) const [collections, setCollections] = useState([]) diff --git a/src/routes/[owner]/settings/index.tsx b/src/routes/[owner]/settings/index.tsx index c20a9b2..cd29c91 100644 --- a/src/routes/[owner]/settings/index.tsx +++ b/src/routes/[owner]/settings/index.tsx @@ -1,13 +1,18 @@ import { type FormEvent, useEffect, useState } from 'react' -import { Link, useNavigate, useParams } from 'react-router' +import { Link, Navigate, useNavigate, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' import { NotFoundError } from '~/components/NotFound' -import { useSSRData } from '~/lib/ssr-data' +import { useAppContext } from '~/lib/app-context' + +export const handle = { + title: (params: Record) => 'Settings — ' + params.owner + ' — Underlay', + requireAuth: true, +} export default function OwnerSettings() { const { owner } = useParams() - const currentUser = useSSRData('currentUser') + const { currentUser } = useAppContext() const [orgData, setOrgData] = useState(null) const [isOwner, setIsOwner] = useState(false) @@ -78,10 +83,7 @@ export default function OwnerSettings() { .catch(() => setKfOrgsLoading(false)) }, [owner, currentUser]) - if (!currentUser) { - window.location.href = '/login' - return null - } + if (!currentUser) return function clearMessages() { setSuccess('') diff --git a/src/routes/[owner]/settings/keys.tsx b/src/routes/[owner]/settings/keys.tsx index fb9e836..ec0c6e2 100644 --- a/src/routes/[owner]/settings/keys.tsx +++ b/src/routes/[owner]/settings/keys.tsx @@ -1,11 +1,11 @@ import { type FormEvent, useEffect, useState } from 'react' -import { Link, useParams } from 'react-router' +import { Link, Navigate, useParams } from 'react-router' import { ApiPlayground } from '~/components/ApiPlayground' import BaseLayout from '~/components/BaseLayout' import { NotFoundError } from '~/components/NotFound' +import { useAppContext } from '~/lib/app-context' import { authClient } from '~/lib/auth-client' -import { useSSRData } from '~/lib/ssr-data' interface Key { id: string @@ -35,9 +35,14 @@ function getScope(permissions?: Record): string { return 'read' } +export const handle = { + title: (params: Record) => 'API Keys — ' + params.owner + ' — Underlay', + requireAuth: true, +} + export default function OwnerSettingsKeys() { const { owner } = useParams() - const currentUser = useSSRData('currentUser') + const { currentUser } = useAppContext() const [orgData, setOrgData] = useState(null) const [isAdmin, setIsAdmin] = useState(false) @@ -89,10 +94,7 @@ export default function OwnerSettingsKeys() { }) }, [owner, currentUser]) - if (!currentUser) { - window.location.href = '/login' - return null - } + if (!currentUser) return async function handleCreateKey(e: FormEvent) { e.preventDefault() diff --git a/src/routes/[owner]/settings/members.tsx b/src/routes/[owner]/settings/members.tsx index 3f1ba83..cde2a59 100644 --- a/src/routes/[owner]/settings/members.tsx +++ b/src/routes/[owner]/settings/members.tsx @@ -1,14 +1,19 @@ import { type FormEvent, useEffect, useState } from 'react' -import { Link, useParams } from 'react-router' +import { Link, Navigate, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' import { NotFoundError } from '~/components/NotFound' +import { useAppContext } from '~/lib/app-context' import { authClient } from '~/lib/auth-client' -import { useSSRData } from '~/lib/ssr-data' + +export const handle = { + title: (params: Record) => 'Members — ' + params.owner + ' — Underlay', + requireAuth: true, +} export default function OwnerSettingsMembers() { const { owner } = useParams() - const currentUser = useSSRData('currentUser') + const { currentUser } = useAppContext() const [orgData, setOrgData] = useState(null) const [orgId, setOrgId] = useState(null) @@ -64,10 +69,7 @@ export default function OwnerSettingsMembers() { }) }, [owner, currentUser]) - if (!currentUser) { - window.location.href = '/login' - return null - } + if (!currentUser) return function clearMessages() { setSuccess('') diff --git a/src/routes/admin/mirror.tsx b/src/routes/admin/mirror.tsx index daaac5a..6a5cf6a 100644 --- a/src/routes/admin/mirror.tsx +++ b/src/routes/admin/mirror.tsx @@ -1,17 +1,11 @@ import BaseLayout from '~/components/BaseLayout' import MirrorAdmin from '~/components/MirrorAdmin' -import { useSSRData } from '~/lib/ssr-data' +import { useAppContext } from '~/lib/app-context' -interface MirrorConfig { - enabled: boolean - nodeName: string - upstream: string - syncSchedule: string -} +export const handle = { title: 'Mirror Admin — Underlay' } export default function AdminMirror() { - const me = useSSRData('currentUser') - const mirrorConfig = useSSRData('mirrorConfig') + const { currentUser, mirrorConfig } = useAppContext() if (!mirrorConfig?.enabled) { if (typeof window !== 'undefined') { @@ -20,7 +14,7 @@ export default function AdminMirror() { return null } - if (!me) return null + if (!currentUser) return null return ( diff --git a/src/routes/blog/[slug].tsx b/src/routes/blog/[slug].tsx index cf30f70..75e50d5 100644 --- a/src/routes/blog/[slug].tsx +++ b/src/routes/blog/[slug].tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react' import { useParams } from 'react-router' import BlogLayout from '~/components/BlogLayout' -import { useSSRData } from '~/lib/ssr-data' // Blog post metadata const posts: Record = { @@ -28,6 +27,8 @@ const posts: Record = }, } +export const handle = { title: 'Blog — Underlay' } + export default function BlogPost() { const { slug } = useParams() const [content, setContent] = useState(null) diff --git a/src/routes/blog/index.tsx b/src/routes/blog/index.tsx index 6fe051e..4e97551 100644 --- a/src/routes/blog/index.tsx +++ b/src/routes/blog/index.tsx @@ -38,6 +38,8 @@ function isoDate(d: string) { return new Date(d).toISOString().slice(0, 10) } +export const handle = { title: 'Blog — Underlay' } + export default function Blog() { return ( diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index ba9e054..94ae95c 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -1,9 +1,9 @@ import { type FormEvent, useEffect, useState } from 'react' -import { Link } from 'react-router' +import { Link, Navigate } from 'react-router' import BaseLayout from '~/components/BaseLayout' +import { useAppContext } from '~/lib/app-context' import { authClient } from '~/lib/auth-client' -import { useSSRData } from '~/lib/ssr-data' interface Collection { id: string @@ -28,8 +28,10 @@ interface KfOrg { role: string } +export const handle = { title: 'Dashboard — Underlay', requireAuth: true } + export default function Dashboard() { - const me = useSSRData('currentUser') + const { currentUser } = useAppContext() const [orgs, setOrgs] = useState([]) const [filter, setFilter] = useState('') @@ -52,14 +54,14 @@ export default function Dashboard() { const [colSubmitting, setColSubmitting] = useState(false) useEffect(() => { - if (!me) return + if (!currentUser) return - const defaultOrg = me.orgs?.find((o: any) => o.isDefault) ?? me.orgs?.[0] - setColOwner(defaultOrg?.slug ?? me.slug) + const defaultOrg = currentUser.orgs?.find((o: any) => o.isDefault) ?? currentUser.orgs?.[0] + setColOwner(defaultOrg?.slug ?? currentUser.slug) - if (me.orgs?.length) { + if (currentUser.orgs?.length) { Promise.all( - me.orgs.map(async (org: any) => { + currentUser.orgs.map(async (org: any) => { const res = await fetch(`/api/accounts/${org.slug}/collections`, { credentials: 'include', }) @@ -83,7 +85,7 @@ export default function Dashboard() { setOrgKfOrgId(orgs[0]!.id) } }) - }, [me]) + }, [currentUser]) async function handleCreateOrg(e: FormEvent) { e.preventDefault() @@ -138,7 +140,7 @@ export default function Dashboard() { return text.toLowerCase().includes(filter.toLowerCase()) } - if (!me) return null + if (!currentUser) return return ( @@ -324,7 +326,7 @@ export default function Dashboard() {

Via API

- POST /api/accounts/{me.slug}/collections + POST /api/accounts/{currentUser.slug}/collections
Your profile → diff --git a/src/routes/docs/api/accounts.tsx b/src/routes/docs/api/accounts.tsx index 14579e5..deb72e2 100644 --- a/src/routes/docs/api/accounts.tsx +++ b/src/routes/docs/api/accounts.tsx @@ -33,6 +33,8 @@ const createKeyRes = `{ const deleteKeyRes = `{"ok": true}` +export const handle = { title: 'Accounts API — Underlay Docs' } + export default function DocsApiAccounts() { return ( diff --git a/src/routes/docs/api/collections.tsx b/src/routes/docs/api/collections.tsx index 6857847..fbfcb81 100644 --- a/src/routes/docs/api/collections.tsx +++ b/src/routes/docs/api/collections.tsx @@ -69,6 +69,8 @@ const listRes = `[ } ]` +export const handle = { title: 'Collections API — Underlay Docs' } + export default function DocsApiCollections() { return ( diff --git a/src/routes/docs/api/files.tsx b/src/routes/docs/api/files.tsx index c124c62..afc619c 100644 --- a/src/routes/docs/api/files.tsx +++ b/src/routes/docs/api/files.tsx @@ -35,6 +35,8 @@ const fileRefExample = `{ } }` +export const handle = { title: 'Files API — Underlay Docs' } + export default function DocsApiFiles() { return ( diff --git a/src/routes/docs/api/index.tsx b/src/routes/docs/api/index.tsx index 91bd139..c7c9fe4 100644 --- a/src/routes/docs/api/index.tsx +++ b/src/routes/docs/api/index.tsx @@ -2,6 +2,8 @@ import { Link } from 'react-router' import DocsLayout from '~/components/DocsLayout' +export const handle = { title: 'API Reference — Underlay Docs' } + export default function DocsApi() { return ( diff --git a/src/routes/docs/api/versions.tsx b/src/routes/docs/api/versions.tsx index 09e3fd0..285c8a8 100644 --- a/src/routes/docs/api/versions.tsx +++ b/src/routes/docs/api/versions.tsx @@ -82,6 +82,8 @@ const diffRes = `{ "removed": ["pub-old"] }` +export const handle = { title: 'Versions API — Underlay Docs' } + export default function DocsApiVersions() { return ( diff --git a/src/routes/docs/concepts.tsx b/src/routes/docs/concepts.tsx index 9eedb1c..22678c3 100644 --- a/src/routes/docs/concepts.tsx +++ b/src/routes/docs/concepts.tsx @@ -13,6 +13,8 @@ const recordExample = `{ const fileRef = '{"$file": "sha256:..."}' +export const handle = { title: 'Core Concepts — Underlay Docs' } + export default function DocsConcepts() { return ( diff --git a/src/routes/docs/index.tsx b/src/routes/docs/index.tsx index 3c31d86..9e195c7 100644 --- a/src/routes/docs/index.tsx +++ b/src/routes/docs/index.tsx @@ -2,6 +2,8 @@ import { Link } from 'react-router' import DocsLayout from '~/components/DocsLayout' +export const handle = { title: 'Documentation — Underlay' } + export default function Docs() { return ( diff --git a/src/routes/docs/integration.tsx b/src/routes/docs/integration.tsx index 962b655..acbfb28 100644 --- a/src/routes/docs/integration.tsx +++ b/src/routes/docs/integration.tsx @@ -72,6 +72,8 @@ curl -X POST https://underlay.org/api/collections/:owner/:slug/versions \\ } }'` +export const handle = { title: 'Integration — Underlay Docs' } + export default function DocsIntegration() { return ( diff --git a/src/routes/docs/quickstart.tsx b/src/routes/docs/quickstart.tsx index 4fd9872..830986d 100644 --- a/src/routes/docs/quickstart.tsx +++ b/src/routes/docs/quickstart.tsx @@ -88,6 +88,8 @@ curl -X PUT "https://underlay.org/api/collections/yourname/my-dataset/files/sha2 # Reference in a record # {"id": "book-1", "type": "Book", "data": {"title": "...", "pdf": {"$file": "sha256:..."}}}` +export const handle = { title: 'Quickstart — Underlay Docs' } + export default function DocsQuickstart() { return ( diff --git a/src/routes/docs/self-host.tsx b/src/routes/docs/self-host.tsx index db3e86d..7743a49 100644 --- a/src/routes/docs/self-host.tsx +++ b/src/routes/docs/self-host.tsx @@ -19,6 +19,8 @@ npm run tool:backup # Backups are stored at: # s3://{bucket}/{BACKUP_S3_PREFIX}{timestamp}/underlay.sql.gz` +export const handle = { title: 'Self-hosting — Underlay Docs' } + export default function DocsSelfHost() { return ( diff --git a/src/routes/explore.tsx b/src/routes/explore.tsx index 6ad5da3..1130e49 100644 --- a/src/routes/explore.tsx +++ b/src/routes/explore.tsx @@ -1,6 +1,8 @@ import BaseLayout from '~/components/BaseLayout' import CollectionExplorer from '~/components/CollectionExplorer' +export const handle = { title: 'Explore — Underlay' } + export default function ExplorePage() { return ( diff --git a/src/routes/forgot-password.tsx b/src/routes/forgot-password.tsx index ae18a6a..351a9a0 100644 --- a/src/routes/forgot-password.tsx +++ b/src/routes/forgot-password.tsx @@ -2,6 +2,8 @@ import { useEffect } from 'react' import BaseLayout from '~/components/BaseLayout' +export const handle = { title: 'Forgot password — Underlay' } + export default function ForgotPasswordPage() { useEffect(() => { // Password management now happens via KF Auth diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 3918aba..9823f96 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,16 +1,17 @@ import { Link } from 'react-router' import BaseLayout from '~/components/BaseLayout' -import { useSSRData } from '~/lib/ssr-data' +import { useAppContext } from '~/lib/app-context' -interface MirrorConfig { - enabled: boolean - nodeName: string - upstream: string +export const handle = { + title: (_p: any, loaderData: any) => + loaderData?.root?.mirrorConfig?.enabled + ? 'Underlay · ' + loaderData.root.mirrorConfig.nodeName + : 'Underlay — A public registry for structured knowledge', } export default function Home() { - const mirrorConfig = useSSRData('mirrorConfig') + const { mirrorConfig } = useAppContext() if (mirrorConfig?.enabled) { return ( diff --git a/src/routes/invitations/accept.tsx b/src/routes/invitations/accept.tsx index 861625e..2212305 100644 --- a/src/routes/invitations/accept.tsx +++ b/src/routes/invitations/accept.tsx @@ -2,10 +2,12 @@ import { type FormEvent, useState } from 'react' import { Link } from 'react-router' import BaseLayout from '~/components/BaseLayout' -import { useSSRData } from '~/lib/ssr-data' +import { useAppContext } from '~/lib/app-context' + +export const handle = { title: 'Accept Invitation — Underlay', requireAuth: true } export default function InvitationsAccept() { - const me = useSSRData('currentUser') + const { currentUser } = useAppContext() const params = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '') const token = params.get('token') ?? '' @@ -63,7 +65,7 @@ export default function InvitationsAccept() { ) : error ? (

{error}

- {!me && token && ( + {!currentUser && token && (

You may need to{' '} ) : ( <> - {!me ? ( + {!currentUser ? (

You've been invited to join an organization. Please log in or sign up to accept. diff --git a/src/routes/login.tsx b/src/routes/login.tsx index c24e6d2..482c9d7 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -3,6 +3,8 @@ import { useSearchParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' +export const handle = { title: 'Log in — Underlay' } + export default function LoginPage() { const [params] = useSearchParams() const error = params.get('error') diff --git a/src/routes/logout.tsx b/src/routes/logout.tsx index 404493f..8eff63f 100644 --- a/src/routes/logout.tsx +++ b/src/routes/logout.tsx @@ -1,11 +1,13 @@ import { useEffect } from 'react' import BaseLayout from '~/components/BaseLayout' +import { useAppContext } from '~/lib/app-context' import { authClient } from '~/lib/auth-client' -import { useSSRData } from '~/lib/ssr-data' + +export const handle = { title: 'Log out — Underlay' } export default function LogoutPage() { - const kfAuthUrl = useSSRData('kfAuthUrl') + const { kfAuthUrl } = useAppContext() useEffect(() => { authClient.signOut().then(() => { diff --git a/src/routes/query.tsx b/src/routes/query.tsx index 5179f0d..59cf3dc 100644 --- a/src/routes/query.tsx +++ b/src/routes/query.tsx @@ -2,14 +2,12 @@ import { Link } from 'react-router' import QueryExplorer from '~/components/QueryExplorer' import UserMenu from '~/components/UserMenu' -import { useSSRData } from '~/lib/ssr-data' +import { useAppContext } from '~/lib/app-context' + +export const handle = { title: 'Query Explorer — Underlay' } export default function QueryPage() { - const currentUser = useSSRData<{ - slug: string - displayName: string - orgs?: { slug: string; displayName: string }[] - } | null>('currentUser') + const { currentUser } = useAppContext() return (

diff --git a/src/routes/reset-password.tsx b/src/routes/reset-password.tsx index 7338c4e..61b69da 100644 --- a/src/routes/reset-password.tsx +++ b/src/routes/reset-password.tsx @@ -2,6 +2,8 @@ import { useEffect } from 'react' import BaseLayout from '~/components/BaseLayout' +export const handle = { title: 'Reset password — Underlay' } + export default function ResetPasswordPage() { useEffect(() => { window.location.href = '/login' diff --git a/src/routes/schemas/[id].tsx b/src/routes/schemas/[id].tsx index b18387b..76a944a 100644 --- a/src/routes/schemas/[id].tsx +++ b/src/routes/schemas/[id].tsx @@ -3,7 +3,6 @@ import { Link, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' import SchemaLabelManager from '~/components/SchemaLabelManager' -import { useSSRData } from '~/lib/ssr-data' interface SchemaData { id: number @@ -14,9 +13,11 @@ interface SchemaData { usage?: { slug: string; semver: string; versionNumber: number; collection: string }[] } +export const handle = { title: 'Schema — Underlay' } + export default function SchemaDetailPage() { const params = useParams() - const schemaId = useSSRData('schemaId') ?? params.id + const schemaId = params.id const [schema, setSchema] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState('') diff --git a/src/routes/schemas/index.tsx b/src/routes/schemas/index.tsx index 4e50b11..2be37a5 100644 --- a/src/routes/schemas/index.tsx +++ b/src/routes/schemas/index.tsx @@ -1,6 +1,8 @@ import BaseLayout from '~/components/BaseLayout' import SchemaBrowser from '~/components/SchemaBrowser' +export const handle = { title: 'Schemas — Underlay' } + export default function SchemasPage() { return ( diff --git a/src/routes/settings/avatar.tsx b/src/routes/settings/avatar.tsx index 2106df0..1eaa9b8 100644 --- a/src/routes/settings/avatar.tsx +++ b/src/routes/settings/avatar.tsx @@ -1,16 +1,18 @@ import { type FormEvent, useState } from 'react' -import { Link } from 'react-router' +import { Link, Navigate } from 'react-router' import BaseLayout from '~/components/BaseLayout' -import { useSSRData } from '~/lib/ssr-data' +import { useAppContext } from '~/lib/app-context' + +export const handle = { title: 'Avatar — Underlay', requireAuth: true } export default function SettingsAvatar() { - const me = useSSRData('currentUser') + const { currentUser } = useAppContext() const [success, setSuccess] = useState('') const [error, setError] = useState('') const [submitting, setSubmitting] = useState(false) - const [avatarUrl, setAvatarUrl] = useState(me?.avatarUrl ?? '') + const [avatarUrl, setAvatarUrl] = useState(currentUser?.avatarUrl ?? '') async function handleUpload(e: FormEvent) { e.preventDefault() @@ -50,7 +52,7 @@ export default function SettingsAvatar() { } } - if (!me) return null + if (!currentUser) return return ( @@ -100,7 +102,7 @@ export default function SettingsAvatar() { /> ) : (
- {me.displayName?.charAt(0)?.toUpperCase() ?? '?'} + {currentUser.displayName?.charAt(0)?.toUpperCase() ?? '?'}
)}
diff --git a/src/routes/settings/index.tsx b/src/routes/settings/index.tsx index 18289d4..e8e99cc 100644 --- a/src/routes/settings/index.tsx +++ b/src/routes/settings/index.tsx @@ -1,24 +1,25 @@ import { type FormEvent, useState } from 'react' -import { Link } from 'react-router' +import { Link, Navigate } from 'react-router' import BaseLayout from '~/components/BaseLayout' -import { useSSRData } from '~/lib/ssr-data' +import { useAppContext } from '~/lib/app-context' + +export const handle = { title: 'Settings — Underlay', requireAuth: true } export default function Settings() { - const me = useSSRData('currentUser') - const kfAccountUrl = useSSRData('kfAccountUrl') + const { currentUser, kfAccountUrl } = useAppContext() const [success, setSuccess] = useState('') const [error, setError] = useState('') // Profile form (Underlay-specific fields only — name/email/avatar managed by KF Auth) - const [slugValue, setSlugValue] = useState(me?.slug ?? '') - const [bio, setBio] = useState(me?.bio ?? '') - const [website, setWebsite] = useState(me?.website ?? '') - const [location, setLocation] = useState(me?.location ?? '') + const [slugValue, setSlugValue] = useState(currentUser?.slug ?? '') + const [bio, setBio] = useState(currentUser?.bio ?? '') + const [website, setWebsite] = useState(currentUser?.website ?? '') + const [location, setLocation] = useState(currentUser?.location ?? '') // Notifications - const notifPrefs = (me?.notificationPrefs as Record) ?? {} + const notifPrefs = (currentUser?.notificationPrefs as Record) ?? {} const [collectionActivity, setCollectionActivity] = useState( notifPrefs.collectionActivity ?? true, ) @@ -39,7 +40,7 @@ export default function Settings() { e.preventDefault() clearMessages() setSubmitting('profile') - const slugChanged = slugValue.trim() !== '' && slugValue.trim() !== me?.slug + const slugChanged = slugValue.trim() !== '' && slugValue.trim() !== currentUser?.slug try { const payload: Record = { bio, website, location } if (slugChanged) payload.slug = slugValue.trim() @@ -110,7 +111,7 @@ export default function Settings() { } } - if (!me) return null + if (!currentUser) return return ( @@ -145,20 +146,20 @@ export default function Settings() {

Profile

- {me.avatarUrl ? ( + {currentUser.avatarUrl ? ( Avatar ) : (
- {me.displayName?.charAt(0)?.toUpperCase() ?? '?'} + {currentUser.displayName?.charAt(0)?.toUpperCase() ?? '?'}
)}
-

{me.displayName}

-

@{me.slug}

+

{currentUser.displayName}

+

@{currentUser.slug}

-

{me.displayName}

+

{currentUser.displayName}

Managed by your{' '} - {slugValue !== me?.slug && ( + {slugValue !== currentUser?.slug && (

Changing your username will update all your URLs.

@@ -334,7 +335,7 @@ export default function Settings() {
): string { return 'read' } +export const handle = { title: 'API Keys — Underlay', requireAuth: true } + export default function SettingsKeys() { - const me = useSSRData('currentUser') + const { currentUser } = useAppContext() const [keys, setKeys] = useState([]) const [collections, setCollections] = useState([]) @@ -58,12 +60,12 @@ export default function SettingsKeys() { } useEffect(() => { - if (!me) return + if (!currentUser) return loadKeys() - fetch(`/api/accounts/${me.slug}/collections`, { credentials: 'include' }) + fetch(`/api/accounts/${currentUser.slug}/collections`, { credentials: 'include' }) .then((r) => (r.ok ? r.json() : [])) .then(setCollections) - }, [me]) + }, [currentUser]) async function handleCreateKey(e: FormEvent) { e.preventDefault() @@ -94,7 +96,7 @@ export default function SettingsKeys() { setKeys((prev) => prev.filter((k) => k.id !== keyId)) } - if (!me) return null + if (!currentUser) return return ( @@ -255,7 +257,7 @@ export default function SettingsKeys() { Test API calls using your session. Select an endpoint to get started.

({ id: c.id, slug: c.slug }))} />
diff --git a/src/routes/settings/sessions.tsx b/src/routes/settings/sessions.tsx index a11e672..d8debd0 100644 --- a/src/routes/settings/sessions.tsx +++ b/src/routes/settings/sessions.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react' -import { Link } from 'react-router' +import { Link, Navigate } from 'react-router' import BaseLayout from '~/components/BaseLayout' -import { useSSRData } from '~/lib/ssr-data' +import { useAppContext } from '~/lib/app-context' interface Session { id: string @@ -23,19 +23,21 @@ function parseUserAgent(ua: string | null): string { return 'Unknown browser' } +export const handle = { title: 'Sessions — Underlay', requireAuth: true } + export default function SettingsSessions() { - const me = useSSRData('currentUser') + const { currentUser } = useAppContext() const [sessions, setSessions] = useState([]) const [success, setSuccess] = useState('') const [error, setError] = useState('') useEffect(() => { - if (!me) return + if (!currentUser) return fetch('/api/accounts/me/sessions', { credentials: 'include' }) .then((r) => (r.ok ? r.json() : [])) .then(setSessions) - }, [me]) + }, [currentUser]) async function handleRevoke(sessionId: string) { setSuccess('') @@ -52,7 +54,7 @@ export default function SettingsSessions() { } } - if (!me) return null + if (!currentUser) return return ( diff --git a/src/routes/signup.tsx b/src/routes/signup.tsx index 62a8953..adafb1f 100644 --- a/src/routes/signup.tsx +++ b/src/routes/signup.tsx @@ -2,6 +2,8 @@ import { useEffect } from 'react' import BaseLayout from '~/components/BaseLayout' +export const handle = { title: 'Sign up — Underlay' } + export default function SignupPage() { useEffect(() => { // Account creation now happens via KF Auth diff --git a/vite.config.ts b/vite.config.ts index c0e8f01..17fe668 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,7 +10,7 @@ function serverOnly(): import('vite').Plugin { resolveId(source, importer) { if (source.endsWith('.server') || source.includes('.server.')) { if (importer && !importer.includes('.server.') && !importer.includes('entry-server')) { - if (importer.endsWith('server.ts') || importer.includes('loaders.server')) { + if (importer.endsWith('server.ts')) { return null } this.error(`Cannot import server-only module "${source}" from client code "${importer}"`)