mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 17:05:56 -04:00
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:
@@ -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.
|
||||
|
||||
[](https://railway.com/deploy/AqGBLF?referralCode=4AUONK&utm_medium=integration&utm_source=template&utm_campaign=generic)
|
||||
|
||||
## Required setup
|
||||
|
||||
### TMDB
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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: "/" });
|
||||
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user