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'