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
|
node_modules
|
||||||
.next
|
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
*.md
|
*.md
|
||||||
@@ -7,10 +6,10 @@ node_modules
|
|||||||
*.db-*
|
*.db-*
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
|
||||||
data
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.vercel
|
data
|
||||||
coverage
|
coverage
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
|
.turbo
|
||||||
|
.tanstack
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Get app version
|
- name: Get app version
|
||||||
id: 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
|
- name: Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Run tests with coverage
|
- name: Lint
|
||||||
run: bun test --coverage --coverage-reporter=lcov
|
run: bunx turbo run lint
|
||||||
|
|
||||||
# - name: Upload to Codecov
|
- name: Type check
|
||||||
# uses: codecov/codecov-action@v3
|
run: bunx turbo run check-types
|
||||||
# with:
|
|
||||||
# file: ./coverage/lcov.info
|
- name: Run tests
|
||||||
# fail_ci_if_error: true
|
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.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
# dependencies
|
# Dependencies
|
||||||
/node_modules
|
node_modules
|
||||||
/.pnp
|
.pnp
|
||||||
.pnp.*
|
.pnp.js
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
# Build outputs
|
||||||
/coverage
|
dist
|
||||||
|
build
|
||||||
|
*.tsbuildinfo
|
||||||
|
.tanstack
|
||||||
|
|
||||||
# next.js
|
# Environment variables
|
||||||
/.next/
|
.env
|
||||||
/out/
|
.env*.local
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# production
|
# IDEs and editors
|
||||||
/build
|
.vscode/*
|
||||||
/data
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
# misc
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
|
||||||
|
|
||||||
# sqlite
|
# Logs
|
||||||
*.db
|
logs
|
||||||
*.db-*
|
*.log
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# Turbo
|
||||||
.env*
|
.turbo
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# vercel
|
# Testing
|
||||||
.vercel
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
# typescript
|
# Misc
|
||||||
*.tsbuildinfo
|
*.tgz
|
||||||
next-env.d.ts
|
.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
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev # Start Next.js dev server
|
# Root commands (via Turborepo)
|
||||||
bun run build # Production build
|
bun run dev # Start API server + Vite dev server
|
||||||
|
bun run build # Production build (both apps)
|
||||||
bun run lint # Biome lint check
|
bun run lint # Biome lint check
|
||||||
bun run format # Biome format (auto-fix)
|
bun run format # Biome format (auto-fix)
|
||||||
bun run db:push # Push schema changes to SQLite database
|
bun run check-types # TypeScript type check
|
||||||
bun run db:generate # Generate Drizzle migration files
|
bun run test # Run tests
|
||||||
bun run db:migrate # Run Drizzle migrations
|
|
||||||
bun run db:studio # Open Drizzle Studio (visual DB browser)
|
# 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:
|
IMPORTANT: Default to using Bun instead of Node.js:
|
||||||
@@ -39,67 +44,113 @@ bun run test
|
|||||||
|
|
||||||
## Architecture
|
## 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
|
### 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)
|
- **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
|
- **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
|
- **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)
|
- **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
|
- **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)
|
- **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
|
- **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
|
### Key directories
|
||||||
|
|
||||||
- `lib/db/schema.ts` — Single file with all Drizzle table definitions (auth tables + app tables)
|
**API server** (`apps/server/src/`):
|
||||||
- `lib/db/migrate.ts` — Auto-migration runner (executed on startup via instrumentation)
|
- `index.ts` — Hono app entry point, startup, CORS, route mounting, graceful shutdown
|
||||||
- `lib/services/` — Business logic layer (metadata, tracking, discovery, availability, credits, person, webhooks, backup, settings, colors, update-check, system-health)
|
- `cron.ts` — Background job scheduler (croner)
|
||||||
- `lib/tmdb/` — TMDB API client and TypeScript types
|
- `orpc/` — oRPC server layer (context, middleware, router, handler, procedures/)
|
||||||
- `lib/auth/` — Better Auth server config (`server.ts`), client hooks (`client.ts`), cached session helper (`session.ts`)
|
- `routes/` — Non-RPC Hono routes (auth, images, avatars, backups, webhooks, lists, health)
|
||||||
- `lib/config.ts` — Server-side environment checks (TMDB configured, OIDC configured, etc.)
|
|
||||||
- `lib/logger.ts` — Structured logger with `LOG_LEVEL` support
|
**Web app** (`apps/web/src/`):
|
||||||
- `lib/types/` — Shared TypeScript types (e.g. `title.ts`)
|
- `main.tsx` — React root: creates router + renders `<RouterProvider />`
|
||||||
- `lib/cron.ts` — Background job scheduler (croner, `globalThis` singleton)
|
- `routes/` — TanStack Router file-based routes (auto-generates `routeTree.gen.ts`)
|
||||||
- `lib/query-client.ts` — Singleton `QueryClient` with default options (30s stale time)
|
- `__root.tsx` — Root layout (providers, head meta, global error/not-found)
|
||||||
- `lib/orpc/` — oRPC API layer (contract-first, type-safe RPC):
|
- `_app.tsx` — Authenticated layout (auth guard via `beforeLoad`, navbar, shell)
|
||||||
- `contract.ts` — Full API contract definition (~30 procedures)
|
- `_app/dashboard.tsx`, `_app/explore.tsx`, `_app/settings.tsx`, `_app/titles.$id.tsx`, `_app/people.$id.tsx`
|
||||||
- `schemas.ts` — Shared Zod input schemas used by contract
|
- `_auth.tsx` — Auth layout (centering wrapper)
|
||||||
- `context.ts` — Context type & `os` implementer
|
- `_auth/login.tsx`, `_auth/register.tsx`
|
||||||
- `middleware.ts` — Auth (`authed`) and admin (`admin`) middleware
|
- `index.tsx`, `setup.tsx`
|
||||||
- `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)
|
|
||||||
- `components/` — App components + `components/ui/` for shadcn primitives
|
- `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
|
### 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
|
```typescript
|
||||||
import { getSession } from "@/lib/auth/session";
|
// In route file (e.g. _app.tsx)
|
||||||
const session = await getSession();
|
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
|
```typescript
|
||||||
// lib/orpc/middleware.ts — applied to procedures
|
// apps/server/src/orpc/middleware.ts
|
||||||
export const authed = base.middleware(async ({ context, next }) => {
|
export const authed = base.middleware(async ({ context, next }) => {
|
||||||
const session = await auth.api.getSession({ headers: context.headers });
|
const session = await auth.api.getSession({ headers: context.headers });
|
||||||
if (!session) throw new ORPCError("UNAUTHORIZED");
|
if (!session) throw new ORPCError("UNAUTHORIZED");
|
||||||
@@ -109,12 +160,18 @@ export const authed = base.middleware(async ({ context, next }) => {
|
|||||||
|
|
||||||
### oRPC API layer
|
### 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:**
|
**Pattern — query in component:**
|
||||||
```typescript
|
```typescript
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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());
|
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" });
|
await client.titles.updateStatus({ id: titleId, status: "in_progress" });
|
||||||
```
|
```
|
||||||
|
|
||||||
**Pattern — procedure definition:**
|
**Pattern — route loader with TanStack Query prefetch:**
|
||||||
```typescript
|
```typescript
|
||||||
// lib/orpc/procedures/dashboard.ts
|
export const Route = createFileRoute("/_app/titles/$id")({
|
||||||
export const stats = os.dashboard.stats.func(async (input, context, meta) => {
|
loader: async ({ params, context }) => {
|
||||||
const { user } = context;
|
await context.queryClient.ensureQueryData(
|
||||||
return getUserStats(user.id);
|
orpc.titles.detail.queryOptions({ input: { id: params.id } }),
|
||||||
}, authed);
|
);
|
||||||
|
},
|
||||||
|
component: TitlePage,
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**Page patterns:**
|
**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.
|
- **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): Server component handles auth only; client components fetch all data via `orpc.*.queryOptions()` with skeleton loading states.
|
- **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:
|
Hono route handlers in `apps/server/src/routes/`:
|
||||||
- `/api/auth/[...all]` — Better Auth catch-all
|
- `/images/:category/:filename` — Image proxy/cache serving
|
||||||
- `/api/images/[...path]` — Image proxy/cache serving
|
- `/api/auth/*` — Better Auth catch-all
|
||||||
- `/api/avatars/[userId]` — Avatar file serving
|
- `/api/avatars/:userId` — Avatar file serving
|
||||||
- `/api/account/avatar` — Avatar upload (FormData)
|
- `/api/backup/:filename` — Backup file download
|
||||||
- `/api/admin/backups/restore` — Backup restore (FormData upload)
|
- `/api/webhooks/:token` — Plex/Jellyfin/Emby webhooks
|
||||||
- `/api/backup/[filename]` — Backup file download
|
- `/api/lists/:token` — External list feeds (Sonarr/Radarr)
|
||||||
- `/api/webhooks/[token]` — Plex/Jellyfin/Emby webhooks
|
|
||||||
- `/api/lists/[token]` — External list feeds
|
|
||||||
- `/api/health` — Simple health check (no auth)
|
- `/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
|
### Database schema
|
||||||
|
|
||||||
All app tables use UUIDv7 text primary keys generated via `Bun.randomUUIDv7()`. Better Auth tables use their own ID format. Key relationships:
|
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
|
- `cronRuns` — Background job execution history
|
||||||
- `appSettings` — Key-value store for runtime app configuration
|
- `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.
|
- **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.
|
- **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.
|
- **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.
|
- **person.ts**: Person detail and filmography lookups.
|
||||||
- **webhooks.ts**: Processes incoming Plex/Jellyfin/Emby webhook events to auto-log watches.
|
- **webhooks.ts**: Processes incoming Plex/Jellyfin/Emby webhook events.
|
||||||
- **backup.ts**: Database backup/restore with configurable scheduled backups and retention pruning.
|
- **backup.ts**: Database backup/restore with configurable scheduled backups.
|
||||||
- **settings.ts**: Runtime app settings (registration open/closed, backup config, etc.) via `appSettings` table.
|
- **settings.ts**: Runtime app settings via `appSettings` table.
|
||||||
- **colors.ts**: Extracts dominant color palettes from title posters via node-vibrant.
|
- **colors.ts**: Extracts dominant color palettes from title posters via node-vibrant.
|
||||||
- **update-check.ts**: Checks for new Sofa releases.
|
- **update-check.ts**: Checks for new Sofa releases.
|
||||||
- **system-health.ts**: System health diagnostics.
|
- **system-health.ts**: System health diagnostics.
|
||||||
|
|
||||||
### TMDB images
|
### 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`).
|
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.
|
||||||
|
|
||||||
Core files: `lib/services/image-cache.ts` (caching logic), `lib/tmdb/image.ts` (URL construction), `app/api/images/[...path]/route.ts` (proxy route).
|
|
||||||
|
|
||||||
### Background jobs
|
### 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.
|
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):
|
||||||
|
|
||||||
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)
|
- `nightlyRefreshLibrary` (`0 3 * * *`) — refreshes stale library titles (7d) and non-library titles (30d)
|
||||||
- `refreshAvailability` (`0 */6 * * *`) — streaming provider data
|
- `refreshAvailability` (`0 */6 * * *`) — streaming provider data
|
||||||
- `refreshRecommendations` (`0 */12 * * *`)
|
- `refreshRecommendations` (`0 */12 * * *`)
|
||||||
@@ -207,10 +264,14 @@ Jobs (all use `protect: true` to prevent overlapping runs, 300ms delay between T
|
|||||||
|
|
||||||
### Environment variables
|
### 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).
|
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
|
## Browser Automation
|
||||||
|
|
||||||
Use `agent-browser` for web automation. Run `agent-browser --help` for all commands.
|
Use `agent-browser` for web automation. Run `agent-browser --help` for all commands.
|
||||||
|
|||||||
@@ -1,62 +1,59 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM oven/bun:1-alpine AS base
|
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
|
FROM base AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY --from=prepare /app/out/json/ .
|
||||||
COPY package.json bun.lock ./
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||||
bun install --no-save --frozen-lockfile
|
bun install --no-save --frozen-lockfile
|
||||||
|
|
||||||
# --- Builder ---
|
# --- Build ---
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/ .
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=prepare /app/out/full/ .
|
||||||
COPY . .
|
|
||||||
|
|
||||||
ARG APP_VERSION
|
ARG APP_VERSION
|
||||||
ARG GIT_COMMIT_SHA
|
ARG GIT_COMMIT_SHA
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV APP_VERSION=${APP_VERSION}
|
ENV APP_VERSION=${APP_VERSION}
|
||||||
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA}
|
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
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG APP_VERSION
|
|
||||||
ARG GIT_COMMIT_SHA
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME=0.0.0.0
|
ENV HOSTNAME=0.0.0.0
|
||||||
ENV DATA_DIR=/data
|
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 \
|
# Built SPA
|
||||||
&& chown bun:bun .next
|
COPY --from=builder --chown=bun:bun /app/apps/web/dist/ ./apps/web/dist/
|
||||||
|
|
||||||
COPY --from=builder --chown=bun:bun /app/.next/standalone ./
|
# DB migrations
|
||||||
COPY --from=builder --chown=bun:bun /app/.next/static ./.next/static
|
COPY --from=builder --chown=bun:bun /app/packages/db/drizzle/ ./packages/db/drizzle/
|
||||||
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
|
|
||||||
|
|
||||||
|
RUN mkdir -p /data && chown bun:bun /data
|
||||||
USER bun
|
USER bun
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
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 { refreshAvailability } from "@sofa/core/availability";
|
||||||
import { and, eq, inArray, isNotNull, lt, or } from "drizzle-orm";
|
import { createBackup, ensureBackupDir, pruneBackups } from "@sofa/core/backup";
|
||||||
import { db } from "@/lib/db/client";
|
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 {
|
import {
|
||||||
availabilityOffers,
|
availabilityOffers,
|
||||||
cronRuns,
|
cronRuns,
|
||||||
@@ -8,30 +24,10 @@ import {
|
|||||||
titleCast,
|
titleCast,
|
||||||
titles,
|
titles,
|
||||||
userTitleStatus,
|
userTitleStatus,
|
||||||
} from "@/lib/db/schema";
|
} from "@sofa/db/schema";
|
||||||
import { createLogger } from "@/lib/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
import { refreshAvailability } from "@/lib/services/availability";
|
import { getTvDetails } from "@sofa/tmdb/client";
|
||||||
import {
|
import { Cron } from "croner";
|
||||||
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";
|
|
||||||
|
|
||||||
export type BackupFrequency = "6h" | "12h" | "1d" | "7d";
|
export type BackupFrequency = "6h" | "12h" | "1d" | "7d";
|
||||||
|
|
||||||
@@ -166,7 +162,6 @@ async function refreshAvailabilityJob() {
|
|||||||
log.debug(`Checking availability for ${libraryIds.length} library titles`);
|
log.debug(`Checking availability for ${libraryIds.length} library titles`);
|
||||||
const stale = new Date(Date.now() - DAY);
|
const stale = new Date(Date.now() - DAY);
|
||||||
|
|
||||||
// Batch: find titles with any offers, and titles with stale offers
|
|
||||||
const titlesWithOffers = new Set(
|
const titlesWithOffers = new Set(
|
||||||
db
|
db
|
||||||
.select({ titleId: availabilityOffers.titleId })
|
.select({ titleId: availabilityOffers.titleId })
|
||||||
@@ -200,7 +195,6 @@ async function refreshAvailabilityJob() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh recommendations for recently active titles
|
|
||||||
async function refreshRecommendationsJob() {
|
async function refreshRecommendationsJob() {
|
||||||
const libraryIds = getLibraryTitleIds();
|
const libraryIds = getLibraryTitleIds();
|
||||||
log.debug(
|
log.debug(
|
||||||
@@ -213,7 +207,6 @@ async function refreshRecommendationsJob() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh TV episodes for returning shows
|
|
||||||
async function refreshTvChildrenJob() {
|
async function refreshTvChildrenJob() {
|
||||||
const returningStatuses = ["Returning Series", "In Production"];
|
const returningStatuses = ["Returning Series", "In Production"];
|
||||||
const stale = new Date(Date.now() - 7 * DAY);
|
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`);
|
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 tvIds = tvShows.map((s) => s.id);
|
||||||
const titlesWithStaleSeasons = new Set(
|
const titlesWithStaleSeasons = new Set(
|
||||||
tvIds.length > 0
|
tvIds.length > 0
|
||||||
@@ -260,7 +252,6 @@ async function refreshTvChildrenJob() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache images for all library titles (posters, backdrops, stills, logos)
|
|
||||||
async function cacheImagesJob() {
|
async function cacheImagesJob() {
|
||||||
if (!imageCacheEnabled()) return;
|
if (!imageCacheEnabled()) return;
|
||||||
|
|
||||||
@@ -282,7 +273,6 @@ async function cacheImagesJob() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh credits for library titles where cast is stale or missing
|
|
||||||
async function refreshCreditsJob() {
|
async function refreshCreditsJob() {
|
||||||
const libraryIds = getLibraryTitleIds();
|
const libraryIds = getLibraryTitleIds();
|
||||||
log.debug(`Checking credits for ${libraryIds.length} library titles`);
|
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 { implement } from "@orpc/server";
|
||||||
import { contract } from "./contract";
|
import { contract } from "@sofa/api/contract";
|
||||||
|
|
||||||
export interface Context {
|
export interface Context {
|
||||||
headers: Headers;
|
headers: Headers;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { onError } from "@orpc/server";
|
import { onError } from "@orpc/server";
|
||||||
import { RPCHandler } from "@orpc/server/fetch";
|
import { RPCHandler } from "@orpc/server/fetch";
|
||||||
import { createLogger } from "@/lib/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
import { router } from "./router";
|
import { router } from "./router";
|
||||||
|
|
||||||
const log = createLogger("orpc");
|
const log = createLogger("orpc");
|
||||||
@@ -8,7 +8,7 @@ const log = createLogger("orpc");
|
|||||||
export const handler = new RPCHandler(router, {
|
export const handler = new RPCHandler(router, {
|
||||||
interceptors: [
|
interceptors: [
|
||||||
onError((error) => {
|
onError((error) => {
|
||||||
log.error("oRPC error", { error });
|
log.error("oRPC error", error);
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { oo } from "@orpc/openapi";
|
import { oo } from "@orpc/openapi";
|
||||||
import { os as baseOs, ORPCError } from "@orpc/server";
|
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 }>();
|
const base = baseOs.$context<{ headers: Headers }>();
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@ import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
|||||||
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
|
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
|
||||||
import { onError } from "@orpc/server";
|
import { onError } from "@orpc/server";
|
||||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||||
import { createLogger } from "@/lib/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
import { router } from "./router";
|
import { router } from "./router";
|
||||||
|
|
||||||
const log = createLogger("openapi");
|
const log = createLogger("openapi");
|
||||||
@@ -42,7 +42,7 @@ export const openApiHandler = new OpenAPIHandler(router, {
|
|||||||
],
|
],
|
||||||
interceptors: [
|
interceptors: [
|
||||||
onError((error) => {
|
onError((error) => {
|
||||||
log.error("OpenAPI error", { error });
|
log.error("OpenAPI error", error);
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { mkdir, rename } from "node:fs/promises";
|
import { mkdir, rename } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@sofa/auth/server";
|
||||||
import { AVATAR_DIR } from "@/lib/constants";
|
import { AVATAR_DIR } from "@sofa/config";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
import { authed } from "../middleware";
|
||||||
|
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { BACKUP_DIR } from "@/lib/constants";
|
import { BACKUP_DIR } from "@sofa/config";
|
||||||
import { rescheduleBackup, triggerJob } from "@/lib/cron";
|
|
||||||
import {
|
import {
|
||||||
createBackup,
|
createBackup,
|
||||||
deleteBackup,
|
deleteBackup,
|
||||||
ensureBackupDir,
|
ensureBackupDir,
|
||||||
listBackups,
|
listBackups,
|
||||||
restoreFromBackup,
|
restoreFromBackup,
|
||||||
} from "@/lib/services/backup";
|
} from "@sofa/core/backup";
|
||||||
import { getSetting, setSetting } from "@/lib/services/settings";
|
import { getSetting, setSetting } from "@sofa/core/settings";
|
||||||
import {
|
import {
|
||||||
getCachedUpdateCheck,
|
getCachedUpdateCheck,
|
||||||
isUpdateCheckEnabled,
|
isUpdateCheckEnabled,
|
||||||
} from "@/lib/services/update-check";
|
} from "@sofa/core/update-check";
|
||||||
|
import { rescheduleBackup, triggerJob } from "../../cron";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { admin } from "../middleware";
|
import { admin } from "../middleware";
|
||||||
|
|
||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
getNewAvailableFeed,
|
getNewAvailableFeed,
|
||||||
getRecommendationsFeed,
|
getRecommendationsFeed,
|
||||||
getUserStats,
|
getUserStats,
|
||||||
} from "@/lib/services/discovery";
|
} from "@sofa/core/discovery";
|
||||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
import { authed } from "../middleware";
|
||||||
|
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { isTmdbConfigured } from "@/lib/config";
|
|
||||||
import {
|
import {
|
||||||
getEpisodeProgressByTmdbIds,
|
getEpisodeProgressByTmdbIds,
|
||||||
getUserStatusesByTmdbIds,
|
getUserStatusesByTmdbIds,
|
||||||
} from "@/lib/services/tracking";
|
} from "@sofa/core/tracking";
|
||||||
import { discover } from "@/lib/tmdb/client";
|
import { discover as discoverTmdb } from "@sofa/tmdb/client";
|
||||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||||
|
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
import { authed } from "../middleware";
|
||||||
|
|
||||||
export const discoverProcedure = os.discover
|
export const discover = os.discover
|
||||||
.use(authed)
|
.use(authed)
|
||||||
.handler(async ({ input, context }) => {
|
.handler(async ({ input, context }) => {
|
||||||
if (!isTmdbConfigured()) {
|
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",
|
sort_by: "popularity.desc",
|
||||||
"vote_count.gte": "50",
|
"vote_count.gte": "50",
|
||||||
with_genres: String(input.genreId),
|
with_genres: String(input.genreId),
|
||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
logEpisodeWatch,
|
logEpisodeWatch,
|
||||||
logEpisodeWatchBatch,
|
logEpisodeWatchBatch,
|
||||||
unwatchEpisode,
|
unwatchEpisode,
|
||||||
} from "@/lib/services/tracking";
|
} from "@sofa/core/tracking";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
import { authed } from "../middleware";
|
||||||
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { isTmdbConfigured } from "@/lib/config";
|
|
||||||
import {
|
import {
|
||||||
getEpisodeProgressByTmdbIds,
|
getEpisodeProgressByTmdbIds,
|
||||||
getUserStatusesByTmdbIds,
|
getUserStatusesByTmdbIds,
|
||||||
} from "@/lib/services/tracking";
|
} from "@sofa/core/tracking";
|
||||||
import { getGenres, getPopular, getTrending } from "@/lib/tmdb/client";
|
import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client";
|
||||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||||
|
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
import { authed } from "../middleware";
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import { db } from "@sofa/db/client";
|
||||||
import { db } from "@/lib/db/client";
|
import { and, desc, eq } from "@sofa/db/helpers";
|
||||||
import { integrationEvents, integrations } from "@/lib/db/schema";
|
import { integrationEvents, integrations } from "@sofa/db/schema";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
import { authed } from "../middleware";
|
||||||
|
|
||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
getLocalFilmography,
|
getLocalFilmography,
|
||||||
getOrFetchPerson,
|
getOrFetchPerson,
|
||||||
getOrFetchPersonByTmdbId,
|
getOrFetchPersonByTmdbId,
|
||||||
} from "@/lib/services/person";
|
} from "@sofa/core/person";
|
||||||
import { getUserStatusesByTitleIds } from "@/lib/services/tracking";
|
import { getUserStatusesByTitleIds } from "@sofa/core/tracking";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
import { authed } from "../middleware";
|
||||||
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { isTmdbConfigured } from "@/lib/config";
|
|
||||||
import {
|
import {
|
||||||
searchMovies,
|
searchMovies,
|
||||||
searchMulti,
|
searchMulti,
|
||||||
searchPerson,
|
searchPerson,
|
||||||
searchTv,
|
searchTv,
|
||||||
} from "@/lib/tmdb/client";
|
} from "@sofa/tmdb/client";
|
||||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||||
|
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
import { authed } from "../middleware";
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { logEpisodeWatchBatch, unwatchSeason } from "@sofa/core/tracking";
|
||||||
import { db } from "@/lib/db/client";
|
import { db } from "@sofa/db/client";
|
||||||
import { episodes } from "@/lib/db/schema";
|
import { eq } from "@sofa/db/helpers";
|
||||||
import { logEpisodeWatchBatch, unwatchSeason } from "@/lib/services/tracking";
|
import { episodes } from "@sofa/db/schema";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
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 "@sofa/core/system-health";
|
||||||
import { getSystemHealth } from "@/lib/services/system-health";
|
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
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 { ORPCError } from "@orpc/server";
|
||||||
import { getRecommendationsForTitle } from "@/lib/services/discovery";
|
import { getRecommendationsForTitle } from "@sofa/core/discovery";
|
||||||
import {
|
import {
|
||||||
|
ensureTvHydrated,
|
||||||
getOrFetchTitle,
|
getOrFetchTitle,
|
||||||
getOrFetchTitleByTmdbId,
|
getOrFetchTitleByTmdbId,
|
||||||
} from "@/lib/services/metadata";
|
} from "@sofa/core/metadata";
|
||||||
import {
|
import {
|
||||||
getUserStatusesByTitleIds,
|
getUserStatusesByTitleIds,
|
||||||
getUserTitleInfo,
|
getUserTitleInfo,
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
rateTitleStars,
|
rateTitleStars,
|
||||||
removeTitleStatus,
|
removeTitleStatus,
|
||||||
setTitleStatus,
|
setTitleStatus,
|
||||||
} from "@/lib/services/tracking";
|
} from "@sofa/core/tracking";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
import { authed } from "../middleware";
|
||||||
|
|
||||||
@@ -78,3 +79,10 @@ export const recommendations = os.titles.recommendations
|
|||||||
);
|
);
|
||||||
return { recommendations: recs, userStatuses };
|
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 { ORPCError } from "@orpc/server";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
|
||||||
import { db } from "@/lib/db/client";
|
import { setTitleStatus } from "@sofa/core/tracking";
|
||||||
import { userTitleStatus } from "@/lib/db/schema";
|
import { db } from "@sofa/db/client";
|
||||||
import { getOrFetchTitleByTmdbId } from "@/lib/services/metadata";
|
import { and, eq } from "@sofa/db/helpers";
|
||||||
import { setTitleStatus } from "@/lib/services/tracking";
|
import { userTitleStatus } from "@sofa/db/schema";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
import { authed } from "../middleware";
|
||||||
|
|
||||||
@@ -2,15 +2,16 @@ import { os } from "./context";
|
|||||||
import * as account from "./procedures/account";
|
import * as account from "./procedures/account";
|
||||||
import * as admin from "./procedures/admin";
|
import * as admin from "./procedures/admin";
|
||||||
import * as dashboard from "./procedures/dashboard";
|
import * as dashboard from "./procedures/dashboard";
|
||||||
import { discoverProcedure } from "./procedures/discover";
|
import { discover } from "./procedures/discover";
|
||||||
import * as episodes from "./procedures/episodes";
|
import * as episodes from "./procedures/episodes";
|
||||||
import * as explore from "./procedures/explore";
|
import * as explore from "./procedures/explore";
|
||||||
import * as integrations from "./procedures/integrations";
|
import * as integrations from "./procedures/integrations";
|
||||||
import * as people from "./procedures/people";
|
import * as people from "./procedures/people";
|
||||||
import { search } from "./procedures/search";
|
import { search } from "./procedures/search";
|
||||||
import * as seasons from "./procedures/seasons";
|
import * as seasons from "./procedures/seasons";
|
||||||
import { statsProcedure } from "./procedures/stats";
|
import { stats } from "./procedures/stats";
|
||||||
import { systemStatus } from "./procedures/status";
|
import { systemStatus } from "./procedures/status";
|
||||||
|
import * as system from "./procedures/system";
|
||||||
import * as titles from "./procedures/titles";
|
import * as titles from "./procedures/titles";
|
||||||
import * as watchlist from "./procedures/watchlist";
|
import * as watchlist from "./procedures/watchlist";
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export const router = os.router({
|
|||||||
watchAll: titles.watchAll,
|
watchAll: titles.watchAll,
|
||||||
userInfo: titles.userInfo,
|
userInfo: titles.userInfo,
|
||||||
recommendations: titles.recommendations,
|
recommendations: titles.recommendations,
|
||||||
|
hydrateSeasons: titles.hydrateSeasons,
|
||||||
},
|
},
|
||||||
episodes: {
|
episodes: {
|
||||||
watch: episodes.watch,
|
watch: episodes.watch,
|
||||||
@@ -50,9 +52,13 @@ export const router = os.router({
|
|||||||
genres: explore.genres,
|
genres: explore.genres,
|
||||||
},
|
},
|
||||||
search,
|
search,
|
||||||
discover: discoverProcedure,
|
discover,
|
||||||
stats: statsProcedure,
|
stats,
|
||||||
systemStatus,
|
systemStatus,
|
||||||
|
system: {
|
||||||
|
publicInfo: system.publicInfo,
|
||||||
|
authConfig: system.authConfig,
|
||||||
|
},
|
||||||
integrations: {
|
integrations: {
|
||||||
list: integrations.list,
|
list: integrations.list,
|
||||||
create: integrations.create,
|
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 { WebhookEvent } from "@sofa/core/webhooks";
|
||||||
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 {
|
import {
|
||||||
parseEmbyPayload,
|
parseEmbyPayload,
|
||||||
parseJellyfinPayload,
|
parseJellyfinPayload,
|
||||||
parsePlexPayload,
|
parsePlexPayload,
|
||||||
processWebhook,
|
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");
|
const log = createLogger("webhooks");
|
||||||
|
|
||||||
export async function POST(
|
const app = new Hono();
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ token: string }> },
|
app.post("/:token", async (c) => {
|
||||||
) {
|
const token = c.req.param("token");
|
||||||
const { token } = await params;
|
|
||||||
|
|
||||||
// Look up connection by token — this IS the auth
|
// Look up connection by token — this IS the auth
|
||||||
const connection = db
|
const connection = db
|
||||||
@@ -29,12 +27,12 @@ export async function POST(
|
|||||||
|
|
||||||
if (!connection || !connection.enabled) {
|
if (!connection || !connection.enabled) {
|
||||||
// Always return 200 to avoid retry storms from media servers
|
// 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
|
// Only webhook-type integrations are handled here
|
||||||
if (connection.type !== "webhook") {
|
if (connection.type !== "webhook") {
|
||||||
return NextResponse.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = connection.provider as "plex" | "jellyfin" | "emby";
|
const provider = connection.provider as "plex" | "jellyfin" | "emby";
|
||||||
@@ -42,19 +40,19 @@ export async function POST(
|
|||||||
try {
|
try {
|
||||||
let event: WebhookEvent | null;
|
let event: WebhookEvent | null;
|
||||||
if (provider === "plex") {
|
if (provider === "plex") {
|
||||||
const formData = await req.formData();
|
const formData = await c.req.raw.formData();
|
||||||
event = parsePlexPayload(formData);
|
event = parsePlexPayload(formData as unknown as FormData);
|
||||||
} else if (provider === "emby") {
|
} else if (provider === "emby") {
|
||||||
const body = await req.json();
|
const body = await c.req.json();
|
||||||
event = parseEmbyPayload(body);
|
event = parseEmbyPayload(body);
|
||||||
} else {
|
} else {
|
||||||
const body = await req.json();
|
const body = await c.req.json();
|
||||||
event = parseJellyfinPayload(body);
|
event = parseJellyfinPayload(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
// Not a relevant event type — silently ignore
|
// 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);
|
await processWebhook(connection.id, connection.userId, provider, event);
|
||||||
@@ -63,5 +61,7 @@ export async function POST(
|
|||||||
log.debug("Webhook processing failed:", err);
|
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",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "base-mira",
|
"style": "base-mira",
|
||||||
"rsc": true,
|
"rsc": false,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "",
|
"config": "",
|
||||||
"css": "app/globals.css",
|
"css": "src/styles/globals.css",
|
||||||
"baseColor": "neutral",
|
"baseColor": "neutral",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"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 { IconKey } from "@tabler/icons-react";
|
||||||
|
import { Link, useNavigate } from "@tanstack/react-router";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SofaLogo } from "@/components/sofa-logo";
|
import { SofaLogo } from "@/components/sofa-logo";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
@@ -38,7 +35,7 @@ export function AuthForm({
|
|||||||
mode: "login" | "register";
|
mode: "login" | "register";
|
||||||
authConfig?: AuthConfig;
|
authConfig?: AuthConfig;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -69,7 +66,7 @@ export function AuthForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
router.push("/dashboard");
|
void navigate({ to: "/dashboard" });
|
||||||
} catch {
|
} catch {
|
||||||
setError("Something went wrong");
|
setError("Something went wrong");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -104,7 +101,7 @@ export function AuthForm({
|
|||||||
transition={{ type: "spring" as const, stiffness: 200, damping: 20 }}
|
transition={{ type: "spring" as const, stiffness: 200, damping: 20 }}
|
||||||
>
|
>
|
||||||
<div className="space-y-2 text-center">
|
<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" />
|
<SofaLogo className="size-9" />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-balance font-medium text-lg">
|
<h1 className="text-balance font-medium text-lg">
|
||||||
@@ -263,7 +260,7 @@ export function AuthForm({
|
|||||||
<>
|
<>
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
to="/login"
|
||||||
className="font-medium text-primary transition-colors hover:text-primary/80"
|
className="font-medium text-primary transition-colors hover:text-primary/80"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
@@ -273,7 +270,7 @@ export function AuthForm({
|
|||||||
<>
|
<>
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
to="/register"
|
||||||
className="font-medium text-primary transition-colors hover:text-primary/80"
|
className="font-medium text-primary transition-colors hover:text-primary/80"
|
||||||
>
|
>
|
||||||
Register
|
Register
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IconDeviceTv,
|
IconDeviceTv,
|
||||||
IconHome,
|
IconHome,
|
||||||
@@ -11,9 +9,8 @@ import {
|
|||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useHotkey, useHotkeySequence } from "@tanstack/react-hotkeys";
|
import { useHotkey, useHotkeySequence } from "@tanstack/react-hotkeys";
|
||||||
import { skipToken, useMutation, useQuery } from "@tanstack/react-query";
|
import { skipToken, useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import Image from "next/image";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useProgress } from "@/components/navigation-progress";
|
import { useProgress } from "@/components/navigation-progress";
|
||||||
@@ -43,7 +40,7 @@ import {
|
|||||||
MAX_RECENT,
|
MAX_RECENT,
|
||||||
recentSearchesAtom,
|
recentSearchesAtom,
|
||||||
} from "@/lib/atoms/command-palette";
|
} from "@/lib/atoms/command-palette";
|
||||||
import { orpc } from "@/lib/orpc/tanstack";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
|
|
||||||
// Static shortcut descriptions for the help dialog.
|
// Static shortcut descriptions for the help dialog.
|
||||||
// TanStack's HotkeyManager/SequenceManager handle all actual key listening.
|
// TanStack's HotkeyManager/SequenceManager handle all actual key listening.
|
||||||
@@ -84,7 +81,7 @@ interface SearchResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CommandPalette() {
|
export function CommandPalette() {
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const progress = useProgress();
|
const progress = useProgress();
|
||||||
const [commandPaletteOpen, setCommandPaletteOpen] = useAtom(
|
const [commandPaletteOpen, setCommandPaletteOpen] = useAtom(
|
||||||
commandPaletteOpenAtom,
|
commandPaletteOpenAtom,
|
||||||
@@ -109,7 +106,7 @@ export function CommandPalette() {
|
|||||||
["G", "H"],
|
["G", "H"],
|
||||||
() => {
|
() => {
|
||||||
progress.start();
|
progress.start();
|
||||||
router.push("/dashboard");
|
void navigate({ to: "/dashboard" });
|
||||||
},
|
},
|
||||||
{ enabled, timeout: 500 },
|
{ enabled, timeout: 500 },
|
||||||
);
|
);
|
||||||
@@ -117,7 +114,7 @@ export function CommandPalette() {
|
|||||||
["G", "E"],
|
["G", "E"],
|
||||||
() => {
|
() => {
|
||||||
progress.start();
|
progress.start();
|
||||||
router.push("/explore");
|
void navigate({ to: "/explore" });
|
||||||
},
|
},
|
||||||
{ enabled, timeout: 500 },
|
{ enabled, timeout: 500 },
|
||||||
);
|
);
|
||||||
@@ -150,7 +147,7 @@ export function CommandPalette() {
|
|||||||
const resolvePersonMutation = useMutation(
|
const resolvePersonMutation = useMutation(
|
||||||
orpc.people.resolve.mutationOptions({
|
orpc.people.resolve.mutationOptions({
|
||||||
onSuccess: ({ id }) => {
|
onSuccess: ({ id }) => {
|
||||||
if (id) router.push(`/people/${id}`);
|
if (id) void navigate({ to: "/people/$id", params: { id } });
|
||||||
else progress.done();
|
else progress.done();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -163,7 +160,7 @@ export function CommandPalette() {
|
|||||||
const resolveTitleMutation = useMutation(
|
const resolveTitleMutation = useMutation(
|
||||||
orpc.titles.resolve.mutationOptions({
|
orpc.titles.resolve.mutationOptions({
|
||||||
onSuccess: ({ id }) => {
|
onSuccess: ({ id }) => {
|
||||||
if (id) router.push(`/titles/${id}`);
|
if (id) void navigate({ to: "/titles/$id", params: { id } });
|
||||||
else progress.done();
|
else progress.done();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -261,11 +258,13 @@ export function CommandPalette() {
|
|||||||
{r.type === "person" ? (
|
{r.type === "person" ? (
|
||||||
<div className="size-10 shrink-0 overflow-hidden rounded-full bg-muted">
|
<div className="size-10 shrink-0 overflow-hidden rounded-full bg-muted">
|
||||||
{r.profilePath ? (
|
{r.profilePath ? (
|
||||||
<Image
|
<img
|
||||||
src={r.profilePath as string}
|
src={r.profilePath}
|
||||||
alt={r.title}
|
alt={r.title}
|
||||||
width={40}
|
width={40}
|
||||||
height={40}
|
height={40}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
className="h-full w-full object-cover"
|
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">
|
<div className="h-12 w-8 shrink-0 overflow-hidden rounded bg-muted">
|
||||||
{r.posterPath ? (
|
{r.posterPath ? (
|
||||||
<Image
|
<img
|
||||||
src={r.posterPath as string}
|
src={r.posterPath}
|
||||||
alt={r.title}
|
alt={r.title}
|
||||||
width={32}
|
width={32}
|
||||||
height={48}
|
height={48}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -387,7 +388,7 @@ export function CommandPalette() {
|
|||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setCommandPaletteOpen(false);
|
setCommandPaletteOpen(false);
|
||||||
progress.start();
|
progress.start();
|
||||||
router.push("/dashboard");
|
void navigate({ to: "/dashboard" });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconHome aria-hidden={true} className="size-3.5" />
|
<IconHome aria-hidden={true} className="size-3.5" />
|
||||||
@@ -398,7 +399,7 @@ export function CommandPalette() {
|
|||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setCommandPaletteOpen(false);
|
setCommandPaletteOpen(false);
|
||||||
progress.start();
|
progress.start();
|
||||||
router.push("/explore");
|
void navigate({ to: "/explore" });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconSearch aria-hidden={true} className="size-3.5" />
|
<IconSearch aria-hidden={true} className="size-3.5" />
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { IconPlayerPlay } from "@tabler/icons-react";
|
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 {
|
export interface ContinueWatchingItemProps {
|
||||||
title: {
|
title: {
|
||||||
@@ -32,17 +32,18 @@ export function ContinueWatchingCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<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"
|
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">
|
<div className="relative aspect-video overflow-hidden rounded-t-xl bg-muted">
|
||||||
{stillUrl ? (
|
{stillUrl ? (
|
||||||
<Image
|
<img
|
||||||
src={stillUrl}
|
src={stillUrl}
|
||||||
alt={item.nextEpisode?.name ?? item.title.title}
|
alt={item.nextEpisode?.name ?? item.title.title}
|
||||||
fill
|
loading="lazy"
|
||||||
sizes="(min-width: 640px) 288px, 256px"
|
decoding="async"
|
||||||
className="object-cover motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-105"
|
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">
|
<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 { IconPlayerPlay } from "@tabler/icons-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { orpc } from "@/lib/orpc/tanstack";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
import {
|
import {
|
||||||
ContinueWatchingList,
|
ContinueWatchingList,
|
||||||
ContinueWatchingSectionSkeleton,
|
ContinueWatchingSectionSkeleton,
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { IconBooks } from "@tabler/icons-react";
|
import { IconBooks } from "@tabler/icons-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { orpc } from "@/lib/orpc/tanstack";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
import { FeedSection } from "./feed-section";
|
import { FeedSection } from "./feed-section";
|
||||||
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
|
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
|
||||||
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { IconThumbUp } from "@tabler/icons-react";
|
import { IconThumbUp } from "@tabler/icons-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { orpc } from "@/lib/orpc/tanstack";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
import { FeedSection } from "./feed-section";
|
import { FeedSection } from "./feed-section";
|
||||||
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
|
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
|
||||||
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
"use client";
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
import { Area, AreaChart, YAxis } from "recharts";
|
||||||
import { useId } from "react";
|
|
||||||
import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
|
|
||||||
|
|
||||||
interface SparklineProps {
|
interface SparklineProps {
|
||||||
data: Array<{ bucket: string; count: number }>;
|
data: Array<{ bucket: string; count: number }>;
|
||||||
@@ -11,16 +9,36 @@ interface SparklineProps {
|
|||||||
export function Sparkline({ data, color }: SparklineProps) {
|
export function Sparkline({ data, color }: SparklineProps) {
|
||||||
const uniqueId = useId();
|
const uniqueId = useId();
|
||||||
const gradientId = `sparkline-${uniqueId.replace(/:/g, "")}`;
|
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;
|
if (!data.some((d) => d.count > 0)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={containerRef}
|
||||||
className={`pointer-events-none absolute inset-0 overflow-hidden ${color}`}
|
className={`pointer-events-none absolute inset-0 overflow-hidden ${color}`}
|
||||||
>
|
>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
{size.width > 0 && size.height > 0 && (
|
||||||
<AreaChart
|
<AreaChart
|
||||||
data={data}
|
data={data}
|
||||||
|
width={size.width}
|
||||||
|
height={size.height}
|
||||||
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
|
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
@@ -40,7 +58,7 @@ export function Sparkline({ data, color }: SparklineProps) {
|
|||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
"use client";
|
import type {
|
||||||
|
DashboardStats,
|
||||||
|
HistoryBucket,
|
||||||
|
TimePeriod,
|
||||||
|
} from "@sofa/api/schemas";
|
||||||
import {
|
import {
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconLibrary,
|
IconLibrary,
|
||||||
@@ -16,12 +19,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { orpc } from "@/lib/orpc/tanstack";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
import type {
|
|
||||||
DashboardStats,
|
|
||||||
HistoryBucket,
|
|
||||||
TimePeriod,
|
|
||||||
} from "@/lib/services/discovery";
|
|
||||||
import { Sparkline } from "./sparkline";
|
import { Sparkline } from "./sparkline";
|
||||||
|
|
||||||
function StatCardSkeleton() {
|
function StatCardSkeleton() {
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { IconDeviceTv } from "@tabler/icons-react";
|
import { IconDeviceTv } from "@tabler/icons-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import Link from "next/link";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { orpc } from "@/lib/orpc/tanstack";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
import { StatsDisplay, StatsSectionSkeleton } from "./stats-display";
|
import { StatsDisplay, StatsSectionSkeleton } from "./stats-display";
|
||||||
|
|
||||||
export function StatsSection() {
|
export function StatsSection() {
|
||||||
@@ -35,7 +33,7 @@ export function StatsSection() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<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"
|
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
|
Start exploring
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
|
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
|
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
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 { FilterableTitleRow } from "./filterable-title-row";
|
||||||
import { HeroBanner } from "./hero-banner";
|
import { HeroBanner } from "./hero-banner";
|
||||||
import { TitleRow } from "./title-row";
|
import { TitleRow } from "./title-row";
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { skipToken, useQuery } from "@tanstack/react-query";
|
import { skipToken, useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
|
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { orpc } from "@/lib/orpc/tanstack";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
|
|
||||||
interface Genre {
|
interface Genre {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IconDeviceTv,
|
IconDeviceTv,
|
||||||
IconMovie,
|
IconMovie,
|
||||||
@@ -7,11 +5,11 @@ import {
|
|||||||
IconStar,
|
IconStar,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
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 { toast } from "sonner";
|
||||||
import { useProgress } from "@/components/navigation-progress";
|
import { useProgress } from "@/components/navigation-progress";
|
||||||
import { orpc } from "@/lib/orpc/tanstack";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
|
|
||||||
interface HeroBannerProps {
|
interface HeroBannerProps {
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
@@ -30,12 +28,12 @@ export function HeroBanner({
|
|||||||
backdropPath,
|
backdropPath,
|
||||||
voteAverage,
|
voteAverage,
|
||||||
}: HeroBannerProps) {
|
}: HeroBannerProps) {
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const progress = useProgress();
|
const progress = useProgress();
|
||||||
const resolveMutation = useMutation(
|
const resolveMutation = useMutation(
|
||||||
orpc.titles.resolve.mutationOptions({
|
orpc.titles.resolve.mutationOptions({
|
||||||
onSuccess: ({ id }) => {
|
onSuccess: ({ id }) => {
|
||||||
if (id) router.push(`/titles/${id}`);
|
if (id) void navigate({ to: "/titles/$id", params: { id } });
|
||||||
else progress.done();
|
else progress.done();
|
||||||
},
|
},
|
||||||
onError: () => {
|
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 -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">
|
<div className="relative aspect-[21/9] max-h-[420px] min-h-[280px] w-full">
|
||||||
{backdropPath ? (
|
{backdropPath ? (
|
||||||
<Image
|
<img
|
||||||
src={backdropPath}
|
src={backdropPath}
|
||||||
alt={title}
|
alt={title}
|
||||||
fill
|
loading="eager"
|
||||||
priority
|
decoding="async"
|
||||||
className="object-cover"
|
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" />
|
<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 { motion } from "motion/react";
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { SofaLogo } from "@/components/sofa-logo";
|
import { SofaLogo } from "@/components/sofa-logo";
|
||||||
|
|
||||||
// Poster positions arranged in angled columns behind the hero
|
// Poster positions arranged in angled columns behind the hero
|
||||||
@@ -63,11 +60,13 @@ export function LandingPage({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden rounded-xl shadow-lg">
|
<div className="overflow-hidden rounded-xl shadow-lg">
|
||||||
<Image
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt=""
|
alt=""
|
||||||
width={300}
|
width={300}
|
||||||
height={450}
|
height={450}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
className="h-auto w-full"
|
className="h-auto w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,7 +146,7 @@ export function LandingPage({
|
|||||||
>
|
>
|
||||||
{freshInstall ? (
|
{freshInstall ? (
|
||||||
<Link
|
<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"
|
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>
|
<span className="relative z-10">Get Started</span>
|
||||||
@@ -156,7 +155,7 @@ export function LandingPage({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Link
|
<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"
|
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>
|
<span className="relative z-10">Sign In</span>
|
||||||
@@ -164,7 +163,7 @@ export function LandingPage({
|
|||||||
</Link>
|
</Link>
|
||||||
{registrationOpen && (
|
{registrationOpen && (
|
||||||
<Link
|
<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"
|
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
|
Register
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IconCompass,
|
IconCompass,
|
||||||
IconHome,
|
IconHome,
|
||||||
@@ -7,10 +5,9 @@ import {
|
|||||||
IconSearch,
|
IconSearch,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import { useLayoutEffect, useRef, useState } from "react";
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
import { SofaLogo } from "@/components/sofa-logo";
|
import { SofaLogo } from "@/components/sofa-logo";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
@@ -127,8 +124,8 @@ export function NavBar({
|
|||||||
userImage?: string;
|
userImage?: string;
|
||||||
userRole?: string;
|
userRole?: string;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const pathname = usePathname();
|
const { pathname } = useLocation();
|
||||||
const setCommandPaletteOpen = useSetAtom(commandPaletteOpenAtom);
|
const setCommandPaletteOpen = useSetAtom(commandPaletteOpenAtom);
|
||||||
|
|
||||||
const initial = userName?.charAt(0).toUpperCase() ?? "?";
|
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="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">
|
<div className="flex items-center gap-3 sm:gap-6">
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
to="/dashboard"
|
||||||
className="shrink-0 text-foreground transition-colors hover:text-primary"
|
className="shrink-0 text-foreground transition-colors hover:text-primary"
|
||||||
>
|
>
|
||||||
<SofaLogo className="size-7" />
|
<SofaLogo className="size-7" />
|
||||||
@@ -168,7 +165,7 @@ export function NavBar({
|
|||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
linkRefs.current[i] = el;
|
linkRefs.current[i] = el;
|
||||||
}}
|
}}
|
||||||
href={link.href}
|
to={link.href}
|
||||||
aria-current={isActive ? "page" : undefined}
|
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"
|
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>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
render={<Link href="/settings" />}
|
render={<Link to="/settings" />}
|
||||||
className="cursor-pointer text-[13px]"
|
className="cursor-pointer text-[13px]"
|
||||||
>
|
>
|
||||||
<IconSettings className="size-3.5" />
|
<IconSettings className="size-3.5" />
|
||||||
@@ -259,8 +256,7 @@ export function NavBar({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
onSelect={async () => {
|
onSelect={async () => {
|
||||||
await signOut();
|
await signOut();
|
||||||
router.push("/");
|
void navigate({ to: "/" });
|
||||||
router.refresh();
|
|
||||||
}}
|
}}
|
||||||
className="cursor-pointer text-[13px]"
|
className="cursor-pointer text-[13px]"
|
||||||
>
|
>
|
||||||
@@ -271,7 +267,7 @@ export function NavBar({
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
{/* Mobile: simple avatar link to settings */}
|
{/* Mobile: simple avatar link to settings */}
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
to="/settings"
|
||||||
className="rounded-full ring-2 ring-transparent transition-all hover:ring-primary/40 sm:hidden"
|
className="rounded-full ring-2 ring-transparent transition-all hover:ring-primary/40 sm:hidden"
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
>
|
>
|
||||||
@@ -289,7 +285,7 @@ export function NavBar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MobileTabBar() {
|
export function MobileTabBar() {
|
||||||
const pathname = usePathname();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
const activeIndex = mobileTabs.findIndex((tab) =>
|
const activeIndex = mobileTabs.findIndex((tab) =>
|
||||||
isLinkActive(pathname, tab.href),
|
isLinkActive(pathname, tab.href),
|
||||||
@@ -318,7 +314,7 @@ export function MobileTabBar() {
|
|||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
tabRefs.current[i] = el;
|
tabRefs.current[i] = el;
|
||||||
}}
|
}}
|
||||||
href={tab.href}
|
to={tab.href}
|
||||||
aria-current={isActive ? "page" : undefined}
|
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"
|
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 { useLocation } from "@tanstack/react-router";
|
||||||
|
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -25,8 +23,7 @@ function clamp(n: number, min: number, max: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProgressProvider({ children }: { children: ReactNode }) {
|
export function ProgressProvider({ children }: { children: ReactNode }) {
|
||||||
const pathname = usePathname();
|
const { pathname, searchStr } = useLocation();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
|
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
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
|
// 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);
|
const firstRenderRef = useRef(true);
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: routeKey drives re-runs on route change
|
// biome-ignore lint/correctness/useExhaustiveDependencies: routeKey drives re-runs on route change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
"use client";
|
import type { PersonCredit } from "@sofa/api/schemas";
|
||||||
|
|
||||||
import { IconMovie } from "@tabler/icons-react";
|
import { IconMovie } from "@tabler/icons-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { TitleCard } from "@/components/title-card";
|
import { TitleCard } from "@/components/title-card";
|
||||||
@@ -11,7 +10,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import type { PersonCredit } from "@/lib/orpc/schemas";
|
|
||||||
|
|
||||||
type Filter = "all" | "movie" | "tv";
|
type Filter = "all" | "movie" | "tv";
|
||||||
type Sort = "newest" | "rating";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import type { PersonCredit, ResolvedPerson } from "@/lib/orpc/schemas";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
import { orpc } from "@/lib/orpc/tanstack";
|
|
||||||
import { FilmographyGrid } from "./filmography-grid";
|
import { FilmographyGrid } from "./filmography-grid";
|
||||||
import { PersonHero } from "./person-hero";
|
import { PersonHero } from "./person-hero";
|
||||||
|
|
||||||
@@ -36,17 +34,10 @@ export interface PersonDetailResponse {
|
|||||||
userStatuses: Record<string, "watchlist" | "in_progress" | "completed">;
|
userStatuses: Record<string, "watchlist" | "in_progress" | "completed">;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PersonDetailClient({
|
export function PersonDetailClient({ id }: { id: string }) {
|
||||||
id,
|
const { data, isPending } = useQuery(
|
||||||
initialData,
|
orpc.people.detail.queryOptions({ input: { id } }),
|
||||||
}: {
|
);
|
||||||
id: string;
|
|
||||||
initialData?: PersonDetailResponse;
|
|
||||||
}) {
|
|
||||||
const { data, isPending } = useQuery({
|
|
||||||
...orpc.people.detail.queryOptions({ input: { id } }),
|
|
||||||
initialData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isPending) return <PersonDetailSkeleton />;
|
if (isPending) return <PersonDetailSkeleton />;
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||