Migrate web app from Next.js to Vite + TanStack Router SPA (#6)
* Convert to Turborepo monorepo with shared API contract package Restructure the repository as a monorepo in preparation for adding future clients (mobile app, CLI). Extract the oRPC contract and Zod schemas into `@sofa/api` (packages/api/) as a JIT internal package, and relocate the Next.js app to `@sofa/web` (apps/web/). - Add Turborepo with Bun workspaces for task orchestration and caching - Extract `contract.ts` and `schemas.ts` into `@sofa/api` package - Move all app code, configs, tests, and migrations to `apps/web/` - Update 17 import paths from `@/lib/orpc/schemas` to `@sofa/api/schemas` - Add `outputFileTracingRoot` and `transpilePackages` to next.config.ts - Rewrite Dockerfile with `turbo prune --docker` for efficient builds - Update CI workflows to use `turbo run` for lint/check-types/test - Update CLAUDE.md with monorepo structure and commands Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Extract standalone Hono API server and split shared packages Separate all server-side concerns from the Next.js frontend into a new `apps/server/` Hono app and dedicated shared packages, making `@sofa/web` a frontend-only app with no direct DB or service access. - Add `@sofa/server` (`apps/server/`) — Hono API on port 3001 hosting oRPC procedures, Better Auth, cron jobs, and non-RPC routes - Add `@sofa/core` (`packages/core/`) — All 15 business logic services moved from `apps/web/lib/services/`; tests moved to `packages/core/test/` - Add `@sofa/db` (`packages/db/`) — DB client, schema, migrations, constants, and logger extracted from `apps/web/lib/db/` and `lib/` - Add `@sofa/tmdb` (`packages/tmdb/`) — TMDB client and image helpers moved from `apps/web/lib/tmdb/` - Add `@sofa/auth` (`packages/auth/`) — Better Auth server config moved from `apps/web/lib/auth/` - Move oRPC procedures, handler, router, middleware to `apps/server/src/orpc/` - Move Hono route handlers (avatars, backups, images, lists, webhooks, health) to `apps/server/src/routes/`; delete equivalent Next.js API routes - Strip `apps/web` to frontend-only: no DB imports, no service imports, all data via oRPC client calls to the API server - Add `entrypoint.sh` to start API server, wait for health, then Next.js - Update `next.config.ts` rewrites to proxy `/rpc/*` and `/api/*` to `INTERNAL_API_URL` (default `http://localhost:3001`) - Update Dockerfile and CLAUDE.md for the new structure * Migrate web app from Next.js to Vite + TanStack Router SPA and add workspace catalog Replace Next.js with a pure Vite SPA using TanStack Router for file-based routing, removing all SSR complexity. The API server (Hono) now serves both API routes and SPA static files in production, simplifying Docker to a single-process container. Key changes: - Vite 7 + @tanstack/react-router with file-based routing via plugin - Route guards via beforeLoad + authClient.getSession() (replaces server-side auth) - Route loaders with queryClient.ensureQueryData() (replaces SSR data fetching) - Self-hosted fonts via @fontsource (replaces next/font/google) - Tailwind v4 via @tailwindcss/vite (replaces @tailwindcss/postcss) - Single oRPC client (removed SSR client and server-side session helper) - Hono serves SPA static files in production (single port 3000) - Single-process Dockerfile (removed entrypoint.sh) - Bun workspace catalog for centralized dependency version management Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Extract @sofa/logger and @sofa/config shared packages - Add `@sofa/logger` (`packages/logger/`) — standalone logger package extracted from `@sofa/db/logger`; update all imports across server, core, auth, db, and tmdb packages - Add `@sofa/config` (`packages/config/`) — standalone config/constants package extracted from `@sofa/db/constants`; exports `DATA_DIR`, `DATABASE_URL`, `CACHE_DIR`, `AVATAR_DIR`, `BACKUP_DIR` - Move `.env.example` from `apps/web/` to repo root; update server dev scripts to load it via `--env-file=../../.env` - Move image serving from `/api/images` to `/images`; add `serveStatic` fast path in `index.ts` for cached files before falling back to the TMDB fetch route; add `/images` proxy to Vite dev config - Fix `Sparkline` component: replace `ResponsiveContainer` with `ResizeObserver` to avoid SSR/hydration issues with recharts - Replace `VITE_SERVER_URL` env var with `window.location.origin` in the oRPC client (always same-origin in both dev and production) * Fix asset caching, SPA 404 fallback, and DATA_DIR resolution - Add `Cache-Control: immutable` header for hashed `/assets/*` files; return 404 for missing asset paths instead of falling back to `index.html` (prevents serving stale chunks after deploy) - Wrap `query.invalidate` in an arrow function in the oRPC QueryClient error handler to avoid illegal invocation errors - Resolve `DATA_DIR` to an absolute path via `path.resolve()` so relative paths work regardless of the process working directory * Migrate @sofa/logger to pino for structured logging - Replace custom logger implementation in `packages/logger/` with pino + pino-pretty; add both as workspace catalog dependencies - Add `pino` and `pino-pretty` to the workspace catalog in `package.json` - Fix `log.error()` calls in oRPC and OpenAPI handlers to pass the error directly instead of wrapping it in `{ error }` to match pino's serializer expectations * Rename discoverProcedure/statsProcedure exports to discover/stats * Add TanStackDevtools unified panel and VS Code workspace config - Replace separate Router/Query devtools with unified `TanStackDevtools` from `@tanstack/react-devtools` + `@tanstack/devtools-vite` plugin - Wrap app in `<StrictMode>` in `main.tsx` - Add `.vscode/settings.json` (Biome formatter, format-on-save, readonly `routeTree.gen.ts`) and `.vscode/extensions.json` (recommended extensions) * Move test DB helpers to @sofa/db/test-utils and add root bunfig.toml Extract in-memory SQLite setup and fixture helpers (insertUser, insertTitle, etc.) from packages/core/test/sqlite.ts into packages/db/src/test-utils.ts so DB test utilities live alongside the schema they depend on. Use import.meta.dir for CWD-independent migration path resolution. Add root bunfig.toml so `bun test` works from the repo root in addition to `bun run test` (turbo). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix devtools plugin order and whitespace-only TMDB token check Move devtools() to first position in Vite plugins array per TanStack docs, and trim TMDB_API_READ_ACCESS_TOKEN before boolean coercion so whitespace-only values are treated as unconfigured. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
@@ -1,5 +1,4 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
@@ -7,10 +6,10 @@ node_modules
|
||||
*.db-*
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
data
|
||||
.DS_Store
|
||||
.vercel
|
||||
data
|
||||
coverage
|
||||
.vscode
|
||||
.idea
|
||||
.turbo
|
||||
.tanstack
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
|
||||
- name: Get app version
|
||||
id: version
|
||||
run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
|
||||
run: echo "version=$(jq -r .version apps/web/package.json)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
|
||||
@@ -18,11 +18,11 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: bun test --coverage --coverage-reporter=lcov
|
||||
- name: Lint
|
||||
run: bunx turbo run lint
|
||||
|
||||
# - name: Upload to Codecov
|
||||
# uses: codecov/codecov-action@v3
|
||||
# with:
|
||||
# file: ./coverage/lcov.info
|
||||
# fail_ci_if_error: true
|
||||
- name: Type check
|
||||
run: bunx turbo run check-types
|
||||
|
||||
- name: Run tests
|
||||
run: bunx turbo run test -- --coverage --coverage-reporter=lcov
|
||||
|
||||
@@ -1,47 +1,56 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
# Build outputs
|
||||
dist
|
||||
build
|
||||
*.tsbuildinfo
|
||||
.tanstack
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
# Environment variables
|
||||
.env
|
||||
.env*.local
|
||||
!.env.example
|
||||
|
||||
# production
|
||||
/build
|
||||
/data
|
||||
|
||||
# misc
|
||||
# IDEs and editors
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# sqlite
|
||||
*.db
|
||||
*.db-*
|
||||
|
||||
# debug
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
# Turbo
|
||||
.turbo
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
# Misc
|
||||
*.tgz
|
||||
.cache
|
||||
tmp
|
||||
temp
|
||||
|
||||
# local data
|
||||
*.db
|
||||
*.db-*
|
||||
/data
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"biomejs.biome",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"vitest.explorer",
|
||||
"vercel.turbo-vsc"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnPaste": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
},
|
||||
"files.readonlyInclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"files.watcherExclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"emmet.showExpandedAbbreviation": "never",
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun run dev # Start Next.js dev server
|
||||
bun run build # Production build
|
||||
# Root commands (via Turborepo)
|
||||
bun run dev # Start API server + Vite dev server
|
||||
bun run build # Production build (both apps)
|
||||
bun run lint # Biome lint check
|
||||
bun run format # Biome format (auto-fix)
|
||||
bun run db:push # Push schema changes to SQLite database
|
||||
bun run db:generate # Generate Drizzle migration files
|
||||
bun run db:migrate # Run Drizzle migrations
|
||||
bun run db:studio # Open Drizzle Studio (visual DB browser)
|
||||
bun run check-types # TypeScript type check
|
||||
bun run test # Run tests
|
||||
|
||||
# Database commands (run from packages/db/)
|
||||
cd packages/db && bun run db:push # Push schema changes to SQLite database
|
||||
cd packages/db && bun run db:generate # Generate Drizzle migration files
|
||||
cd packages/db && bun run db:migrate # Run Drizzle migrations
|
||||
cd packages/db && bun run db:studio # Open Drizzle Studio (visual DB browser)
|
||||
```
|
||||
|
||||
IMPORTANT: Default to using Bun instead of Node.js:
|
||||
@@ -39,67 +44,113 @@ bun run test
|
||||
|
||||
## Architecture
|
||||
|
||||
**Sofa** is a self-hosted movie & TV tracking app (like Trakt/TVTime) built as a single Next.js 16 application with SQLite.
|
||||
**Sofa** is a self-hosted movie & TV tracking app (like Trakt/TVTime) built as a Turborepo monorepo with a standalone Hono API server, a Vite + TanStack Router SPA frontend, and shared packages.
|
||||
|
||||
### Monorepo structure
|
||||
|
||||
```
|
||||
couch-potato/
|
||||
├── apps/
|
||||
│ ├── server/ # @sofa/server — Hono API server (oRPC, auth, cron, webhooks)
|
||||
│ └── web/ # @sofa/web — Vite SPA (TanStack Router, TanStack Query)
|
||||
├── packages/
|
||||
│ ├── api/ # @sofa/api — oRPC contract + Zod schemas (shared)
|
||||
│ ├── auth/ # @sofa/auth — Better Auth server config
|
||||
│ ├── core/ # @sofa/core — Business logic services
|
||||
│ ├── db/ # @sofa/db — Database client, schema, migrations, constants, logger
|
||||
│ └── tmdb/ # @sofa/tmdb — TMDB API client + image URL helper
|
||||
├── turbo.json # Turborepo task configuration
|
||||
├── biome.json # Shared Biome config
|
||||
├── Dockerfile # Multi-stage Docker build (turbo prune, single process)
|
||||
└── package.json # Root workspace config
|
||||
```
|
||||
|
||||
- **`@sofa/server`** (`apps/server/`) — Hono API server. Hosts oRPC procedures, Better Auth, webhook/image/backup routes, and cron jobs. Runs DB migrations on startup. In production, also serves the SPA static files on port 3000. In dev, runs on port 3001.
|
||||
- **`@sofa/web`** (`apps/web/`) — Vite SPA with TanStack Router (file-based routing). No SSR, no DB access, no services. All data fetched via oRPC client calls. Vite dev server proxies `/api/*` and `/rpc/*` to the API server.
|
||||
- **`@sofa/api`** (`packages/api/`) — JIT package with the oRPC contract and Zod schemas. No build step.
|
||||
- **`@sofa/db`** (`packages/db/`) — Database layer: Drizzle schema, client, migrations, constants, logger.
|
||||
- **`@sofa/tmdb`** (`packages/tmdb/`) — TMDB API client (openapi-fetch) and image URL construction.
|
||||
- **`@sofa/core`** (`packages/core/`) — All business logic services (metadata, tracking, discovery, etc.).
|
||||
- **`@sofa/auth`** (`packages/auth/`) — Better Auth server config + environment checks.
|
||||
|
||||
All shared packages are JIT (raw TypeScript exports, no build step). Consumers transpile via their own bundlers.
|
||||
|
||||
### Stack
|
||||
|
||||
- **Framework**: Next.js 16 (App Router), React 19, TypeScript
|
||||
- **Frontend**: Vite 7 SPA, React 19, TypeScript, TanStack Router (file-based routing)
|
||||
- **API Server**: Hono (standalone Bun server)
|
||||
- **Monorepo**: Turborepo with Bun workspaces
|
||||
- **Database**: SQLite via bun:sqlite + Drizzle ORM (WAL mode, singleton via `globalThis`, sync queries, auto-migrations on startup)
|
||||
- **Auth**: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO via `genericOAuth` plugin
|
||||
- **Styling**: Tailwind CSS v4, shadcn components, dark cinema theme with warm primary accents
|
||||
- **Fonts**: DM Serif Display (display), DM Sans (body), Geist Mono (mono)
|
||||
- **Styling**: Tailwind CSS v4 (via `@tailwindcss/vite`), shadcn components, dark cinema theme with warm primary accents
|
||||
- **Fonts**: DM Serif Display (display), DM Sans (body), Geist Mono (mono) — self-hosted via `@fontsource`
|
||||
- **API**: oRPC (contract-first, type-safe RPC) with `@orpc/tanstack-query` for TanStack Query integration
|
||||
- **State**: Jotai (client), TanStack Query (data fetching & mutations via oRPC)
|
||||
- **Linting**: Biome (2-space indent, organized imports, React/Next.js recommended rules)
|
||||
- **Linting**: Biome (2-space indent, organized imports, React recommended rules)
|
||||
- **External API**: TMDB (The Movie Database) with Bearer token auth
|
||||
|
||||
### Path alias
|
||||
### Package imports
|
||||
|
||||
`@/*` maps to project root (`./`), e.g. `@/lib/db/client` → `./lib/db/client`.
|
||||
Within `apps/web/`, `@/*` maps to `apps/web/src/`, e.g. `@/components/nav-bar` → `apps/web/src/components/nav-bar`.
|
||||
|
||||
Cross-package imports use the package name:
|
||||
- `@sofa/api/contract`, `@sofa/api/schemas` — Contract and types
|
||||
- `@sofa/db/client`, `@sofa/db/schema`, `@sofa/db/constants`, `@sofa/db/logger` — Database layer
|
||||
- `@sofa/tmdb/client`, `@sofa/tmdb/image` — TMDB client
|
||||
- `@sofa/core/metadata`, `@sofa/core/tracking`, etc. — Business logic
|
||||
- `@sofa/auth/server`, `@sofa/auth/config` — Auth config
|
||||
|
||||
### Key directories
|
||||
|
||||
- `lib/db/schema.ts` — Single file with all Drizzle table definitions (auth tables + app tables)
|
||||
- `lib/db/migrate.ts` — Auto-migration runner (executed on startup via instrumentation)
|
||||
- `lib/services/` — Business logic layer (metadata, tracking, discovery, availability, credits, person, webhooks, backup, settings, colors, update-check, system-health)
|
||||
- `lib/tmdb/` — TMDB API client and TypeScript types
|
||||
- `lib/auth/` — Better Auth server config (`server.ts`), client hooks (`client.ts`), cached session helper (`session.ts`)
|
||||
- `lib/config.ts` — Server-side environment checks (TMDB configured, OIDC configured, etc.)
|
||||
- `lib/logger.ts` — Structured logger with `LOG_LEVEL` support
|
||||
- `lib/types/` — Shared TypeScript types (e.g. `title.ts`)
|
||||
- `lib/cron.ts` — Background job scheduler (croner, `globalThis` singleton)
|
||||
- `lib/query-client.ts` — Singleton `QueryClient` with default options (30s stale time)
|
||||
- `lib/orpc/` — oRPC API layer (contract-first, type-safe RPC):
|
||||
- `contract.ts` — Full API contract definition (~30 procedures)
|
||||
- `schemas.ts` — Shared Zod input schemas used by contract
|
||||
- `context.ts` — Context type & `os` implementer
|
||||
- `middleware.ts` — Auth (`authed`) and admin (`admin`) middleware
|
||||
- `router.ts` — Assembled router implementing the contract
|
||||
- `handler.ts` — RPCHandler instance with error logging
|
||||
- `client.ts` — Client-side oRPC client (RPCLink)
|
||||
- `client.server.ts` — Server-side oRPC client (zero HTTP overhead for SSR)
|
||||
- `tanstack.ts` — `orpc` utils for TanStack Query (`orpc.*.queryOptions()`)
|
||||
- `procedures/` — Procedure implementations by domain (titles, episodes, seasons, people, dashboard, explore, search, discover, stats, status, integrations, admin, account, watchlist)
|
||||
- `app/rpc/[[...rest]]/route.ts` — Next.js catch-all route handler for oRPC
|
||||
- `app/api/` — Non-RPC routes (auth, images, avatars, backups, webhooks, lists, health)
|
||||
- `app/(pages)/` — Authenticated pages (dashboard, explore, titles/[id], people/[id], settings)
|
||||
- `app/(auth)/` — Auth pages (login, register, setup)
|
||||
**API server** (`apps/server/src/`):
|
||||
- `index.ts` — Hono app entry point, startup, CORS, route mounting, graceful shutdown
|
||||
- `cron.ts` — Background job scheduler (croner)
|
||||
- `orpc/` — oRPC server layer (context, middleware, router, handler, procedures/)
|
||||
- `routes/` — Non-RPC Hono routes (auth, images, avatars, backups, webhooks, lists, health)
|
||||
|
||||
**Web app** (`apps/web/src/`):
|
||||
- `main.tsx` — React root: creates router + renders `<RouterProvider />`
|
||||
- `routes/` — TanStack Router file-based routes (auto-generates `routeTree.gen.ts`)
|
||||
- `__root.tsx` — Root layout (providers, head meta, global error/not-found)
|
||||
- `_app.tsx` — Authenticated layout (auth guard via `beforeLoad`, navbar, shell)
|
||||
- `_app/dashboard.tsx`, `_app/explore.tsx`, `_app/settings.tsx`, `_app/titles.$id.tsx`, `_app/people.$id.tsx`
|
||||
- `_auth.tsx` — Auth layout (centering wrapper)
|
||||
- `_auth/login.tsx`, `_auth/register.tsx`
|
||||
- `index.tsx`, `setup.tsx`
|
||||
- `components/` — App components + `components/ui/` for shadcn primitives
|
||||
- `components/query-provider.tsx` — `QueryClientProvider` wrapper used in root layout
|
||||
- `lib/orpc/client.ts` — oRPC client (always same-origin via Vite proxy or Hono static serving)
|
||||
- `lib/orpc/tanstack.ts` — `orpc` utils for TanStack Query
|
||||
- `lib/auth/client.ts` — Better Auth client hooks
|
||||
- `lib/theme.ts` — Color theme CSS properties from title palettes
|
||||
- `styles/globals.css` — Tailwind + font imports
|
||||
|
||||
**Shared packages**:
|
||||
- `packages/api/src/contract.ts` — Full oRPC API contract definition (~30 procedures)
|
||||
- `packages/api/src/schemas.ts` — Shared Zod schemas and inferred TypeScript types
|
||||
- `packages/db/src/schema.ts` — All Drizzle table definitions
|
||||
- `packages/db/drizzle/` — Migration files
|
||||
- `packages/core/src/` — All service files (metadata, tracking, discovery, availability, credits, person, webhooks, backup, settings, colors, update-check, system-health, lists, image-cache)
|
||||
- `packages/core/test/` — Service tests with in-memory SQLite
|
||||
|
||||
### Auth pattern
|
||||
|
||||
Server components and layouts use the cached session helper to avoid redundant lookups:
|
||||
**Web app** — Route `beforeLoad` guards check session via Better Auth client SDK:
|
||||
|
||||
```typescript
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
const session = await getSession();
|
||||
// In route file (e.g. _app.tsx)
|
||||
beforeLoad: async () => {
|
||||
const { data: session } = await authClient.getSession();
|
||||
if (!session) throw redirect({ to: "/login" });
|
||||
return { session };
|
||||
},
|
||||
```
|
||||
|
||||
oRPC procedures use auth middleware that calls Better Auth:
|
||||
Session is available to child routes via `Route.useRouteContext()`.
|
||||
|
||||
**API server** — oRPC procedures use auth middleware that calls Better Auth:
|
||||
|
||||
```typescript
|
||||
// lib/orpc/middleware.ts — applied to procedures
|
||||
// apps/server/src/orpc/middleware.ts
|
||||
export const authed = base.middleware(async ({ context, next }) => {
|
||||
const session = await auth.api.getSession({ headers: context.headers });
|
||||
if (!session) throw new ORPCError("UNAUTHORIZED");
|
||||
@@ -109,12 +160,18 @@ export const authed = base.middleware(async ({ context, next }) => {
|
||||
|
||||
### oRPC API layer
|
||||
|
||||
All API procedures use oRPC with a contract-first approach. The contract defines the API shape in `lib/orpc/contract.ts`, procedures implement it in `lib/orpc/procedures/`, and the client consumes it with full type safety.
|
||||
All API procedures use oRPC with a contract-first approach. The contract is defined in `packages/api/src/contract.ts`, procedures implement it in `apps/server/src/orpc/procedures/`, and the client consumes it with full type safety.
|
||||
|
||||
**Pattern — importing types from the shared package:**
|
||||
```typescript
|
||||
import type { ResolvedTitle, Season } from "@sofa/api/schemas";
|
||||
import { contract } from "@sofa/api/contract";
|
||||
```
|
||||
|
||||
**Pattern — query in component:**
|
||||
```typescript
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
const { data } = useQuery(orpc.dashboard.stats.queryOptions());
|
||||
```
|
||||
@@ -126,32 +183,36 @@ import { client } from "@/lib/orpc/client";
|
||||
await client.titles.updateStatus({ id: titleId, status: "in_progress" });
|
||||
```
|
||||
|
||||
**Pattern — procedure definition:**
|
||||
**Pattern — route loader with TanStack Query prefetch:**
|
||||
```typescript
|
||||
// lib/orpc/procedures/dashboard.ts
|
||||
export const stats = os.dashboard.stats.func(async (input, context, meta) => {
|
||||
const { user } = context;
|
||||
return getUserStats(user.id);
|
||||
}, authed);
|
||||
export const Route = createFileRoute("/_app/titles/$id")({
|
||||
loader: async ({ params, context }) => {
|
||||
await context.queryClient.ensureQueryData(
|
||||
orpc.titles.detail.queryOptions({ input: { id: params.id } }),
|
||||
);
|
||||
},
|
||||
component: TitlePage,
|
||||
});
|
||||
```
|
||||
|
||||
**Page patterns:**
|
||||
- **Thin server shell** (titles, people): Server component fetches initial data via service layer, passes as `initialData` prop to client component that uses TanStack Query via `orpc` for refetching/mutations.
|
||||
- **Full client-side** (dashboard, explore, settings): Server component handles auth only; client components fetch all data via `orpc.*.queryOptions()` with skeleton loading states.
|
||||
- **Route loaders** (titles, people): `beforeLoad`/`loader` prefetch data into TanStack Query cache via `queryClient.ensureQueryData()`. Components read via `useQuery()`/`useSuspenseQuery()`.
|
||||
- **Full client-side** (dashboard, explore, settings): Components fetch all data via `orpc.*.queryOptions()` with skeleton loading states.
|
||||
- **Error/loading states**: Routes use `pendingComponent`, `errorComponent`, `notFoundComponent`.
|
||||
|
||||
### Non-RPC routes
|
||||
### Non-RPC routes (API server)
|
||||
|
||||
Only file-serving, auth, and webhook routes remain as traditional Next.js API routes:
|
||||
- `/api/auth/[...all]` — Better Auth catch-all
|
||||
- `/api/images/[...path]` — Image proxy/cache serving
|
||||
- `/api/avatars/[userId]` — Avatar file serving
|
||||
- `/api/account/avatar` — Avatar upload (FormData)
|
||||
- `/api/admin/backups/restore` — Backup restore (FormData upload)
|
||||
- `/api/backup/[filename]` — Backup file download
|
||||
- `/api/webhooks/[token]` — Plex/Jellyfin/Emby webhooks
|
||||
- `/api/lists/[token]` — External list feeds
|
||||
Hono route handlers in `apps/server/src/routes/`:
|
||||
- `/images/:category/:filename` — Image proxy/cache serving
|
||||
- `/api/auth/*` — Better Auth catch-all
|
||||
- `/api/avatars/:userId` — Avatar file serving
|
||||
- `/api/backup/:filename` — Backup file download
|
||||
- `/api/webhooks/:token` — Plex/Jellyfin/Emby webhooks
|
||||
- `/api/lists/:token` — External list feeds (Sonarr/Radarr)
|
||||
- `/api/health` — Simple health check (no auth)
|
||||
|
||||
In dev, Vite proxies these to the API server. In production, Hono serves both API and SPA — the browser only sees port 3000.
|
||||
|
||||
### Database schema
|
||||
|
||||
All app tables use UUIDv7 text primary keys generated via `Bun.randomUUIDv7()`. Better Auth tables use their own ID format. Key relationships:
|
||||
@@ -167,35 +228,31 @@ All app tables use UUIDv7 text primary keys generated via `Bun.randomUUIDv7()`.
|
||||
- `cronRuns` — Background job execution history
|
||||
- `appSettings` — Key-value store for runtime app configuration
|
||||
|
||||
### Service layer
|
||||
### Service layer (`packages/core/src/`)
|
||||
|
||||
- **metadata.ts**: `importTitle()` fetches from TMDB, inserts into DB, fire-and-forgets availability + recommendations + credits + image caching. `refreshTvChildren()` fetches all seasons/episodes with 250ms rate limiting.
|
||||
- **image-cache.ts**: Downloads and caches TMDB images to local disk. `cacheImagesForTitle()` caches poster + backdrop, `cacheEpisodeStills()` caches episode stills, `cacheProviderLogos()` caches streaming provider logos, `cacheProfilePhotos()` caches person profile images.
|
||||
- **image-cache.ts**: Downloads and caches TMDB images to local disk.
|
||||
- **tracking.ts**: Auto-transitions — logging a movie watch sets status to `completed`; logging an episode watch sets `in_progress`; all episodes watched auto-completes the series.
|
||||
- **discovery.ts**: Feed generators — continue watching (next unwatched episode per in-progress show), library titles with availability, personalized recommendations from completed/highly-rated titles.
|
||||
- **discovery.ts**: Feed generators — continue watching, library with availability, personalized recommendations.
|
||||
- **availability.ts**: Caches US streaming providers from TMDB watch/providers endpoint.
|
||||
- **credits.ts**: Fetches and caches cast/crew data from TMDB, links persons to titles via `titleCast`.
|
||||
- **credits.ts**: Fetches and caches cast/crew data from TMDB.
|
||||
- **person.ts**: Person detail and filmography lookups.
|
||||
- **webhooks.ts**: Processes incoming Plex/Jellyfin/Emby webhook events to auto-log watches.
|
||||
- **backup.ts**: Database backup/restore with configurable scheduled backups and retention pruning.
|
||||
- **settings.ts**: Runtime app settings (registration open/closed, backup config, etc.) via `appSettings` table.
|
||||
- **webhooks.ts**: Processes incoming Plex/Jellyfin/Emby webhook events.
|
||||
- **backup.ts**: Database backup/restore with configurable scheduled backups.
|
||||
- **settings.ts**: Runtime app settings via `appSettings` table.
|
||||
- **colors.ts**: Extracts dominant color palettes from title posters via node-vibrant.
|
||||
- **update-check.ts**: Checks for new Sofa releases.
|
||||
- **system-health.ts**: System health diagnostics.
|
||||
|
||||
### TMDB images
|
||||
|
||||
Only paths are stored in DB. Image URLs are resolved **server-side only** — API routes and server components call `tmdbImageUrl()` from `lib/tmdb/image.ts` before sending data to clients. Client components never import `tmdbImageUrl`; they receive ready-to-use URLs.
|
||||
Only paths are stored in DB. Image URLs are resolved by the API server — `tmdbImageUrl()` from `@sofa/tmdb/image` is called before sending data to clients. Client components receive ready-to-use URLs.
|
||||
|
||||
When `IMAGE_CACHE_ENABLED` is set (default), images are downloaded to local disk (`CACHE_DIR`, derived from `DATA_DIR/images`) and served via `app/api/images/[...path]/route.ts`. Categories: `posters` (w500), `backdrops` (w1280), `stills` (w1280), `logos` (w92), `profiles` (w185). When disabled, `tmdbImageUrl()` returns direct TMDB CDN URLs using `TMDB_IMAGE_BASE_URL` (defaults to `https://image.tmdb.org/t/p`).
|
||||
|
||||
Core files: `lib/services/image-cache.ts` (caching logic), `lib/tmdb/image.ts` (URL construction), `app/api/images/[...path]/route.ts` (proxy route).
|
||||
When `IMAGE_CACHE_ENABLED` is set (default), images are downloaded to local disk and served via `/images/:category/:filename`. When disabled, `tmdbImageUrl()` returns direct TMDB CDN URLs.
|
||||
|
||||
### Background jobs
|
||||
|
||||
Defined in `lib/cron.ts` using croner cron expressions, started via Next.js instrumentation hook (`instrumentation.ts`) when `NEXT_RUNTIME === "nodejs"`. The instrumentation hook also runs DB migrations on startup and registers graceful shutdown handlers.
|
||||
|
||||
Jobs (all use `protect: true` to prevent overlapping runs, 300ms delay between TMDB calls):
|
||||
Defined in `apps/server/src/cron.ts` using croner, started when the API server boots. Jobs (all use `protect: true` to prevent overlapping runs, 300ms delay between TMDB calls):
|
||||
- `nightlyRefreshLibrary` (`0 3 * * *`) — refreshes stale library titles (7d) and non-library titles (30d)
|
||||
- `refreshAvailability` (`0 */6 * * *`) — streaming provider data
|
||||
- `refreshRecommendations` (`0 */12 * * *`)
|
||||
@@ -207,10 +264,14 @@ Jobs (all use `protect: true` to prevent overlapping runs, 300ms delay between T
|
||||
|
||||
### Environment variables
|
||||
|
||||
See `.env.example`: `DATA_DIR` (root for DB + cache, default `./data`), `TMDB_API_READ_ACCESS_TOKEN`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`. `DATABASE_URL` and `CACHE_DIR` are derived from `DATA_DIR` but can be overridden individually.
|
||||
`DATA_DIR` (root for DB + cache, default `./data`), `TMDB_API_READ_ACCESS_TOKEN`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`. `DATABASE_URL` and `CACHE_DIR` are derived from `DATA_DIR` but can be overridden individually.
|
||||
|
||||
Optional: `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_ISSUER_URL`, `OIDC_PROVIDER_NAME`, `OIDC_AUTO_REGISTER`, `DISABLE_PASSWORD_LOGIN` (OIDC/SSO support). `LOG_LEVEL` (error/warn/info/debug, default: info). `IMAGE_CACHE_ENABLED` (default: true).
|
||||
|
||||
### Docker deployment
|
||||
|
||||
Single container, single process. Hono serves both the API and the Vite-built SPA static files on port 3000. API routes (`/rpc/*`, `/api/*`) are mounted first; unmatched routes fall back to `index.html` for SPA routing.
|
||||
|
||||
## Browser Automation
|
||||
|
||||
Use `agent-browser` for web automation. Run `agent-browser --help` for all commands.
|
||||
|
||||
@@ -1,62 +1,59 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM oven/bun:1-alpine AS base
|
||||
|
||||
# --- Dependencies ---
|
||||
# --- Prune workspace for Docker layer caching ---
|
||||
FROM base AS prepare
|
||||
WORKDIR /app
|
||||
RUN bun add -g turbo@^2
|
||||
COPY . .
|
||||
RUN turbo prune @sofa/web @sofa/server --docker
|
||||
|
||||
# --- Install dependencies (cached by package.json + lockfile only) ---
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock ./
|
||||
|
||||
COPY --from=prepare /app/out/json/ .
|
||||
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||
bun install --no-save --frozen-lockfile
|
||||
|
||||
# --- Builder ---
|
||||
# --- Build ---
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
COPY --from=deps /app/ .
|
||||
COPY --from=prepare /app/out/full/ .
|
||||
|
||||
ARG APP_VERSION
|
||||
ARG GIT_COMMIT_SHA
|
||||
ENV NODE_ENV=production
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA}
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN bun run build
|
||||
RUN bunx turbo run build --filter=@sofa/web --filter=@sofa/server
|
||||
|
||||
# --- Runner ---
|
||||
# --- Production runner ---
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ARG APP_VERSION
|
||||
ARG GIT_COMMIT_SHA
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV DATA_DIR=/data
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA}
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY --from=builder --chown=bun:bun /app/public ./public
|
||||
# API server + packages
|
||||
COPY --from=builder --chown=bun:bun /app/apps/server/ ./apps/server/
|
||||
COPY --from=builder --chown=bun:bun /app/packages/ ./packages/
|
||||
COPY --from=builder --chown=bun:bun /app/node_modules/ ./node_modules/
|
||||
|
||||
RUN mkdir .next \
|
||||
&& chown bun:bun .next
|
||||
# Built SPA
|
||||
COPY --from=builder --chown=bun:bun /app/apps/web/dist/ ./apps/web/dist/
|
||||
|
||||
COPY --from=builder --chown=bun:bun /app/.next/standalone ./
|
||||
COPY --from=builder --chown=bun:bun /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=bun:bun /app/.next/cache ./.next/cache
|
||||
COPY --from=builder --chown=bun:bun /app/drizzle ./drizzle
|
||||
|
||||
RUN mkdir -p /data \
|
||||
&& chown bun:bun /data
|
||||
# DB migrations
|
||||
COPY --from=builder --chown=bun:bun /app/packages/db/drizzle/ ./packages/db/drizzle/
|
||||
|
||||
RUN mkdir -p /data && chown bun:bun /data
|
||||
USER bun
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
||||
CMD ["bun", "-e", "fetch('http://localhost:3000/api/health').then(r => process.exit(+!r.ok)).catch(() => process.exit(1))"]
|
||||
CMD ["bun", "-e", "fetch('http://localhost:3000/api/health').then(r=>process.exit(+!r.ok)).catch(()=>process.exit(1))"]
|
||||
|
||||
CMD ["bun", "server.js"]
|
||||
CMD ["bun", "apps/server/src/index.ts"]
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<div className="flex min-h-[80vh] items-center justify-center px-4">
|
||||
<Suspense>{children}</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthForm } from "@/components/auth-form";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import {
|
||||
getOidcProviderName,
|
||||
isOidcConfigured,
|
||||
isPasswordLoginDisabled,
|
||||
} from "@/lib/config";
|
||||
import { getUserCount, isRegistrationOpen } from "@/lib/services/settings";
|
||||
|
||||
export default async function LoginPage() {
|
||||
const session = await getSession();
|
||||
if (session) redirect("/dashboard");
|
||||
|
||||
if (getUserCount() === 0) {
|
||||
redirect("/register");
|
||||
}
|
||||
|
||||
const oidcEnabled = isOidcConfigured();
|
||||
|
||||
return (
|
||||
<AuthForm
|
||||
mode="login"
|
||||
authConfig={{
|
||||
oidcEnabled,
|
||||
oidcProviderName: oidcEnabled ? getOidcProviderName() : null,
|
||||
passwordLoginDisabled: isPasswordLoginDisabled(),
|
||||
registrationOpen: isRegistrationOpen(),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { ContinueWatchingSection } from "./_components/continue-watching-section";
|
||||
import { LibrarySection } from "./_components/library-section";
|
||||
import { RecommendationsSection } from "./_components/recommendations-section";
|
||||
import { StatsSection } from "./_components/stats-section";
|
||||
import { WelcomeHeader } from "./_components/welcome-header";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await getSession();
|
||||
if (!session) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<WelcomeHeader name={session.user.name} />
|
||||
<StatsSection />
|
||||
<ContinueWatchingSection />
|
||||
<LibrarySection />
|
||||
<RecommendationsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { TitleCardSkeleton } from "@/components/title-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
function TitleRowSkeleton({ withGenreChips }: { withGenreChips?: boolean }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded" />
|
||||
<Skeleton className="h-6 w-40" />
|
||||
</div>
|
||||
{withGenreChips && (
|
||||
<div className="-mx-4 flex gap-2 overflow-x-hidden px-4 sm:-mx-0 sm:flex-wrap sm:px-0">
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<Skeleton
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
|
||||
key={`chip-${i}`}
|
||||
className="h-7 w-20 shrink-0 rounded-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="-mx-4 flex gap-4 overflow-hidden px-4 sm:-mx-0 sm:px-0">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
|
||||
<div key={`card-${i}`} className="w-[140px] shrink-0 sm:w-[160px]">
|
||||
<TitleCardSkeleton />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExploreLoading() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* Hero skeleton — full viewport width like the actual HeroBanner */}
|
||||
<div className="-mt-6 mr-[calc(-50vw+50%)] mb-4 ml-[calc(-50vw+50%)]">
|
||||
<Skeleton className="aspect-[21/9] max-h-[420px] min-h-[280px] w-full rounded-none" />
|
||||
</div>
|
||||
|
||||
<TitleRowSkeleton />
|
||||
<TitleRowSkeleton withGenreChips />
|
||||
<TitleRowSkeleton withGenreChips />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ExploreClient } from "./_components/explore-client";
|
||||
|
||||
export default function ExplorePage() {
|
||||
return <ExploreClient />;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { MobileTabBar, NavBar } from "@/components/nav-bar";
|
||||
import { ProgressProvider } from "@/components/navigation-progress";
|
||||
import { QueryProvider } from "@/components/query-provider";
|
||||
import { UpdateToast } from "@/components/update-toast";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getCachedUpdateCheck } from "@/lib/services/update-check";
|
||||
|
||||
export default function PagesLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Suspense>
|
||||
<QueryProvider>
|
||||
<ProgressProvider>
|
||||
<AuthenticatedShell>{children}</AuthenticatedShell>
|
||||
</ProgressProvider>
|
||||
</QueryProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function AuthenticatedShell({ children }: { children: React.ReactNode }) {
|
||||
const session = await getSession();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative z-0 min-h-screen pb-[calc(3.5rem+env(safe-area-inset-bottom))] sm:pb-0">
|
||||
<NavBar
|
||||
userName={session.user.name}
|
||||
userEmail={session.user.email}
|
||||
userImage={session.user.image || undefined}
|
||||
userRole={session.user.role || undefined}
|
||||
/>
|
||||
{/* Ambient glow — smaller on mobile to add warmth without overwhelming */}
|
||||
<div className="pointer-events-none fixed top-1/4 left-1/2 h-[600px] w-[800px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/3 blur-[200px]" />
|
||||
<main
|
||||
id="main-content"
|
||||
className="relative mx-auto max-w-6xl py-6 pr-[max(1rem,env(safe-area-inset-right))] pl-[max(1rem,env(safe-area-inset-left))] sm:pr-[max(1.5rem,env(safe-area-inset-right))] sm:pl-[max(1.5rem,env(safe-area-inset-left))]"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<MobileTabBar />
|
||||
<CommandPalette />
|
||||
{session.user.role === "admin" && (
|
||||
<UpdateToast data={getCachedUpdateCheck()} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { PersonDetailSkeleton } from "./_components/person-detail-client";
|
||||
|
||||
export default function PersonLoading() {
|
||||
return <PersonDetailSkeleton />;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { persons } from "@/lib/db/schema";
|
||||
import { getLocalFilmography, getOrFetchPerson } from "@/lib/services/person";
|
||||
import { getUserStatusesByTitleIds } from "@/lib/services/tracking";
|
||||
import { PersonDetailClient } from "./_components/person-detail-client";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const person = db.select().from(persons).where(eq(persons.id, id)).get();
|
||||
if (!person) return { title: "Not Found — Sofa" };
|
||||
|
||||
return {
|
||||
title: `${person.name} — Sofa`,
|
||||
description: person.biography?.slice(0, 160),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PersonDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
const person = await getOrFetchPerson(id);
|
||||
if (!person) notFound();
|
||||
|
||||
const filmography = getLocalFilmography(person.id);
|
||||
const session = await getSession();
|
||||
const userStatuses = session
|
||||
? getUserStatusesByTitleIds(
|
||||
session.user.id,
|
||||
filmography.map((c) => c.titleId),
|
||||
)
|
||||
: {};
|
||||
|
||||
return (
|
||||
<PersonDetailClient
|
||||
id={id}
|
||||
initialData={{ person, filmography, userStatuses }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
function SectionSkeleton({
|
||||
headingWidth,
|
||||
children,
|
||||
}: {
|
||||
headingWidth: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Skeleton className="size-4 rounded" />
|
||||
<Skeleton className={`h-3.5 ${headingWidth}`} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsLoading() {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded" />
|
||||
<Skeleton className="h-9 w-40" />
|
||||
</div>
|
||||
<Skeleton className="mt-1 h-4 w-64" />
|
||||
</div>
|
||||
|
||||
{/* Account card */}
|
||||
<SectionSkeleton headingWidth="w-16">
|
||||
<Skeleton className="h-[76px] w-full rounded-xl" />
|
||||
</SectionSkeleton>
|
||||
|
||||
{/* Integrations cards */}
|
||||
<SectionSkeleton headingWidth="w-24">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-16 w-full rounded-xl" />
|
||||
<Skeleton className="h-16 w-full rounded-xl" />
|
||||
<Skeleton className="h-16 w-full rounded-xl" />
|
||||
</div>
|
||||
</SectionSkeleton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { ensureTvHydrated } from "@/lib/services/metadata";
|
||||
import { TitleSeasons } from "./title-seasons";
|
||||
|
||||
export async function AsyncTitleSeasons({
|
||||
titleId,
|
||||
tmdbId,
|
||||
}: {
|
||||
titleId: string;
|
||||
tmdbId: number;
|
||||
}) {
|
||||
const seasons = await ensureTvHydrated(titleId, tmdbId);
|
||||
if (seasons.length === 0) return null;
|
||||
return <TitleSeasons seasons={seasons} />;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Season } from "@/lib/orpc/schemas";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import { TitleContext } from "./title-context";
|
||||
|
||||
export function TitleProvider({
|
||||
titleId,
|
||||
titleType,
|
||||
titleName,
|
||||
initialStatus,
|
||||
initialRating,
|
||||
initialEpisodeWatches,
|
||||
seasons: initialSeasons,
|
||||
children,
|
||||
}: {
|
||||
titleId: string;
|
||||
titleType: "movie" | "tv";
|
||||
titleName: string;
|
||||
initialStatus: string | null;
|
||||
initialRating: number;
|
||||
initialEpisodeWatches: string[];
|
||||
seasons: Season[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [seasons, setSeasons] = useState(initialSeasons);
|
||||
const [watchingEp, setWatchingEp] = useState<string | null>(null);
|
||||
|
||||
// Seed the query cache with server-fetched data on mount.
|
||||
// Unlike initialData (which is a no-op when the cache already has an entry),
|
||||
// this ensures revisiting a title or signing into a different account never
|
||||
// renders stale or another user's data.
|
||||
// The parent passes key={title.id}, so React remounts on navigation and the
|
||||
// cache for the new title ID starts empty.
|
||||
useEffect(() => {
|
||||
queryClient.setQueryData(
|
||||
orpc.titles.userInfo.queryKey({ input: { id: titleId } }),
|
||||
{
|
||||
status: initialStatus as
|
||||
| "watchlist"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| null,
|
||||
rating: initialRating,
|
||||
episodeWatches: initialEpisodeWatches,
|
||||
},
|
||||
);
|
||||
}, [
|
||||
queryClient,
|
||||
titleId,
|
||||
initialStatus,
|
||||
initialRating,
|
||||
initialEpisodeWatches,
|
||||
]);
|
||||
|
||||
return (
|
||||
<TitleContext
|
||||
value={{
|
||||
titleId,
|
||||
titleType,
|
||||
titleName,
|
||||
seasons,
|
||||
setSeasons,
|
||||
watchingEp,
|
||||
setWatchingEp,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TitleContext>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function TitleDetailLoading() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<Skeleton className="-mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-80 rounded-none md:h-[28rem]" />
|
||||
<div className="flex flex-col gap-4 md:flex-row md:gap-8">
|
||||
<Skeleton className="aspect-[2/3] w-[140px] shrink-0 self-center rounded-xl md:w-[220px] md:self-start" />
|
||||
<div className="flex-1 space-y-5">
|
||||
<div>
|
||||
<Skeleton className="h-7 w-2/3 md:h-12" />
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-6 rounded" />
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Skeleton className="h-9 w-28 rounded-lg" />
|
||||
<Skeleton className="h-4 w-px" />
|
||||
<Skeleton className="h-5 w-24 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function TitleNotFound() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6 py-24 text-center">
|
||||
<h1
|
||||
className="animate-stagger-item font-display text-[6rem] text-foreground/[0.06] leading-[0.85] tracking-tight sm:text-[8rem]"
|
||||
style={{ "--stagger-index": 0 } as React.CSSProperties}
|
||||
>
|
||||
404
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className="-mt-4 animate-stagger-item space-y-2"
|
||||
style={{ "--stagger-index": 1 } as React.CSSProperties}
|
||||
>
|
||||
<h2 className="font-display text-2xl tracking-tight sm:text-3xl">
|
||||
Title not found
|
||||
</h2>
|
||||
<p className="mx-auto max-w-sm text-muted-foreground text-sm leading-relaxed">
|
||||
The title you're looking for doesn't exist or may have been
|
||||
removed from the database.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex animate-stagger-item items-center gap-3"
|
||||
style={{ "--stagger-index": 2 } as React.CSSProperties}
|
||||
>
|
||||
<Link
|
||||
href="/explore"
|
||||
className="group relative inline-flex h-10 items-center justify-center overflow-hidden rounded-lg bg-primary px-5 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-lg hover:shadow-primary/20"
|
||||
>
|
||||
<span className="relative z-10">Explore titles</span>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex h-10 items-center justify-center rounded-lg border border-border px-5 font-medium text-sm transition-colors hover:border-primary/40 hover:bg-primary/5"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { cache, Suspense } from "react";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { titles } from "@/lib/db/schema";
|
||||
import { getOrFetchTitle } from "@/lib/services/metadata";
|
||||
import { getUserTitleInfo } from "@/lib/services/tracking";
|
||||
import { getThemeCssProperties } from "@/lib/theme";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import { AsyncTitleSeasons } from "./_components/async-title-seasons";
|
||||
import { TitleActions } from "./_components/title-actions";
|
||||
import { TitleAvailability } from "./_components/title-availability";
|
||||
import { TitleCast } from "./_components/title-cast";
|
||||
import { TitleHero } from "./_components/title-hero";
|
||||
import { TitleKeyboardShortcuts } from "./_components/title-keyboard-shortcuts";
|
||||
import { TitleProvider } from "./_components/title-provider";
|
||||
import { TitleRecommendations } from "./_components/title-recommendations";
|
||||
import { SeasonsSkeleton, TitleSeasons } from "./_components/title-seasons";
|
||||
import { TitleTheme } from "./_components/title-theme";
|
||||
|
||||
const getCachedOrFetchTitle = cache((id: string) => getOrFetchTitle(id));
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const title = db.select().from(titles).where(eq(titles.id, id)).get();
|
||||
if (!title) return { title: "Not Found — Sofa" };
|
||||
|
||||
const year = (title.releaseDate ?? title.firstAirDate)?.slice(0, 4);
|
||||
return {
|
||||
title: `${title.title}${year ? ` (${year})` : ""} — Sofa`,
|
||||
description: title.overview?.slice(0, 160),
|
||||
openGraph: {
|
||||
title: title.title,
|
||||
images: title.posterPath
|
||||
? [{ url: tmdbImageUrl(title.posterPath, "posters") ?? "" }]
|
||||
: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TitleDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
// Fetch title + user info in parallel
|
||||
const session = await getSession();
|
||||
const [result, userInfo] = await Promise.all([
|
||||
getCachedOrFetchTitle(id),
|
||||
session ? getUserTitleInfo(session.user.id, id) : null,
|
||||
]);
|
||||
if (!result) notFound();
|
||||
|
||||
const { title, seasons, needsHydration, availability, cast } = result;
|
||||
|
||||
const themeStyle = getThemeCssProperties(title.colorPalette);
|
||||
|
||||
return (
|
||||
<div className="relative space-y-10" style={themeStyle}>
|
||||
<TitleTheme style={themeStyle as Record<string, string>} />
|
||||
<TitleProvider
|
||||
key={title.id}
|
||||
titleId={title.id}
|
||||
titleType={title.type}
|
||||
titleName={title.title}
|
||||
initialStatus={userInfo?.status ?? null}
|
||||
initialRating={userInfo?.rating ?? 0}
|
||||
initialEpisodeWatches={userInfo?.episodeWatches ?? []}
|
||||
seasons={seasons}
|
||||
>
|
||||
<TitleHero
|
||||
title={title}
|
||||
trailerVideoKey={title.trailerVideoKey}
|
||||
actions={<TitleActions />}
|
||||
>
|
||||
<TitleAvailability availability={availability} />
|
||||
</TitleHero>
|
||||
|
||||
{title.type === "tv" && needsHydration && (
|
||||
<Suspense fallback={<SeasonsSkeleton />}>
|
||||
<AsyncTitleSeasons titleId={title.id} tmdbId={title.tmdbId} />
|
||||
</Suspense>
|
||||
)}
|
||||
{title.type === "tv" && !needsHydration && seasons.length > 0 && (
|
||||
<TitleSeasons />
|
||||
)}
|
||||
|
||||
<TitleCast cast={cast} titleType={title.type} />
|
||||
|
||||
<TitleKeyboardShortcuts />
|
||||
</TitleProvider>
|
||||
|
||||
<TitleRecommendations titleId={title.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
@@ -1,42 +0,0 @@
|
||||
import path from "node:path";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { AVATAR_DIR } from "@/lib/constants";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ userId: string }> },
|
||||
) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { userId } = await params;
|
||||
|
||||
// Sanitize userId to prevent path traversal
|
||||
const safeUserId = path.basename(userId);
|
||||
if (!safeUserId || safeUserId !== userId || safeUserId.includes("..")) {
|
||||
return NextResponse.json({ error: "Invalid user ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find the avatar file (could be .jpg, .png, .webp, .gif)
|
||||
const glob = new Bun.Glob(`${safeUserId}.*`);
|
||||
const matches = await Array.fromAsync(glob.scan(AVATAR_DIR));
|
||||
if (matches.length === 0) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const file = Bun.file(path.join(AVATAR_DIR, matches[0]));
|
||||
if (!(await file.exists())) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Cache for 1 year (cache-busted via ?v= query param)
|
||||
return new NextResponse(await file.arrayBuffer(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import path from "node:path";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getBackupPath } from "@/lib/services/backup";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ filename: string }> },
|
||||
) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (session.user.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { filename } = await params;
|
||||
|
||||
// Sanitize to prevent path traversal
|
||||
const safe = path.basename(filename);
|
||||
if (!safe || safe !== filename || safe.includes("..")) {
|
||||
return NextResponse.json({ error: "Invalid filename" }, { status: 400 });
|
||||
}
|
||||
|
||||
const backupPath = await getBackupPath(safe);
|
||||
if (!backupPath) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const file = Bun.file(backupPath);
|
||||
const buffer = await file.arrayBuffer();
|
||||
|
||||
return new NextResponse(new Uint8Array(buffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/x-sqlite3",
|
||||
"Content-Disposition": `attachment; filename="${safe}"`,
|
||||
"Content-Length": String(file.size),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { connection, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("health");
|
||||
|
||||
export async function GET() {
|
||||
await connection();
|
||||
|
||||
try {
|
||||
db.run(sql`SELECT 1`);
|
||||
|
||||
return NextResponse.json({ status: "healthy" }, { status: 200 });
|
||||
} catch (err) {
|
||||
log.error("Health check failed:", err);
|
||||
return NextResponse.json({ status: "unhealthy" }, { status: 503 });
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import path from "node:path";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
fetchAndMaybeCache,
|
||||
imageCacheEnabled,
|
||||
} from "@/lib/services/image-cache";
|
||||
|
||||
const categorySchema = z.enum([
|
||||
"posters",
|
||||
"backdrops",
|
||||
"stills",
|
||||
"logos",
|
||||
"profiles",
|
||||
]);
|
||||
|
||||
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
const segments = await params;
|
||||
|
||||
if (!imageCacheEnabled()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Image cache disabled" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
if (segments.path.length !== 2) {
|
||||
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [rawCategory, rawFilename] = segments.path;
|
||||
|
||||
const catResult = categorySchema.safeParse(rawCategory);
|
||||
if (!catResult.success) {
|
||||
return NextResponse.json({ error: "Invalid category" }, { status: 400 });
|
||||
}
|
||||
const category = catResult.data;
|
||||
|
||||
// Sanitize filename — only allow basename to prevent path traversal
|
||||
const filename = path.basename(rawFilename);
|
||||
if (!filename || filename !== rawFilename || filename.includes("..")) {
|
||||
return NextResponse.json({ error: "Invalid filename" }, { status: 400 });
|
||||
}
|
||||
|
||||
const tmdbPath = `/${filename}`;
|
||||
const result = await fetchAndMaybeCache(tmdbPath, category);
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return new NextResponse(new Uint8Array(result.buffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": result.contentType,
|
||||
"Cache-Control": IMMUTABLE_CACHE,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getRadarrList,
|
||||
getSonarrList,
|
||||
parseStatusParam,
|
||||
resolveListToken,
|
||||
} from "@/lib/services/lists";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const { token } = await params;
|
||||
const result = resolveListToken(token);
|
||||
if (!result) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const statuses = parseStatusParam(req.nextUrl.searchParams.get("status"));
|
||||
|
||||
if (result.provider === "sonarr") {
|
||||
return NextResponse.json(await getSonarrList(result.userId, statuses));
|
||||
}
|
||||
return NextResponse.json(getRadarrList(result.userId, statuses));
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { openApiHandler } from "@/lib/orpc/openapi-handler";
|
||||
|
||||
async function handleRequest(request: Request) {
|
||||
const { response } = await openApiHandler.handle(request, {
|
||||
prefix: "/api/v1",
|
||||
context: { headers: request.headers },
|
||||
});
|
||||
return response ?? new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
export const GET = handleRequest;
|
||||
export const POST = handleRequest;
|
||||
export const PUT = handleRequest;
|
||||
export const DELETE = handleRequest;
|
||||
@@ -1,60 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { IconAlertTriangle, IconRefresh } from "@tabler/icons-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function PagesError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("[pages error boundary]", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6 py-24 text-center">
|
||||
{/* Icon ring */}
|
||||
<div className="flex size-14 items-center justify-center rounded-full border border-destructive/20 bg-destructive/5 text-destructive">
|
||||
<IconAlertTriangle className="size-6" strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 className="font-display text-2xl tracking-tight sm:text-3xl">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p className="mx-auto max-w-sm text-muted-foreground text-sm leading-relaxed">
|
||||
An unexpected error occurred while loading this page. You can try
|
||||
again or head back to the dashboard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error.digest && (
|
||||
<p className="font-mono text-[11px] text-muted-foreground/40">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="group relative inline-flex h-10 items-center justify-center gap-2 overflow-hidden rounded-lg bg-primary px-5 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-lg hover:shadow-primary/20"
|
||||
>
|
||||
<IconRefresh className="size-4" />
|
||||
<span className="relative z-10">Try again</span>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex h-10 items-center justify-center rounded-lg border border-border px-5 font-medium text-sm transition-colors hover:border-primary/40 hover:bg-primary/5"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
"use client";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" style={{ colorScheme: "dark" }}>
|
||||
<head>
|
||||
<style>{`
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'DM Sans', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
background-color: oklch(0.13 0.006 55);
|
||||
color: oklch(0.93 0.015 80);
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ge-glow {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 40%;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background: oklch(0.8 0.14 65 / 0.04);
|
||||
filter: blur(120px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.ge-icon-ring {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 1.5rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid oklch(0.8 0.14 65 / 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: oklch(0.8 0.14 65);
|
||||
}
|
||||
.ge-title {
|
||||
font-family: 'DM Serif Display', 'Georgia', ui-serif, serif;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.ge-desc {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
color: oklch(0.63 0.02 80);
|
||||
max-width: 22rem;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
.ge-digest {
|
||||
font-size: 0.7rem;
|
||||
font-family: monospace;
|
||||
color: oklch(0.63 0.02 80 / 0.5);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.ge-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
.ge-btn {
|
||||
height: 40px;
|
||||
padding: 0 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, border-color 0.2s, background 0.2s;
|
||||
}
|
||||
.ge-btn-primary {
|
||||
border: none;
|
||||
background: oklch(0.8 0.14 65);
|
||||
color: oklch(0.13 0.006 55);
|
||||
}
|
||||
.ge-btn-primary:hover, .ge-btn-primary:focus-visible {
|
||||
box-shadow: 0 10px 25px -5px oklch(0.8 0.14 65 / 0.2);
|
||||
}
|
||||
.ge-btn-secondary {
|
||||
border: 1px solid oklch(1 0.015 65 / 0.1);
|
||||
background: transparent;
|
||||
color: oklch(0.93 0.015 80);
|
||||
}
|
||||
.ge-btn-secondary:hover, .ge-btn-secondary:focus-visible {
|
||||
border-color: oklch(0.8 0.14 65 / 0.4);
|
||||
background: oklch(0.8 0.14 65 / 0.05);
|
||||
}
|
||||
`}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div className="ge-glow" />
|
||||
|
||||
<div style={{ position: "relative", zIndex: 1 }}>
|
||||
<div className="ge-icon-ring">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role="img"
|
||||
aria-label="Error"
|
||||
>
|
||||
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
|
||||
<path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M13.41 10.59l2.59 -2.59" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 className="ge-title">Intermission</h1>
|
||||
|
||||
<p className="ge-desc">
|
||||
We hit a snag loading this page. This is usually temporary —
|
||||
try refreshing or come back in a moment.
|
||||
</p>
|
||||
|
||||
{error.digest && (
|
||||
<p className="ge-digest">Error ID: {error.digest}</p>
|
||||
)}
|
||||
|
||||
<div className="ge-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="ge-btn ge-btn-primary"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.href = "/";
|
||||
}}
|
||||
className="ge-btn ge-btn-secondary"
|
||||
>
|
||||
Return home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Provider as StoreProvider } from "jotai";
|
||||
import { MotionConfig } from "motion/react";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { DM_Sans, DM_Serif_Display, Geist_Mono } from "next/font/google";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
const dmSans = DM_Sans({
|
||||
variable: "--font-dm-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const dmSerif = DM_Serif_Display({
|
||||
variable: "--font-dm-serif",
|
||||
weight: "400",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sofa",
|
||||
description: "Track your movies and TV shows",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
viewportFit: "cover",
|
||||
themeColor: "#090706",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" style={{ colorScheme: "dark" }}>
|
||||
<body
|
||||
className={`${dmSans.variable} ${dmSerif.variable} ${geistMono.variable} overflow-x-hidden font-sans antialiased`}
|
||||
style={{ touchAction: "manipulation" }}
|
||||
>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<StoreProvider>
|
||||
<MotionConfig reducedMotion="user">
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
<Toaster position="bottom-right" />
|
||||
</MotionConfig>
|
||||
</StoreProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "Sofa",
|
||||
short_name: "Sofa",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
icons: [
|
||||
{
|
||||
src: "/web-app-manifest-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
{
|
||||
src: "/web-app-manifest-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { SofaLogo } from "@/components/sofa-logo";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-6">
|
||||
{/* Warm projector glow */}
|
||||
<div className="pointer-events-none absolute top-[40%] left-1/2 h-[500px] w-[500px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/4 blur-[160px]" />
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center text-center">
|
||||
{/* Large ghosted 404 */}
|
||||
<h1
|
||||
className="animate-stagger-item font-display text-[8rem] text-foreground/[0.06] leading-[0.85] tracking-tight sm:text-[11rem] md:text-[14rem]"
|
||||
style={{ "--stagger-index": 0 } as React.CSSProperties}
|
||||
>
|
||||
404
|
||||
</h1>
|
||||
|
||||
{/* Message */}
|
||||
<div
|
||||
className="animate-stagger-item space-y-3"
|
||||
style={{ "--stagger-index": 1 } as React.CSSProperties}
|
||||
>
|
||||
<h2 className="font-display text-2xl tracking-tight sm:text-3xl">
|
||||
Scene not found
|
||||
</h2>
|
||||
<p className="mx-auto max-w-sm text-muted-foreground text-sm leading-relaxed">
|
||||
This page was left on the cutting room floor. It may have been
|
||||
moved, removed, or never made it past the screenplay.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
className="mt-8 flex animate-stagger-item items-center gap-3"
|
||||
style={{ "--stagger-index": 2 } as React.CSSProperties}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="group relative inline-flex h-10 items-center justify-center overflow-hidden rounded-lg bg-primary px-6 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-lg hover:shadow-primary/20"
|
||||
>
|
||||
<span className="relative z-10">Return home</span>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Logo watermark */}
|
||||
<div
|
||||
className="mt-16 animate-stagger-item text-muted-foreground/20"
|
||||
style={{ "--stagger-index": 3 } as React.CSSProperties}
|
||||
>
|
||||
<SofaLogo className="size-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { LandingPage } from "@/components/landing-page";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { isTmdbConfigured } from "@/lib/config";
|
||||
import { getUserCount, isRegistrationOpen } from "@/lib/services/settings";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
|
||||
// Well-known TMDB poster paths for the background collage
|
||||
const posterPaths = [
|
||||
"/qJ2tW6WMUDux911r6m7haRef0WH.jpg", // The Dark Knight
|
||||
"/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg", // Interstellar
|
||||
"/rCzpDGLbOoPwLjy3OAm5NUPOTrC.jpg", // The Lord of the Rings
|
||||
"/pB8BM7pdSp6B6Ih7QZ4DrQ3PmJK.jpg", // Fight Club
|
||||
"/d5NXSklXo0qyIYkgV94XAgMIckC.jpg", // Dune
|
||||
"/ztkUQFLlC19CCMYHW9o1zWhJRNq.jpg", // Breaking Bad
|
||||
"/7DJKHzAi83BmQrWLrYYOqcoKfhR.jpg", // The Office
|
||||
"/u3bZgnGQ9T01sWNhyveQz0wH0Hl.jpg", // Stranger Things
|
||||
"/ggFHVNu6YYI5L9pCfOacjizRGt.jpg", // Back to the Future
|
||||
"/q6y0Go1tsGEsmtFryDOJo3dEmqu.jpg", // The Shawshank Redemption
|
||||
"/pIkRyD18kl4FhoCNQuWxWu5cBLM.jpg", // Inception
|
||||
"/saHP97rTPS5eLmrLQEcANmKrsFl.jpg", // Forrest Gump
|
||||
];
|
||||
|
||||
const posterUrls = posterPaths
|
||||
.map((p) => tmdbImageUrl(p, "posters", "w300"))
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense>
|
||||
<HomeContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function HomeContent() {
|
||||
const session = await getSession();
|
||||
if (session?.user) redirect("/dashboard");
|
||||
if (!isTmdbConfigured()) redirect("/setup");
|
||||
|
||||
const userCount = getUserCount();
|
||||
return (
|
||||
<LandingPage
|
||||
posterUrls={posterUrls}
|
||||
freshInstall={userCount === 0}
|
||||
registrationOpen={isRegistrationOpen()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
disallow: ["/"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { handler } from "@/lib/orpc/handler";
|
||||
|
||||
async function handleRequest(request: Request) {
|
||||
const { response } = await handler.handle(request, {
|
||||
prefix: "/rpc",
|
||||
context: { headers: request.headers },
|
||||
});
|
||||
return response ?? new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
export const GET = handleRequest;
|
||||
export const POST = handleRequest;
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@sofa/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --env-file=../../.env --watch src/index.ts",
|
||||
"start": "bun --env-file=../../.env src/index.ts",
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@orpc/client": "catalog:",
|
||||
"@orpc/contract": "catalog:",
|
||||
"@orpc/json-schema": "1.13.6",
|
||||
"@orpc/openapi": "1.13.6",
|
||||
"@orpc/server": "1.13.6",
|
||||
"@orpc/zod": "1.13.6",
|
||||
"@sofa/api": "workspace:*",
|
||||
"@sofa/auth": "workspace:*",
|
||||
"@sofa/config": "workspace:*",
|
||||
"@sofa/core": "workspace:*",
|
||||
"@sofa/db": "workspace:*",
|
||||
"@sofa/logger": "workspace:*",
|
||||
"@sofa/tmdb": "workspace:*",
|
||||
"croner": "10.0.2-dev.2",
|
||||
"hono": "4.12.7",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,22 @@
|
||||
import { Cron } from "croner";
|
||||
import { and, eq, inArray, isNotNull, lt, or } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { refreshAvailability } from "@sofa/core/availability";
|
||||
import { createBackup, ensureBackupDir, pruneBackups } from "@sofa/core/backup";
|
||||
import { refreshCredits } from "@sofa/core/credits";
|
||||
import {
|
||||
cacheEpisodeStills,
|
||||
cacheImagesForTitle,
|
||||
cacheProfilePhotos,
|
||||
cacheProviderLogos,
|
||||
imageCacheEnabled,
|
||||
} from "@sofa/core/image-cache";
|
||||
import {
|
||||
refreshRecommendations,
|
||||
refreshTitle,
|
||||
refreshTvChildren,
|
||||
} from "@sofa/core/metadata";
|
||||
import { getSetting } from "@sofa/core/settings";
|
||||
import { performUpdateCheck } from "@sofa/core/update-check";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { and, eq, inArray, isNotNull, lt, or } from "@sofa/db/helpers";
|
||||
import {
|
||||
availabilityOffers,
|
||||
cronRuns,
|
||||
@@ -8,30 +24,10 @@ import {
|
||||
titleCast,
|
||||
titles,
|
||||
userTitleStatus,
|
||||
} from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { refreshAvailability } from "@/lib/services/availability";
|
||||
import {
|
||||
createBackup,
|
||||
ensureBackupDir,
|
||||
pruneBackups,
|
||||
} from "@/lib/services/backup";
|
||||
import { refreshCredits } from "@/lib/services/credits";
|
||||
import {
|
||||
cacheEpisodeStills,
|
||||
cacheImagesForTitle,
|
||||
cacheProfilePhotos,
|
||||
cacheProviderLogos,
|
||||
imageCacheEnabled,
|
||||
} from "@/lib/services/image-cache";
|
||||
import {
|
||||
refreshRecommendations,
|
||||
refreshTitle,
|
||||
refreshTvChildren,
|
||||
} from "@/lib/services/metadata";
|
||||
import { getSetting } from "@/lib/services/settings";
|
||||
import { performUpdateCheck } from "@/lib/services/update-check";
|
||||
import { getTvDetails } from "@/lib/tmdb/client";
|
||||
} from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { getTvDetails } from "@sofa/tmdb/client";
|
||||
import { Cron } from "croner";
|
||||
|
||||
export type BackupFrequency = "6h" | "12h" | "1d" | "7d";
|
||||
|
||||
@@ -166,7 +162,6 @@ async function refreshAvailabilityJob() {
|
||||
log.debug(`Checking availability for ${libraryIds.length} library titles`);
|
||||
const stale = new Date(Date.now() - DAY);
|
||||
|
||||
// Batch: find titles with any offers, and titles with stale offers
|
||||
const titlesWithOffers = new Set(
|
||||
db
|
||||
.select({ titleId: availabilityOffers.titleId })
|
||||
@@ -200,7 +195,6 @@ async function refreshAvailabilityJob() {
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh recommendations for recently active titles
|
||||
async function refreshRecommendationsJob() {
|
||||
const libraryIds = getLibraryTitleIds();
|
||||
log.debug(
|
||||
@@ -213,7 +207,6 @@ async function refreshRecommendationsJob() {
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh TV episodes for returning shows
|
||||
async function refreshTvChildrenJob() {
|
||||
const returningStatuses = ["Returning Series", "In Production"];
|
||||
const stale = new Date(Date.now() - 7 * DAY);
|
||||
@@ -232,7 +225,6 @@ async function refreshTvChildrenJob() {
|
||||
|
||||
log.debug(`Checking ${tvShows.length} returning TV shows for stale episodes`);
|
||||
|
||||
// Batch: find shows with at least one stale season
|
||||
const tvIds = tvShows.map((s) => s.id);
|
||||
const titlesWithStaleSeasons = new Set(
|
||||
tvIds.length > 0
|
||||
@@ -260,7 +252,6 @@ async function refreshTvChildrenJob() {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache images for all library titles (posters, backdrops, stills, logos)
|
||||
async function cacheImagesJob() {
|
||||
if (!imageCacheEnabled()) return;
|
||||
|
||||
@@ -282,7 +273,6 @@ async function cacheImagesJob() {
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh credits for library titles where cast is stale or missing
|
||||
async function refreshCreditsJob() {
|
||||
const libraryIds = getLibraryTitleIds();
|
||||
log.debug(`Checking credits for ${libraryIds.length} library titles`);
|
||||
@@ -0,0 +1,139 @@
|
||||
import { CACHE_DIR } from "@sofa/config";
|
||||
import { ensureBackupDir } from "@sofa/core/backup";
|
||||
import { ensureImageDirs, imageCacheEnabled } from "@sofa/core/image-cache";
|
||||
import { registerJobScheduleProvider } from "@sofa/core/system-health";
|
||||
import { closeDatabase } from "@sofa/db/client";
|
||||
import { runMigrations } from "@sofa/db/migrate";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { cors } from "hono/cors";
|
||||
import { getJobSchedules, startJobs, stopJobs } from "./cron";
|
||||
import { handler as rpcHandler } from "./orpc/handler";
|
||||
import { openApiHandler } from "./orpc/openapi-handler";
|
||||
import authRoutes from "./routes/auth";
|
||||
import avatarsRoutes from "./routes/avatars";
|
||||
import backupsRoutes from "./routes/backups";
|
||||
import healthRoutes from "./routes/health";
|
||||
import imagesRoutes from "./routes/images";
|
||||
import listsRoutes from "./routes/lists";
|
||||
import webhooksRoutes from "./routes/webhooks";
|
||||
|
||||
const log = createLogger("server");
|
||||
|
||||
// ─── Startup ──────────────────────────────────────────────────
|
||||
|
||||
// Ensure directories
|
||||
if (imageCacheEnabled()) {
|
||||
await ensureImageDirs();
|
||||
}
|
||||
await ensureBackupDir();
|
||||
|
||||
// Run database migrations
|
||||
runMigrations();
|
||||
|
||||
// Wire up job schedule provider for system health
|
||||
registerJobScheduleProvider(getJobSchedules);
|
||||
|
||||
// Start background jobs
|
||||
startJobs();
|
||||
|
||||
// ─── App ──────────────────────────────────────────────────────
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// CORS — allow the web app's origin
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Non-RPC routes
|
||||
app.route("/api/health", healthRoutes);
|
||||
app.route("/api/auth", authRoutes);
|
||||
app.route("/api/avatars", avatarsRoutes);
|
||||
app.route("/api/backup", backupsRoutes);
|
||||
app.route("/api/webhooks", webhooksRoutes);
|
||||
app.route("/api/lists", listsRoutes);
|
||||
|
||||
// Cached images — serve from disk (fast path), fall back to TMDB fetch on miss
|
||||
if (imageCacheEnabled()) {
|
||||
app.use(
|
||||
"/images/*",
|
||||
serveStatic({
|
||||
root: CACHE_DIR,
|
||||
rewriteRequestPath: (path) => path.replace("/images", ""),
|
||||
onFound: (_path, c) => {
|
||||
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
app.route("/images", imagesRoutes);
|
||||
|
||||
// oRPC RPC handler
|
||||
app.all("/rpc/*", async (c) => {
|
||||
const { response } = await rpcHandler.handle(c.req.raw, {
|
||||
prefix: "/rpc",
|
||||
context: { headers: c.req.raw.headers },
|
||||
});
|
||||
return response ?? c.text("Not found", 404);
|
||||
});
|
||||
|
||||
// oRPC OpenAPI handler + Scalar docs
|
||||
app.all("/api/v1/*", async (c) => {
|
||||
const { response } = await openApiHandler.handle(c.req.raw, {
|
||||
prefix: "/api/v1",
|
||||
context: { headers: c.req.raw.headers },
|
||||
});
|
||||
return response ?? c.text("Not found", 404);
|
||||
});
|
||||
|
||||
// ─── SPA static serving (production) ─────────────────────────
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
const { resolve } = await import("node:path");
|
||||
const spaDir = resolve(import.meta.dir, "../../../apps/web/dist");
|
||||
|
||||
// Hashed assets — immutable cache
|
||||
app.use(
|
||||
"/assets/*",
|
||||
serveStatic({
|
||||
root: spaDir,
|
||||
onFound: (_path, c) => {
|
||||
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||
},
|
||||
}),
|
||||
);
|
||||
// Return 404 for missing /assets/* (stale chunks after deploy) instead of SPA fallback
|
||||
app.all("/assets/*", (c) => c.text("Not found", 404));
|
||||
// Other static files (icons, manifest, etc.)
|
||||
app.use("*", serveStatic({ root: spaDir }));
|
||||
// SPA fallback — serve index.html for all unmatched routes
|
||||
app.get("*", serveStatic({ root: spaDir, path: "/index.html" }));
|
||||
}
|
||||
|
||||
// ─── Graceful shutdown ────────────────────────────────────────
|
||||
|
||||
const shutdown = () => {
|
||||
log.info("Stopping scheduler...");
|
||||
stopJobs();
|
||||
log.info("Closing database...");
|
||||
closeDatabase();
|
||||
log.info("Clean shutdown complete");
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
const port = Number(process.env.PORT || process.env.API_PORT || 3001);
|
||||
log.info(`API server listening on port ${port}`);
|
||||
|
||||
export default {
|
||||
port,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { implement } from "@orpc/server";
|
||||
import { contract } from "./contract";
|
||||
import { contract } from "@sofa/api/contract";
|
||||
|
||||
export interface Context {
|
||||
headers: Headers;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { onError } from "@orpc/server";
|
||||
import { RPCHandler } from "@orpc/server/fetch";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { router } from "./router";
|
||||
|
||||
const log = createLogger("orpc");
|
||||
@@ -8,7 +8,7 @@ const log = createLogger("orpc");
|
||||
export const handler = new RPCHandler(router, {
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
log.error("oRPC error", { error });
|
||||
log.error("oRPC error", error);
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { oo } from "@orpc/openapi";
|
||||
import { os as baseOs, ORPCError } from "@orpc/server";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { auth } from "@sofa/auth/server";
|
||||
|
||||
const base = baseOs.$context<{ headers: Headers }>();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
||||
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
|
||||
import { onError } from "@orpc/server";
|
||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { router } from "./router";
|
||||
|
||||
const log = createLogger("openapi");
|
||||
@@ -42,7 +42,7 @@ export const openApiHandler = new OpenAPIHandler(router, {
|
||||
],
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
log.error("OpenAPI error", { error });
|
||||
log.error("OpenAPI error", error);
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdir, rename } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { AVATAR_DIR } from "@/lib/constants";
|
||||
import { auth } from "@sofa/auth/server";
|
||||
import { AVATAR_DIR } from "@sofa/config";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import path from "node:path";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { BACKUP_DIR } from "@/lib/constants";
|
||||
import { rescheduleBackup, triggerJob } from "@/lib/cron";
|
||||
import { BACKUP_DIR } from "@sofa/config";
|
||||
import {
|
||||
createBackup,
|
||||
deleteBackup,
|
||||
ensureBackupDir,
|
||||
listBackups,
|
||||
restoreFromBackup,
|
||||
} from "@/lib/services/backup";
|
||||
import { getSetting, setSetting } from "@/lib/services/settings";
|
||||
} from "@sofa/core/backup";
|
||||
import { getSetting, setSetting } from "@sofa/core/settings";
|
||||
import {
|
||||
getCachedUpdateCheck,
|
||||
isUpdateCheckEnabled,
|
||||
} from "@/lib/services/update-check";
|
||||
} from "@sofa/core/update-check";
|
||||
import { rescheduleBackup, triggerJob } from "../../cron";
|
||||
import { os } from "../context";
|
||||
import { admin } from "../middleware";
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
getNewAvailableFeed,
|
||||
getRecommendationsFeed,
|
||||
getUserStats,
|
||||
} from "@/lib/services/discovery";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
} from "@sofa/core/discovery";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { isTmdbConfigured } from "@/lib/config";
|
||||
import {
|
||||
getEpisodeProgressByTmdbIds,
|
||||
getUserStatusesByTmdbIds,
|
||||
} from "@/lib/services/tracking";
|
||||
import { discover } from "@/lib/tmdb/client";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
} from "@sofa/core/tracking";
|
||||
import { discover as discoverTmdb } from "@sofa/tmdb/client";
|
||||
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const discoverProcedure = os.discover
|
||||
export const discover = os.discover
|
||||
.use(authed)
|
||||
.handler(async ({ input, context }) => {
|
||||
if (!isTmdbConfigured()) {
|
||||
@@ -18,7 +18,7 @@ export const discoverProcedure = os.discover
|
||||
});
|
||||
}
|
||||
|
||||
const results = await discover(input.mediaType, {
|
||||
const results = await discoverTmdb(input.mediaType, {
|
||||
sort_by: "popularity.desc",
|
||||
"vote_count.gte": "50",
|
||||
with_genres: String(input.genreId),
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
logEpisodeWatch,
|
||||
logEpisodeWatchBatch,
|
||||
unwatchEpisode,
|
||||
} from "@/lib/services/tracking";
|
||||
} from "@sofa/core/tracking";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { isTmdbConfigured } from "@/lib/config";
|
||||
import {
|
||||
getEpisodeProgressByTmdbIds,
|
||||
getUserStatusesByTmdbIds,
|
||||
} from "@/lib/services/tracking";
|
||||
import { getGenres, getPopular, getTrending } from "@/lib/tmdb/client";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
} from "@sofa/core/tracking";
|
||||
import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client";
|
||||
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { integrationEvents, integrations } from "@/lib/db/schema";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { and, desc, eq } from "@sofa/db/helpers";
|
||||
import { integrationEvents, integrations } from "@sofa/db/schema";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
getLocalFilmography,
|
||||
getOrFetchPerson,
|
||||
getOrFetchPersonByTmdbId,
|
||||
} from "@/lib/services/person";
|
||||
import { getUserStatusesByTitleIds } from "@/lib/services/tracking";
|
||||
} from "@sofa/core/person";
|
||||
import { getUserStatusesByTitleIds } from "@sofa/core/tracking";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { isTmdbConfigured } from "@/lib/config";
|
||||
import {
|
||||
searchMovies,
|
||||
searchMulti,
|
||||
searchPerson,
|
||||
searchTv,
|
||||
} from "@/lib/tmdb/client";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
} from "@sofa/tmdb/client";
|
||||
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { episodes } from "@/lib/db/schema";
|
||||
import { logEpisodeWatchBatch, unwatchSeason } from "@/lib/services/tracking";
|
||||
import { logEpisodeWatchBatch, unwatchSeason } from "@sofa/core/tracking";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import { episodes } from "@sofa/db/schema";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { getWatchCount, getWatchHistory } from "@sofa/core/discovery";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const stats = os.stats.use(authed).handler(({ input, context }) => {
|
||||
const count = getWatchCount(context.user.id, input.type, input.period);
|
||||
const history = getWatchHistory(context.user.id, input.type, input.period);
|
||||
return { count, history };
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isTmdbConfigured } from "@/lib/config";
|
||||
import { getSystemHealth } from "@/lib/services/system-health";
|
||||
import { getSystemHealth } from "@sofa/core/system-health";
|
||||
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
getOidcProviderName,
|
||||
isOidcConfigured,
|
||||
isPasswordLoginDisabled,
|
||||
} from "@sofa/auth/config";
|
||||
import { getUserCount, isRegistrationOpen } from "@sofa/core/settings";
|
||||
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
import { os } from "../context";
|
||||
|
||||
// Well-known TMDB poster paths for the background collage
|
||||
const posterPaths = [
|
||||
"/qJ2tW6WMUDux911r6m7haRef0WH.jpg",
|
||||
"/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg",
|
||||
"/rCzpDGLbOoPwLjy3OAm5NUPOTrC.jpg",
|
||||
"/pB8BM7pdSp6B6Ih7QZ4DrQ3PmJK.jpg",
|
||||
"/d5NXSklXo0qyIYkgV94XAgMIckC.jpg",
|
||||
"/ztkUQFLlC19CCMYHW9o1zWhJRNq.jpg",
|
||||
"/7DJKHzAi83BmQrWLrYYOqcoKfhR.jpg",
|
||||
"/u3bZgnGQ9T01sWNhyveQz0wH0Hl.jpg",
|
||||
"/ggFHVNu6YYI5L9pCfOacjizRGt.jpg",
|
||||
"/q6y0Go1tsGEsmtFryDOJo3dEmqu.jpg",
|
||||
"/pIkRyD18kl4FhoCNQuWxWu5cBLM.jpg",
|
||||
"/saHP97rTPS5eLmrLQEcANmKrsFl.jpg",
|
||||
];
|
||||
|
||||
export const publicInfo = os.system.publicInfo.handler(async () => {
|
||||
const posterUrls = posterPaths
|
||||
.map((p) => tmdbImageUrl(p, "posters", "w300"))
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
return {
|
||||
tmdbConfigured: isTmdbConfigured(),
|
||||
userCount: getUserCount(),
|
||||
registrationOpen: isRegistrationOpen(),
|
||||
posterUrls,
|
||||
};
|
||||
});
|
||||
|
||||
export const authConfig = os.system.authConfig.handler(async () => {
|
||||
const oidcEnabled = isOidcConfigured();
|
||||
return {
|
||||
oidcEnabled,
|
||||
oidcProviderName: oidcEnabled ? getOidcProviderName() : null,
|
||||
passwordLoginDisabled: isPasswordLoginDisabled(),
|
||||
registrationOpen: isRegistrationOpen(),
|
||||
userCount: getUserCount(),
|
||||
};
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { getRecommendationsForTitle } from "@/lib/services/discovery";
|
||||
import { getRecommendationsForTitle } from "@sofa/core/discovery";
|
||||
import {
|
||||
ensureTvHydrated,
|
||||
getOrFetchTitle,
|
||||
getOrFetchTitleByTmdbId,
|
||||
} from "@/lib/services/metadata";
|
||||
} from "@sofa/core/metadata";
|
||||
import {
|
||||
getUserStatusesByTitleIds,
|
||||
getUserTitleInfo,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
rateTitleStars,
|
||||
removeTitleStatus,
|
||||
setTitleStatus,
|
||||
} from "@/lib/services/tracking";
|
||||
} from "@sofa/core/tracking";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
@@ -78,3 +79,10 @@ export const recommendations = os.titles.recommendations
|
||||
);
|
||||
return { recommendations: recs, userStatuses };
|
||||
});
|
||||
|
||||
export const hydrateSeasons = os.titles.hydrateSeasons
|
||||
.use(authed)
|
||||
.handler(async ({ input }) => {
|
||||
const seasons = await ensureTvHydrated(input.id, input.tmdbId);
|
||||
return { seasons };
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { userTitleStatus } from "@/lib/db/schema";
|
||||
import { getOrFetchTitleByTmdbId } from "@/lib/services/metadata";
|
||||
import { setTitleStatus } from "@/lib/services/tracking";
|
||||
import { getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
|
||||
import { setTitleStatus } from "@sofa/core/tracking";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { and, eq } from "@sofa/db/helpers";
|
||||
import { userTitleStatus } from "@sofa/db/schema";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
@@ -2,15 +2,16 @@ import { os } from "./context";
|
||||
import * as account from "./procedures/account";
|
||||
import * as admin from "./procedures/admin";
|
||||
import * as dashboard from "./procedures/dashboard";
|
||||
import { discoverProcedure } from "./procedures/discover";
|
||||
import { discover } from "./procedures/discover";
|
||||
import * as episodes from "./procedures/episodes";
|
||||
import * as explore from "./procedures/explore";
|
||||
import * as integrations from "./procedures/integrations";
|
||||
import * as people from "./procedures/people";
|
||||
import { search } from "./procedures/search";
|
||||
import * as seasons from "./procedures/seasons";
|
||||
import { statsProcedure } from "./procedures/stats";
|
||||
import { stats } from "./procedures/stats";
|
||||
import { systemStatus } from "./procedures/status";
|
||||
import * as system from "./procedures/system";
|
||||
import * as titles from "./procedures/titles";
|
||||
import * as watchlist from "./procedures/watchlist";
|
||||
|
||||
@@ -24,6 +25,7 @@ export const router = os.router({
|
||||
watchAll: titles.watchAll,
|
||||
userInfo: titles.userInfo,
|
||||
recommendations: titles.recommendations,
|
||||
hydrateSeasons: titles.hydrateSeasons,
|
||||
},
|
||||
episodes: {
|
||||
watch: episodes.watch,
|
||||
@@ -50,9 +52,13 @@ export const router = os.router({
|
||||
genres: explore.genres,
|
||||
},
|
||||
search,
|
||||
discover: discoverProcedure,
|
||||
stats: statsProcedure,
|
||||
discover,
|
||||
stats,
|
||||
systemStatus,
|
||||
system: {
|
||||
publicInfo: system.publicInfo,
|
||||
authConfig: system.authConfig,
|
||||
},
|
||||
integrations: {
|
||||
list: integrations.list,
|
||||
create: integrations.create,
|
||||
@@ -0,0 +1,8 @@
|
||||
import { auth } from "@sofa/auth/server";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.all("/*", (c) => auth.handler(c.req.raw));
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,43 @@
|
||||
import path from "node:path";
|
||||
import { auth } from "@sofa/auth/server";
|
||||
import { AVATAR_DIR } from "@sofa/config";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/:userId", async (c) => {
|
||||
// Auth check
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const userId = c.req.param("userId");
|
||||
|
||||
// Sanitize userId to prevent path traversal
|
||||
const safeUserId = path.basename(userId);
|
||||
if (!safeUserId || safeUserId !== userId || safeUserId.includes("..")) {
|
||||
return c.json({ error: "Invalid user ID" }, 400);
|
||||
}
|
||||
|
||||
// Find the avatar file (could be .jpg, .png, .webp, .gif)
|
||||
const glob = new Bun.Glob(`${safeUserId}.*`);
|
||||
const matches = await Array.fromAsync(glob.scan(AVATAR_DIR));
|
||||
if (matches.length === 0) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
const file = Bun.file(path.join(AVATAR_DIR, matches[0]));
|
||||
if (!(await file.exists())) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
return new Response(await file.arrayBuffer(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,44 @@
|
||||
import path from "node:path";
|
||||
import { auth } from "@sofa/auth/server";
|
||||
import { getBackupPath } from "@sofa/core/backup";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/:filename", async (c) => {
|
||||
// Auth + admin check
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
if (session.user.role !== "admin") {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const filename = c.req.param("filename");
|
||||
|
||||
// Sanitize to prevent path traversal
|
||||
const safe = path.basename(filename);
|
||||
if (!safe || safe !== filename || safe.includes("..")) {
|
||||
return c.json({ error: "Invalid filename" }, 400);
|
||||
}
|
||||
|
||||
const backupPath = await getBackupPath(safe);
|
||||
if (!backupPath) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
const file = Bun.file(backupPath);
|
||||
const buffer = await file.arrayBuffer();
|
||||
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/x-sqlite3",
|
||||
"Content-Disposition": `attachment; filename="${safe}"`,
|
||||
"Content-Length": String(file.size),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { db } from "@sofa/db/client";
|
||||
import { sql } from "@sofa/db/helpers";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const log = createLogger("health");
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", (c) => {
|
||||
try {
|
||||
db.run(sql`SELECT 1`);
|
||||
return c.json({ status: "healthy" }, 200);
|
||||
} catch (err) {
|
||||
log.error("Health check failed:", err);
|
||||
return c.json({ status: "unhealthy" }, 503);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,51 @@
|
||||
import path from "node:path";
|
||||
import { fetchAndMaybeCache, imageCacheEnabled } from "@sofa/core/image-cache";
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
|
||||
const categorySchema = z.enum([
|
||||
"posters",
|
||||
"backdrops",
|
||||
"stills",
|
||||
"logos",
|
||||
"profiles",
|
||||
]);
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/:category/:filename", async (c) => {
|
||||
if (!imageCacheEnabled()) {
|
||||
return c.json({ error: "Image cache disabled" }, 404);
|
||||
}
|
||||
|
||||
const rawCategory = c.req.param("category");
|
||||
const rawFilename = c.req.param("filename");
|
||||
|
||||
const catResult = categorySchema.safeParse(rawCategory);
|
||||
if (!catResult.success) {
|
||||
return c.json({ error: "Invalid category" }, 400);
|
||||
}
|
||||
const category = catResult.data;
|
||||
|
||||
// Sanitize filename — only allow basename to prevent path traversal
|
||||
const filename = path.basename(rawFilename);
|
||||
if (!filename || filename !== rawFilename || filename.includes("..")) {
|
||||
return c.json({ error: "Invalid filename" }, 400);
|
||||
}
|
||||
|
||||
const tmdbPath = `/${filename}`;
|
||||
const result = await fetchAndMaybeCache(tmdbPath, category);
|
||||
|
||||
if (!result) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
return new Response(new Uint8Array(result.buffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": result.contentType,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
getRadarrList,
|
||||
getSonarrList,
|
||||
parseStatusParam,
|
||||
resolveListToken,
|
||||
} from "@sofa/core/lists";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/:token", async (c) => {
|
||||
const token = c.req.param("token");
|
||||
const result = resolveListToken(token);
|
||||
if (!result) {
|
||||
return c.json([]);
|
||||
}
|
||||
|
||||
const statuses = parseStatusParam(c.req.query("status") ?? null);
|
||||
|
||||
if (result.provider === "sonarr") {
|
||||
return c.json(await getSonarrList(result.userId, statuses));
|
||||
}
|
||||
return c.json(getRadarrList(result.userId, statuses));
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -1,24 +1,22 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { integrations } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import type { WebhookEvent } from "@/lib/services/webhooks";
|
||||
import type { WebhookEvent } from "@sofa/core/webhooks";
|
||||
import {
|
||||
parseEmbyPayload,
|
||||
parseJellyfinPayload,
|
||||
parsePlexPayload,
|
||||
processWebhook,
|
||||
} from "@/lib/services/webhooks";
|
||||
} from "@sofa/core/webhooks";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import { integrations } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const log = createLogger("webhooks");
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const { token } = await params;
|
||||
const app = new Hono();
|
||||
|
||||
app.post("/:token", async (c) => {
|
||||
const token = c.req.param("token");
|
||||
|
||||
// Look up connection by token — this IS the auth
|
||||
const connection = db
|
||||
@@ -29,12 +27,12 @@ export async function POST(
|
||||
|
||||
if (!connection || !connection.enabled) {
|
||||
// Always return 200 to avoid retry storms from media servers
|
||||
return NextResponse.json({ ok: true });
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
|
||||
// Only webhook-type integrations are handled here
|
||||
if (connection.type !== "webhook") {
|
||||
return NextResponse.json({ ok: true });
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
|
||||
const provider = connection.provider as "plex" | "jellyfin" | "emby";
|
||||
@@ -42,19 +40,19 @@ export async function POST(
|
||||
try {
|
||||
let event: WebhookEvent | null;
|
||||
if (provider === "plex") {
|
||||
const formData = await req.formData();
|
||||
event = parsePlexPayload(formData);
|
||||
const formData = await c.req.raw.formData();
|
||||
event = parsePlexPayload(formData as unknown as FormData);
|
||||
} else if (provider === "emby") {
|
||||
const body = await req.json();
|
||||
const body = await c.req.json();
|
||||
event = parseEmbyPayload(body);
|
||||
} else {
|
||||
const body = await req.json();
|
||||
const body = await c.req.json();
|
||||
event = parseJellyfinPayload(body);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
// Not a relevant event type — silently ignore
|
||||
return NextResponse.json({ ok: true });
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
|
||||
await processWebhook(connection.id, connection.userId, provider, event);
|
||||
@@ -63,5 +61,7 @@ export async function POST(
|
||||
log.debug("Webhook processing failed:", err);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["esnext"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-mira",
|
||||
"rsc": true,
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en" style="color-scheme: dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
</head>
|
||||
<body style="touch-action: manipulation">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "@sofa/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.2.0",
|
||||
"@fontsource-variable/dm-sans": "5.2.8",
|
||||
"@fontsource-variable/geist-mono": "5.2.7",
|
||||
"@fontsource/dm-serif-display": "5.2.8",
|
||||
"@orpc/client": "catalog:",
|
||||
"@orpc/contract": "catalog:",
|
||||
"@orpc/tanstack-query": "1.13.6",
|
||||
"@player.style/sutro": "0.2.1",
|
||||
"@sofa/api": "workspace:*",
|
||||
"@tabler/icons-react": "3.40.0",
|
||||
"@tanstack/react-hotkeys": "0.4.1",
|
||||
"@tanstack/react-query": "5.90.21",
|
||||
"@tanstack/react-router": "1.166.6",
|
||||
"better-auth": "catalog:",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"date-fns": "catalog:",
|
||||
"jotai": "2.18.1",
|
||||
"media-chrome": "4.18.0",
|
||||
"motion": "12.35.2",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "3.8.0",
|
||||
"shadcn": "4.0.3",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"vaul": "1.1.2",
|
||||
"youtube-video-element": "1.9.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "4.2.1",
|
||||
"@tanstack/devtools-vite": "0.5.3",
|
||||
"@tanstack/react-devtools": "0.9.10",
|
||||
"@tanstack/react-query-devtools": "5.91.3",
|
||||
"@tanstack/react-router-devtools": "1.166.6",
|
||||
"@tanstack/router-plugin": "1.166.6",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "25.4.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"tailwindcss": "4.2.1",
|
||||
"typescript": "catalog:",
|
||||
"vite": "7.3.1",
|
||||
"vite-tsconfig-paths": "6.1.1"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 579 B After Width: | Height: | Size: 579 B |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Sofa",
|
||||
"short_name": "Sofa",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { IconKey } from "@tabler/icons-react";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { SofaLogo } from "@/components/sofa-logo";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
@@ -38,7 +35,7 @@ export function AuthForm({
|
||||
mode: "login" | "register";
|
||||
authConfig?: AuthConfig;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -69,7 +66,7 @@ export function AuthForm({
|
||||
return;
|
||||
}
|
||||
}
|
||||
router.push("/dashboard");
|
||||
void navigate({ to: "/dashboard" });
|
||||
} catch {
|
||||
setError("Something went wrong");
|
||||
} finally {
|
||||
@@ -104,7 +101,7 @@ export function AuthForm({
|
||||
transition={{ type: "spring" as const, stiffness: 200, damping: 20 }}
|
||||
>
|
||||
<div className="space-y-2 text-center">
|
||||
<Link href="/" className="inline-flex justify-center text-primary">
|
||||
<Link to="/" className="inline-flex justify-center text-primary">
|
||||
<SofaLogo className="size-9" />
|
||||
</Link>
|
||||
<h1 className="text-balance font-medium text-lg">
|
||||
@@ -263,7 +260,7 @@ export function AuthForm({
|
||||
<>
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
to="/login"
|
||||
className="font-medium text-primary transition-colors hover:text-primary/80"
|
||||
>
|
||||
Sign in
|
||||
@@ -273,7 +270,7 @@ export function AuthForm({
|
||||
<>
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
to="/register"
|
||||
className="font-medium text-primary transition-colors hover:text-primary/80"
|
||||
>
|
||||
Register
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
IconDeviceTv,
|
||||
IconHome,
|
||||
@@ -11,9 +9,8 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
import { useHotkey, useHotkeySequence } from "@tanstack/react-hotkeys";
|
||||
import { skipToken, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useAtom } from "jotai";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useProgress } from "@/components/navigation-progress";
|
||||
@@ -43,7 +40,7 @@ import {
|
||||
MAX_RECENT,
|
||||
recentSearchesAtom,
|
||||
} from "@/lib/atoms/command-palette";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
// Static shortcut descriptions for the help dialog.
|
||||
// TanStack's HotkeyManager/SequenceManager handle all actual key listening.
|
||||
@@ -84,7 +81,7 @@ interface SearchResult {
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const progress = useProgress();
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useAtom(
|
||||
commandPaletteOpenAtom,
|
||||
@@ -109,7 +106,7 @@ export function CommandPalette() {
|
||||
["G", "H"],
|
||||
() => {
|
||||
progress.start();
|
||||
router.push("/dashboard");
|
||||
void navigate({ to: "/dashboard" });
|
||||
},
|
||||
{ enabled, timeout: 500 },
|
||||
);
|
||||
@@ -117,7 +114,7 @@ export function CommandPalette() {
|
||||
["G", "E"],
|
||||
() => {
|
||||
progress.start();
|
||||
router.push("/explore");
|
||||
void navigate({ to: "/explore" });
|
||||
},
|
||||
{ enabled, timeout: 500 },
|
||||
);
|
||||
@@ -150,7 +147,7 @@ export function CommandPalette() {
|
||||
const resolvePersonMutation = useMutation(
|
||||
orpc.people.resolve.mutationOptions({
|
||||
onSuccess: ({ id }) => {
|
||||
if (id) router.push(`/people/${id}`);
|
||||
if (id) void navigate({ to: "/people/$id", params: { id } });
|
||||
else progress.done();
|
||||
},
|
||||
onError: () => {
|
||||
@@ -163,7 +160,7 @@ export function CommandPalette() {
|
||||
const resolveTitleMutation = useMutation(
|
||||
orpc.titles.resolve.mutationOptions({
|
||||
onSuccess: ({ id }) => {
|
||||
if (id) router.push(`/titles/${id}`);
|
||||
if (id) void navigate({ to: "/titles/$id", params: { id } });
|
||||
else progress.done();
|
||||
},
|
||||
onError: () => {
|
||||
@@ -261,11 +258,13 @@ export function CommandPalette() {
|
||||
{r.type === "person" ? (
|
||||
<div className="size-10 shrink-0 overflow-hidden rounded-full bg-muted">
|
||||
{r.profilePath ? (
|
||||
<Image
|
||||
src={r.profilePath as string}
|
||||
<img
|
||||
src={r.profilePath}
|
||||
alt={r.title}
|
||||
width={40}
|
||||
height={40}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
@@ -280,11 +279,13 @@ export function CommandPalette() {
|
||||
) : (
|
||||
<div className="h-12 w-8 shrink-0 overflow-hidden rounded bg-muted">
|
||||
{r.posterPath ? (
|
||||
<Image
|
||||
src={r.posterPath as string}
|
||||
<img
|
||||
src={r.posterPath}
|
||||
alt={r.title}
|
||||
width={32}
|
||||
height={48}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
@@ -387,7 +388,7 @@ export function CommandPalette() {
|
||||
onSelect={() => {
|
||||
setCommandPaletteOpen(false);
|
||||
progress.start();
|
||||
router.push("/dashboard");
|
||||
void navigate({ to: "/dashboard" });
|
||||
}}
|
||||
>
|
||||
<IconHome aria-hidden={true} className="size-3.5" />
|
||||
@@ -398,7 +399,7 @@ export function CommandPalette() {
|
||||
onSelect={() => {
|
||||
setCommandPaletteOpen(false);
|
||||
progress.start();
|
||||
router.push("/explore");
|
||||
void navigate({ to: "/explore" });
|
||||
}}
|
||||
>
|
||||
<IconSearch aria-hidden={true} className="size-3.5" />
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IconPlayerPlay } from "@tabler/icons-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
export interface ContinueWatchingItemProps {
|
||||
title: {
|
||||
@@ -32,17 +32,18 @@ export function ContinueWatchingCard({
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/titles/${item.title.id}`}
|
||||
to="/titles/$id"
|
||||
params={{ id: item.title.id }}
|
||||
className="group relative inline-block w-64 shrink-0 overflow-hidden rounded-xl bg-card/50 ring-1 ring-white/[0.06] transition-shadow hover:shadow-black/25 hover:shadow-lg sm:w-72"
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden rounded-t-xl bg-muted">
|
||||
{stillUrl ? (
|
||||
<Image
|
||||
<img
|
||||
src={stillUrl}
|
||||
alt={item.nextEpisode?.name ?? item.title.title}
|
||||
fill
|
||||
sizes="(min-width: 640px) 288px, 256px"
|
||||
className="object-cover motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 h-full w-full object-cover motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-gradient-to-br from-card via-secondary to-muted">
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import {
|
||||
ContinueWatchingList,
|
||||
ContinueWatchingSectionSkeleton,
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { IconBooks } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import { FeedSection } from "./feed-section";
|
||||
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { IconThumbUp } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import { FeedSection } from "./feed-section";
|
||||
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Area, AreaChart, YAxis } from "recharts";
|
||||
|
||||
interface SparklineProps {
|
||||
data: Array<{ bucket: string; count: number }>;
|
||||
@@ -11,16 +9,36 @@ interface SparklineProps {
|
||||
export function Sparkline({ data, color }: SparklineProps) {
|
||||
const uniqueId = useId();
|
||||
const gradientId = `sparkline-${uniqueId.replace(/:/g, "")}`;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry.contentRect;
|
||||
if (width > 0 && height > 0) {
|
||||
setSize({ width, height });
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
if (!data.some((d) => d.count > 0)) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`pointer-events-none absolute inset-0 overflow-hidden ${color}`}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{size.width > 0 && size.height > 0 && (
|
||||
<AreaChart
|
||||
data={data}
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
@@ -40,7 +58,7 @@ export function Sparkline({ data, color }: SparklineProps) {
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
DashboardStats,
|
||||
HistoryBucket,
|
||||
TimePeriod,
|
||||
} from "@sofa/api/schemas";
|
||||
import {
|
||||
IconCheck,
|
||||
IconLibrary,
|
||||
@@ -16,12 +19,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import type {
|
||||
DashboardStats,
|
||||
HistoryBucket,
|
||||
TimePeriod,
|
||||
} from "@/lib/services/discovery";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import { Sparkline } from "./sparkline";
|
||||
|
||||
function StatCardSkeleton() {
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { IconDeviceTv } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import { StatsDisplay, StatsSectionSkeleton } from "./stats-display";
|
||||
|
||||
export function StatsSection() {
|
||||
@@ -35,7 +33,7 @@ export function StatsSection() {
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/explore"
|
||||
to="/explore"
|
||||
className="inline-flex h-9 items-center rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20"
|
||||
>
|
||||
Start exploring
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import { FilterableTitleRow } from "./filterable-title-row";
|
||||
import { HeroBanner } from "./hero-banner";
|
||||
import { TitleRow } from "./title-row";
|
||||
@@ -1,11 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { skipToken, useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
interface Genre {
|
||||
id: number;
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
IconDeviceTv,
|
||||
IconMovie,
|
||||
@@ -7,11 +5,11 @@ import {
|
||||
IconStar,
|
||||
} from "@tabler/icons-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useProgress } from "@/components/navigation-progress";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
interface HeroBannerProps {
|
||||
tmdbId: number;
|
||||
@@ -30,12 +28,12 @@ export function HeroBanner({
|
||||
backdropPath,
|
||||
voteAverage,
|
||||
}: HeroBannerProps) {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const progress = useProgress();
|
||||
const resolveMutation = useMutation(
|
||||
orpc.titles.resolve.mutationOptions({
|
||||
onSuccess: ({ id }) => {
|
||||
if (id) router.push(`/titles/${id}`);
|
||||
if (id) void navigate({ to: "/titles/$id", params: { id } });
|
||||
else progress.done();
|
||||
},
|
||||
onError: () => {
|
||||
@@ -55,12 +53,12 @@ export function HeroBanner({
|
||||
<div className="relative -mt-6 mr-[calc(-50vw+50%)] mb-4 ml-[calc(-50vw+50%)] animate-stagger-item overflow-hidden">
|
||||
<div className="relative aspect-[21/9] max-h-[420px] min-h-[280px] w-full">
|
||||
{backdropPath ? (
|
||||
<Image
|
||||
<img
|
||||
src={backdropPath}
|
||||
alt={title}
|
||||
fill
|
||||
priority
|
||||
className="object-cover"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-gradient-to-br from-card via-secondary to-muted" />
|
||||
@@ -1,8 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { SofaLogo } from "@/components/sofa-logo";
|
||||
|
||||
// Poster positions arranged in angled columns behind the hero
|
||||
@@ -63,11 +60,13 @@ export function LandingPage({
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl shadow-lg">
|
||||
<Image
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
width={300}
|
||||
height={450}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -147,7 +146,7 @@ export function LandingPage({
|
||||
>
|
||||
{freshInstall ? (
|
||||
<Link
|
||||
href="/register"
|
||||
to="/register"
|
||||
className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20"
|
||||
>
|
||||
<span className="relative z-10">Get Started</span>
|
||||
@@ -156,7 +155,7 @@ export function LandingPage({
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
to="/login"
|
||||
className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20"
|
||||
>
|
||||
<span className="relative z-10">Sign In</span>
|
||||
@@ -164,7 +163,7 @@ export function LandingPage({
|
||||
</Link>
|
||||
{registrationOpen && (
|
||||
<Link
|
||||
href="/register"
|
||||
to="/register"
|
||||
className="inline-flex h-12 items-center justify-center rounded-lg border border-border px-8 font-medium transition-colors hover:border-primary/40 hover:bg-primary/5"
|
||||
>
|
||||
Register
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
IconCompass,
|
||||
IconHome,
|
||||
@@ -7,10 +5,9 @@ import {
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { SofaLogo } from "@/components/sofa-logo";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
@@ -127,8 +124,8 @@ export function NavBar({
|
||||
userImage?: string;
|
||||
userRole?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const setCommandPaletteOpen = useSetAtom(commandPaletteOpenAtom);
|
||||
|
||||
const initial = userName?.charAt(0).toUpperCase() ?? "?";
|
||||
@@ -150,7 +147,7 @@ export function NavBar({
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-5 pr-[max(1rem,env(safe-area-inset-right))] pl-[max(1rem,env(safe-area-inset-left))] sm:gap-0 sm:pr-[max(1.5rem,env(safe-area-inset-right))] sm:pl-[max(1.5rem,env(safe-area-inset-left))]">
|
||||
<div className="flex items-center gap-3 sm:gap-6">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
to="/dashboard"
|
||||
className="shrink-0 text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<SofaLogo className="size-7" />
|
||||
@@ -168,7 +165,7 @@ export function NavBar({
|
||||
ref={(el) => {
|
||||
linkRefs.current[i] = el;
|
||||
}}
|
||||
href={link.href}
|
||||
to={link.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className="relative inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-muted-foreground text-sm transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
>
|
||||
@@ -248,7 +245,7 @@ export function NavBar({
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
render={<Link href="/settings" />}
|
||||
render={<Link to="/settings" />}
|
||||
className="cursor-pointer text-[13px]"
|
||||
>
|
||||
<IconSettings className="size-3.5" />
|
||||
@@ -259,8 +256,7 @@ export function NavBar({
|
||||
variant="destructive"
|
||||
onSelect={async () => {
|
||||
await signOut();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
void navigate({ to: "/" });
|
||||
}}
|
||||
className="cursor-pointer text-[13px]"
|
||||
>
|
||||
@@ -271,7 +267,7 @@ export function NavBar({
|
||||
</DropdownMenu>
|
||||
{/* Mobile: simple avatar link to settings */}
|
||||
<Link
|
||||
href="/settings"
|
||||
to="/settings"
|
||||
className="rounded-full ring-2 ring-transparent transition-all hover:ring-primary/40 sm:hidden"
|
||||
aria-label="Settings"
|
||||
>
|
||||
@@ -289,7 +285,7 @@ export function NavBar({
|
||||
}
|
||||
|
||||
export function MobileTabBar() {
|
||||
const pathname = usePathname();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const activeIndex = mobileTabs.findIndex((tab) =>
|
||||
isLinkActive(pathname, tab.href),
|
||||
@@ -318,7 +314,7 @@ export function MobileTabBar() {
|
||||
ref={(el) => {
|
||||
tabRefs.current[i] = el;
|
||||
}}
|
||||
href={tab.href}
|
||||
to={tab.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className="relative flex flex-1 flex-col items-center justify-center gap-0.5 focus-visible:text-foreground focus-visible:outline-none"
|
||||
>
|
||||
@@ -1,6 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useLocation } from "@tanstack/react-router";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
@@ -25,8 +23,7 @@ function clamp(n: number, min: number, max: number) {
|
||||
}
|
||||
|
||||
export function ProgressProvider({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { pathname, searchStr } = useLocation();
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
@@ -135,7 +132,7 @@ export function ProgressProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
// Finish on route change — routeKey is intentionally a dep to trigger on navigation
|
||||
const routeKey = pathname + (searchParams?.toString() ?? "");
|
||||
const routeKey = pathname + (searchStr ?? "");
|
||||
const firstRenderRef = useRef(true);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: routeKey drives re-runs on route change
|
||||
useEffect(() => {
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import type { PersonCredit } from "@sofa/api/schemas";
|
||||
import { IconMovie } from "@tabler/icons-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { TitleCard } from "@/components/title-card";
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { PersonCredit } from "@/lib/orpc/schemas";
|
||||
|
||||
type Filter = "all" | "movie" | "tv";
|
||||
type Sort = "newest" | "rating";
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { PersonCredit, ResolvedPerson } from "@sofa/api/schemas";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { PersonCredit, ResolvedPerson } from "@/lib/orpc/schemas";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import { FilmographyGrid } from "./filmography-grid";
|
||||
import { PersonHero } from "./person-hero";
|
||||
|
||||
@@ -36,17 +34,10 @@ export interface PersonDetailResponse {
|
||||
userStatuses: Record<string, "watchlist" | "in_progress" | "completed">;
|
||||
}
|
||||
|
||||
export function PersonDetailClient({
|
||||
id,
|
||||
initialData,
|
||||
}: {
|
||||
id: string;
|
||||
initialData?: PersonDetailResponse;
|
||||
}) {
|
||||
const { data, isPending } = useQuery({
|
||||
...orpc.people.detail.queryOptions({ input: { id } }),
|
||||
initialData,
|
||||
});
|
||||
export function PersonDetailClient({ id }: { id: string }) {
|
||||
const { data, isPending } = useQuery(
|
||||
orpc.people.detail.queryOptions({ input: { id } }),
|
||||
);
|
||||
|
||||
if (isPending) return <PersonDetailSkeleton />;
|
||||
if (!data) return null;
|
||||