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
This commit is contained in:
2026-03-19 16:47:23 -04:00
parent e7da1e5109
commit 1670c6f494
11 changed files with 51 additions and 14 deletions
+8
View File
@@ -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:<version>` or use the matching `jakejarvis/sofa:<version>` 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
+2
View File
@@ -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()),
+2
View File
@@ -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(
+6 -4
View File
@@ -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: () => <PersonDetailSkeleton />,
notFoundComponent: PersonNotFound,
+2
View File
@@ -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<unknown>[] = [
context.queryClient.ensureQueryData(orpc.integrations.list.queryOptions()),
+1
View File
@@ -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(
+7 -1
View File
@@ -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,
});
+1 -4
View File
@@ -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,
});
+2 -5
View File
@@ -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,
});
+1
View File
@@ -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: "/" });
+19
View File
@@ -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'