From 1670c6f49434942b333d62cedf3cbb70df9566bb Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Thu, 19 Mar 2026 16:47:23 -0400 Subject: [PATCH] feat(web): add page titles, staleTime, and lift auth redirect into layout - Add `head()` with document titles to dashboard, explore, settings, setup, login, register, and person detail routes - Return `personName` from the person detail loader so the title can be set server-side instead of after data loads - Add `staleTime` to all loader routes (30s for dashboard/settings, 60s for explore/titles/people) - Move the "redirect if already authenticated" `beforeLoad` guard from individual login/register routes into the shared `_auth` layout so it runs once for all unauthenticated routes - Add a Cloud deploy section to the README with a one-click Railway button --- README.md | 8 ++++++++ apps/web/src/routes/_app/dashboard.tsx | 2 ++ apps/web/src/routes/_app/explore.tsx | 2 ++ apps/web/src/routes/_app/people.$id.tsx | 10 ++++++---- apps/web/src/routes/_app/settings.tsx | 2 ++ apps/web/src/routes/_app/titles.$id.tsx | 1 + apps/web/src/routes/_auth.tsx | 8 +++++++- apps/web/src/routes/_auth/login.tsx | 5 +---- apps/web/src/routes/_auth/register.tsx | 7 ++----- apps/web/src/routes/setup.tsx | 1 + nixpacks.toml | 19 +++++++++++++++++++ 11 files changed, 51 insertions(+), 14 deletions(-) create mode 100644 nixpacks.toml diff --git a/README.md b/README.md index 40dd96a..e3535a6 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ ## Quick start +### Self-hosted + A minimal [`docker-compose.yml`](./docker-compose.yml) is provided in this repo. For most setups, the shortest path is: 1. Copy the example environment file: @@ -58,6 +60,12 @@ docker compose up -d > The included Compose file uses `ghcr.io/jakejarvis/sofa:edge`. If you prefer to pin releases, switch to a published version tag like `ghcr.io/jakejarvis/sofa:` or use the matching `jakejarvis/sofa:` image on Docker Hub. Multi-arch images are published for `linux/amd64` and `linux/arm64`. +### Cloud + +If you prefer to deploy Sofa to the cloud, there are several great options. All you'll need is the ability to run Docker containers and mount some form of persistent storage volume to `/data` within the container. + +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/AqGBLF?referralCode=4AUONK&utm_medium=integration&utm_source=template&utm_campaign=generic) + ## Required setup ### TMDB diff --git a/apps/web/src/routes/_app/dashboard.tsx b/apps/web/src/routes/_app/dashboard.tsx index 95e040c..4ee3d68 100644 --- a/apps/web/src/routes/_app/dashboard.tsx +++ b/apps/web/src/routes/_app/dashboard.tsx @@ -12,6 +12,8 @@ import { Skeleton } from "@/components/ui/skeleton"; import { orpc } from "@/lib/orpc/client"; export const Route = createFileRoute("/_app/dashboard")({ + staleTime: 30_000, + head: () => ({ meta: [{ title: "Dashboard — Sofa" }] }), loader: async ({ context }) => { await Promise.all([ context.queryClient.ensureQueryData(orpc.dashboard.stats.queryOptions()), diff --git a/apps/web/src/routes/_app/explore.tsx b/apps/web/src/routes/_app/explore.tsx index 43a1495..233b3cd 100644 --- a/apps/web/src/routes/_app/explore.tsx +++ b/apps/web/src/routes/_app/explore.tsx @@ -11,6 +11,8 @@ import { Skeleton } from "@/components/ui/skeleton"; import { orpc } from "@/lib/orpc/client"; export const Route = createFileRoute("/_app/explore")({ + staleTime: 60_000, + head: () => ({ meta: [{ title: "Explore — Sofa" }] }), loader: async ({ context }) => { await Promise.all([ context.queryClient.ensureQueryData( diff --git a/apps/web/src/routes/_app/people.$id.tsx b/apps/web/src/routes/_app/people.$id.tsx index 12c366a..dc9d32a 100644 --- a/apps/web/src/routes/_app/people.$id.tsx +++ b/apps/web/src/routes/_app/people.$id.tsx @@ -5,8 +5,9 @@ import { PersonDetailClient, PersonDetailSkeleton } from "@/components/people/pe import { orpc } from "@/lib/orpc/client"; export const Route = createFileRoute("/_app/people/$id")({ + staleTime: 60_000, loader: async ({ params, context }) => { - await context.queryClient.ensureInfiniteQueryData( + const data = await context.queryClient.ensureInfiniteQueryData( orpc.people.detail.infiniteOptions({ input: (pageParam: number) => ({ id: params.id, @@ -18,10 +19,11 @@ export const Route = createFileRoute("/_app/people/$id")({ lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined, }), ); + return { personName: data.pages[0]?.person.name }; }, - head: ({ loaderData: _loaderData, params: _params }) => { - // Title is set by the component after data loads - return {}; + head: ({ loaderData }) => { + if (!loaderData?.personName) return {}; + return { meta: [{ title: `${loaderData.personName} — Sofa` }] }; }, pendingComponent: () => , notFoundComponent: PersonNotFound, diff --git a/apps/web/src/routes/_app/settings.tsx b/apps/web/src/routes/_app/settings.tsx index 726956d..ac7c652 100644 --- a/apps/web/src/routes/_app/settings.tsx +++ b/apps/web/src/routes/_app/settings.tsx @@ -28,6 +28,8 @@ import { orpc } from "@/lib/orpc/client"; const GITHUB_REPO = "jakejarvis/sofa"; export const Route = createFileRoute("/_app/settings")({ + staleTime: 30_000, + head: () => ({ meta: [{ title: "Settings — Sofa" }] }), loader: async ({ context }) => { const promises: Promise[] = [ context.queryClient.ensureQueryData(orpc.integrations.list.queryOptions()), diff --git a/apps/web/src/routes/_app/titles.$id.tsx b/apps/web/src/routes/_app/titles.$id.tsx index 7283cf0..8192135 100644 --- a/apps/web/src/routes/_app/titles.$id.tsx +++ b/apps/web/src/routes/_app/titles.$id.tsx @@ -15,6 +15,7 @@ import { orpc } from "@/lib/orpc/client"; import { getThemeCssProperties } from "@/lib/theme"; export const Route = createFileRoute("/_app/titles/$id")({ + staleTime: 60_000, loader: async ({ params, context }) => { const [titleResult, userInfo] = await Promise.all([ context.queryClient.ensureQueryData( diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx index a12f819..e55073a 100644 --- a/apps/web/src/routes/_auth.tsx +++ b/apps/web/src/routes/_auth.tsx @@ -1,6 +1,12 @@ -import { createFileRoute, Outlet } from "@tanstack/react-router"; +import { createFileRoute, redirect, Outlet } from "@tanstack/react-router"; + +import { authClient } from "@/lib/auth/client"; export const Route = createFileRoute("/_auth")({ + beforeLoad: async () => { + const { data: session } = await authClient.getSession(); + if (session) throw redirect({ to: "/dashboard" }); + }, component: AuthLayout, }); diff --git a/apps/web/src/routes/_auth/login.tsx b/apps/web/src/routes/_auth/login.tsx index 168e2a4..efca8bd 100644 --- a/apps/web/src/routes/_auth/login.tsx +++ b/apps/web/src/routes/_auth/login.tsx @@ -1,19 +1,16 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { AuthForm } from "@/components/auth-form"; -import { authClient } from "@/lib/auth/client"; import { client } from "@/lib/orpc/client"; export const Route = createFileRoute("/_auth/login")({ beforeLoad: async () => { - const { data: session } = await authClient.getSession(); - if (session) throw redirect({ to: "/dashboard" }); - const authConfig = await client.system.authConfig({}); if (authConfig.userCount === 0) throw redirect({ to: "/register" }); return { authConfig }; }, + head: () => ({ meta: [{ title: "Sign in — Sofa" }] }), component: LoginPage, }); diff --git a/apps/web/src/routes/_auth/register.tsx b/apps/web/src/routes/_auth/register.tsx index dd14b00..54f5913 100644 --- a/apps/web/src/routes/_auth/register.tsx +++ b/apps/web/src/routes/_auth/register.tsx @@ -1,19 +1,16 @@ import { Trans } from "@lingui/react/macro"; import { IconLock } from "@tabler/icons-react"; -import { createFileRoute, Link, redirect } from "@tanstack/react-router"; +import { createFileRoute, Link } from "@tanstack/react-router"; import { AuthForm } from "@/components/auth-form"; -import { authClient } from "@/lib/auth/client"; import { client } from "@/lib/orpc/client"; export const Route = createFileRoute("/_auth/register")({ beforeLoad: async () => { - const { data: session } = await authClient.getSession(); - if (session) throw redirect({ to: "/dashboard" }); - const authConfig = await client.system.authConfig({}); return { authConfig }; }, + head: () => ({ meta: [{ title: "Create account — Sofa" }] }), component: RegisterPage, }); diff --git a/apps/web/src/routes/setup.tsx b/apps/web/src/routes/setup.tsx index 995d44c..f79c1de 100644 --- a/apps/web/src/routes/setup.tsx +++ b/apps/web/src/routes/setup.tsx @@ -78,6 +78,7 @@ const envSnippets = [ ]; export const Route = createFileRoute("/setup")({ + head: () => ({ meta: [{ title: "Setup — Sofa" }] }), beforeLoad: async () => { const info = await client.system.publicInfo({}); if (info.tmdbConfigured) throw redirect({ to: "/" }); diff --git a/nixpacks.toml b/nixpacks.toml new file mode 100644 index 0000000..49aadc9 --- /dev/null +++ b/nixpacks.toml @@ -0,0 +1,19 @@ +# Nixpacks configuration for Sofa +# Bun runtime auto-detected via packageManager field in package.json + +[variables] +NODE_ENV = 'production' +PORT = '3000' +HOSTNAME = '0.0.0.0' +DATA_DIR = './data' + +# Install phase — auto-detected (bun install --frozen-lockfile) +[phases.install] +cacheDirectories = ['node_modules'] + +[phases.build] +cmds = ['bunx turbo run build --filter=@sofa/web --filter=@sofa/server'] +cacheDirectories = ['.turbo', 'node_modules/.cache'] + +[start] +cmd = 'bun apps/server/src/index.ts'