diff --git a/server.ts b/server.ts index ef70d11..4a4a8f4 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 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) @@ -253,30 +266,20 @@ if (isProd) { await runMigrations() const template = readFileSync(clientHtml, 'utf-8') - const { render, loadData } = await import(ssrBundle as string) - - app.get('/__data', async (c) => { - const path = new URL(c.req.url).searchParams.get('path') - if (!path) return c.json({ error: 'Missing path param' }, 400) - const fakeUrl = new URL(path, c.req.url) - const req = new Request(fakeUrl, { headers: c.req.raw.headers }) - const result = await loadData(req) - if (result.redirect) return c.json({ redirect: result.redirect }, 200) - return c.json(result) - }) + 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) - } + if (redirect) return c.redirect(redirect, statusCode ?? 302) let page = template .replace('', html) .replace( '', - ``, + ``, ) if (title) { @@ -309,34 +312,23 @@ if (isProd) { }) }) - app.get('/__data', async (c) => { - const path = new URL(c.req.url).searchParams.get('path') - if (!path) return c.json({ error: 'Missing path param' }, 400) - const { loadData } = await vite!.ssrLoadModule('/src/entry-server.tsx') - const fakeUrl = new URL(path, c.req.url) - const req = new Request(fakeUrl, { headers: c.req.raw.headers }) - const result = await loadData(req) - if (result.redirect) return c.json({ redirect: result.redirect }, 200) - return c.json(result) - }) - app.get('*', async (c) => { const url = c.req.url let template = readFileSync(resolve('index.html'), 'utf-8') 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) - } + if (redirect) 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..fcc2fb5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,27 +1,35 @@ -import { lazy, Suspense } from 'react' -import { Route, Routes } from 'react-router' +import type { LoaderFunctionArgs, RouteObject } from 'react-router' -import { AppErrorBoundary } from '~/components/NotFound' -import { buildRoutes } from '~/route-gen' +import Root from '~/components/Root' +import { buildDataRoutes } from '~/route-gen' -const modules = import.meta.glob<{ default: React.ComponentType }>('./routes/**/[!_]*.tsx') -const routes = buildRoutes(modules) +const components = import.meta.glob<{ default: React.ComponentType }>('./routes/**/[!_]*.tsx') +const dataModules = import.meta.glob<{ + loader?: RouteObject['loader'] + handle?: unknown + middleware?: RouteObject['middleware'] +}>('./routes/**/*.data.ts', { eager: true }) -const componentMap = new Map(routes.map((r) => [r.path, lazy(modules[r.filePath]!)])) - -export { routes } - -export default function App() { - return ( - - - - {routes.map((r) => { - const Page = componentMap.get(r.path) - return Page ? } /> : null - })} - - - - ) +async function rootLoader({ request }: LoaderFunctionArgs) { + const res = await fetch(new URL('/api/context', request.url), { + headers: { Cookie: request.headers.get('Cookie') ?? '' }, + }) + if (!res.ok) { + return { + currentUser: null, + mirrorConfig: { enabled: false, upstream: '', nodeName: '', syncSchedule: '', apiKey: '' }, + kfAccountUrl: '', + kfAuthUrl: '', + } + } + return res.json() } + +export const routes: RouteObject[] = [ + { + id: 'root', + Component: Root, + loader: rootLoader, + children: buildDataRoutes(components, dataModules), + }, +] 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..56755b5 --- /dev/null +++ b/src/components/Root.tsx @@ -0,0 +1,36 @@ +import { Suspense, useEffect } from 'react' +import { Outlet, useMatches } from 'react-router' + +import { AppErrorBoundary } from '~/components/NotFound' + +function DocumentTitle() { + const matches = useMatches() + + useEffect(() => { + for (let i = matches.length - 1; i >= 0; i--) { + const { handle, params, data } = matches[i] as { + handle?: { title?: string | ((p: Record, d: unknown) => string) } + params: Record + data: unknown + } + if (handle?.title) { + document.title = + typeof handle.title === 'function' ? handle.title(params, data) : handle.title + break + } + } + }, [matches]) + + return null +} + +export default function Root() { + return ( + + + + + + + ) +} diff --git a/src/entry-client.tsx b/src/entry-client.tsx index 5a992e0..735261c 100644 --- a/src/entry-client.tsx +++ b/src/entry-client.tsx @@ -1,18 +1,25 @@ import { hydrateRoot } from 'react-dom/client' -import { BrowserRouter } from 'react-router' +import { createBrowserRouter, RouterProvider } from 'react-router' -import App from '~/App' -import { getClientSSRData, SSRDataProvider } from '~/lib/ssr-data' +import { routes } from '~/App' import '~/global.css' -const ssrData = getClientSSRData() +const router = createBrowserRouter(routes, { + hydrationData: (window as any).__staticRouterHydrationData, + future: { v8_middleware: true }, +}) -hydrateRoot( - document.getElementById('root')!, - - - - - , -) +// Wait for React.lazy route components to resolve before hydrating +if (!router.state.initialized) { + await new Promise((resolve) => { + const unsub = router.subscribe((state) => { + if (state.initialized) { + unsub() + resolve() + } + }) + }) +} + +hydrateRoot(document.getElementById('root')!, ) diff --git a/src/entry-server.tsx b/src/entry-server.tsx index 8d8aa4b..d30716a 100644 --- a/src/entry-server.tsx +++ b/src/entry-server.tsx @@ -1,74 +1,92 @@ 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 { matchRoutes } from '~/route-gen' +import { routes } from '~/App' -type LoaderResult = { - data: Record - redirect?: string - statusCode?: number - title?: string - description?: string -} - -export async function loadData(request: Request): Promise { - return runLoaders(matchRoutes(routes, request.url), request) -} +const handler = createStaticHandler(routes, { future: { v8_middleware: true } }) export async function render(request: Request): Promise<{ html: string - ssrData: Record + hydrationData: string redirect?: string statusCode?: number title?: string description?: string }> { - const pathname = new URL(request.url, 'http://localhost').pathname + const context = await handler.query(request) - const result = await loadData(request).catch((err: unknown) => { - console.error('Loader error:', err) - return { data: {}, statusCode: 500 } as LoaderResult - }) - - if (result.redirect) { + if (context instanceof Response) { return { html: '', - ssrData: {}, - redirect: result.redirect, - statusCode: result.statusCode ?? 302, + hydrationData: '{}', + redirect: context.headers.get('Location') ?? '/', + statusCode: context.status, } } + // Extract title from deepest matched route's handle + let title: string | undefined + let description: string | undefined + for (let i = context.matches.length - 1; i >= 0; i--) { + const match = context.matches[i]! + const handle = match.route.handle as + | { + title?: string | ((p: Record, d: unknown) => string) + description?: string | ((p: Record, d: unknown) => string) + } + | undefined + if (handle?.title && !title) { + title = + typeof handle.title === 'function' + ? handle.title( + match.params as Record, + context.loaderData[match.route.id!], + ) + : handle.title + } + if (handle?.description && !description) { + description = + typeof handle.description === 'function' + ? handle.description( + match.params as Record, + context.loaderData[match.route.id!], + ) + : handle.description + } + if (title && description) break + } + + 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(/ { + const res = await fetch(new URL('/api/context', request.url), { + headers: { Cookie: request.headers.get('Cookie') ?? '' }, + }) + const { currentUser } = await res.json() + if (!currentUser) throw redirect('/login') + return next() +} diff --git a/src/lib/collections.server.ts b/src/lib/collections.server.ts deleted file mode 100644 index 4de9e2c..0000000 --- a/src/lib/collections.server.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { and, eq, sql } from 'drizzle-orm' - -import { db, schema } from '~/db/client.server' -import { buildArkUrl, DEFAULT_NAAN } from '~/lib/ark' - -export async function getCollectionPageData(owner: string, slug: string, userId?: string) { - const [result] = await db - .select({ - id: schema.collections.id, - slug: schema.collections.slug, - name: schema.collections.name, - description: schema.collections.description, - public: schema.collections.public, - ownerSlug: schema.organization.slug, - ownerName: schema.organization.name, - createdAt: schema.collections.createdAt, - updatedAt: schema.collections.updatedAt, - }) - .from(schema.collections) - .innerJoin(schema.organization, eq(schema.collections.organizationId, schema.organization.id)) - .where(and(eq(schema.organization.slug, owner), eq(schema.collections.slug, slug))) - .limit(1) - - if (!result) return null - - if (!result.public) { - const [org] = await db - .select({ id: schema.organization.id }) - .from(schema.organization) - .where(eq(schema.organization.slug, owner)) - .limit(1) - - if (!org) return null - - let hasAccess = false - if (userId) { - const [membership] = await db - .select() - .from(schema.member) - .where(and(eq(schema.member.organizationId, org.id), eq(schema.member.userId, userId))) - .limit(1) - hasAccess = !!membership - } - if (!hasAccess) return null - } - - const [latestVersion] = await db - .select({ - id: schema.versions.id, - number: schema.versions.number, - semver: schema.versions.semver, - recordCount: schema.versions.recordCount, - fileCount: schema.versions.fileCount, - totalBytes: schema.versions.totalBytes, - createdAt: schema.versions.createdAt, - message: schema.versions.message, - readme: schema.versions.readme, - }) - .from(schema.versions) - .where(eq(schema.versions.collectionId, result.id)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(1) - - let typeCounts: { type: string; count: number }[] = [] - if (latestVersion) { - const rows = await db - .select({ - type: schema.records.type, - count: sql`count(*)::int`, - }) - .from(schema.records) - .where(eq(schema.records.versionId, latestVersion.id)) - .groupBy(schema.records.type) - typeCounts = rows.map((r) => ({ type: r.type, count: r.count })) - } - - let ark: string | null = null - try { - const [arkRow] = await db - .select({ - arkId: schema.arkCollections.arkId, - enabled: schema.arkCollections.enabled, - shoulder: schema.arkShoulders.shoulder, - ownerNaan: schema.organization.arkNaan, - }) - .from(schema.arkCollections) - .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id)) - .innerJoin(schema.organization, eq(schema.collections.organizationId, schema.organization.id)) - .innerJoin( - schema.arkShoulders, - eq(schema.arkShoulders.organizationId, schema.organization.id), - ) - .where(eq(schema.arkCollections.collectionId, result.id)) - .limit(1) - if (arkRow?.enabled) { - ark = buildArkUrl(arkRow.ownerNaan ?? DEFAULT_NAAN, arkRow.shoulder, arkRow.arkId) - } - } catch { - // Non-fatal - } - - const { id: _id, ...collectionData } = result - const { id: _vid, ...latestVersionData } = latestVersion ?? { id: undefined } - return { - ...collectionData, - ark, - latestVersion: latestVersion ? { ...latestVersionData, typeCounts } : null, - } -} diff --git a/src/lib/ssr-data.tsx b/src/lib/ssr-data.tsx deleted file mode 100644 index 2f26539..0000000 --- a/src/lib/ssr-data.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { createContext, useContext, useEffect, useRef, useState } from 'react' -import { useLocation } from 'react-router' - -type SSRData = Record - -const SSRDataContext = createContext({}) -const SSRNavigatingContext = createContext(false) - -export function SSRDataProvider({ data, children }: { data: SSRData; children: React.ReactNode }) { - const location = useLocation() - const [currentData, setCurrentData] = useState(data) - const [dataPath, setDataPath] = useState(location.pathname) - const isInitial = useRef(true) - const abortRef = useRef(null) - - // Computed synchronously during render — true when the route changed but data hasn't arrived - const navigating = !isInitial.current && location.pathname !== dataPath - - useEffect(() => { - if (isInitial.current) { - isInitial.current = false - return - } - if (location.pathname === dataPath) return - - abortRef.current?.abort() - const controller = new AbortController() - abortRef.current = controller - - fetch(`/__data?path=${encodeURIComponent(location.pathname)}`, { - credentials: 'include', - signal: controller.signal, - }) - .then((r) => r.json()) - .then((result) => { - if (controller.signal.aborted) return - if (result.redirect) { - window.location.href = result.redirect - return - } - setCurrentData(result.data) - setDataPath(location.pathname) - if (result.title) document.title = result.title - }) - .catch((err) => { - if (err instanceof DOMException && err.name === 'AbortError') return - console.error('Failed to load route data:', err) - setDataPath(location.pathname) - }) - - return () => controller.abort() - }, [location.pathname, dataPath]) - - return ( - - {children} - - ) -} - -export function useSSRData(key: string): T { - return useContext(SSRDataContext)[key] as T -} - -export function useSSRNavigating(): boolean { - return useContext(SSRNavigatingContext) -} - -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 75f52e1..0000000 --- a/src/loaders.server.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { getSessionUser } from '~/lib/auth.server' -import { getCollectionPageData } from '~/lib/collections.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() - -async function requireUser(request: Request): Promise<{ user: any; redirect?: string }> { - const user = await getSessionUser(request) - if (!user) return { user: null, redirect: '/login' } - return { user } -} - -function page(title: string, extra?: Record): LoaderFn { - return async ({ request }) => { - const user = await getSessionUser(request) - return { data: { currentUser: user, mirrorConfig, ...extra }, title } - } -} - -function authPage(title: string, extra?: Record): LoaderFn { - return async ({ request }) => { - const { user, redirect } = await requireUser(request) - if (redirect) return { data: {}, redirect } - return { data: { currentUser: user, mirrorConfig, ...extra }, title } - } -} - -const loaders: Record = { - '/': 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': page('Explore — Underlay'), - '/query': page('Query Explorer — Underlay'), - '/schemas': page('Schemas — Underlay'), - '/schemas/:id': page('Schema — Underlay'), - '/blog': page('Blog — Underlay'), - '/blog/:slug': page('Blog — Underlay'), - '/docs': page('Documentation — Underlay'), - '/docs/concepts': page('Core Concepts — Underlay Docs'), - '/docs/quickstart': page('Quickstart — Underlay Docs'), - '/docs/integration': page('Integration — Underlay Docs'), - '/docs/self-host': page('Self-hosting — Underlay Docs'), - '/docs/api': page('API Reference — Underlay Docs'), - '/docs/api/accounts': page('Accounts API — Underlay Docs'), - '/docs/api/collections': page('Collections API — Underlay Docs'), - '/docs/api/versions': page('Versions API — Underlay Docs'), - '/docs/api/files': page('Files API — Underlay Docs'), - '/admin/mirror': page('Mirror Admin — Underlay'), - - '/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 () => ({ - data: { kfAuthUrl: process.env.OIDC_ISSUER_URL ?? 'http://localhost:3000' }, - }), - - '/forgot-password': async () => ({ - data: { currentUser: null, mirrorConfig }, - title: 'Forgot password — Underlay', - }), - - '/reset-password': async () => ({ - data: { currentUser: null, mirrorConfig }, - title: 'Reset password — Underlay', - }), - - '/dashboard': authPage('Dashboard — Underlay'), - '/settings': authPage('Settings — Underlay'), - '/settings/keys': authPage('API Keys — Underlay'), - '/settings/sessions': authPage('Sessions — Underlay'), - '/settings/avatar': authPage('Avatar — Underlay'), - '/invitations/accept': authPage('Accept Invitation — Underlay'), - - '/:owner': async ({ params, request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - 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 }, - 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 }, - 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 }, - title: `Members — ${params['owner']} — Underlay`, - } - }, - - '/:owner/:collection': async ({ params, request }) => { - const user = await getSessionUser(request) - const collection = await getCollectionPageData( - params['owner']!, - params['collection']!, - user?.id, - ) - return { - data: { - currentUser: user, - mirrorConfig, - collection, - }, - title: `${params['owner']}/${params['collection']} — Underlay`, - ...(collection ? {} : { statusCode: 404 }), - } - }, - - '/: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 d6e932f..9382a89 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 { lazy, 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" → "" + let route = file.replace(/^\.\/routes\//, '').replace(/\.(tsx|data\.ts)$/, '') 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), @@ -80,31 +50,26 @@ export function buildRoutes(globResult: Record Promise>): return sortRoutes(entries) } -export function matchRoutes( - routes: RouteEntry[], - url: string, -): { path: string; params: Record }[] { - const pathname = new URL(url, 'http://localhost').pathname - - for (const route of routes) { - const patternParts = route.path.split('/').filter(Boolean) - const pathParts = pathname.split('/').filter(Boolean) - - if (patternParts.length !== pathParts.length) continue - - const params: Record = {} - let matched = true - 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) { - matched = false - break - } +export function buildDataRoutes( + components: Record Promise<{ default: ComponentType }>>, + dataModules: Record< + string, + { loader?: RouteObject['loader']; handle?: unknown; middleware?: RouteObject['middleware'] } + >, +): RouteObject[] { + const entries = buildRoutes(components) + + return entries.map((entry) => { + const dataPath = entry.filePath.replace('.tsx', '.data.ts') + const data = dataModules[dataPath] + + const route: RouteObject = { + path: entry.path, + Component: lazy(components[entry.filePath]!), } - if (matched) return [{ path: route.path, params }] - } - return [] + if (data?.loader) route.loader = data.loader + if (data?.handle) route.handle = data.handle + if (data?.middleware) route.middleware = data.middleware + return route + }) } diff --git a/src/routes/[owner]/[collection]/diff.tsx b/src/routes/[owner]/[collection]/diff.tsx index d8ab3ff..26355e7 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 '.' @@ -19,7 +19,7 @@ function groupByType(records: any[]) { 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.data.ts b/src/routes/[owner]/[collection]/index.data.ts new file mode 100644 index 0000000..708de53 --- /dev/null +++ b/src/routes/[owner]/[collection]/index.data.ts @@ -0,0 +1,14 @@ +import type { LoaderFunctionArgs } from 'react-router' + +export const handle = { + title: (params: Record) => `${params.owner}/${params.collection} — Underlay`, +} + +export async function loader({ params, request }: LoaderFunctionArgs) { + const res = await fetch( + new URL(`/api/collections/${params.owner}/${params.collection}`, request.url), + { headers: { Cookie: request.headers.get('Cookie') ?? '' } }, + ) + if (!res.ok) throw new Response('Not Found', { status: 404 }) + return res.json() +} diff --git a/src/routes/[owner]/[collection]/index.tsx b/src/routes/[owner]/[collection]/index.tsx index 58c9fb2..437739d 100644 --- a/src/routes/[owner]/[collection]/index.tsx +++ b/src/routes/[owner]/[collection]/index.tsx @@ -1,9 +1,9 @@ -import { useEffect, useMemo, useState } from 'react' -import { Link, useParams } from 'react-router' +import { marked } from 'marked' +import { useMemo } from 'react' +import { Link, useLoaderData, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' -import { NotFoundError } from '~/components/NotFound' -import { useSSRData, useSSRNavigating } from '~/lib/ssr-data' +import { useAppContext } from '~/lib/app-context' function CollectionNav({ owner, @@ -85,11 +85,8 @@ function formatBytes(bytes: number): string { export default function CollectionPage() { const { owner, collection } = useParams() - const currentUser = useSSRData('currentUser') - const mirrorConfig = useSSRData('mirrorConfig') - const data = useSSRData('collection') - const navigating = useSSRNavigating() - const [readmeHtml, setReadmeHtml] = useState(null) + const { currentUser, mirrorConfig } = useAppContext() + const data = useLoaderData() as any const isOwner = useMemo( () => @@ -98,47 +95,11 @@ export default function CollectionPage() { [currentUser, owner], ) - useEffect(() => { - const readmeSource = data?.latestVersion?.readme || data?.latestVersion?.message || null - if (readmeSource) { - import('marked').then(({ marked }) => { - setReadmeHtml(marked.parse(readmeSource) as string) - }) - } else { - setReadmeHtml(null) - } + const readmeHtml = useMemo(() => { + const source = data?.latestVersion?.readme || data?.latestVersion?.message || null + return source ? (marked.parse(source) as string) : null }, [data]) - // Client navigation: data hasn't arrived yet — show skeleton - if (navigating && !data) { - return ( - -
-
- {owner} - / - {collection} -
-
-
-
-
-
-
-
-
-
-
-
-
-
- - ) - } - - // SSR or data arrived: no collection found - if (!data) throw new NotFoundError() - const totalVersions = data.latestVersion?.number ?? 0 const typeCounts: { type: string; count: number }[] = data.latestVersion?.typeCounts ?? [] const allTypes = typeCounts.sort((a: any, b: any) => a.type.localeCompare(b.type)) @@ -210,6 +171,7 @@ export default function CollectionPage() { month: 'short', day: 'numeric', year: 'numeric', + timeZone: 'UTC', })} ('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..59a36a3 100644 --- a/src/routes/[owner]/[collection]/settings.tsx +++ b/src/routes/[owner]/[collection]/settings.tsx @@ -3,13 +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' import { CollectionNav } from '.' export default function CollectionSettingsPage() { const { owner, collection } = useParams() - const currentUser = useSSRData('currentUser') + const { currentUser } = useAppContext() const [data, setData] = useState(null) const [arkSettings, setArkSettings] = useState({ diff --git a/src/routes/[owner]/[collection]/v/[n].tsx b/src/routes/[owner]/[collection]/v/[n].tsx index f52a86c..64174b9 100644 --- a/src/routes/[owner]/[collection]/v/[n].tsx +++ b/src/routes/[owner]/[collection]/v/[n].tsx @@ -3,14 +3,14 @@ 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 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..e929bc9 100644 --- a/src/routes/[owner]/[collection]/versions.tsx +++ b/src/routes/[owner]/[collection]/versions.tsx @@ -3,13 +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' import { CollectionNav, formatBytes } from '.' 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..3cd73ac 100644 --- a/src/routes/[owner]/index.tsx +++ b/src/routes/[owner]/index.tsx @@ -3,11 +3,11 @@ 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 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..fc5e9ee 100644 --- a/src/routes/[owner]/settings/index.tsx +++ b/src/routes/[owner]/settings/index.tsx @@ -3,11 +3,11 @@ import { Link, 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 default function OwnerSettings() { const { owner } = useParams() - const currentUser = useSSRData('currentUser') + const { currentUser } = useAppContext() const [orgData, setOrgData] = useState(null) const [isOwner, setIsOwner] = useState(false) diff --git a/src/routes/[owner]/settings/keys.tsx b/src/routes/[owner]/settings/keys.tsx index fb9e836..5252b1c 100644 --- a/src/routes/[owner]/settings/keys.tsx +++ b/src/routes/[owner]/settings/keys.tsx @@ -4,8 +4,8 @@ import { Link, 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 @@ -37,7 +37,7 @@ function getScope(permissions?: Record): string { export default function OwnerSettingsKeys() { const { owner } = useParams() - const currentUser = useSSRData('currentUser') + const { currentUser } = useAppContext() const [orgData, setOrgData] = useState(null) const [isAdmin, setIsAdmin] = useState(false) diff --git a/src/routes/[owner]/settings/members.tsx b/src/routes/[owner]/settings/members.tsx index 3f1ba83..315c73a 100644 --- a/src/routes/[owner]/settings/members.tsx +++ b/src/routes/[owner]/settings/members.tsx @@ -3,12 +3,12 @@ import { Link, 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 default function OwnerSettingsMembers() { const { owner } = useParams() - const currentUser = useSSRData('currentUser') + const { currentUser } = useAppContext() const [orgData, setOrgData] = useState(null) const [orgId, setOrgId] = useState(null) diff --git a/src/routes/admin/mirror.data.ts b/src/routes/admin/mirror.data.ts new file mode 100644 index 0000000..a9c29a8 --- /dev/null +++ b/src/routes/admin/mirror.data.ts @@ -0,0 +1,4 @@ +import { requireAuth } from '~/lib/auth-middleware' + +export const middleware = [requireAuth] +export const handle = { title: 'Mirror Admin — Underlay' } diff --git a/src/routes/admin/mirror.tsx b/src/routes/admin/mirror.tsx index daaac5a..36b7ec4 100644 --- a/src/routes/admin/mirror.tsx +++ b/src/routes/admin/mirror.tsx @@ -1,17 +1,9 @@ import BaseLayout from '~/components/BaseLayout' import MirrorAdmin from '~/components/MirrorAdmin' -import { useSSRData } from '~/lib/ssr-data' - -interface MirrorConfig { - enabled: boolean - nodeName: string - upstream: string - syncSchedule: string -} +import { useAppContext } from '~/lib/app-context' export default function AdminMirror() { - const me = useSSRData('currentUser') - const mirrorConfig = useSSRData('mirrorConfig') + const { currentUser, mirrorConfig } = useAppContext() if (!mirrorConfig?.enabled) { if (typeof window !== 'undefined') { @@ -20,8 +12,6 @@ export default function AdminMirror() { return null } - if (!me) return null - return (
diff --git a/src/routes/blog/[slug].tsx b/src/routes/blog/[slug].tsx index cf30f70..ffdb658 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 = { diff --git a/src/routes/dashboard.data.ts b/src/routes/dashboard.data.ts new file mode 100644 index 0000000..7abc5c5 --- /dev/null +++ b/src/routes/dashboard.data.ts @@ -0,0 +1,4 @@ +import { requireAuth } from '~/lib/auth-middleware' + +export const middleware = [requireAuth] +export const handle = { title: 'Dashboard — Underlay' } diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index ba9e054..4e13f6d 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -2,8 +2,8 @@ import { type FormEvent, useEffect, useState } from 'react' import { Link } 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 @@ -29,7 +29,7 @@ interface KfOrg { } export default function Dashboard() { - const me = useSSRData('currentUser') + const { currentUser } = useAppContext() const [orgs, setOrgs] = useState([]) const [filter, setFilter] = useState('') @@ -52,14 +52,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 +83,7 @@ export default function Dashboard() { setOrgKfOrgId(orgs[0]!.id) } }) - }, [me]) + }, [currentUser]) async function handleCreateOrg(e: FormEvent) { e.preventDefault() @@ -138,8 +138,6 @@ export default function Dashboard() { return text.toLowerCase().includes(filter.toLowerCase()) } - if (!me) return null - return (
@@ -324,7 +322,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/index.tsx b/src/routes/index.tsx index 3918aba..e52934b 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,16 +1,10 @@ import { Link } from 'react-router' import BaseLayout from '~/components/BaseLayout' -import { useSSRData } from '~/lib/ssr-data' - -interface MirrorConfig { - enabled: boolean - nodeName: string - upstream: string -} +import { useAppContext } from '~/lib/app-context' 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..9db68d3 100644 --- a/src/routes/invitations/accept.tsx +++ b/src/routes/invitations/accept.tsx @@ -2,10 +2,10 @@ 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 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 +63,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/logout.tsx b/src/routes/logout.tsx index 404493f..a3916c9 100644 --- a/src/routes/logout.tsx +++ b/src/routes/logout.tsx @@ -1,11 +1,11 @@ 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 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..29f6e16 100644 --- a/src/routes/query.tsx +++ b/src/routes/query.tsx @@ -2,14 +2,10 @@ 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 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/schemas/[id].tsx b/src/routes/schemas/[id].tsx index b18387b..dfd4669 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 @@ -16,7 +15,7 @@ interface SchemaData { 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/settings/avatar.data.ts b/src/routes/settings/avatar.data.ts new file mode 100644 index 0000000..26d3ecd --- /dev/null +++ b/src/routes/settings/avatar.data.ts @@ -0,0 +1,4 @@ +import { requireAuth } from '~/lib/auth-middleware' + +export const middleware = [requireAuth] +export const handle = { title: 'Avatar — Underlay' } diff --git a/src/routes/settings/avatar.tsx b/src/routes/settings/avatar.tsx index 2106df0..29a5a67 100644 --- a/src/routes/settings/avatar.tsx +++ b/src/routes/settings/avatar.tsx @@ -2,15 +2,15 @@ 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 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,8 +50,6 @@ export default function SettingsAvatar() { } } - if (!me) return null - return (
@@ -100,7 +98,7 @@ export default function SettingsAvatar() { /> ) : (
- {me.displayName?.charAt(0)?.toUpperCase() ?? '?'} + {currentUser.displayName?.charAt(0)?.toUpperCase() ?? '?'}
)}
diff --git a/src/routes/settings/index.data.ts b/src/routes/settings/index.data.ts new file mode 100644 index 0000000..b6df305 --- /dev/null +++ b/src/routes/settings/index.data.ts @@ -0,0 +1,4 @@ +import { requireAuth } from '~/lib/auth-middleware' + +export const middleware = [requireAuth] +export const handle = { title: 'Settings — Underlay' } diff --git a/src/routes/settings/index.tsx b/src/routes/settings/index.tsx index 18289d4..c3ebe4c 100644 --- a/src/routes/settings/index.tsx +++ b/src/routes/settings/index.tsx @@ -2,23 +2,22 @@ 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 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 +38,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,8 +109,6 @@ export default function Settings() { } } - if (!me) return null - return (
@@ -145,20 +142,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 +331,7 @@ export default function Settings() {
): string { } export default function SettingsKeys() { - const me = useSSRData('currentUser') + const { currentUser } = useAppContext() const [keys, setKeys] = useState([]) const [collections, setCollections] = useState([]) @@ -58,12 +58,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,8 +94,6 @@ export default function SettingsKeys() { setKeys((prev) => prev.filter((k) => k.id !== keyId)) } - if (!me) return null - return (
@@ -255,7 +253,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.data.ts b/src/routes/settings/sessions.data.ts new file mode 100644 index 0000000..3755aa7 --- /dev/null +++ b/src/routes/settings/sessions.data.ts @@ -0,0 +1,4 @@ +import { requireAuth } from '~/lib/auth-middleware' + +export const middleware = [requireAuth] +export const handle = { title: 'Sessions — Underlay' } diff --git a/src/routes/settings/sessions.tsx b/src/routes/settings/sessions.tsx index a11e672..402a26d 100644 --- a/src/routes/settings/sessions.tsx +++ b/src/routes/settings/sessions.tsx @@ -2,7 +2,7 @@ import { useEffect, 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' interface Session { id: string @@ -24,18 +24,18 @@ function parseUserAgent(ua: string | null): string { } 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,8 +52,6 @@ export default function SettingsSessions() { } } - if (!me) return null - return (
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}"`)