Add @sofa/native Expo React Native app (#7)
@@ -13,3 +13,4 @@ coverage
|
|||||||
.idea
|
.idea
|
||||||
.turbo
|
.turbo
|
||||||
.tanstack
|
.tanstack
|
||||||
|
apps/native
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
name: EAS Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
platform:
|
||||||
|
description: Platform to build
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- all
|
||||||
|
- ios
|
||||||
|
- android
|
||||||
|
default: all
|
||||||
|
profile:
|
||||||
|
description: Build profile
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- production
|
||||||
|
- development
|
||||||
|
default: production
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- apps/native/**
|
||||||
|
- packages/api/**
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: EAS Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Setup EAS
|
||||||
|
uses: expo/expo-github-action@v8
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
working-directory: apps/native
|
||||||
|
run: eas build --platform ${{ inputs.platform || 'all' }} --profile ${{ inputs.profile || 'production' }} --non-interactive --no-wait
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
name: EAS Submit
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
platform:
|
||||||
|
description: Platform to submit
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- all
|
||||||
|
- ios
|
||||||
|
- android
|
||||||
|
default: all
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
submit:
|
||||||
|
name: EAS Submit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Setup EAS
|
||||||
|
uses: expo/expo-github-action@v8
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Submit
|
||||||
|
working-directory: apps/native
|
||||||
|
run: eas submit --platform ${{ inputs.platform || 'all' }} --latest --non-interactive
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: EAS Update
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- apps/native/**
|
||||||
|
- packages/api/**
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update:
|
||||||
|
name: EAS Update
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Setup EAS
|
||||||
|
uses: expo/expo-github-action@v8
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Publish update
|
||||||
|
working-directory: apps/native
|
||||||
|
run: eas update --branch ${{ github.head_ref }} --message "${{ github.event.pull_request.title }}" --non-interactive
|
||||||
@@ -18,6 +18,10 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate Expo types
|
||||||
|
run: bunx expo customize tsconfig.json
|
||||||
|
working-directory: ./apps/native
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: bunx turbo run lint
|
run: bunx turbo run lint
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"recommendations": [
|
"recommendations": [
|
||||||
"biomejs.biome",
|
"biomejs.biome",
|
||||||
"bradlc.vscode-tailwindcss",
|
"bradlc.vscode-tailwindcss",
|
||||||
"vitest.explorer",
|
"expo.vscode-expo-tools",
|
||||||
"vercel.turbo-vsc"
|
"vercel.turbo-vsc",
|
||||||
|
"vitest.explorer"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Root commands (via Turborepo)
|
||||||
|
bun run dev # Start API server + Vite dev server
|
||||||
|
bun run build # Production build (both apps)
|
||||||
|
bun run lint # Biome lint check
|
||||||
|
bun run format # Biome format (auto-fix)
|
||||||
|
bun run check-types # TypeScript type check
|
||||||
|
bun run test # Run tests
|
||||||
|
|
||||||
|
# Database commands (run from packages/db/)
|
||||||
|
cd packages/db && bun run db:push # Push schema changes to SQLite database
|
||||||
|
cd packages/db && bun run db:generate # Generate Drizzle migration files
|
||||||
|
cd packages/db && bun run db:migrate # Run Drizzle migrations
|
||||||
|
cd packages/db && bun run db:studio # Open Drizzle Studio (visual DB browser)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Bun, not Node.js** — `bun <file>`, `bun test`, `bun install`, `bun run <script>`, `bunx <pkg>`. Bun auto-loads `.env`.
|
||||||
|
|
||||||
|
## Pre-commit checks
|
||||||
|
|
||||||
|
All three must pass with zero warnings or errors:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run lint
|
||||||
|
bun run check-types
|
||||||
|
bun run test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**Sofa** is a self-hosted movie & TV tracking app built as a Turborepo monorepo.
|
||||||
|
|
||||||
|
```
|
||||||
|
couch-potato/
|
||||||
|
├── apps/
|
||||||
|
│ ├── native/ # @sofa/native — Expo 55 React Native app (Expo Router, UniWind)
|
||||||
|
│ ├── 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, JIT)
|
||||||
|
│ ├── auth/ # @sofa/auth — Better Auth server config (JIT)
|
||||||
|
│ ├── config/ # @sofa/config — Path constants + TMDB URLs from env (JIT)
|
||||||
|
│ ├── core/ # @sofa/core — Business logic services (JIT)
|
||||||
|
│ ├── db/ # @sofa/db — Drizzle schema, client, migrations (JIT)
|
||||||
|
│ ├── logger/ # @sofa/logger — Pino-based structured logging (JIT)
|
||||||
|
│ └── tmdb/ # @sofa/tmdb — TMDB API client + image URL helper (JIT)
|
||||||
|
├── turbo.json
|
||||||
|
├── biome.json
|
||||||
|
├── Dockerfile
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
All shared packages are JIT (raw TypeScript exports, no build step).
|
||||||
|
|
||||||
|
### Apps
|
||||||
|
|
||||||
|
- **`@sofa/server`** — Hono API server. Hosts oRPC procedures, Better Auth, webhook/image/backup routes, and cron jobs. Runs DB migrations on startup. Dev: port 3001. Prod: serves SPA static files too, port 3000.
|
||||||
|
- **`@sofa/web`** — Vite SPA with TanStack Router (file-based routing). No SSR, no DB. All data via oRPC. Vite dev server proxies `/api/*` and `/rpc/*` to the API server.
|
||||||
|
- **`@sofa/native`** — Expo Router app with 4-tab layout (Home, Explore, Search, Settings). UniWind for styling, `@better-auth/expo` with SecureStore for auth, oRPC client for API calls. Dark-only cinema theme matching web.
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
|
||||||
|
- **Frontend**: Vite 7 SPA, React 19, TanStack Router (file-based), Tailwind CSS v4, shadcn, Jotai
|
||||||
|
- **Mobile**: Expo 55, Expo Router, UniWind (Tailwind for RN), `@tabler/icons-react-native`
|
||||||
|
- **API**: Hono on Bun, oRPC (contract-first) with `@orpc/tanstack-query`
|
||||||
|
- **Database**: SQLite via `bun:sqlite` + Drizzle ORM (WAL mode, sync queries, auto-migrations)
|
||||||
|
- **Auth**: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO
|
||||||
|
- **Monorepo**: Turborepo with Bun workspaces
|
||||||
|
- **Linting**: Biome (2-space indent, organized imports)
|
||||||
|
- **External API**: TMDB (The Movie Database)
|
||||||
|
|
||||||
|
### Package imports
|
||||||
|
|
||||||
|
Path aliases: `@/*` maps to `src/` in both `apps/web/` and `apps/native/`.
|
||||||
|
|
||||||
|
Cross-package imports:
|
||||||
|
- `@sofa/api/contract`, `@sofa/api/schemas` — Contract and Zod types
|
||||||
|
- `@sofa/db/client`, `@sofa/db/schema`, `@sofa/db/helpers`, `@sofa/db/migrate`, `@sofa/db/test-utils`
|
||||||
|
- `@sofa/tmdb/client`, `@sofa/tmdb/image`
|
||||||
|
- `@sofa/core/metadata`, `@sofa/core/tracking`, etc.
|
||||||
|
- `@sofa/auth/server`, `@sofa/auth/config`
|
||||||
|
- `@sofa/config` — Path constants (`DATA_DIR`, `DATABASE_URL`, `CACHE_DIR`, `BACKUP_DIR`, `AVATAR_DIR`) and TMDB URLs
|
||||||
|
- `@sofa/logger` — `createLogger(name)` for structured logging
|
||||||
|
|
||||||
|
### Key patterns
|
||||||
|
|
||||||
|
**oRPC queries** use `orpc.*.queryOptions()` with TanStack Query. **Mutations** use `client.*()` directly. **Route loaders** prefetch via `queryClient.ensureQueryData()`.
|
||||||
|
|
||||||
|
**Auth guards** — Web routes use `beforeLoad` + `authClient.getSession()`. API procedures use auth middleware that calls `auth.api.getSession()`.
|
||||||
|
|
||||||
|
**TMDB images** — Only paths stored in DB. The API server resolves full URLs via `tmdbImageUrl()`. When `IMAGE_CACHE_ENABLED` (default), images are downloaded to disk and served via `/images/:category/:filename`.
|
||||||
|
|
||||||
|
**Database IDs** — All app tables use UUIDv7 text PKs via `Bun.randomUUIDv7()`.
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
Required: `TMDB_API_READ_ACCESS_TOKEN`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`.
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
- `DATA_DIR` — Root for DB + cache (default `./data`). `DATABASE_URL` and `CACHE_DIR` derived from it but overridable.
|
||||||
|
- `TMDB_API_BASE_URL`, `TMDB_IMAGE_BASE_URL` — Override TMDB endpoints.
|
||||||
|
- `IMAGE_CACHE_ENABLED` — Default `true`. Set `false` for direct TMDB CDN URLs.
|
||||||
|
- `LOG_LEVEL` — `error`/`warn`/`info`/`debug` (default: `info`).
|
||||||
|
- OIDC: `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_ISSUER_URL`, `OIDC_PROVIDER_NAME`, `OIDC_AUTO_REGISTER`, `DISABLE_PASSWORD_LOGIN`.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Tests live in `packages/core/test/`. `preload.ts` mocks `@sofa/db/client` with in-memory SQLite. Never mock a service module with stubs — `bun`'s `mock.module` persists across test files.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
Single container, single process. Hono serves API + SPA on port 3000. API routes (`/rpc/*`, `/api/*`) mounted first; unmatched routes fall back to `index.html`.
|
||||||
|
|
||||||
|
## Browser Automation
|
||||||
|
|
||||||
|
Use `agent-browser` for web automation (`agent-browser --help` for all commands). Auth credentials: `demo@sofa.watch` / `password`.
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Root commands (via Turborepo)
|
|
||||||
bun run dev # Start API server + Vite dev server
|
|
||||||
bun run build # Production build (both apps)
|
|
||||||
bun run lint # Biome lint check
|
|
||||||
bun run format # Biome format (auto-fix)
|
|
||||||
bun run check-types # TypeScript type check
|
|
||||||
bun run test # Run tests
|
|
||||||
|
|
||||||
# Database commands (run from packages/db/)
|
|
||||||
cd packages/db && bun run db:push # Push schema changes to SQLite database
|
|
||||||
cd packages/db && bun run db:generate # Generate Drizzle migration files
|
|
||||||
cd packages/db && bun run db:migrate # Run Drizzle migrations
|
|
||||||
cd packages/db && bun run db:studio # Open Drizzle Studio (visual DB browser)
|
|
||||||
```
|
|
||||||
|
|
||||||
IMPORTANT: Default to using Bun instead of Node.js:
|
|
||||||
|
|
||||||
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
|
||||||
- Use `bun test` instead of `jest` or `vitest`
|
|
||||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
|
||||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
|
||||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
|
||||||
- Use `bunx <package> <command>` instead of `npx <package> <command>`
|
|
||||||
- Bun automatically loads .env, so don't use dotenv.
|
|
||||||
|
|
||||||
For more information, read the Bun API docs in node_modules/bun-types/docs/**.mdx.
|
|
||||||
|
|
||||||
## Pre-commit checks
|
|
||||||
|
|
||||||
Before committing, all three of these must pass with zero warnings or errors:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run lint
|
|
||||||
bun run check-types
|
|
||||||
bun run test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
- **Frontend**: Vite 7 SPA, React 19, TypeScript, TanStack Router (file-based routing)
|
|
||||||
- **API Server**: Hono (standalone Bun server)
|
|
||||||
- **Monorepo**: Turborepo with Bun workspaces
|
|
||||||
- **Database**: SQLite via bun:sqlite + Drizzle ORM (WAL mode, singleton via `globalThis`, sync queries, auto-migrations on startup)
|
|
||||||
- **Auth**: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO via `genericOAuth` plugin
|
|
||||||
- **Styling**: Tailwind CSS v4 (via `@tailwindcss/vite`), shadcn components, dark cinema theme with warm primary accents
|
|
||||||
- **Fonts**: DM Serif Display (display), DM Sans (body), Geist Mono (mono) — self-hosted via `@fontsource`
|
|
||||||
- **API**: oRPC (contract-first, type-safe RPC) with `@orpc/tanstack-query` for TanStack Query integration
|
|
||||||
- **State**: Jotai (client), TanStack Query (data fetching & mutations via oRPC)
|
|
||||||
- **Linting**: Biome (2-space indent, organized imports, React recommended rules)
|
|
||||||
- **External API**: TMDB (The Movie Database) with Bearer token auth
|
|
||||||
|
|
||||||
### Package imports
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
**API server** (`apps/server/src/`):
|
|
||||||
- `index.ts` — Hono app entry point, startup, CORS, route mounting, graceful shutdown
|
|
||||||
- `cron.ts` — Background job scheduler (croner)
|
|
||||||
- `orpc/` — oRPC server layer (context, middleware, router, handler, procedures/)
|
|
||||||
- `routes/` — Non-RPC Hono routes (auth, images, avatars, backups, webhooks, lists, health)
|
|
||||||
|
|
||||||
**Web app** (`apps/web/src/`):
|
|
||||||
- `main.tsx` — React root: creates router + renders `<RouterProvider />`
|
|
||||||
- `routes/` — TanStack Router file-based routes (auto-generates `routeTree.gen.ts`)
|
|
||||||
- `__root.tsx` — Root layout (providers, head meta, global error/not-found)
|
|
||||||
- `_app.tsx` — Authenticated layout (auth guard via `beforeLoad`, navbar, shell)
|
|
||||||
- `_app/dashboard.tsx`, `_app/explore.tsx`, `_app/settings.tsx`, `_app/titles.$id.tsx`, `_app/people.$id.tsx`
|
|
||||||
- `_auth.tsx` — Auth layout (centering wrapper)
|
|
||||||
- `_auth/login.tsx`, `_auth/register.tsx`
|
|
||||||
- `index.tsx`, `setup.tsx`
|
|
||||||
- `components/` — App components + `components/ui/` for shadcn primitives
|
|
||||||
- `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
|
|
||||||
|
|
||||||
**Web app** — Route `beforeLoad` guards check session via Better Auth client SDK:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In route file (e.g. _app.tsx)
|
|
||||||
beforeLoad: async () => {
|
|
||||||
const { data: session } = await authClient.getSession();
|
|
||||||
if (!session) throw redirect({ to: "/login" });
|
|
||||||
return { session };
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
Session is available to child routes via `Route.useRouteContext()`.
|
|
||||||
|
|
||||||
**API server** — oRPC procedures use auth middleware that calls Better Auth:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// apps/server/src/orpc/middleware.ts
|
|
||||||
export const authed = base.middleware(async ({ context, next }) => {
|
|
||||||
const session = await auth.api.getSession({ headers: context.headers });
|
|
||||||
if (!session) throw new ORPCError("UNAUTHORIZED");
|
|
||||||
return next({ context: { user: session.user, session: session.session } });
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### oRPC API layer
|
|
||||||
|
|
||||||
All API procedures use oRPC with a contract-first approach. The contract is defined in `packages/api/src/contract.ts`, procedures implement it in `apps/server/src/orpc/procedures/`, and the client consumes it with full type safety.
|
|
||||||
|
|
||||||
**Pattern — importing types from the shared package:**
|
|
||||||
```typescript
|
|
||||||
import type { ResolvedTitle, Season } from "@sofa/api/schemas";
|
|
||||||
import { contract } from "@sofa/api/contract";
|
|
||||||
```
|
|
||||||
|
|
||||||
**Pattern — query in component:**
|
|
||||||
```typescript
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { orpc } from "@/lib/orpc/client";
|
|
||||||
|
|
||||||
const { data } = useQuery(orpc.dashboard.stats.queryOptions());
|
|
||||||
```
|
|
||||||
|
|
||||||
**Pattern — mutation:**
|
|
||||||
```typescript
|
|
||||||
import { client } from "@/lib/orpc/client";
|
|
||||||
|
|
||||||
await client.titles.updateStatus({ id: titleId, status: "in_progress" });
|
|
||||||
```
|
|
||||||
|
|
||||||
**Pattern — route loader with TanStack Query prefetch:**
|
|
||||||
```typescript
|
|
||||||
export const Route = createFileRoute("/_app/titles/$id")({
|
|
||||||
loader: async ({ params, context }) => {
|
|
||||||
await context.queryClient.ensureQueryData(
|
|
||||||
orpc.titles.detail.queryOptions({ input: { id: params.id } }),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
component: TitlePage,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Page patterns:**
|
|
||||||
- **Route loaders** (titles, people): `beforeLoad`/`loader` prefetch data into TanStack Query cache via `queryClient.ensureQueryData()`. Components read via `useQuery()`/`useSuspenseQuery()`.
|
|
||||||
- **Full client-side** (dashboard, explore, settings): Components fetch all data via `orpc.*.queryOptions()` with skeleton loading states.
|
|
||||||
- **Error/loading states**: Routes use `pendingComponent`, `errorComponent`, `notFoundComponent`.
|
|
||||||
|
|
||||||
### Non-RPC routes (API server)
|
|
||||||
|
|
||||||
Hono route handlers in `apps/server/src/routes/`:
|
|
||||||
- `/images/:category/:filename` — Image proxy/cache serving
|
|
||||||
- `/api/auth/*` — Better Auth catch-all
|
|
||||||
- `/api/avatars/:userId` — Avatar file serving
|
|
||||||
- `/api/backup/:filename` — Backup file download
|
|
||||||
- `/api/webhooks/:token` — Plex/Jellyfin/Emby webhooks
|
|
||||||
- `/api/lists/:token` — External list feeds (Sonarr/Radarr)
|
|
||||||
- `/api/health` — Simple health check (no auth)
|
|
||||||
|
|
||||||
In dev, Vite proxies these to the API server. In production, Hono serves both API and SPA — the browser only sees port 3000.
|
|
||||||
|
|
||||||
### Database schema
|
|
||||||
|
|
||||||
All app tables use UUIDv7 text primary keys generated via `Bun.randomUUIDv7()`. Better Auth tables use their own ID format. Key relationships:
|
|
||||||
|
|
||||||
- `titles` → `seasons` → `episodes` (TV hierarchy)
|
|
||||||
- `persons` + `titleCast` (cast/crew linked to titles)
|
|
||||||
- `userTitleStatus` links users to titles with status (watchlist/in_progress/completed)
|
|
||||||
- `userMovieWatches` / `userEpisodeWatches` track watch history (source: manual/import/plex/jellyfin/emby)
|
|
||||||
- `userRatings` stores 1-5 star ratings
|
|
||||||
- `availabilityOffers` caches streaming provider data per title
|
|
||||||
- `titleRecommendations` stores TMDB recommendations and similar titles
|
|
||||||
- `webhookConnections` / `webhookEventLog` — Plex/Jellyfin/Emby webhook integrations
|
|
||||||
- `cronRuns` — Background job execution history
|
|
||||||
- `appSettings` — Key-value store for runtime app configuration
|
|
||||||
|
|
||||||
### Service layer (`packages/core/src/`)
|
|
||||||
|
|
||||||
- **metadata.ts**: `importTitle()` fetches from TMDB, inserts into DB, fire-and-forgets availability + recommendations + credits + image caching. `refreshTvChildren()` fetches all seasons/episodes with 250ms rate limiting.
|
|
||||||
- **image-cache.ts**: Downloads and caches TMDB images to local disk.
|
|
||||||
- **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, library with availability, personalized recommendations.
|
|
||||||
- **availability.ts**: Caches US streaming providers from TMDB watch/providers endpoint.
|
|
||||||
- **credits.ts**: Fetches and caches cast/crew data from TMDB.
|
|
||||||
- **person.ts**: Person detail and filmography lookups.
|
|
||||||
- **webhooks.ts**: Processes incoming Plex/Jellyfin/Emby webhook events.
|
|
||||||
- **backup.ts**: Database backup/restore with configurable scheduled backups.
|
|
||||||
- **settings.ts**: Runtime app settings via `appSettings` table.
|
|
||||||
- **colors.ts**: Extracts dominant color palettes from title posters via node-vibrant.
|
|
||||||
- **update-check.ts**: Checks for new Sofa releases.
|
|
||||||
- **system-health.ts**: System health diagnostics.
|
|
||||||
|
|
||||||
### TMDB images
|
|
||||||
|
|
||||||
Only paths are stored in DB. Image URLs are resolved 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 and served via `/images/:category/:filename`. When disabled, `tmdbImageUrl()` returns direct TMDB CDN URLs.
|
|
||||||
|
|
||||||
### Background jobs
|
|
||||||
|
|
||||||
Defined in `apps/server/src/cron.ts` using croner, started when the API server boots. Jobs (all use `protect: true` to prevent overlapping runs, 300ms delay between TMDB calls):
|
|
||||||
- `nightlyRefreshLibrary` (`0 3 * * *`) — refreshes stale library titles (7d) and non-library titles (30d)
|
|
||||||
- `refreshAvailability` (`0 */6 * * *`) — streaming provider data
|
|
||||||
- `refreshRecommendations` (`0 */12 * * *`)
|
|
||||||
- `refreshTvChildren` (`30 */12 * * *`) — episodes for returning/in-production TV shows
|
|
||||||
- `cacheImages` (`0 1,13 * * *`) — posters, backdrops, stills, logos, profile photos
|
|
||||||
- `refreshCredits` (`0 2 * * *`) — cast/crew data for library titles
|
|
||||||
- `scheduledBackup` (configurable via settings) — database backups with retention pruning
|
|
||||||
- `updateCheck` (`0 */6 * * *`) — checks for new Sofa releases
|
|
||||||
|
|
||||||
### Environment variables
|
|
||||||
|
|
||||||
`DATA_DIR` (root for DB + cache, default `./data`), `TMDB_API_READ_ACCESS_TOKEN`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`. `DATABASE_URL` and `CACHE_DIR` are derived from `DATA_DIR` but can be overridden individually.
|
|
||||||
|
|
||||||
Optional: `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_ISSUER_URL`, `OIDC_PROVIDER_NAME`, `OIDC_AUTO_REGISTER`, `DISABLE_PASSWORD_LOGIN` (OIDC/SSO support). `LOG_LEVEL` (error/warn/info/debug, default: info). `IMAGE_CACHE_ENABLED` (default: true).
|
|
||||||
|
|
||||||
### Docker deployment
|
|
||||||
|
|
||||||
Single container, single process. Hono serves both the API and the Vite-built SPA static files on port 3000. API routes (`/rpc/*`, `/api/*`) are mounted first; unmatched routes fall back to `index.html` for SPA routing.
|
|
||||||
|
|
||||||
## Browser Automation
|
|
||||||
|
|
||||||
Use `agent-browser` for web automation. Run `agent-browser --help` for all commands.
|
|
||||||
|
|
||||||
Core workflow:
|
|
||||||
1. `agent-browser open <url>` - Navigate to page
|
|
||||||
2. `agent-browser snapshot -i` - Get interactive elements with refs (@e1, @e2)
|
|
||||||
3. `agent-browser click @e1` / `fill @e2 "text"` - Interact using refs
|
|
||||||
4. Re-snapshot after page changes
|
|
||||||
|
|
||||||
When encountering auth, use `demo@sofa.watch` for the username/email and `password` for the password.
|
|
||||||
@@ -128,9 +128,8 @@ Useful commands:
|
|||||||
- `bun run test`
|
- `bun run test`
|
||||||
- `bun run lint`
|
- `bun run lint`
|
||||||
- `bun run check-types`
|
- `bun run check-types`
|
||||||
- `bun run db:generate`
|
- `cd packages/db && bun run db:generate`
|
||||||
- `bun run db:migrate`
|
- `cd packages/db && bun run db:migrate`
|
||||||
- `bun run db:seed`
|
|
||||||
|
|
||||||
## TMDB notice
|
## TMDB notice
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
EXPO_PUBLIC_POSTHOG_KEY=
|
||||||
|
EXPO_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
npm-debug.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
*.orig.*
|
||||||
|
web-build/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Expo prebuild
|
||||||
|
/android
|
||||||
|
/ios
|
||||||
|
|
||||||
|
# Google Play service account key
|
||||||
|
google-service-account.json
|
||||||
|
|
||||||
|
# Temporary files created by Metro to check the health of the file watcher
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||||
|
# The following patterns were generated by expo-cli
|
||||||
|
|
||||||
|
expo-env.d.ts
|
||||||
|
# @end expo-cli
|
||||||
|
|
||||||
|
credentials.json
|
||||||
|
credentials/
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Sofa",
|
||||||
|
"slug": "sofa",
|
||||||
|
"owner": "jakejarvis",
|
||||||
|
"scheme": "sofa",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"userInterfaceStyle": "dark",
|
||||||
|
"ios": {
|
||||||
|
"config": {
|
||||||
|
"usesNonExemptEncryption": false
|
||||||
|
},
|
||||||
|
"icon": "./assets/images/sofa.icon",
|
||||||
|
"bundleIdentifier": "com.jakejarvis.sofa",
|
||||||
|
"appleTeamId": "B5ZWKBCUTU"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"package": "com.jakejarvis.sofa"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"expo-router",
|
||||||
|
{
|
||||||
|
"root": "./src/app"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-secure-store",
|
||||||
|
{
|
||||||
|
"configureAndroidBackup": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-font",
|
||||||
|
{
|
||||||
|
"ios": {
|
||||||
|
"fonts": [
|
||||||
|
"../../node_modules/@expo-google-fonts/dm-sans/400Regular/DMSans_400Regular.ttf",
|
||||||
|
"../../node_modules/@expo-google-fonts/dm-sans/500Medium/DMSans_500Medium.ttf",
|
||||||
|
"../../node_modules/@expo-google-fonts/dm-sans/600SemiBold/DMSans_600SemiBold.ttf",
|
||||||
|
"../../node_modules/@expo-google-fonts/dm-sans/700Bold/DMSans_700Bold.ttf",
|
||||||
|
"../../node_modules/@expo-google-fonts/dm-serif-display/400Regular/DMSerifDisplay_400Regular.ttf",
|
||||||
|
"../../node_modules/@expo-google-fonts/geist-mono/400Regular/GeistMono_400Regular.ttf"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"fonts": [
|
||||||
|
{
|
||||||
|
"fontFamily": "DMSans-Regular",
|
||||||
|
"fontDefinitions": [
|
||||||
|
{
|
||||||
|
"path": "../../node_modules/@expo-google-fonts/dm-sans/400Regular/DMSans_400Regular.ttf",
|
||||||
|
"weight": 400
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fontFamily": "DMSans-Medium",
|
||||||
|
"fontDefinitions": [
|
||||||
|
{
|
||||||
|
"path": "../../node_modules/@expo-google-fonts/dm-sans/500Medium/DMSans_500Medium.ttf",
|
||||||
|
"weight": 500
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fontFamily": "DMSans-SemiBold",
|
||||||
|
"fontDefinitions": [
|
||||||
|
{
|
||||||
|
"path": "../../node_modules/@expo-google-fonts/dm-sans/600SemiBold/DMSans_600SemiBold.ttf",
|
||||||
|
"weight": 600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fontFamily": "DMSans-Bold",
|
||||||
|
"fontDefinitions": [
|
||||||
|
{
|
||||||
|
"path": "../../node_modules/@expo-google-fonts/dm-sans/700Bold/DMSans_700Bold.ttf",
|
||||||
|
"weight": 700
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fontFamily": "DMSerifDisplay-Regular",
|
||||||
|
"fontDefinitions": [
|
||||||
|
{
|
||||||
|
"path": "../../node_modules/@expo-google-fonts/dm-serif-display/400Regular/DMSerifDisplay_400Regular.ttf",
|
||||||
|
"weight": 400
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fontFamily": "GeistMono-Regular",
|
||||||
|
"fontDefinitions": [
|
||||||
|
{
|
||||||
|
"path": "../../node_modules/@expo-google-fonts/geist-mono/400Regular/GeistMono_400Regular.ttf",
|
||||||
|
"weight": 400
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"backgroundColor": "#101010",
|
||||||
|
"image": "./assets/images/splash-icon.png",
|
||||||
|
"imageWidth": 200
|
||||||
|
}
|
||||||
|
],
|
||||||
|
["expo-system-ui"],
|
||||||
|
[
|
||||||
|
"expo-image-picker",
|
||||||
|
{
|
||||||
|
"microphonePermission": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
["expo-localization"]
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true,
|
||||||
|
"reactCompiler": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": "a9f021eb-f58d-49af-b291-d72f7dc34051"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 764 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 719 KiB |
|
After Width: | Height: | Size: 719 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,37 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="b" cx=".5" cy=".33" r=".4">
|
||||||
|
<stop offset="0%" stop-color="#e8a87c" stop-opacity=".2"/>
|
||||||
|
<stop offset="50%" stop-color="#c48060" stop-opacity=".06"/>
|
||||||
|
<stop offset="100%" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="f" cx=".5" cy=".5" r=".7">
|
||||||
|
<stop offset="55%" stop-opacity="0"/>
|
||||||
|
<stop offset="100%" stop-opacity=".3"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="c" cx=".5" cy=".5" r=".5">
|
||||||
|
<stop offset="0%" stop-opacity=".25"/>
|
||||||
|
<stop offset="100%" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="a" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#151210"/>
|
||||||
|
<stop offset="100%" stop-color="#080605"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="d" x1="12" y1="5" x2="12" y2="13" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#f0c9a4"/>
|
||||||
|
<stop offset="100%" stop-color="#dda278"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="e" x1="12" y1="11" x2="12" y2="19.5" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#e8a87c"/>
|
||||||
|
<stop offset="100%" stop-color="#ba7d52"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g clip-path="url(#m)">
|
||||||
|
<path fill="url(#a)" d="M0 0h1024v1024H0z"/>
|
||||||
|
<path fill="url(#b)" d="M0 0h1024v1024H0z"/>
|
||||||
|
<ellipse cx="512" cy="710" rx="220" ry="22" fill="url(#c)"/>
|
||||||
|
<path fill="url(#d)" d="M7 12v1h10v-1a3 3 0 0 1 2.993-3 4.6 4.6 0 0 0-.07-.78 4 4 0 0 0-3.143-3.143C16.394 5 15.93 5 15 5H9c-.93 0-1.394 0-1.78.077A4 4 0 0 0 4.077 8.22a4.6 4.6 0 0 0-.07.78A3 3 0 0 1 7 12" transform="matrix(28 0 0 28 176 172)"/>
|
||||||
|
<path fill="url(#e)" d="M18.444 18H5.556a3.6 3.6 0 0 1-.806-.092V19a.75.75 0 0 1-1.5 0v-1.849A3.55 3.55 0 0 1 2 14.444V12a2 2 0 1 1 4 0v1.2a.8.8 0 0 0 .8.8h10.4a.8.8 0 0 0 .8-.8V12a2 2 0 1 1 4 0v2.444a3.55 3.55 0 0 1-1.25 2.707V19a.75.75 0 0 1-1.5 0v-1.092a3.6 3.6 0 0 1-.806.092" transform="matrix(28 0 0 28 176 172)"/>
|
||||||
|
<path fill="url(#f)" d="M0 0h1024v1024H0z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"fill": {
|
||||||
|
"linear-gradient": [
|
||||||
|
"display-p3:0.89697,0.77637,0.64404,1.00000",
|
||||||
|
"display-p3:0.07726,0.07726,0.07726,1.00000"
|
||||||
|
],
|
||||||
|
"orientation": {
|
||||||
|
"start": {
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"stop": {
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 0.7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"blend-mode": "normal",
|
||||||
|
"blur-material": null,
|
||||||
|
"hidden": false,
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"blend-mode": "normal",
|
||||||
|
"fill": "none",
|
||||||
|
"glass": true,
|
||||||
|
"image-name": "couch-with-glow.svg",
|
||||||
|
"name": "couch-with-glow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lighting": "individual",
|
||||||
|
"shadow": {
|
||||||
|
"kind": "neutral",
|
||||||
|
"opacity": 0.5
|
||||||
|
},
|
||||||
|
"specular": true,
|
||||||
|
"translucency": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms": {
|
||||||
|
"squares": ["iOS"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 129 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 18.3.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true,
|
||||||
|
"ios": {
|
||||||
|
"resourceClass": "m-medium"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {
|
||||||
|
"ios": {
|
||||||
|
"ascAppId": "6760432427"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"serviceAccountKeyPath": "./google-service-account.json",
|
||||||
|
"track": "internal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
const { withUniwindConfig } = require("uniwind/metro");
|
||||||
|
const {
|
||||||
|
wrapWithReanimatedMetroConfig,
|
||||||
|
} = require("react-native-reanimated/metro-config");
|
||||||
|
|
||||||
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
const uniwindConfig = withUniwindConfig(wrapWithReanimatedMetroConfig(config), {
|
||||||
|
cssEntryFile: "./src/global.css",
|
||||||
|
dtsFile: "./uniwind-types.d.ts",
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = uniwindConfig;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"name": "@sofa/native",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"dev": "expo start --clear",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"prebuild": "expo prebuild",
|
||||||
|
"lint": "biome check",
|
||||||
|
"format": "biome format --write",
|
||||||
|
"check-types": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@better-auth/expo": "catalog:",
|
||||||
|
"@expo-google-fonts/dm-sans": "0.4.2",
|
||||||
|
"@expo-google-fonts/dm-serif-display": "0.4.2",
|
||||||
|
"@expo-google-fonts/geist-mono": "0.4.1",
|
||||||
|
"@expo/metro-runtime": "55.0.6",
|
||||||
|
"@gorhom/bottom-sheet": "5.2.8",
|
||||||
|
"@orpc/client": "catalog:",
|
||||||
|
"@orpc/contract": "catalog:",
|
||||||
|
"@orpc/tanstack-query": "catalog:",
|
||||||
|
"@react-native-menu/menu": "2.0.0",
|
||||||
|
"@react-navigation/elements": "2.9.10",
|
||||||
|
"@sofa/api": "workspace:*",
|
||||||
|
"@sofa/tmdb": "workspace:*",
|
||||||
|
"@tabler/icons-react-native": "3.40.0",
|
||||||
|
"@tanstack/query-async-storage-persister": "5.90.24",
|
||||||
|
"@tanstack/react-form": "1.28.5",
|
||||||
|
"@tanstack/react-query": "catalog:",
|
||||||
|
"@tanstack/react-query-persist-client": "5.90.24",
|
||||||
|
"better-auth": "catalog:",
|
||||||
|
"expo": "55.0.6",
|
||||||
|
"expo-application": "55.0.9",
|
||||||
|
"expo-clipboard": "55.0.8",
|
||||||
|
"expo-constants": "55.0.7",
|
||||||
|
"expo-dev-client": "55.0.16",
|
||||||
|
"expo-device": "55.0.9",
|
||||||
|
"expo-font": "55.0.4",
|
||||||
|
"expo-haptics": "55.0.8",
|
||||||
|
"expo-image": "55.0.6",
|
||||||
|
"expo-image-picker": "55.0.12",
|
||||||
|
"expo-linking": "55.0.7",
|
||||||
|
"expo-localization": "55.0.8",
|
||||||
|
"expo-network": "55.0.8",
|
||||||
|
"expo-router": "55.0.5",
|
||||||
|
"expo-secure-store": "55.0.8",
|
||||||
|
"expo-splash-screen": "55.0.10",
|
||||||
|
"expo-status-bar": "55.0.4",
|
||||||
|
"expo-system-ui": "55.0.9",
|
||||||
|
"expo-web-browser": "55.0.9",
|
||||||
|
"posthog-react-native": "4.37.2",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0",
|
||||||
|
"react-native": "0.83.2",
|
||||||
|
"react-native-gesture-handler": "2.30.0",
|
||||||
|
"react-native-ios-context-menu": "3.2.0",
|
||||||
|
"react-native-ios-utilities": "5.2.0",
|
||||||
|
"react-native-keyboard-controller": "1.20.7",
|
||||||
|
"react-native-mmkv": "4.2.0",
|
||||||
|
"react-native-nitro-modules": "0.35.0",
|
||||||
|
"react-native-reanimated": "4.2.1",
|
||||||
|
"react-native-safe-area-context": "5.6.2",
|
||||||
|
"react-native-screens": "4.23.0",
|
||||||
|
"react-native-svg": "15.15.3",
|
||||||
|
"react-native-worklets": "0.7.2",
|
||||||
|
"tailwind-merge": "catalog:",
|
||||||
|
"tailwindcss": "catalog:",
|
||||||
|
"uniwind": "1.5.0",
|
||||||
|
"zeego": "3.0.6",
|
||||||
|
"zod": "catalog:"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "catalog:",
|
||||||
|
"@types/react": "catalog:",
|
||||||
|
"typescript": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { useResolveClassNames } from "uniwind";
|
||||||
|
import { hasStoredServerUrl } from "@/lib/server-url";
|
||||||
|
|
||||||
|
export const unstable_settings = {
|
||||||
|
initialRouteName:
|
||||||
|
process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl()
|
||||||
|
? "login"
|
||||||
|
: "server-url",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
const contentStyle = useResolveClassNames("bg-background");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle,
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { useForm } from "@tanstack/react-form";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link } from "expo-router";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { Alert, Pressable, type TextInput, View } from "react-native";
|
||||||
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { AuthScreen } from "@/components/auth-screen";
|
||||||
|
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import {
|
||||||
|
FieldError,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
TextField,
|
||||||
|
} from "@/components/ui/text-field";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { orpc } from "@/lib/orpc";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
|
const signInSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Email is required")
|
||||||
|
.email("Enter a valid email address"),
|
||||||
|
password: z.string().min(1, "Password is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatFormErrors(errors: unknown): string | null {
|
||||||
|
if (!errors) return null;
|
||||||
|
if (typeof errors === "string") return errors;
|
||||||
|
if (typeof errors === "object") {
|
||||||
|
const first = Object.values(errors as Record<string, { message: string }[]>)
|
||||||
|
.flat()
|
||||||
|
.find((e) => e.message);
|
||||||
|
if (first) return first.message;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const passwordRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
const authConfig = useQuery(orpc.system.authConfig.queryOptions());
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: { email: "", password: "" },
|
||||||
|
validators: { onSubmit: signInSchema },
|
||||||
|
onSubmit: async ({ value, formApi }) => {
|
||||||
|
await authClient.signIn.email(
|
||||||
|
{ email: value.email.trim(), password: value.password },
|
||||||
|
{
|
||||||
|
onError(error) {
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
|
Alert.alert("Error", error.error?.message || "Failed to sign in");
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
formApi.reset();
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const showPasswordLogin = !authConfig.data?.passwordLoginDisabled;
|
||||||
|
const showOidc = authConfig.data?.oidcEnabled;
|
||||||
|
const showRegister = authConfig.data?.registrationOpen;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthScreen title="Sofa" subtitle="Sign in to continue">
|
||||||
|
{showOidc && (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.duration(300).delay(100)}
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
authClient.signIn.oauth2({
|
||||||
|
providerId: "oidc",
|
||||||
|
callbackURL: "/(tabs)/(home)",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<ButtonLabel>
|
||||||
|
Sign in with {authConfig.data?.oidcProviderName ?? "SSO"}
|
||||||
|
</ButtonLabel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showPasswordLogin && (
|
||||||
|
<View className="my-4 flex-row items-center">
|
||||||
|
<View className="h-px flex-1 bg-border" />
|
||||||
|
<Text className="px-3 text-muted-foreground text-xs">OR</Text>
|
||||||
|
<View className="h-px flex-1 bg-border" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showPasswordLogin && (
|
||||||
|
<form.Subscribe
|
||||||
|
selector={(state) => ({
|
||||||
|
isSubmitting: state.isSubmitting,
|
||||||
|
validationError: formatFormErrors(state.errorMap.onSubmit),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{({ isSubmitting, validationError }) => (
|
||||||
|
<View className="gap-3">
|
||||||
|
{validationError && (
|
||||||
|
<FieldError isInvalid className="mb-1">
|
||||||
|
{validationError}
|
||||||
|
</FieldError>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||||
|
<form.Field name="email">
|
||||||
|
{(field) => (
|
||||||
|
<TextField>
|
||||||
|
<Label>Email</Label>
|
||||||
|
<Input
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChangeText={field.handleChange}
|
||||||
|
placeholder="email@example.com"
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="email"
|
||||||
|
textContentType="emailAddress"
|
||||||
|
returnKeyType="next"
|
||||||
|
blurOnSubmit={false}
|
||||||
|
onSubmitEditing={() => passwordRef.current?.focus()}
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
|
||||||
|
<form.Field name="password">
|
||||||
|
{(field) => (
|
||||||
|
<TextField>
|
||||||
|
<Label>Password</Label>
|
||||||
|
<Input
|
||||||
|
ref={passwordRef}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChangeText={field.handleChange}
|
||||||
|
placeholder="••••••••"
|
||||||
|
secureTextEntry
|
||||||
|
autoComplete="password"
|
||||||
|
textContentType="password"
|
||||||
|
returnKeyType="go"
|
||||||
|
onSubmitEditing={form.handleSubmit}
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
||||||
|
<Button
|
||||||
|
onPress={form.handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="mt-1 bg-primary"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
<ButtonLabel>Sign In</ButtonLabel>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</form.Subscribe>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRegister && (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(300).delay(500)}
|
||||||
|
className="mt-6 items-center"
|
||||||
|
>
|
||||||
|
<Link href="/(auth)/register" asChild>
|
||||||
|
<Pressable>
|
||||||
|
<Text className="text-primary text-sm">Create an account</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(300).delay(500)}
|
||||||
|
className="mt-4 items-center"
|
||||||
|
>
|
||||||
|
<Link href="/(auth)/server-url" asChild>
|
||||||
|
<Pressable>
|
||||||
|
<Text className="text-muted-foreground text-xs">Change server</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
</Animated.View>
|
||||||
|
</AuthScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { useForm } from "@tanstack/react-form";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link } from "expo-router";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { Alert, Pressable, type TextInput, View } from "react-native";
|
||||||
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { AuthScreen } from "@/components/auth-screen";
|
||||||
|
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import {
|
||||||
|
FieldError,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
TextField,
|
||||||
|
} from "@/components/ui/text-field";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { orpc } from "@/lib/orpc";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
|
const signUpSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Name is required")
|
||||||
|
.min(2, "Name must be at least 2 characters"),
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Email is required")
|
||||||
|
.email("Enter a valid email address"),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Password is required")
|
||||||
|
.min(8, "Use at least 8 characters"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatFormErrors(errors: unknown): string | null {
|
||||||
|
if (!errors) return null;
|
||||||
|
if (typeof errors === "string") return errors;
|
||||||
|
if (typeof errors === "object") {
|
||||||
|
const first = Object.values(errors as Record<string, { message: string }[]>)
|
||||||
|
.flat()
|
||||||
|
.find((e) => e.message);
|
||||||
|
if (first) return first.message;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RegisterScreen() {
|
||||||
|
const emailRef = useRef<TextInput>(null);
|
||||||
|
const passwordRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
const publicInfo = useQuery(orpc.system.publicInfo.queryOptions());
|
||||||
|
const registrationOpen = publicInfo.data?.registrationOpen ?? false;
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: { name: "", email: "", password: "" },
|
||||||
|
validators: { onSubmit: signUpSchema },
|
||||||
|
onSubmit: async ({ value, formApi }) => {
|
||||||
|
await authClient.signUp.email(
|
||||||
|
{
|
||||||
|
name: value.name.trim(),
|
||||||
|
email: value.email.trim(),
|
||||||
|
password: value.password,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError(error) {
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
|
Alert.alert(
|
||||||
|
"Error",
|
||||||
|
error.error?.message || "Failed to create account",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
formApi.reset();
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!registrationOpen && !publicInfo.isPending) {
|
||||||
|
return (
|
||||||
|
<AuthScreen
|
||||||
|
title="Registration Closed"
|
||||||
|
subtitle="New account creation is currently disabled."
|
||||||
|
>
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||||
|
<Link href="/(auth)/login" asChild>
|
||||||
|
<Button className="mt-6 bg-primary">
|
||||||
|
<ButtonLabel className="text-primary-foreground">
|
||||||
|
Back to Login
|
||||||
|
</ButtonLabel>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Animated.View>
|
||||||
|
</AuthScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthScreen title="Create Account">
|
||||||
|
<form.Subscribe
|
||||||
|
selector={(state) => ({
|
||||||
|
isSubmitting: state.isSubmitting,
|
||||||
|
validationError: formatFormErrors(state.errorMap.onSubmit),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{({ isSubmitting, validationError }) => (
|
||||||
|
<View className="gap-3">
|
||||||
|
{validationError && (
|
||||||
|
<FieldError isInvalid className="mb-1">
|
||||||
|
{validationError}
|
||||||
|
</FieldError>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||||
|
<form.Field name="name">
|
||||||
|
{(field) => (
|
||||||
|
<TextField>
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChangeText={field.handleChange}
|
||||||
|
placeholder="Your name"
|
||||||
|
autoComplete="name"
|
||||||
|
textContentType="name"
|
||||||
|
returnKeyType="next"
|
||||||
|
blurOnSubmit={false}
|
||||||
|
onSubmitEditing={() => emailRef.current?.focus()}
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||||
|
<form.Field name="email">
|
||||||
|
{(field) => (
|
||||||
|
<TextField>
|
||||||
|
<Label>Email</Label>
|
||||||
|
<Input
|
||||||
|
ref={emailRef}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChangeText={field.handleChange}
|
||||||
|
placeholder="email@example.com"
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="email"
|
||||||
|
textContentType="emailAddress"
|
||||||
|
returnKeyType="next"
|
||||||
|
blurOnSubmit={false}
|
||||||
|
onSubmitEditing={() => passwordRef.current?.focus()}
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
|
||||||
|
<form.Field name="password">
|
||||||
|
{(field) => (
|
||||||
|
<TextField>
|
||||||
|
<Label>Password</Label>
|
||||||
|
<Input
|
||||||
|
ref={passwordRef}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChangeText={field.handleChange}
|
||||||
|
placeholder="••••••••"
|
||||||
|
secureTextEntry
|
||||||
|
autoComplete="new-password"
|
||||||
|
textContentType="newPassword"
|
||||||
|
returnKeyType="go"
|
||||||
|
onSubmitEditing={form.handleSubmit}
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
||||||
|
<Button
|
||||||
|
onPress={form.handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="mt-1 bg-primary"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
<ButtonLabel>Create Account</ButtonLabel>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</form.Subscribe>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(300).delay(500)}
|
||||||
|
className="mt-6 items-center"
|
||||||
|
>
|
||||||
|
<Link href="/(auth)/login" asChild>
|
||||||
|
<Pressable>
|
||||||
|
<Text className="text-primary text-sm">
|
||||||
|
Already have an account? Sign in
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
</Animated.View>
|
||||||
|
</AuthScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import {
|
||||||
|
IconAlertCircle,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconInfoCircle,
|
||||||
|
} from "@tabler/icons-react-native";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Linking, Pressable, TextInput, View } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
FadeIn,
|
||||||
|
FadeInDown,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import { AuthScreen } from "@/components/auth-screen";
|
||||||
|
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import {
|
||||||
|
getServerUrl,
|
||||||
|
hasStoredServerUrl,
|
||||||
|
normalizeUrl,
|
||||||
|
setServerUrl,
|
||||||
|
type ValidationError,
|
||||||
|
validateServerUrl,
|
||||||
|
} from "@/lib/server-url";
|
||||||
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
|
type ConnectionState =
|
||||||
|
| { phase: "idle" }
|
||||||
|
| { phase: "connecting" }
|
||||||
|
| { phase: "success" }
|
||||||
|
| { phase: "error"; error: ValidationError };
|
||||||
|
|
||||||
|
const ERROR_MESSAGES: Record<ValidationError, string> = {
|
||||||
|
network_unreachable:
|
||||||
|
"Could not reach the server. Check your connection and the URL.",
|
||||||
|
timeout: "Connection timed out. The server might be down or unreachable.",
|
||||||
|
not_sofa_server:
|
||||||
|
"This doesn't appear to be a Sofa server. Double-check the URL.",
|
||||||
|
server_unhealthy:
|
||||||
|
"Server found but reporting an issue. Try again in a moment.",
|
||||||
|
invalid_url: "That URL doesn't look right. Include the full server address.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ServerUrlScreen() {
|
||||||
|
const { replace } = useRouter();
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
const successTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const [url, setUrl] = useState(() =>
|
||||||
|
hasStoredServerUrl() ? getServerUrl() : "",
|
||||||
|
);
|
||||||
|
const [connection, setConnection] = useState<ConnectionState>({
|
||||||
|
phase: "idle",
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusCompletedColor = useCSSVariable(
|
||||||
|
"--color-status-completed",
|
||||||
|
) as string;
|
||||||
|
const destructiveColor = useCSSVariable("--color-destructive") as string;
|
||||||
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
|
|
||||||
|
const [isFirstLaunch] = useState(() => !hasStoredServerUrl());
|
||||||
|
|
||||||
|
// Icon pulse animation
|
||||||
|
const iconOpacity = useSharedValue(1);
|
||||||
|
const iconAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: iconOpacity.get(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Pulsing dot animation for connecting state
|
||||||
|
const dotOpacity = useSharedValue(0.4);
|
||||||
|
const dotAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: dotOpacity.get(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (connection.phase === "connecting") {
|
||||||
|
iconOpacity.set(withRepeat(withTiming(0.4, { duration: 600 }), -1, true));
|
||||||
|
dotOpacity.set(withRepeat(withTiming(1, { duration: 600 }), -1, true));
|
||||||
|
} else {
|
||||||
|
iconOpacity.set(withTiming(1, { duration: 200 }));
|
||||||
|
dotOpacity.set(0.4);
|
||||||
|
}
|
||||||
|
}, [connection.phase, iconOpacity, dotOpacity]);
|
||||||
|
|
||||||
|
// Cleanup timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (successTimeout.current !== null) clearTimeout(successTimeout.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-focus on first launch
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstLaunch) {
|
||||||
|
const timer = setTimeout(() => inputRef.current?.focus(), 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isFirstLaunch]);
|
||||||
|
|
||||||
|
const handleChangeText = (text: string) => {
|
||||||
|
setUrl(text);
|
||||||
|
if (connection.phase === "error") {
|
||||||
|
setConnection({ phase: "idle" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
const trimmed = url.trim().replace(/\/+$/, "");
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
const fullUrl = normalizeUrl(trimmed);
|
||||||
|
setConnection({ phase: "connecting" });
|
||||||
|
|
||||||
|
const result = await validateServerUrl(fullUrl);
|
||||||
|
|
||||||
|
if (result.status === "success") {
|
||||||
|
setConnection({ phase: "success" });
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
setServerUrl(fullUrl);
|
||||||
|
successTimeout.current = setTimeout(() => {
|
||||||
|
replace("/(auth)/login");
|
||||||
|
}, 800);
|
||||||
|
} else {
|
||||||
|
setConnection({ phase: "error", error: result.error });
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isConnecting = connection.phase === "connecting";
|
||||||
|
const isSuccess = connection.phase === "success";
|
||||||
|
const isDisabled = isConnecting || isSuccess;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthScreen
|
||||||
|
title="Sofa"
|
||||||
|
subtitle="Enter your Sofa server URL to get started"
|
||||||
|
logoStyle={iconAnimatedStyle}
|
||||||
|
>
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||||
|
<View
|
||||||
|
className="h-12 flex-row items-center rounded-[12px] border border-border bg-input px-3.5"
|
||||||
|
style={{ borderCurve: "continuous" }}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={url}
|
||||||
|
onChangeText={handleChangeText}
|
||||||
|
placeholder="https://sofa.example.com"
|
||||||
|
placeholderTextColorClassName="accent-muted-foreground/50"
|
||||||
|
keyboardType="url"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
textContentType="URL"
|
||||||
|
returnKeyType="go"
|
||||||
|
editable={!isDisabled}
|
||||||
|
onSubmitEditing={handleConnect}
|
||||||
|
className="flex-1 py-0 font-mono text-[15px] text-foreground"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Connect Button / Status */}
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.duration(300).delay(300)}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
{isConnecting ? (
|
||||||
|
<View className="h-12 flex-row items-center justify-center gap-2">
|
||||||
|
<Animated.View
|
||||||
|
className="size-1.5 rounded-full bg-primary"
|
||||||
|
style={dotAnimatedStyle}
|
||||||
|
/>
|
||||||
|
<Text className="text-[13px] text-muted-foreground">
|
||||||
|
Connecting to server...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : isSuccess ? (
|
||||||
|
<View className="h-12 flex-row items-center justify-center gap-1.5">
|
||||||
|
<IconCircleCheck size={16} color={statusCompletedColor} />
|
||||||
|
<Text className="font-sans-medium text-[13px] text-status-completed">
|
||||||
|
Connected
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Button onPress={handleConnect} className="bg-primary">
|
||||||
|
<ButtonLabel>Connect</ButtonLabel>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<View className="mt-3 min-h-10">
|
||||||
|
{connection.phase === "error" && (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(200)}
|
||||||
|
className="flex-row items-start gap-2 px-1"
|
||||||
|
>
|
||||||
|
<IconAlertCircle
|
||||||
|
size={16}
|
||||||
|
color={destructiveColor}
|
||||||
|
style={{ marginTop: 1 }}
|
||||||
|
/>
|
||||||
|
<Text className="flex-1 text-[13px] text-destructive">
|
||||||
|
{ERROR_MESSAGES[connection.error]}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.duration(300).delay(400)}
|
||||||
|
className="mt-4 flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => Linking.openURL("https://sofa.watch")}
|
||||||
|
className="flex-row items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<IconInfoCircle size={16} color={mutedFgColor} />
|
||||||
|
<Text className="font-sans-medium text-[13px] text-primary">
|
||||||
|
Don't have a server?
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
</AuthScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
|
||||||
|
|
||||||
|
export default function ExploreLayout() {
|
||||||
|
return <Stack screenOptions={useTabScreenOptions()} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react-native";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { RefreshControl, ScrollView, View } from "react-native";
|
||||||
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
|
|
||||||
|
import { FilterableTitleRow } from "@/components/explore/filterable-title-row";
|
||||||
|
import { HeroBanner } from "@/components/explore/hero-banner";
|
||||||
|
import { orpc } from "@/lib/orpc";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
|
||||||
|
export default function ExploreScreen() {
|
||||||
|
const trending = useQuery(
|
||||||
|
orpc.explore.trending.queryOptions({ input: { type: "all" } }),
|
||||||
|
);
|
||||||
|
const popularMovies = useQuery(
|
||||||
|
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
|
||||||
|
);
|
||||||
|
const popularTv = useQuery(
|
||||||
|
orpc.explore.popular.queryOptions({ input: { type: "tv" } }),
|
||||||
|
);
|
||||||
|
const movieGenres = useQuery(
|
||||||
|
orpc.explore.genres.queryOptions({ input: { type: "movie" } }),
|
||||||
|
);
|
||||||
|
const tvGenres = useQuery(
|
||||||
|
orpc.explore.genres.queryOptions({ input: { type: "tv" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isRefreshing =
|
||||||
|
trending.isRefetching ||
|
||||||
|
popularMovies.isRefetching ||
|
||||||
|
popularTv.isRefetching;
|
||||||
|
|
||||||
|
const onRefresh = useCallback(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.explore.key() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.discover.key() });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const heroItem = trending.data?.hero;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
className="bg-background"
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColorClassName="accent-primary"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack.Screen options={{ title: "Explore" }} />
|
||||||
|
<View className="gap-8">
|
||||||
|
{heroItem && (
|
||||||
|
<Animated.View entering={FadeIn.duration(400)}>
|
||||||
|
<HeroBanner item={heroItem} />
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||||
|
<FilterableTitleRow
|
||||||
|
title="Trending Today"
|
||||||
|
icon={IconFlame}
|
||||||
|
mediaType="movie"
|
||||||
|
defaultItems={trending.data?.items ?? []}
|
||||||
|
defaultUserStatuses={trending.data?.userStatuses ?? {}}
|
||||||
|
defaultEpisodeProgress={trending.data?.episodeProgress ?? {}}
|
||||||
|
isLoading={trending.isPending}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||||
|
<FilterableTitleRow
|
||||||
|
title="Popular Movies"
|
||||||
|
icon={IconMovie}
|
||||||
|
mediaType="movie"
|
||||||
|
defaultItems={popularMovies.data?.items ?? []}
|
||||||
|
defaultUserStatuses={popularMovies.data?.userStatuses ?? {}}
|
||||||
|
defaultEpisodeProgress={popularMovies.data?.episodeProgress ?? {}}
|
||||||
|
genres={movieGenres.data?.genres}
|
||||||
|
isLoading={popularMovies.isPending}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
|
||||||
|
<FilterableTitleRow
|
||||||
|
title="Popular TV Shows"
|
||||||
|
icon={IconDeviceTv}
|
||||||
|
mediaType="tv"
|
||||||
|
defaultItems={popularTv.data?.items ?? []}
|
||||||
|
defaultUserStatuses={popularTv.data?.userStatuses ?? {}}
|
||||||
|
defaultEpisodeProgress={popularTv.data?.episodeProgress ?? {}}
|
||||||
|
genres={tvGenres.data?.genres}
|
||||||
|
isLoading={popularTv.isPending}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
|
||||||
|
|
||||||
|
export default function HomeLayout() {
|
||||||
|
return <Stack screenOptions={useTabScreenOptions()} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import {
|
||||||
|
IconBooks,
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconThumbUp,
|
||||||
|
} from "@tabler/icons-react-native";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Stack, useRouter } from "expo-router";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { FlatList, RefreshControl, ScrollView, View } from "react-native";
|
||||||
|
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||||
|
|
||||||
|
import { ContinueWatchingCard } from "@/components/dashboard/continue-watching-card";
|
||||||
|
import { HorizontalPosterRow } from "@/components/dashboard/horizontal-poster-row";
|
||||||
|
import { StatsCard } from "@/components/dashboard/stats-card";
|
||||||
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
|
import { SectionHeader } from "@/components/ui/section-header";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { orpc } from "@/lib/orpc";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
|
||||||
|
export default function DashboardScreen() {
|
||||||
|
const { push } = useRouter();
|
||||||
|
authClient.useSession();
|
||||||
|
|
||||||
|
const stats = useQuery(orpc.dashboard.stats.queryOptions());
|
||||||
|
const continueWatching = useQuery(
|
||||||
|
orpc.dashboard.continueWatching.queryOptions(),
|
||||||
|
);
|
||||||
|
const library = useQuery(orpc.dashboard.library.queryOptions());
|
||||||
|
const recommendations = useQuery(
|
||||||
|
orpc.dashboard.recommendations.queryOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isRefreshing =
|
||||||
|
stats.isRefetching || continueWatching.isRefetching || library.isRefetching;
|
||||||
|
|
||||||
|
const onRefresh = useCallback(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasLibrary = (library.data?.items?.length ?? 0) > 0;
|
||||||
|
const hasContinueWatching = (continueWatching.data?.items?.length ?? 0) > 0;
|
||||||
|
const hasRecommendations = (recommendations.data?.items?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
const statsData = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: "Movies this month", value: stats.data?.moviesThisMonth },
|
||||||
|
{ label: "Episodes this week", value: stats.data?.episodesThisWeek },
|
||||||
|
{ label: "In library", value: stats.data?.librarySize },
|
||||||
|
{ label: "Completed", value: stats.data?.completed },
|
||||||
|
],
|
||||||
|
[stats.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
className="bg-background"
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColorClassName="accent-primary"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack.Screen options={{ title: "Home" }} />
|
||||||
|
<View className="gap-8">
|
||||||
|
{/* Stats */}
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
data={statsData}
|
||||||
|
keyExtractor={(item) => item.label}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<StatsCard label={item.label} value={item.value} />
|
||||||
|
)}
|
||||||
|
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Continue Watching */}
|
||||||
|
{hasContinueWatching && (
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||||
|
<View className="px-4">
|
||||||
|
<SectionHeader title="Continue Watching" icon={IconPlayerPlay} />
|
||||||
|
</View>
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
data={continueWatching.data?.items ?? []}
|
||||||
|
keyExtractor={(item) => item.title.id}
|
||||||
|
renderItem={({ item }) => <ContinueWatchingCard item={item} />}
|
||||||
|
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Library */}
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
|
||||||
|
<View className="px-4">
|
||||||
|
<SectionHeader title="In Your Library" icon={IconBooks} />
|
||||||
|
</View>
|
||||||
|
{library.isPending ? (
|
||||||
|
<HorizontalPosterRow items={[]} isLoading />
|
||||||
|
) : hasLibrary ? (
|
||||||
|
<HorizontalPosterRow items={library.data?.items ?? []} />
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="Your library is empty"
|
||||||
|
description="Start tracking movies and shows"
|
||||||
|
actionLabel="Explore"
|
||||||
|
onAction={() => push("/(tabs)/(explore)")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Recommendations */}
|
||||||
|
{hasRecommendations && (
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
||||||
|
<View className="px-4">
|
||||||
|
<SectionHeader title="Recommended for You" icon={IconThumbUp} />
|
||||||
|
</View>
|
||||||
|
<HorizontalPosterRow
|
||||||
|
items={recommendations.data?.items ?? []}
|
||||||
|
isLoading={recommendations.isPending}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
|
||||||
|
|
||||||
|
export default function SearchLayout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={useTabScreenOptions()}>
|
||||||
|
<Stack.Screen name="index" options={{ title: "Search" }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { IconSearch } from "@tabler/icons-react-native";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Stack, useRouter } from "expo-router";
|
||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { FlatList, View } from "react-native";
|
||||||
|
import Animated, { FadeIn } from "react-native-reanimated";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import {
|
||||||
|
type SearchResultItem,
|
||||||
|
SearchResultRow,
|
||||||
|
} from "@/components/search/search-result-row";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
import { orpc } from "@/lib/orpc";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
|
export default function SearchScreen() {
|
||||||
|
const { navigate } = useRouter();
|
||||||
|
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const debouncedQuery = useDebounce(query.trim(), 300);
|
||||||
|
|
||||||
|
const searchResults = useQuery({
|
||||||
|
...orpc.search.queryOptions({ input: { query: debouncedQuery } }),
|
||||||
|
enabled: debouncedQuery.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track which item is currently being resolved/added
|
||||||
|
const [resolvingId, setResolvingId] = useState<string | null>(null);
|
||||||
|
const [addingId, setAddingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const resolveTitleMutation = useMutation(
|
||||||
|
orpc.titles.resolve.mutationOptions({
|
||||||
|
onSuccess: ({ id }) => {
|
||||||
|
setResolvingId(null);
|
||||||
|
if (id) navigate(`/title/${id}`);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setResolvingId(null);
|
||||||
|
toast.error("Failed to load title");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolvePersonMutation = useMutation(
|
||||||
|
orpc.people.resolve.mutationOptions({
|
||||||
|
onSuccess: ({ id }) => {
|
||||||
|
setResolvingId(null);
|
||||||
|
if (id) navigate(`/person/${id}`);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setResolvingId(null);
|
||||||
|
toast.error("Failed to load person");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const quickAddMutation = useMutation(
|
||||||
|
orpc.titles.quickAdd.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
setAddingId(null);
|
||||||
|
toast.success("Added to watchlist");
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setAddingId(null);
|
||||||
|
toast.error("Failed to add to watchlist");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use refs for mutation.mutate to keep callbacks stable across renders
|
||||||
|
const resolveTitleMutateRef = useRef(resolveTitleMutation.mutate);
|
||||||
|
resolveTitleMutateRef.current = resolveTitleMutation.mutate;
|
||||||
|
const resolvePersonMutateRef = useRef(resolvePersonMutation.mutate);
|
||||||
|
resolvePersonMutateRef.current = resolvePersonMutation.mutate;
|
||||||
|
const quickAddMutateRef = useRef(quickAddMutation.mutate);
|
||||||
|
quickAddMutateRef.current = quickAddMutation.mutate;
|
||||||
|
|
||||||
|
const handleResolve = useCallback((item: SearchResultItem) => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
setResolvingId(`${item.type}-${item.tmdbId}`);
|
||||||
|
if (item.type === "person") {
|
||||||
|
resolvePersonMutateRef.current({ tmdbId: item.tmdbId });
|
||||||
|
} else {
|
||||||
|
resolveTitleMutateRef.current({ tmdbId: item.tmdbId, type: item.type });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleQuickAdd = useCallback((tmdbId: number, type: "movie" | "tv") => {
|
||||||
|
setAddingId(`${type}-${tmdbId}`);
|
||||||
|
quickAddMutateRef.current({ tmdbId, type });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Memoize mapped results to maintain stable references
|
||||||
|
const allResults = useMemo<SearchResultItem[]>(
|
||||||
|
() =>
|
||||||
|
searchResults.data?.results?.map((r) => ({
|
||||||
|
tmdbId: r.tmdbId,
|
||||||
|
title: r.title,
|
||||||
|
type: r.type,
|
||||||
|
posterPath: r.posterPath,
|
||||||
|
profilePath: r.profilePath,
|
||||||
|
releaseDate: r.releaseDate,
|
||||||
|
})) ?? [],
|
||||||
|
[searchResults.data?.results],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item }: { item: SearchResultItem }) => (
|
||||||
|
<SearchResultRow
|
||||||
|
item={item}
|
||||||
|
onResolve={handleResolve}
|
||||||
|
onQuickAdd={handleQuickAdd}
|
||||||
|
isResolving={resolvingId === `${item.type}-${item.tmdbId}`}
|
||||||
|
isAdding={addingId === `${item.type}-${item.tmdbId}`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[handleResolve, handleQuickAdd, resolvingId, addingId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyExtractor = useCallback(
|
||||||
|
(item: SearchResultItem) => `${item.type}-${item.tmdbId}`,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-background">
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
headerSearchBarOptions: {
|
||||||
|
placeholder: "Search movies, shows, people...",
|
||||||
|
onChangeText: (e) => setQuery(e.nativeEvent.text),
|
||||||
|
hideWhenScrolling: false,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{debouncedQuery.length === 0 ? (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(400)}
|
||||||
|
className="flex-1 items-center justify-center"
|
||||||
|
>
|
||||||
|
<IconSearch size={64} color={mutedForeground} />
|
||||||
|
<Text className="mt-3 text-[15px] text-muted-foreground">
|
||||||
|
Search for movies, shows, or people
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
) : searchResults.isPending ? (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<Spinner colorClassName="accent-primary" />
|
||||||
|
</View>
|
||||||
|
) : allResults.length === 0 ? (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(300)}
|
||||||
|
className="flex-1 items-center justify-center"
|
||||||
|
>
|
||||||
|
<Text className="text-[15px] text-muted-foreground">
|
||||||
|
No results for "{debouncedQuery}"
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={allResults}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
|
||||||
|
|
||||||
|
export default function SettingsLayout() {
|
||||||
|
return <Stack screenOptions={useTabScreenOptions()} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
import {
|
||||||
|
IconArrowUpRight,
|
||||||
|
IconBrandGithub,
|
||||||
|
IconCamera,
|
||||||
|
IconChartBar,
|
||||||
|
IconCloud,
|
||||||
|
IconDatabase,
|
||||||
|
IconDeviceMobileCog,
|
||||||
|
IconDots,
|
||||||
|
IconLink,
|
||||||
|
IconLogout,
|
||||||
|
IconPhoto,
|
||||||
|
IconServer,
|
||||||
|
IconShield,
|
||||||
|
IconUser,
|
||||||
|
IconUserPlus,
|
||||||
|
IconWorld,
|
||||||
|
} from "@tabler/icons-react-native";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import * as Application from "expo-application";
|
||||||
|
import * as ImagePicker from "expo-image-picker";
|
||||||
|
import { Stack, useRouter } from "expo-router";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Linking,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import { IntegrationsSection } from "@/components/settings/integrations-section";
|
||||||
|
import { SettingsRow } from "@/components/settings/settings-row";
|
||||||
|
import { SettingsSection } from "@/components/settings/settings-section";
|
||||||
|
import { TmdbLogo } from "@/components/tmdb-logo";
|
||||||
|
import { Image } from "@/components/ui/image";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { orpc } from "@/lib/orpc";
|
||||||
|
import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
import { getServerUrl } from "@/lib/server-url";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
|
export default function SettingsScreen() {
|
||||||
|
const { push } = useRouter();
|
||||||
|
const { data: session, refetch: refetchSession } = authClient.useSession();
|
||||||
|
const [isEditingName, setIsEditingName] = useState(false);
|
||||||
|
const [nameInput, setNameInput] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session?.user?.name) setNameInput(session.user.name);
|
||||||
|
}, [session?.user?.name]);
|
||||||
|
|
||||||
|
const [analyticsEnabled, setAnalyticsToggle] = useState(isAnalyticsEnabled);
|
||||||
|
const isAdmin = session?.user?.role === "admin";
|
||||||
|
const serverUrl = getServerUrl();
|
||||||
|
|
||||||
|
const systemHealth = useQuery({
|
||||||
|
...orpc.system.health.queryOptions(),
|
||||||
|
enabled: isAdmin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateName = useMutation(
|
||||||
|
orpc.account.updateName.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Name updated");
|
||||||
|
setIsEditingName(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.account.key() });
|
||||||
|
refetchSession();
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to update name"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadAvatar = useMutation(
|
||||||
|
orpc.account.uploadAvatar.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Profile picture updated");
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.account.key() });
|
||||||
|
refetchSession();
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to upload avatar"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeAvatar = useMutation(
|
||||||
|
orpc.account.removeAvatar.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Profile picture removed");
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.account.key() });
|
||||||
|
refetchSession();
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to remove profile picture"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pickAvatar = useCallback(async () => {
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ["images"],
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [1, 1],
|
||||||
|
quality: 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || !result.assets[0]) return;
|
||||||
|
|
||||||
|
const asset = result.assets[0];
|
||||||
|
const response = await fetch(asset.uri);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const file = new File([blob], asset.fileName ?? "avatar.jpg", {
|
||||||
|
type: asset.mimeType ?? "image/jpeg",
|
||||||
|
});
|
||||||
|
uploadAvatar.mutate(file);
|
||||||
|
}, [uploadAvatar]);
|
||||||
|
|
||||||
|
const hasAvatarImage = !!session?.user?.image;
|
||||||
|
|
||||||
|
const registration = useQuery({
|
||||||
|
...orpc.admin.registration.queryOptions(),
|
||||||
|
enabled: isAdmin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleRegistration = useMutation(
|
||||||
|
orpc.admin.toggleRegistration.mutationOptions({
|
||||||
|
onSuccess: (_data, { open }) => {
|
||||||
|
toast.success(open ? "Registration opened" : "Registration closed");
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: orpc.admin.registration.key(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to update registration setting"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCheck = useQuery({
|
||||||
|
...orpc.admin.updateCheck.queryOptions(),
|
||||||
|
enabled: isAdmin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleUpdateCheck = useMutation(
|
||||||
|
orpc.admin.toggleUpdateCheck.mutationOptions({
|
||||||
|
onSuccess: (_data, { enabled }) => {
|
||||||
|
toast.success(
|
||||||
|
enabled ? "Update checks enabled" : "Update checks disabled",
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: orpc.admin.updateCheck.key(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to update setting"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const primaryFgColor = useCSSVariable("--color-primary-foreground") as string;
|
||||||
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
|
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const onRefresh = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
setRefreshing(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSignOut = () => {
|
||||||
|
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Sign Out",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => {
|
||||||
|
authClient.signOut();
|
||||||
|
queryClient.clear();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
className="bg-background"
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 32,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
}}
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColorClassName="accent-primary"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack.Screen options={{ title: "Settings" }} />
|
||||||
|
|
||||||
|
{/* Account */}
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||||
|
<SettingsSection title="Account" icon={IconUser}>
|
||||||
|
<View className="flex-row items-center py-3.5">
|
||||||
|
{hasAvatarImage ? (
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger asChild>
|
||||||
|
<Pressable className="mr-3">
|
||||||
|
<View className="size-11 overflow-hidden rounded-full bg-secondary">
|
||||||
|
{uploadAvatar.isPending ? (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<Spinner size="sm" colorClassName="accent-primary" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
source={{ uri: session.user.image ?? undefined }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="absolute right-0 bottom-0 size-[18px] items-center justify-center rounded-full bg-primary">
|
||||||
|
<IconCamera size={10} color={primaryFgColor} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content>
|
||||||
|
<DropdownMenu.Item key="change" onSelect={() => pickAvatar()}>
|
||||||
|
<DropdownMenu.ItemIcon
|
||||||
|
ios={{ name: "photo.on.rectangle.angled" }}
|
||||||
|
/>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
Change Photo
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="remove"
|
||||||
|
destructive
|
||||||
|
onSelect={() => removeAvatar.mutate()}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIcon ios={{ name: "trash" }} />
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
Remove Photo
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
) : (
|
||||||
|
<Pressable onPress={pickAvatar} className="mr-3">
|
||||||
|
<View className="size-11 overflow-hidden rounded-full bg-secondary">
|
||||||
|
{uploadAvatar.isPending ? (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<Spinner size="sm" colorClassName="accent-primary" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="flex-1 items-center justify-center bg-primary/[0.08]">
|
||||||
|
<Text className="font-sans-medium text-lg text-primary">
|
||||||
|
{session?.user?.name?.charAt(0)?.toUpperCase() ?? "?"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="absolute right-0 bottom-0 size-[18px] items-center justify-center rounded-full bg-primary">
|
||||||
|
<IconCamera size={10} color={primaryFgColor} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
<View className="flex-1">
|
||||||
|
{isEditingName ? (
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<TextInput
|
||||||
|
value={nameInput}
|
||||||
|
onChangeText={setNameInput}
|
||||||
|
className="flex-1 border-primary border-b py-0.5 font-sans text-[15px] text-foreground"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => updateName.mutate({ name: nameInput })}
|
||||||
|
>
|
||||||
|
<Text className="text-primary text-sm">Save</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
setNameInput(session?.user?.name ?? "");
|
||||||
|
setIsEditingName(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-muted-foreground text-sm">
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Pressable onPress={() => setIsEditingName(true)}>
|
||||||
|
<Text className="font-sans-medium text-base text-foreground">
|
||||||
|
{session?.user?.name}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
selectable
|
||||||
|
className="mt-0.5 text-[13px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{session?.user?.email}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isAdmin && (
|
||||||
|
<View className="rounded-full bg-primary/10 px-2 py-0.5">
|
||||||
|
<Text className="font-sans-medium text-[10px] text-primary">
|
||||||
|
Admin
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
label="Sign out"
|
||||||
|
icon={IconLogout}
|
||||||
|
onPress={handleSignOut}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Server */}
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||||
|
<SettingsSection title="Application" icon={IconDeviceMobileCog}>
|
||||||
|
<SettingsRow
|
||||||
|
label="Server URL"
|
||||||
|
value={serverUrl}
|
||||||
|
icon={IconLink}
|
||||||
|
onPress={() => {
|
||||||
|
Alert.alert(
|
||||||
|
"Change Server",
|
||||||
|
"You'll be signed out to change the server URL.",
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Continue",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
await authClient.signOut();
|
||||||
|
queryClient.clear();
|
||||||
|
push("/(auth)/server-url");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label="Anonymous usage reporting"
|
||||||
|
icon={IconChartBar}
|
||||||
|
right={
|
||||||
|
<Switch
|
||||||
|
value={analyticsEnabled}
|
||||||
|
onValueChange={(enabled) => {
|
||||||
|
setAnalyticsToggle(enabled);
|
||||||
|
setAnalyticsEnabled(enabled);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Integrations */}
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
|
||||||
|
<IntegrationsSection />
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Admin: Server Health */}
|
||||||
|
{isAdmin && (
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
||||||
|
<SettingsSection
|
||||||
|
title="Server Health"
|
||||||
|
icon={IconServer}
|
||||||
|
badge="Admin"
|
||||||
|
>
|
||||||
|
{systemHealth.isPending ? (
|
||||||
|
<View className="items-center py-4">
|
||||||
|
<Spinner colorClassName="accent-primary" />
|
||||||
|
</View>
|
||||||
|
) : systemHealth.data ? (
|
||||||
|
<>
|
||||||
|
<SettingsRow
|
||||||
|
label="Database"
|
||||||
|
value={
|
||||||
|
systemHealth.data?.database
|
||||||
|
? `${systemHealth.data.database.titleCount} titles`
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
icon={IconDatabase}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label="TMDB"
|
||||||
|
value={systemHealth.data?.tmdb?.connected ? "Connected" : "—"}
|
||||||
|
icon={IconCloud}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label="Image Cache"
|
||||||
|
value={
|
||||||
|
systemHealth.data?.imageCache
|
||||||
|
? `${systemHealth.data.imageCache.imageCount} images`
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
icon={IconPhoto}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</SettingsSection>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Admin: Security */}
|
||||||
|
{isAdmin && (
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(500)}>
|
||||||
|
<SettingsSection title="Security" icon={IconShield} badge="Admin">
|
||||||
|
<SettingsRow
|
||||||
|
label="Open registration"
|
||||||
|
icon={IconUserPlus}
|
||||||
|
right={
|
||||||
|
<Switch
|
||||||
|
value={registration.data?.open ?? false}
|
||||||
|
onValueChange={(open) => toggleRegistration.mutate({ open })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label="Check for updates"
|
||||||
|
icon={IconCloud}
|
||||||
|
right={
|
||||||
|
<Switch
|
||||||
|
value={updateCheck.data?.enabled ?? false}
|
||||||
|
onValueChange={(enabled) =>
|
||||||
|
toggleUpdateCheck.mutate({ enabled })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{updateCheck.data?.updateCheck?.updateAvailable && (
|
||||||
|
<View className="py-3.5">
|
||||||
|
<Text className="font-sans-medium text-[13px] text-status-completed">
|
||||||
|
Update available: {updateCheck.data.updateCheck.latestVersion}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* More Settings */}
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
||||||
|
<SettingsSection title="More Settings" icon={IconDots}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => Linking.openURL(`${serverUrl}/settings`)}
|
||||||
|
className="flex-row items-center justify-center py-3.5 active:opacity-70"
|
||||||
|
>
|
||||||
|
<IconWorld size={18} color={mutedFgColor} />
|
||||||
|
<Text className="ml-2 flex-1 text-[15px] text-foreground">
|
||||||
|
Open in browser…
|
||||||
|
</Text>
|
||||||
|
<IconArrowUpRight size={16} color={mutedFgColor} />
|
||||||
|
</Pressable>
|
||||||
|
</SettingsSection>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Version */}
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.duration(300).delay(400)}
|
||||||
|
className="mt-6 items-center"
|
||||||
|
>
|
||||||
|
<Text className="text-muted-foreground text-xs">
|
||||||
|
Native
|
||||||
|
{Application.nativeApplicationVersion
|
||||||
|
? ` v${Application.nativeApplicationVersion}`
|
||||||
|
: ""}
|
||||||
|
{Application.nativeBuildVersion
|
||||||
|
? ` (${Application.nativeBuildVersion})`
|
||||||
|
: ""}
|
||||||
|
{updateCheck.data?.updateCheck?.currentVersion
|
||||||
|
? ` · Server v${updateCheck.data.updateCheck.currentVersion}`
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* GitHub */}
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.duration(300).delay(450)}
|
||||||
|
className="mt-3 items-center"
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => Linking.openURL("https://github.com/jakejarvis/sofa")}
|
||||||
|
className="flex-row items-center gap-1.5 active:opacity-70"
|
||||||
|
>
|
||||||
|
<IconBrandGithub size={14} color={mutedFgColor} />
|
||||||
|
<Text className="text-muted-foreground text-xs">GitHub</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* TMDB Attribution */}
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.duration(300).delay(500)}
|
||||||
|
className="mt-4 items-center gap-2"
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => Linking.openURL("https://www.themoviedb.org/")}
|
||||||
|
className="items-center gap-2 active:opacity-70"
|
||||||
|
>
|
||||||
|
<TmdbLogo height={12} />
|
||||||
|
<Text className="text-center text-[10px] text-muted-foreground leading-relaxed">
|
||||||
|
This product uses the TMDB API but is not endorsed or certified by
|
||||||
|
TMDB.
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
|
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NativeTabs
|
||||||
|
iconColor={{
|
||||||
|
default: mutedFgColor,
|
||||||
|
selected: primaryColor,
|
||||||
|
}}
|
||||||
|
labelStyle={{
|
||||||
|
default: { color: mutedFgColor },
|
||||||
|
selected: { color: primaryColor },
|
||||||
|
}}
|
||||||
|
screenListeners={{
|
||||||
|
tabPress: () => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NativeTabs.Trigger name="(home)">
|
||||||
|
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||||
|
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="(explore)">
|
||||||
|
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
|
||||||
|
<NativeTabs.Trigger.Icon sf="safari" md="explore" />
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="(search)" role="search" />
|
||||||
|
<NativeTabs.Trigger name="(settings)">
|
||||||
|
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
||||||
|
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
</NativeTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { IconAlertTriangle } from "@tabler/icons-react-native";
|
||||||
|
import { Link, Stack } from "expo-router";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import { Container } from "@/components/container";
|
||||||
|
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
export default function NotFoundScreen() {
|
||||||
|
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: "Not Found" }} />
|
||||||
|
<Container>
|
||||||
|
<View className="flex-1 items-center justify-center p-4">
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(400)}
|
||||||
|
className="items-center"
|
||||||
|
>
|
||||||
|
<IconAlertTriangle size={48} color={mutedForeground} />
|
||||||
|
<Text className="mt-3 font-display text-foreground text-xl">
|
||||||
|
Page Not Found
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 mb-4 text-center text-muted-foreground text-sm">
|
||||||
|
The page you're looking for doesn't exist.
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||||
|
<Link href="/" asChild>
|
||||||
|
<Button size="sm" className="bg-primary">
|
||||||
|
<ButtonLabel className="text-primary-foreground">
|
||||||
|
Go Home
|
||||||
|
</ButtonLabel>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import "@/global.css";
|
||||||
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
|
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
|
||||||
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { PostHogErrorBoundary, PostHogProvider } from "posthog-react-native";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
|
import { Uniwind, useResolveClassNames } from "uniwind";
|
||||||
|
|
||||||
|
import { OfflineBanner } from "@/components/ui/offline-banner";
|
||||||
|
import { ToastProvider } from "@/components/ui/toast-provider";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { queryPersister } from "@/lib/mmkv";
|
||||||
|
import { posthog } from "@/lib/posthog";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
import { hasStoredServerUrl, onServerUrlChange } from "@/lib/server-url";
|
||||||
|
|
||||||
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
|
export const unstable_settings = {
|
||||||
|
initialRouteName: "(tabs)",
|
||||||
|
};
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const contentStyle = useResolveClassNames("bg-background");
|
||||||
|
|
||||||
|
// Force re-render when server URL changes so useSession()
|
||||||
|
// re-subscribes to the rebuilt authClient's session atom.
|
||||||
|
const [, setUrlVersion] = useState(0);
|
||||||
|
useEffect(() => onServerUrlChange(() => setUrlVersion((n) => n + 1)), []);
|
||||||
|
|
||||||
|
const { data: session, isPending } = authClient.useSession();
|
||||||
|
const hasServerUrl =
|
||||||
|
!!process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl();
|
||||||
|
|
||||||
|
// --- PostHog screen tracking ---
|
||||||
|
const pathname = usePathname();
|
||||||
|
const params = useGlobalSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (posthog && pathname) {
|
||||||
|
posthog.screen(pathname, params);
|
||||||
|
}
|
||||||
|
}, [pathname, params]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Uniwind.setTheme("dark");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPending || !hasServerUrl) {
|
||||||
|
SplashScreen.hideAsync();
|
||||||
|
}
|
||||||
|
}, [isPending, hasServerUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
<OfflineBanner />
|
||||||
|
<ToastProvider />
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle,
|
||||||
|
animation: "slide_from_right",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Protected guard={!session}>
|
||||||
|
<Stack.Screen name="(auth)" />
|
||||||
|
</Stack.Protected>
|
||||||
|
|
||||||
|
<Stack.Protected guard={!!session}>
|
||||||
|
<Stack.Screen name="(tabs)" />
|
||||||
|
<Stack.Screen
|
||||||
|
name="title/[id]"
|
||||||
|
dangerouslySingular
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerTransparent: true,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTintColor: "white",
|
||||||
|
headerBackButtonDisplayMode: "minimal",
|
||||||
|
headerTitle: "",
|
||||||
|
animation: "slide_from_right",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="person/[id]"
|
||||||
|
dangerouslySingular
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerTransparent: true,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTintColor: "white",
|
||||||
|
headerBackButtonDisplayMode: "minimal",
|
||||||
|
headerTitle: "",
|
||||||
|
animation: "slide_from_right",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack.Protected>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const inner = (
|
||||||
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{ persister: queryPersister }}
|
||||||
|
>
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<KeyboardProvider>
|
||||||
|
<AppContent />
|
||||||
|
</KeyboardProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
</PersistQueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!posthog) return inner;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PostHogProvider client={posthog} autocapture={{ captureScreens: false }}>
|
||||||
|
<PostHogErrorBoundary>{inner}</PostHogErrorBoundary>
|
||||||
|
</PostHogProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import {
|
||||||
|
IconAlertTriangle,
|
||||||
|
IconMovie,
|
||||||
|
IconUser,
|
||||||
|
} from "@tabler/icons-react-native";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { FlatList, Pressable, useWindowDimensions, View } from "react-native";
|
||||||
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import { ExpandableText } from "@/components/ui/expandable-text";
|
||||||
|
import { Image } from "@/components/ui/image";
|
||||||
|
import { PosterCard } from "@/components/ui/poster-card";
|
||||||
|
import { SectionHeader } from "@/components/ui/section-header";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { orpc } from "@/lib/orpc";
|
||||||
|
|
||||||
|
const FILMOGRAPHY_GAP = 12;
|
||||||
|
const FILMOGRAPHY_PADDING = 16;
|
||||||
|
|
||||||
|
export default function PersonDetailScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { back } = useRouter();
|
||||||
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
|
const columnWidth =
|
||||||
|
(screenWidth - FILMOGRAPHY_PADDING * 2 - FILMOGRAPHY_GAP) / 2;
|
||||||
|
|
||||||
|
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
||||||
|
|
||||||
|
const { data, isPending, isError } = useQuery(
|
||||||
|
orpc.people.detail.queryOptions({ input: { id } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const person = data?.person;
|
||||||
|
const filmography = data?.filmography ?? [];
|
||||||
|
|
||||||
|
const renderFilmographyItem = useCallback(
|
||||||
|
({ item: credit }: { item: (typeof filmography)[number] }) => (
|
||||||
|
<View style={{ width: columnWidth }}>
|
||||||
|
<PosterCard
|
||||||
|
id={credit.titleId}
|
||||||
|
tmdbId={credit.tmdbId}
|
||||||
|
title={credit.title}
|
||||||
|
type={credit.type}
|
||||||
|
posterPath={credit.posterPath}
|
||||||
|
releaseDate={credit.releaseDate ?? credit.firstAirDate}
|
||||||
|
voteAverage={credit.voteAverage}
|
||||||
|
userStatus={data?.userStatuses?.[credit.titleId] ?? null}
|
||||||
|
width={undefined}
|
||||||
|
/>
|
||||||
|
{credit.character ? (
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className="mt-1 text-center text-[11px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
as {credit.character}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[columnWidth, data?.userStatuses],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex-1 items-center bg-background"
|
||||||
|
style={{ paddingTop: insets.top + 56 }}
|
||||||
|
>
|
||||||
|
{/* Profile photo skeleton */}
|
||||||
|
<Skeleton width={120} height={120} borderRadius={60} />
|
||||||
|
{/* Name skeleton */}
|
||||||
|
<Skeleton
|
||||||
|
width={180}
|
||||||
|
height={28}
|
||||||
|
borderRadius={6}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
/>
|
||||||
|
{/* Department badge skeleton */}
|
||||||
|
<Skeleton
|
||||||
|
width={80}
|
||||||
|
height={24}
|
||||||
|
borderRadius={12}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
{/* Bio skeleton */}
|
||||||
|
<View className="mt-6 gap-2 self-stretch px-4">
|
||||||
|
<Skeleton width="100%" height={14} />
|
||||||
|
<Skeleton width="100%" height={14} />
|
||||||
|
<Skeleton width="60%" height={14} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError && !data) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex-1 items-center justify-center bg-background"
|
||||||
|
style={{ paddingTop: insets.top }}
|
||||||
|
>
|
||||||
|
<Animated.View entering={FadeIn.duration(400)} className="items-center">
|
||||||
|
<IconAlertTriangle size={48} color={mutedForeground} />
|
||||||
|
<Text className="mt-3 font-display text-foreground text-xl">
|
||||||
|
Something went wrong
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-center text-muted-foreground text-sm">
|
||||||
|
Could not load person details
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={() => back()} className="mt-4">
|
||||||
|
<Text className="text-primary">Go back</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!person) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex-1 items-center justify-center bg-background"
|
||||||
|
style={{ paddingTop: insets.top }}
|
||||||
|
>
|
||||||
|
<Animated.View entering={FadeIn.duration(400)} className="items-center">
|
||||||
|
<IconUser size={48} color={mutedForeground} />
|
||||||
|
<Text className="mt-3 font-display text-foreground text-xl">
|
||||||
|
Person not found
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={() => back()} className="mt-4">
|
||||||
|
<Text className="text-primary">Go back</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listHeader = (
|
||||||
|
<>
|
||||||
|
{/* Profile hero */}
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(400)}
|
||||||
|
className="items-center"
|
||||||
|
style={{ paddingTop: insets.top + 56, paddingBottom: 24 }}
|
||||||
|
>
|
||||||
|
<View className="size-[120px] overflow-hidden rounded-full bg-secondary">
|
||||||
|
{person.profilePath && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: person.profilePath }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
selectable
|
||||||
|
className="mt-4 text-center font-display text-[28px] text-foreground"
|
||||||
|
>
|
||||||
|
{person.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{person.knownForDepartment ? (
|
||||||
|
<View className="mt-2 rounded-full bg-secondary px-3 py-1">
|
||||||
|
<Text className="text-muted-foreground text-xs">
|
||||||
|
{person.knownForDepartment}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{person.birthday || person.deathday ? (
|
||||||
|
<Text selectable className="mt-2 text-[13px] text-muted-foreground">
|
||||||
|
{person.birthday?.slice(0, 4)}
|
||||||
|
{person.deathday ? ` — ${person.deathday.slice(0, 4)}` : ""}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Biography */}
|
||||||
|
{person.biography ? (
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||||
|
<View className="mb-6 px-4">
|
||||||
|
<ExpandableText text={person.biography} maxLines={4} />
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Filmography section header */}
|
||||||
|
{filmography.length > 0 && (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.duration(300).delay(200)}
|
||||||
|
className="px-4"
|
||||||
|
>
|
||||||
|
<SectionHeader title="Filmography" icon={IconMovie} />
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: person?.name ?? "",
|
||||||
|
headerTransparent: true,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTintColor: "white",
|
||||||
|
headerBackButtonDisplayMode: "minimal",
|
||||||
|
headerTitle: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FlatList
|
||||||
|
data={filmography}
|
||||||
|
keyExtractor={(item) => item.titleId}
|
||||||
|
renderItem={renderFilmographyItem}
|
||||||
|
numColumns={2}
|
||||||
|
columnWrapperStyle={{
|
||||||
|
gap: FILMOGRAPHY_GAP,
|
||||||
|
paddingHorizontal: FILMOGRAPHY_PADDING,
|
||||||
|
}}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 32,
|
||||||
|
gap: FILMOGRAPHY_GAP,
|
||||||
|
}}
|
||||||
|
ListHeaderComponent={listHeader}
|
||||||
|
className="bg-background"
|
||||||
|
initialNumToRender={6}
|
||||||
|
maxToRenderPerBatch={8}
|
||||||
|
windowSize={5}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
import {
|
||||||
|
IconCheck,
|
||||||
|
IconList,
|
||||||
|
IconMovie,
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconStarFilled,
|
||||||
|
IconThumbUp,
|
||||||
|
IconUsers,
|
||||||
|
} from "@tabler/icons-react-native";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import * as WebBrowser from "expo-web-browser";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
FlatList,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import { CastCard } from "@/components/titles/cast-card";
|
||||||
|
import { ContinueWatchingBanner } from "@/components/titles/continue-watching-banner";
|
||||||
|
import { SeasonAccordion } from "@/components/titles/season-accordion";
|
||||||
|
import { StatusActionButton } from "@/components/titles/status-action-button";
|
||||||
|
import { ExpandableText } from "@/components/ui/expandable-text";
|
||||||
|
import { Image } from "@/components/ui/image";
|
||||||
|
import { PosterCard } from "@/components/ui/poster-card";
|
||||||
|
import { SectionHeader } from "@/components/ui/section-header";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { StarRating } from "@/components/ui/star-rating";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { orpc } from "@/lib/orpc";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
|
export default function TitleDetailScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { back } = useRouter();
|
||||||
|
|
||||||
|
const [primary, mutedForeground, primaryForeground] = useCSSVariable([
|
||||||
|
"--color-primary",
|
||||||
|
"--color-muted-foreground",
|
||||||
|
"--color-primary-foreground",
|
||||||
|
]) as [string, string, string];
|
||||||
|
|
||||||
|
const detail = useQuery(orpc.titles.detail.queryOptions({ input: { id } }));
|
||||||
|
const userInfo = useQuery(
|
||||||
|
orpc.titles.userInfo.queryOptions({ input: { id } }),
|
||||||
|
);
|
||||||
|
const recommendations = useQuery(
|
||||||
|
orpc.titles.recommendations.queryOptions({ input: { id } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateStatus = useMutation(
|
||||||
|
orpc.titles.updateStatus.mutationOptions({
|
||||||
|
onSuccess: (_data, { status }) => {
|
||||||
|
const statusMessages: Record<string, string> = {
|
||||||
|
watchlist: "Added to watchlist",
|
||||||
|
in_progress: "Marked as watching",
|
||||||
|
completed: "Marked as completed",
|
||||||
|
};
|
||||||
|
toast.success(
|
||||||
|
status
|
||||||
|
? (statusMessages[status] ?? "Status updated")
|
||||||
|
: "Removed from library",
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to update status"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateRating = useMutation(
|
||||||
|
orpc.titles.updateRating.mutationOptions({
|
||||||
|
onSuccess: (_data, { stars }) => {
|
||||||
|
toast.success(
|
||||||
|
stars > 0
|
||||||
|
? `Rated ${stars} star${stars > 1 ? "s" : ""}`
|
||||||
|
: "Rating removed",
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to update rating"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const watchMovie = useMutation(
|
||||||
|
orpc.titles.watchMovie.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(
|
||||||
|
title?.title
|
||||||
|
? `Marked "${title.title}" as watched`
|
||||||
|
: "Marked as watched",
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to mark as watched"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const watchAll = useMutation(
|
||||||
|
orpc.titles.watchAll.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Marked all episodes as watched");
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to mark all episodes as watched"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const quickAddMutation = useMutation(
|
||||||
|
orpc.titles.quickAdd.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Added to watchlist");
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to add to watchlist"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hydrateMutation = useMutation(
|
||||||
|
orpc.titles.hydrateSeasons.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const onRefresh = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||||
|
setRefreshing(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const title = detail.data?.title;
|
||||||
|
const seasons = detail.data?.seasons ?? [];
|
||||||
|
const cast = detail.data?.cast ?? [];
|
||||||
|
const availability = detail.data?.availability ?? [];
|
||||||
|
const watchedEpisodeIds = useMemo(
|
||||||
|
() => new Set(userInfo.data?.episodeWatches ?? []),
|
||||||
|
[userInfo.data?.episodeWatches],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hydratedTitleId = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
detail.data?.needsHydration &&
|
||||||
|
title?.type === "tv" &&
|
||||||
|
hydratedTitleId.current !== id
|
||||||
|
) {
|
||||||
|
hydratedTitleId.current = id;
|
||||||
|
hydrateMutation.mutate({ id, tmdbId: title.tmdbId });
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
detail.data?.needsHydration,
|
||||||
|
title?.type,
|
||||||
|
title?.tmdbId,
|
||||||
|
id,
|
||||||
|
hydrateMutation.mutate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (detail.isPending) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-background">
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerTransparent: true,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTintColor: "white",
|
||||||
|
headerBackButtonDisplayMode: "minimal",
|
||||||
|
headerTitle: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Hero skeleton */}
|
||||||
|
<Skeleton width="100%" height={300} borderRadius={0} />
|
||||||
|
{/* Genre chips skeleton */}
|
||||||
|
<View className="mt-3 flex-row gap-2 px-4">
|
||||||
|
<Skeleton width={60} height={24} borderRadius={12} />
|
||||||
|
<Skeleton width={80} height={24} borderRadius={12} />
|
||||||
|
<Skeleton width={50} height={24} borderRadius={12} />
|
||||||
|
</View>
|
||||||
|
{/* Actions skeleton */}
|
||||||
|
<View className="mt-4 flex-row gap-2 px-4">
|
||||||
|
<Skeleton width={100} height={36} borderRadius={18} />
|
||||||
|
<Skeleton width={90} height={36} borderRadius={18} />
|
||||||
|
<Skeleton width={105} height={36} borderRadius={18} />
|
||||||
|
</View>
|
||||||
|
{/* Overview skeleton */}
|
||||||
|
<View className="mt-5 gap-2 px-4">
|
||||||
|
<Skeleton width="100%" height={14} />
|
||||||
|
<Skeleton width="100%" height={14} />
|
||||||
|
<Skeleton width="70%" height={14} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center bg-background px-6">
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerTransparent: true,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTintColor: "white",
|
||||||
|
headerBackButtonDisplayMode: "minimal",
|
||||||
|
headerTitle: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconMovie size={48} color={mutedForeground} />
|
||||||
|
<Text className="mt-3 font-display text-foreground text-xl">
|
||||||
|
Title not found
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={() => back()} className="mt-4">
|
||||||
|
<Text className="text-primary">Go back</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = (title.releaseDate ?? title.firstAirDate)?.slice(0, 4);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
className="bg-background"
|
||||||
|
contentContainerStyle={{ paddingBottom: insets.bottom + 32 }}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColorClassName="accent-primary"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: title?.title ?? "",
|
||||||
|
headerTransparent: true,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTintColor: "white",
|
||||||
|
headerBackButtonDisplayMode: "minimal",
|
||||||
|
headerTitle: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<View className="h-[300px]">
|
||||||
|
{title.backdropPath && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: title.backdropPath }}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
position: "absolute",
|
||||||
|
}}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.55)" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{title.trailerVideoKey && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() =>
|
||||||
|
WebBrowser.openBrowserAsync(
|
||||||
|
`https://www.youtube.com/watch?v=${title.trailerVideoKey}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="absolute inset-0 items-center justify-center"
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="h-14 w-14 items-center justify-center rounded-full"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
|
||||||
|
>
|
||||||
|
<IconPlayerPlay size={28} color="white" fill="white" />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="absolute right-0 bottom-0 left-0 flex-row items-end p-4">
|
||||||
|
{title.posterPath && (
|
||||||
|
<View
|
||||||
|
className="mr-3 h-[150px] w-[100px] overflow-hidden rounded-lg"
|
||||||
|
style={{ borderCurve: "continuous" }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: title.posterPath }}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className="flex-1 pb-1">
|
||||||
|
<Text
|
||||||
|
selectable
|
||||||
|
className="font-display text-2xl text-white"
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{title.title}
|
||||||
|
</Text>
|
||||||
|
<View className="mt-1.5 flex-row flex-wrap items-center gap-2">
|
||||||
|
<View className="rounded-full bg-primary px-2 py-0.5">
|
||||||
|
<Text className="font-sans-medium text-[10px] text-primary-foreground">
|
||||||
|
{title.type === "movie" ? "Movie" : "TV"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{year ? (
|
||||||
|
<Text className="text-[13px] text-white/70">{year}</Text>
|
||||||
|
) : null}
|
||||||
|
{title.contentRating ? (
|
||||||
|
<Text className="text-white/50 text-xs">
|
||||||
|
{title.contentRating}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{title.voteAverage != null && title.voteAverage > 0 && (
|
||||||
|
<View className="flex-row items-center gap-0.5">
|
||||||
|
<IconStarFilled size={12} color={primary} />
|
||||||
|
<Text className="text-primary text-xs">
|
||||||
|
{title.voteAverage.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Genres */}
|
||||||
|
{title.genres && title.genres.length > 0 && (
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
className="mt-3"
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||||
|
>
|
||||||
|
{title.genres.map((genre: string) => (
|
||||||
|
<View
|
||||||
|
key={genre}
|
||||||
|
className="mr-2 rounded-full bg-secondary px-2.5 py-1"
|
||||||
|
>
|
||||||
|
<Text className="text-[11px] text-muted-foreground">
|
||||||
|
{genre}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.duration(300).delay(200)}
|
||||||
|
className="mt-4 px-4"
|
||||||
|
>
|
||||||
|
<StatusActionButton
|
||||||
|
currentStatus={userInfo.data?.status ?? null}
|
||||||
|
onStatusChange={(status) => {
|
||||||
|
if (status === null) {
|
||||||
|
updateStatus.mutate({ id, status: null });
|
||||||
|
} else if (status === "watchlist") {
|
||||||
|
quickAddMutation.mutate({
|
||||||
|
tmdbId: title.tmdbId,
|
||||||
|
type: title.type,
|
||||||
|
});
|
||||||
|
} else if (status === "completed" && title.type === "movie") {
|
||||||
|
watchMovie.mutate({ id });
|
||||||
|
} else if (status === "completed" && title.type === "tv") {
|
||||||
|
watchAll.mutate({ id });
|
||||||
|
} else {
|
||||||
|
updateStatus.mutate({ id, status });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isPending={
|
||||||
|
updateStatus.isPending ||
|
||||||
|
quickAddMutation.isPending ||
|
||||||
|
watchMovie.isPending ||
|
||||||
|
watchAll.isPending
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="mt-4 flex-row items-center justify-between">
|
||||||
|
<StarRating
|
||||||
|
rating={userInfo.data?.rating ?? 0}
|
||||||
|
onRate={(stars) => updateRating.mutate({ id, stars })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{title.type === "movie" && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => watchMovie.mutate({ id })}
|
||||||
|
disabled={watchMovie.isPending}
|
||||||
|
className="flex-row items-center gap-1.5 rounded-full bg-primary px-4 py-2"
|
||||||
|
>
|
||||||
|
{watchMovie.isPending ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconCheck size={16} color={primaryForeground} />
|
||||||
|
<Text className="font-sans-medium text-[13px] text-primary-foreground">
|
||||||
|
Mark Watched
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Overview */}
|
||||||
|
{title.overview ? (
|
||||||
|
<Animated.View entering={FadeIn.duration(300).delay(300)}>
|
||||||
|
<View className="mt-5 px-4">
|
||||||
|
<ExpandableText text={title.overview} />
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Availability */}
|
||||||
|
{availability.length > 0 && (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.duration(300).delay(400)}
|
||||||
|
className="mt-6"
|
||||||
|
>
|
||||||
|
<View className="px-4">
|
||||||
|
<SectionHeader title="Where to Watch" icon={IconPlayerPlay} />
|
||||||
|
</View>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ gap: 8, paddingHorizontal: 16 }}
|
||||||
|
>
|
||||||
|
{availability.map((offer) => (
|
||||||
|
<View
|
||||||
|
key={`${offer.providerId}-${offer.offerType}`}
|
||||||
|
className="items-center"
|
||||||
|
>
|
||||||
|
{offer.logoPath && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: offer.logoPath }}
|
||||||
|
style={{ width: 44, height: 44, borderRadius: 10 }}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
className="mt-1 max-w-[60px] text-center text-[10px] text-muted-foreground"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{offer.providerName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Continue Watching */}
|
||||||
|
{title.type === "tv" && (
|
||||||
|
<ContinueWatchingBanner
|
||||||
|
seasons={seasons}
|
||||||
|
watchedEpisodeIds={watchedEpisodeIds}
|
||||||
|
userStatus={userInfo.data?.status ?? null}
|
||||||
|
backdropPath={title.backdropPath}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Seasons & Episodes */}
|
||||||
|
{title.type === "tv" && seasons.length > 0 && (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.duration(300).delay(400)}
|
||||||
|
className="mt-6 px-4"
|
||||||
|
>
|
||||||
|
<SectionHeader title="Seasons" icon={IconList} />
|
||||||
|
{seasons.map((season) => (
|
||||||
|
<SeasonAccordion
|
||||||
|
key={season.id}
|
||||||
|
season={season}
|
||||||
|
episodes={season.episodes ?? []}
|
||||||
|
watchedEpisodeIds={watchedEpisodeIds}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hydrateMutation.isPending && (
|
||||||
|
<View className="items-center py-6">
|
||||||
|
<Spinner colorClassName="accent-primary" />
|
||||||
|
<Text className="mt-2 text-[13px] text-muted-foreground">
|
||||||
|
Loading season data...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cast */}
|
||||||
|
{cast.length > 0 && (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.duration(300).delay(500)}
|
||||||
|
className="mt-6"
|
||||||
|
>
|
||||||
|
<View className="px-4">
|
||||||
|
<SectionHeader title="Cast" icon={IconUsers} />
|
||||||
|
</View>
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
data={cast}
|
||||||
|
keyExtractor={(item, index) => `${item.id}-${index}`}
|
||||||
|
renderItem={({ item }) => <CastCard person={item} />}
|
||||||
|
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recommendations */}
|
||||||
|
{recommendations.data &&
|
||||||
|
recommendations.data.recommendations?.length > 0 && (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.duration(300).delay(600)}
|
||||||
|
className="mt-6"
|
||||||
|
>
|
||||||
|
<View className="px-4">
|
||||||
|
<SectionHeader title="More Like This" icon={IconThumbUp} />
|
||||||
|
</View>
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
data={recommendations.data.recommendations}
|
||||||
|
keyExtractor={(item) => item.id ?? String(item.tmdbId)}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<PosterCard
|
||||||
|
id={item.id}
|
||||||
|
tmdbId={item.tmdbId}
|
||||||
|
title={item.title}
|
||||||
|
type={item.type}
|
||||||
|
posterPath={item.posterPath}
|
||||||
|
releaseDate={item.releaseDate ?? item.firstAirDate}
|
||||||
|
voteAverage={item.voteAverage}
|
||||||
|
userStatus={
|
||||||
|
item.id
|
||||||
|
? (recommendations.data?.userStatuses?.[item.id] ?? null)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { StyleProp, ViewStyle } from "react-native";
|
||||||
|
import { ScrollView } from "react-native";
|
||||||
|
import Animated, { FadeIn } from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { SofaLogo } from "@/components/ui/sofa-logo";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
interface AuthScreenProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
logoStyle?: StyleProp<ViewStyle>;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthScreen({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
logoStyle,
|
||||||
|
children,
|
||||||
|
}: AuthScreenProps) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingTop: insets.top,
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
|
}}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
bounces={false}
|
||||||
|
className="bg-background"
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(400)}
|
||||||
|
className="mb-4 items-center"
|
||||||
|
>
|
||||||
|
{logoStyle ? (
|
||||||
|
<Animated.View style={logoStyle}>
|
||||||
|
<SofaLogo size={48} />
|
||||||
|
</Animated.View>
|
||||||
|
) : (
|
||||||
|
<SofaLogo size={48} />
|
||||||
|
)}
|
||||||
|
<Text className="mt-1 font-display text-[32px] text-foreground">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle && (
|
||||||
|
<Text className="mt-2 text-muted-foreground text-sm">{subtitle}</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import {
|
||||||
|
ScrollView,
|
||||||
|
type ScrollViewProps,
|
||||||
|
View,
|
||||||
|
type ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import Animated, { type AnimatedProps } from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { cn } from "@/utils/cn";
|
||||||
|
|
||||||
|
const AnimatedView = Animated.createAnimatedComponent(View);
|
||||||
|
|
||||||
|
type Props = AnimatedProps<ViewProps> & {
|
||||||
|
className?: string;
|
||||||
|
isScrollable?: boolean;
|
||||||
|
scrollViewProps?: Omit<ScrollViewProps, "contentContainerStyle">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Container({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
isScrollable = true,
|
||||||
|
scrollViewProps,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<Props>) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedView
|
||||||
|
className={cn("flex-1 bg-background", className)}
|
||||||
|
{...props}
|
||||||
|
style={[{ paddingBottom: insets.bottom }, props.style]}
|
||||||
|
>
|
||||||
|
{isScrollable ? (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ flexGrow: 1 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
{...scrollViewProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<View className="flex-1">{children}</View>
|
||||||
|
)}
|
||||||
|
</AnimatedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { Link } from "expo-router";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
import Animated, {
|
||||||
|
interpolate,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withSpring,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { Image } from "@/components/ui/image";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
export interface ContinueWatchingItem {
|
||||||
|
title: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
backdropPath: string | null;
|
||||||
|
};
|
||||||
|
watchedEpisodes: number;
|
||||||
|
totalEpisodes: number;
|
||||||
|
nextEpisode?: {
|
||||||
|
seasonNumber: number;
|
||||||
|
episodeNumber: number;
|
||||||
|
name: string | null;
|
||||||
|
stillPath: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
||||||
|
const pressed = useSharedValue(0);
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.97]) }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tapGesture = Gesture.Tap()
|
||||||
|
.onBegin(() => {
|
||||||
|
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
|
||||||
|
})
|
||||||
|
.onFinalize(() => {
|
||||||
|
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/title/${item.title.id}` as `/title/${string}`}>
|
||||||
|
<Link.Trigger>
|
||||||
|
<GestureDetector gesture={tapGesture}>
|
||||||
|
<Animated.View
|
||||||
|
className="w-[200px] overflow-hidden rounded-[12px] border bg-card"
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
borderColor: "rgba(255,255,255,0.06)",
|
||||||
|
borderCurve: "continuous",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View className="h-28 w-[200px]">
|
||||||
|
{(item.nextEpisode?.stillPath || item.title.backdropPath) && (
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: (item.nextEpisode?.stillPath ??
|
||||||
|
item.title.backdropPath) as string,
|
||||||
|
}}
|
||||||
|
className="h-full w-full"
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.4)" }}
|
||||||
|
/>
|
||||||
|
{item.nextEpisode && (
|
||||||
|
<View className="absolute right-2.5 bottom-3 left-2.5">
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className="font-sans-medium text-[11px] text-white/60"
|
||||||
|
>
|
||||||
|
S{item.nextEpisode.seasonNumber} E
|
||||||
|
{item.nextEpisode.episodeNumber}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
className="absolute right-0 bottom-0 left-0 h-[3px]"
|
||||||
|
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="h-full bg-status-watching"
|
||||||
|
style={{
|
||||||
|
width: `${item.totalEpisodes > 0 ? (item.watchedEpisodes / item.totalEpisodes) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="p-2.5">
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className="font-sans-medium text-[13px] text-foreground"
|
||||||
|
>
|
||||||
|
{item.title.title}
|
||||||
|
</Text>
|
||||||
|
{item.nextEpisode && (
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className="mt-0.5 text-[11px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{item.nextEpisode.name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
</Link.Trigger>
|
||||||
|
<Link.Preview />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { FlatList } from "react-native";
|
||||||
|
|
||||||
|
import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
|
||||||
|
|
||||||
|
export interface PosterRowItem {
|
||||||
|
id: string;
|
||||||
|
tmdbId: number;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
posterPath: string | null;
|
||||||
|
releaseDate?: string | null;
|
||||||
|
firstAirDate?: string | null;
|
||||||
|
voteAverage?: number | null;
|
||||||
|
userStatus?: "watchlist" | "in_progress" | "completed" | null;
|
||||||
|
episodeProgress?: { watched: number; total: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HorizontalPosterRow({
|
||||||
|
items,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
items: PosterRowItem[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
}) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
data={[1, 2, 3, 4]}
|
||||||
|
keyExtractor={(item) => String(item)}
|
||||||
|
renderItem={() => <PosterCardSkeleton />}
|
||||||
|
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<PosterCard
|
||||||
|
id={item.id}
|
||||||
|
tmdbId={item.tmdbId}
|
||||||
|
title={item.title}
|
||||||
|
type={item.type as "movie" | "tv"}
|
||||||
|
posterPath={item.posterPath}
|
||||||
|
releaseDate={item.releaseDate ?? item.firstAirDate}
|
||||||
|
voteAverage={item.voteAverage}
|
||||||
|
userStatus={item.userStatus}
|
||||||
|
episodeProgress={item.episodeProgress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
export function StatsCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | undefined;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="min-w-[120px] rounded-xl border border-white/[0.06] bg-card px-4 py-3"
|
||||||
|
style={{ borderCurve: "continuous" }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="font-sans-bold text-[24px] text-primary"
|
||||||
|
style={{ fontVariant: ["tabular-nums"] }}
|
||||||
|
>
|
||||||
|
{value ?? "—"}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-0.5 text-[11px] text-muted-foreground">{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { Icon } from "@tabler/icons-react-native";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { FlatList, ScrollView, View } from "react-native";
|
||||||
|
import { GenreChip } from "@/components/explore/genre-chip";
|
||||||
|
import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
|
||||||
|
import { SectionHeader } from "@/components/ui/section-header";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { orpc } from "@/lib/orpc";
|
||||||
|
|
||||||
|
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
||||||
|
|
||||||
|
export function FilterableTitleRow({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
mediaType,
|
||||||
|
defaultItems,
|
||||||
|
defaultUserStatuses,
|
||||||
|
defaultEpisodeProgress,
|
||||||
|
genres,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: Icon;
|
||||||
|
mediaType: "movie" | "tv";
|
||||||
|
defaultItems: Array<{
|
||||||
|
tmdbId: number;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
posterPath: string | null;
|
||||||
|
releaseDate?: string | null;
|
||||||
|
firstAirDate?: string | null;
|
||||||
|
voteAverage?: number | null;
|
||||||
|
}>;
|
||||||
|
defaultUserStatuses: Record<string, TitleStatus>;
|
||||||
|
defaultEpisodeProgress: Record<string, { watched: number; total: number }>;
|
||||||
|
genres?: Array<{ id: number; name: string }>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}) {
|
||||||
|
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const discover = useQuery({
|
||||||
|
...orpc.discover.queryOptions({
|
||||||
|
input: { type: mediaType, genreId: selectedGenre ?? 0 },
|
||||||
|
}),
|
||||||
|
enabled: selectedGenre !== null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items =
|
||||||
|
selectedGenre === null ? defaultItems : (discover.data?.items ?? []);
|
||||||
|
const userStatuses =
|
||||||
|
selectedGenre === null
|
||||||
|
? defaultUserStatuses
|
||||||
|
: (discover.data?.userStatuses ?? {});
|
||||||
|
const episodeProgress =
|
||||||
|
selectedGenre === null
|
||||||
|
? defaultEpisodeProgress
|
||||||
|
: (discover.data?.episodeProgress ?? {});
|
||||||
|
const showLoading =
|
||||||
|
isLoading || (selectedGenre !== null && discover.isPending);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View className="px-4">
|
||||||
|
<SectionHeader title={title} icon={icon} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{genres && genres.length > 0 && (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
className="mb-3"
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||||
|
>
|
||||||
|
<GenreChip
|
||||||
|
label="All"
|
||||||
|
isSelected={selectedGenre === null}
|
||||||
|
onPress={() => setSelectedGenre(null)}
|
||||||
|
/>
|
||||||
|
{genres.map((genre) => (
|
||||||
|
<GenreChip
|
||||||
|
key={genre.id}
|
||||||
|
label={genre.name}
|
||||||
|
isSelected={selectedGenre === genre.id}
|
||||||
|
onPress={() =>
|
||||||
|
setSelectedGenre(selectedGenre === genre.id ? null : genre.id)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showLoading ? (
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
data={[1, 2, 3, 4]}
|
||||||
|
keyExtractor={(item) => String(item)}
|
||||||
|
renderItem={() => <PosterCardSkeleton />}
|
||||||
|
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
/>
|
||||||
|
) : items.length === 0 && selectedGenre !== null ? (
|
||||||
|
<View className="items-center py-6">
|
||||||
|
<Text className="text-[13px] text-muted-foreground">
|
||||||
|
No titles found for this genre.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(item) => `${item.tmdbId}-${item.type}`}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<PosterCard
|
||||||
|
tmdbId={item.tmdbId}
|
||||||
|
title={item.title}
|
||||||
|
type={item.type as "movie" | "tv"}
|
||||||
|
posterPath={item.posterPath}
|
||||||
|
releaseDate={item.releaseDate ?? item.firstAirDate}
|
||||||
|
voteAverage={item.voteAverage}
|
||||||
|
userStatus={userStatuses[`${item.tmdbId}-${item.type}`] ?? null}
|
||||||
|
episodeProgress={
|
||||||
|
episodeProgress[`${item.tmdbId}-${item.type}`] ?? null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Pressable } from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
|
export function GenreChip({
|
||||||
|
label,
|
||||||
|
isSelected,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
onPress();
|
||||||
|
}}
|
||||||
|
className={`mr-2 rounded-full px-3 py-1.5 ${isSelected ? "bg-primary" : "bg-secondary"}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`font-sans-medium text-[12px] ${isSelected ? "text-primary-foreground" : "text-foreground"}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { IconStarFilled } from "@tabler/icons-react-native";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
import Animated, {
|
||||||
|
interpolate,
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withSpring,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import { Image } from "@/components/ui/image";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { orpc } from "@/lib/orpc";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
|
export interface HeroBannerItem {
|
||||||
|
tmdbId: number;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
backdropPath?: string | null;
|
||||||
|
overview?: string | null;
|
||||||
|
voteAverage?: number | null;
|
||||||
|
releaseDate?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
||||||
|
const { navigate } = useRouter();
|
||||||
|
const primary = useCSSVariable("--color-primary") as string;
|
||||||
|
const pressed = useSharedValue(0);
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.98]) }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resolveMutation = useMutation(
|
||||||
|
orpc.titles.resolve.mutationOptions({
|
||||||
|
onSuccess: ({ id }) => {
|
||||||
|
navigate(`/title/${id}`);
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to load title"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
resolveMutation.mutate({
|
||||||
|
tmdbId: item.tmdbId,
|
||||||
|
type: item.type as "movie" | "tv",
|
||||||
|
});
|
||||||
|
}, [item.tmdbId, item.type, resolveMutation]);
|
||||||
|
|
||||||
|
const tapGesture = Gesture.Tap()
|
||||||
|
.onBegin(() => {
|
||||||
|
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
|
||||||
|
})
|
||||||
|
.onFinalize(() => {
|
||||||
|
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
runOnJS(handlePress)();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureDetector gesture={tapGesture}>
|
||||||
|
<Animated.View
|
||||||
|
className="mx-4 overflow-hidden rounded-2xl"
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
height: 220,
|
||||||
|
opacity: resolveMutation.isPending ? 0.7 : 1,
|
||||||
|
borderCurve: "continuous",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{item.backdropPath && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: item.backdropPath }}
|
||||||
|
className="absolute h-full w-full"
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||||
|
/>
|
||||||
|
<View className="flex-1 justify-end p-4">
|
||||||
|
<Text className="font-display text-2xl text-white" numberOfLines={2}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
{item.overview ? (
|
||||||
|
<Text className="mt-1 text-white/70 text-xs" numberOfLines={2}>
|
||||||
|
{item.overview}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<View className="mt-2 flex-row items-center gap-2">
|
||||||
|
{item.voteAverage != null && item.voteAverage > 0 && (
|
||||||
|
<View className="flex-row items-center gap-1">
|
||||||
|
<IconStarFilled size={12} color={primary} />
|
||||||
|
<Text className="text-primary text-xs">
|
||||||
|
{item.voteAverage.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text className="text-white/50 text-xs">
|
||||||
|
{item.releaseDate?.slice(0, 4)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { Alert, Pressable, View } from "react-native";
|
||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import { Image } from "@/components/ui/image";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
|
export function HeaderAvatar() {
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
if (!session?.user) return null;
|
||||||
|
|
||||||
|
const { user } = session;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger asChild>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)}
|
||||||
|
>
|
||||||
|
<View className="size-8 overflow-hidden rounded-full">
|
||||||
|
{user.image ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: user.image }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className="flex-1 items-center justify-center bg-primary/[0.08]">
|
||||||
|
<Text className="font-sans-medium text-[13px] text-primary">
|
||||||
|
{user.name?.charAt(0)?.toUpperCase() ?? "?"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
|
<DropdownMenu.Content>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="settings"
|
||||||
|
onSelect={() => router.navigate("/(tabs)/(settings)")}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIcon
|
||||||
|
ios={{ name: "gear" }}
|
||||||
|
androidIconName="ic_menu_preferences"
|
||||||
|
/>
|
||||||
|
<DropdownMenu.ItemTitle>Settings</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="sign-out"
|
||||||
|
destructive
|
||||||
|
onSelect={() => {
|
||||||
|
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Sign Out",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => {
|
||||||
|
authClient.signOut();
|
||||||
|
queryClient.clear();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIcon
|
||||||
|
ios={{ name: "rectangle.portrait.and.arrow.right" }}
|
||||||
|
androidIconName="ic_menu_close_clear_cancel"
|
||||||
|
/>
|
||||||
|
<DropdownMenu.ItemTitle>Sign out</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { IconLoader, IconPlus } from "@tabler/icons-react-native";
|
||||||
|
import { memo } from "react";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import { Image } from "@/components/ui/image";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
export interface SearchResultItem {
|
||||||
|
tmdbId: number;
|
||||||
|
title: string;
|
||||||
|
type: "movie" | "tv" | "person";
|
||||||
|
posterPath?: string | null;
|
||||||
|
profilePath?: string | null;
|
||||||
|
releaseDate?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchResultRow = memo(function SearchResultRow({
|
||||||
|
item,
|
||||||
|
onResolve,
|
||||||
|
onQuickAdd,
|
||||||
|
isResolving,
|
||||||
|
isAdding,
|
||||||
|
}: {
|
||||||
|
item: SearchResultItem;
|
||||||
|
onResolve: (item: SearchResultItem) => void;
|
||||||
|
onQuickAdd: (tmdbId: number, type: "movie" | "tv") => void;
|
||||||
|
isResolving: boolean;
|
||||||
|
isAdding: boolean;
|
||||||
|
}) {
|
||||||
|
const primary = useCSSVariable("--color-primary") as string;
|
||||||
|
const imageSrc = item.posterPath ?? item.profilePath;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onResolve(item)}
|
||||||
|
disabled={isResolving}
|
||||||
|
className="flex-row items-center border-border border-b px-4 py-3"
|
||||||
|
style={{
|
||||||
|
borderBottomWidth: 0.5,
|
||||||
|
opacity: isResolving ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="mr-3 overflow-hidden bg-secondary"
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: item.type === "person" ? 44 : 66,
|
||||||
|
borderRadius: item.type === "person" ? 22 : 8,
|
||||||
|
borderCurve: item.type === "person" ? undefined : "continuous",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageSrc ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageSrc }}
|
||||||
|
className="h-full w-full"
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className="font-sans-medium text-[15px] text-foreground"
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
<View className="mt-1 flex-row items-center gap-2">
|
||||||
|
<View className="rounded-full bg-secondary px-2 py-0.5">
|
||||||
|
<Text className="text-[10px] text-muted-foreground">
|
||||||
|
{item.type === "movie"
|
||||||
|
? "Movie"
|
||||||
|
: item.type === "tv"
|
||||||
|
? "TV"
|
||||||
|
: "Person"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{item.releaseDate ? (
|
||||||
|
<Text className="text-muted-foreground text-xs">
|
||||||
|
{item.releaseDate.slice(0, 4)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{item.type !== "person" && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onQuickAdd(item.tmdbId, item.type as "movie" | "tv")}
|
||||||
|
disabled={isAdding}
|
||||||
|
hitSlop={8}
|
||||||
|
className="ml-2"
|
||||||
|
>
|
||||||
|
{isAdding ? (
|
||||||
|
<IconLoader size={22} color={primary} />
|
||||||
|
) : (
|
||||||
|
<IconPlus size={22} color={primary} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isResolving && (
|
||||||
|
<Spinner size="sm" colorClassName="accent-primary" className="ml-2" />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { SvgProps } from "react-native-svg";
|
||||||
|
import Svg, { Path } from "react-native-svg";
|
||||||
|
|
||||||
|
export function PlexIcon(props: SvgProps) {
|
||||||
|
return (
|
||||||
|
<Svg width={24} height={24} viewBox="0 0 32 32" {...props}>
|
||||||
|
{/* Icon from CoreUI Brands by creativeLabs Łukasz Holeczek - https://creativecommons.org/publicdomain/zero/1.0/ */}
|
||||||
|
<Path
|
||||||
|
fill={props.color ?? "currentColor"}
|
||||||
|
d="M15.527 0H6.24l10.239 16L6.24 32h9.287L25.76 16z"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JellyfinIcon(props: SvgProps) {
|
||||||
|
return (
|
||||||
|
<Svg width={24} height={24} viewBox="0 0 24 24" {...props}>
|
||||||
|
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
|
||||||
|
<Path
|
||||||
|
fill={props.color ?? "currentColor"}
|
||||||
|
d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0S15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189c.518 1.04 7.348 1.027 7.86 0c.511-1.027-2.874-7.19-3.93-7.19z"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmbyIcon(props: SvgProps) {
|
||||||
|
return (
|
||||||
|
<Svg width={24} height={24} viewBox="0 0 24 24" {...props}>
|
||||||
|
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
|
||||||
|
<Path
|
||||||
|
fill={props.color ?? "currentColor"}
|
||||||
|
d="M11.041 0c-.007 0-1.456 1.43-3.219 3.176L4.615 6.352l.512.513l.512.512l-2.819 2.791L0 12.961l1.83 1.848l3.182 3.209l1.351 1.359l.508-.496c.28-.273.515-.498.524-.498c.008 0 1.266 1.264 2.794 2.808L12.97 24l.187-.182c.23-.225 5.007-4.95 5.717-5.656l.52-.516l-.502-.513c-.276-.282-.5-.52-.496-.53c.003-.009 1.264-1.26 2.802-2.783s2.8-2.776 2.803-2.785c.005-.012-3.617-3.684-6.107-6.193L17.65 4.6l-.505.505c-.279.278-.517.501-.53.497s-1.27-1.267-2.793-2.805A450 450 0 0 0 11.041 0M9.223 7.367c.091.038 7.951 4.608 7.957 4.627c.003.013-1.781 1.056-3.965 2.32a1000 1000 0 0 1-3.996 2.307c-.019.006-.026-1.266-.026-4.629c0-3.7.007-4.634.03-4.625"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SonarrIcon(props: SvgProps) {
|
||||||
|
return (
|
||||||
|
<Svg width={24} height={24} viewBox="0 0 24 24" {...props}>
|
||||||
|
{/* Icon from Custom Brand Icons by Emanuele & rchiileea - https://github.com/elax46/custom-brand-icons/blob/main/LICENSE */}
|
||||||
|
<Path
|
||||||
|
fill={props.color ?? "currentColor"}
|
||||||
|
d="m7.338 16.322l.165.159l-2.491 2.495l.13.129l2.493-2.498l.164.159l1.531-1.59l-.461-.444zm.106-8.651l-.161.161L8.855 9.4l.452-.453l-1.572-1.568l-.162.162L5 4.976q-.064.065-.127.132ZM5 4.976l-.128.131c.043-.043.083-.088.128-.131m0-.001l-.129.13l.128-.131ZM16.631 16.24l-1.648-1.64l-.451.453l1.647 1.64l.161-.162l2.533 2.621l.007-.006l.053-.052q.035-.035.067-.073L16.469 16.4ZM19 19.025q-.032.038-.067.073zm-.127.127l.007-.006zm-2.397-11.69l.062.065zl-.163-.162l-1.549 1.575l.455.449l1.549-1.572l-.163-.16l2.544-2.476q-.062-.067-.126-.132Zm2.672-2.346l-.127-.132q.065.065.127.132m.024-.023l-.128-.131l-.022.021l.127.132zm-7.156 4.139a2.66 2.66 0 0 0-1.941.8a2.62 2.62 0 0 0-.795 1.745v.384a3 3 0 0 0 .037.325a2.6 2.6 0 0 0 .763 1.434a2.4 2.4 0 0 0 .342.292a2.76 2.76 0 0 0 3.2 0a2.4 2.4 0 0 0 .279-.233l.059-.059a2.76 2.76 0 0 0 0-3.888a2.65 2.65 0 0 0-1.944-.8m6.917 9.862l-.053.052q.029-.025.053-.052M5.823 4.238l-.008.007Zm-.822.736l-.002.002zm.307 14.333l-.02-.018ZM7.505 12.1a5.64 5.64 0 0 0-1.426-4.257c-.806-.806-1.92-1.916-1.923-1.919a9.3 9.3 0 0 0-2.024 5.35a.13.13 0 0 0-.018.064Q2.1 11.653 2.1 12c0 .219 0 .439.014.658a10 10 0 0 0 .132 1.169a9.3 9.3 0 0 0 2.038 4.4c.007-.007.9-.9 1.75-1.754A5.63 5.63 0 0 0 7.505 12.1m4.527 4.587c-1.806 0-3.036.167-4.358 1.49a432 432 0 0 0-1.694 1.7q.125.098.255.189a9.43 9.43 0 0 0 5.774 1.846a9.5 9.5 0 0 0 5.784-1.846c.1-.068.189-.139.282-.211l-1.6-1.6c-1.428-1.431-2.56-1.568-4.443-1.568m-6.113 3.142L5.9 19.81Zm-.31-.252l-.023-.021Zm6.423-11.986a5.86 5.86 0 0 0 4.441-1.562c.753-.753 1.744-1.74 1.762-1.758a9.52 9.52 0 0 0-6.226-2.18a9.56 9.56 0 0 0-6.186 2.147L7.683 6.1a5.8 5.8 0 0 0 4.349 1.491m6.99-2.607v-.001l-.009-.009l-.002-.002l.002.002Zm-1.183 3.037c-1.2 1.2-1.3 2.238-1.3 4.075a5.7 5.7 0 0 0 1.48 4.358c.879.879 1.712 1.708 1.734 1.73A9.55 9.55 0 0 0 21.9 12a9.6 9.6 0 0 0-2.429-6.531q.217.25.414.5zm1.525 10.616l-.022.023zM19.233 5.2l-.084-.089z"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadarrIcon(props: SvgProps) {
|
||||||
|
return (
|
||||||
|
<Svg width={24} height={24} viewBox="0 0 24 24" {...props}>
|
||||||
|
{/* Icon from Custom Brand Icons by Emanuele & rchiileea - https://github.com/elax46/custom-brand-icons/blob/main/LICENSE */}
|
||||||
|
<Path
|
||||||
|
fill={props.color ?? "currentColor"}
|
||||||
|
d="m8.06 16.01l7.199-4.113l-7.052-3.966Zm-1.028 3.82a2.96 2.96 0 0 1-2.5.294A3.37 3.37 0 0 0 8.648 21.3l10.136-5.876a1.73 1.73 0 0 0 .294-2.645zM19.225 9.106L8.8 3.083C6.738 1.614 3.359 2.5 3.359 6.168l.147 11.605c0 1.175.882 1.763 2.057 1.616L5.416 5.433c0-1.322.735-1.469 1.616-.881l11.752 6.61a2.9 2.9 0 0 1 1.47 2.2a3.307 3.307 0 0 0-1.029-4.256"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import {
|
||||||
|
IconCheck,
|
||||||
|
IconChevronDown,
|
||||||
|
IconCopy,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconRefresh,
|
||||||
|
IconTrash,
|
||||||
|
} from "@tabler/icons-react-native";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Alert, Pressable, View } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
FadeIn,
|
||||||
|
FadeOut,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import type { IntegrationConfig } from "@/components/settings/integration-configs";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { orpc } from "@/lib/orpc";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
|
interface IntegrationCardProps {
|
||||||
|
config: IntegrationConfig;
|
||||||
|
connection: {
|
||||||
|
provider: string;
|
||||||
|
type: string;
|
||||||
|
token: string;
|
||||||
|
enabled: boolean;
|
||||||
|
lastEventAt: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
||||||
|
const { provider, label } = config;
|
||||||
|
const providerInput = provider as
|
||||||
|
| "plex"
|
||||||
|
| "jellyfin"
|
||||||
|
| "emby"
|
||||||
|
| "sonarr"
|
||||||
|
| "radarr";
|
||||||
|
|
||||||
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
|
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const chevronRotation = useSharedValue(0);
|
||||||
|
const setupChevronRotation = useSharedValue(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chevronRotation.set(withTiming(expanded ? 180 : 0, { duration: 200 }));
|
||||||
|
}, [expanded, chevronRotation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setupChevronRotation.set(
|
||||||
|
withTiming(setupOpen ? 180 : 0, { duration: 200 }),
|
||||||
|
);
|
||||||
|
}, [setupOpen, setupChevronRotation]);
|
||||||
|
|
||||||
|
const chevronStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: `${chevronRotation.get()}deg` }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const setupChevronStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: `${setupChevronRotation.get()}deg` }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const toggleExpanded = useCallback(() => setExpanded((v) => !v), []);
|
||||||
|
const toggleSetup = useCallback(() => setSetupOpen((v) => !v), []);
|
||||||
|
|
||||||
|
const connectMutation = useMutation(
|
||||||
|
orpc.integrations.create.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(`${label} connected`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error(`Failed to connect ${label}`),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteMutation = useMutation(
|
||||||
|
orpc.integrations.delete.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(`${label} disconnected`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error(`Failed to disconnect ${label}`),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const regenerateMutation = useMutation(
|
||||||
|
orpc.integrations.regenerateToken.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(`${label} URL regenerated`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error(`Failed to regenerate ${label} URL`),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = connection ? config.buildUrl(connection.token) : null;
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
if (!url) return;
|
||||||
|
await Clipboard.setStringAsync(url);
|
||||||
|
setCopied(true);
|
||||||
|
toast.success("URL copied to clipboard");
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
const handleRegenerate = useCallback(() => {
|
||||||
|
Alert.alert(
|
||||||
|
"Regenerate URL",
|
||||||
|
`This will invalidate the current ${label} URL. You'll need to update it in ${label}.`,
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Regenerate",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => regenerateMutation.mutate({ provider: providerInput }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}, [label, providerInput, regenerateMutation]);
|
||||||
|
|
||||||
|
const handleDisconnect = useCallback(() => {
|
||||||
|
Alert.alert(
|
||||||
|
`Disconnect ${label}`,
|
||||||
|
`Are you sure you want to disconnect ${label}? The current URL will stop working.`,
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Disconnect",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => deleteMutation.mutate({ provider: providerInput }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}, [label, providerInput, deleteMutation]);
|
||||||
|
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="mb-2 overflow-hidden rounded-xl border bg-card"
|
||||||
|
style={{
|
||||||
|
borderColor: "rgba(255,255,255,0.06)",
|
||||||
|
borderCurve: "continuous",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Pressable
|
||||||
|
onPress={toggleExpanded}
|
||||||
|
className="flex-row items-center justify-between p-3"
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<Icon
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
color={connection ? primaryColor : mutedFgColor}
|
||||||
|
/>
|
||||||
|
<View>
|
||||||
|
<Text className="font-sans-medium text-[15px] text-foreground">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-0.5 text-muted-foreground text-xs">
|
||||||
|
{connection
|
||||||
|
? config.connectedStatus(connection.lastEventAt)
|
||||||
|
: "Not configured"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Animated.View style={chevronStyle}>
|
||||||
|
<IconChevronDown size={18} color={mutedFgColor} />
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Expanded content */}
|
||||||
|
{expanded && (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(200)}
|
||||||
|
exiting={FadeOut.duration(150)}
|
||||||
|
className="border-white/[0.06] border-t px-4 pt-3 pb-4"
|
||||||
|
>
|
||||||
|
{/* Requirement note */}
|
||||||
|
{config.requirementNote && (
|
||||||
|
<View className="mb-3 flex-row items-start gap-2 rounded-lg bg-primary/[0.06] px-3 py-2.5">
|
||||||
|
<IconInfoCircle
|
||||||
|
size={14}
|
||||||
|
color={primaryColor}
|
||||||
|
className="mt-px"
|
||||||
|
/>
|
||||||
|
<Text className="flex-1 text-[12px] text-foreground/80">
|
||||||
|
{config.requirementNote}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!connection ? (
|
||||||
|
/* Connect button */
|
||||||
|
<Pressable
|
||||||
|
onPress={() =>
|
||||||
|
connectMutation.mutate({ provider: providerInput })
|
||||||
|
}
|
||||||
|
disabled={connectMutation.isPending}
|
||||||
|
className="items-center rounded-lg bg-primary py-2.5 active:opacity-80"
|
||||||
|
>
|
||||||
|
<Text className="font-sans-medium text-primary-foreground">
|
||||||
|
{connectMutation.isPending ? "Connecting…" : `Connect ${label}`}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<View className="gap-3">
|
||||||
|
{/* URL display */}
|
||||||
|
<View>
|
||||||
|
<Text className="mb-1.5 text-muted-foreground text-xs">
|
||||||
|
{config.urlLabel}
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center rounded-lg bg-secondary/50 px-3 py-2.5">
|
||||||
|
<Text
|
||||||
|
className="flex-1 font-mono text-[11px] text-muted-foreground"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleCopy}
|
||||||
|
className="ml-2 active:opacity-60"
|
||||||
|
hitSlop={8}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<IconCheck size={16} color="#4ade80" />
|
||||||
|
) : (
|
||||||
|
<IconCopy size={16} color={mutedFgColor} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<View className="flex-row gap-2">
|
||||||
|
<Pressable
|
||||||
|
onPress={handleRegenerate}
|
||||||
|
disabled={regenerateMutation.isPending}
|
||||||
|
className="flex-1 flex-row items-center justify-center gap-1.5 rounded-lg bg-secondary py-2.5 active:opacity-80"
|
||||||
|
>
|
||||||
|
<IconRefresh size={14} color={mutedFgColor} />
|
||||||
|
<Text className="font-sans-medium text-[13px] text-foreground">
|
||||||
|
Regenerate
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleDisconnect}
|
||||||
|
className="flex-1 flex-row items-center justify-center gap-1.5 rounded-lg bg-destructive/10 py-2.5 active:opacity-80"
|
||||||
|
>
|
||||||
|
<IconTrash size={14} color="#ef4444" />
|
||||||
|
<Text className="font-sans-medium text-[13px] text-destructive">
|
||||||
|
Disconnect
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Setup instructions (nested accordion) */}
|
||||||
|
<View className="mt-3">
|
||||||
|
<Pressable
|
||||||
|
onPress={toggleSetup}
|
||||||
|
className="flex-row items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Animated.View style={setupChevronStyle}>
|
||||||
|
<IconChevronDown size={12} color={mutedFgColor} />
|
||||||
|
</Animated.View>
|
||||||
|
<Text className="text-muted-foreground text-xs">
|
||||||
|
Setup instructions
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{setupOpen && (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(200)}
|
||||||
|
exiting={FadeOut.duration(150)}
|
||||||
|
className="mt-2 rounded-lg border border-white/[0.04] bg-secondary/30 px-3 py-2.5"
|
||||||
|
>
|
||||||
|
{config.setupSteps.map((step, i) => (
|
||||||
|
<View key={step} className="flex-row py-1">
|
||||||
|
<Text className="w-5 text-muted-foreground text-xs">
|
||||||
|
{i + 1}.
|
||||||
|
</Text>
|
||||||
|
<Text className="flex-1 text-muted-foreground text-xs leading-[18px]">
|
||||||
|
{step}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { SvgProps } from "react-native-svg";
|
||||||
|
import {
|
||||||
|
EmbyIcon,
|
||||||
|
JellyfinIcon,
|
||||||
|
PlexIcon,
|
||||||
|
RadarrIcon,
|
||||||
|
SonarrIcon,
|
||||||
|
} from "@/components/settings/icons";
|
||||||
|
import { getServerUrl } from "@/lib/server-url";
|
||||||
|
import { timeAgo } from "@/utils/time-ago";
|
||||||
|
|
||||||
|
export interface IntegrationConfig {
|
||||||
|
provider: "plex" | "jellyfin" | "emby" | "sonarr" | "radarr";
|
||||||
|
label: string;
|
||||||
|
icon: React.ComponentType<SvgProps>;
|
||||||
|
type: "webhook" | "list";
|
||||||
|
buildUrl: (token: string) => string;
|
||||||
|
urlLabel: string;
|
||||||
|
connectedStatus: (lastEventAt: string | null) => string;
|
||||||
|
requirementNote?: string;
|
||||||
|
setupSteps: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function webhookStatus(lastEventAt: string | null): string {
|
||||||
|
return lastEventAt
|
||||||
|
? `Last event ${timeAgo(lastEventAt)}`
|
||||||
|
: "Ready — nothing received yet";
|
||||||
|
}
|
||||||
|
|
||||||
|
function listStatus(lastEventAt: string | null): string {
|
||||||
|
return lastEventAt
|
||||||
|
? `Last polled ${timeAgo(lastEventAt)}`
|
||||||
|
: "Ready — not polled yet";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
|
||||||
|
{
|
||||||
|
provider: "plex",
|
||||||
|
label: "Plex",
|
||||||
|
icon: PlexIcon,
|
||||||
|
type: "webhook",
|
||||||
|
buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`,
|
||||||
|
urlLabel: "Webhook URL",
|
||||||
|
connectedStatus: webhookStatus,
|
||||||
|
requirementNote: "Requires an active Plex Pass subscription.",
|
||||||
|
setupSteps: [
|
||||||
|
"Open Plex, go to Settings > Webhooks",
|
||||||
|
'Click "Add Webhook" and paste the URL above',
|
||||||
|
"Sofa will automatically log movies and episodes when you finish watching them",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "jellyfin",
|
||||||
|
label: "Jellyfin",
|
||||||
|
icon: JellyfinIcon,
|
||||||
|
type: "webhook",
|
||||||
|
buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`,
|
||||||
|
urlLabel: "Webhook URL",
|
||||||
|
connectedStatus: webhookStatus,
|
||||||
|
setupSteps: [
|
||||||
|
"Install the Webhook plugin from Jellyfin's plugin catalog",
|
||||||
|
"Go to Dashboard > Plugins > Webhook",
|
||||||
|
'Add a "Generic Destination" and paste the URL above',
|
||||||
|
'Enable the "Playback Stop" notification type',
|
||||||
|
"Sofa will automatically log movies and episodes when you finish watching them",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "emby",
|
||||||
|
label: "Emby",
|
||||||
|
icon: EmbyIcon,
|
||||||
|
type: "webhook",
|
||||||
|
buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`,
|
||||||
|
urlLabel: "Webhook URL",
|
||||||
|
connectedStatus: webhookStatus,
|
||||||
|
requirementNote:
|
||||||
|
"Requires Emby Server 4.7.9+ and an active Emby Premiere license.",
|
||||||
|
setupSteps: [
|
||||||
|
"Open Emby, go to Settings > Webhooks",
|
||||||
|
"Add a new webhook and paste the URL above",
|
||||||
|
'Enable the "Playback" event category',
|
||||||
|
"Sofa will automatically log movies and episodes when you finish watching them",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "sonarr",
|
||||||
|
label: "Sonarr",
|
||||||
|
icon: SonarrIcon,
|
||||||
|
type: "list",
|
||||||
|
buildUrl: (token) => `${getServerUrl()}/api/lists/${token}`,
|
||||||
|
urlLabel: "Sonarr List URL",
|
||||||
|
connectedStatus: listStatus,
|
||||||
|
setupSteps: [
|
||||||
|
"Open Sonarr, go to Settings > Import Lists",
|
||||||
|
'Click "+" and select "Custom Lists"',
|
||||||
|
"Paste the Sonarr URL above into the List URL field",
|
||||||
|
"Set your preferred quality profile and root folder",
|
||||||
|
"Titles on your Sofa watchlist will be automatically added for download when Sonarr polls this list (every 6 hours by default)",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "radarr",
|
||||||
|
label: "Radarr",
|
||||||
|
icon: RadarrIcon,
|
||||||
|
type: "list",
|
||||||
|
buildUrl: (token) => `${getServerUrl()}/api/lists/${token}`,
|
||||||
|
urlLabel: "Radarr List URL",
|
||||||
|
connectedStatus: listStatus,
|
||||||
|
setupSteps: [
|
||||||
|
"Open Radarr, go to Settings > Import Lists",
|
||||||
|
'Click "+" and select "Custom Lists"',
|
||||||
|
"Paste the Radarr URL above into the List URL field",
|
||||||
|
"Set your preferred quality profile and root folder",
|
||||||
|
"Titles on your Sofa watchlist will be automatically added for download when Radarr polls this list (every 12 hours by default)",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { IconRefresh, IconWebhook } from "@tabler/icons-react-native";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { IntegrationCard } from "@/components/settings/integration-card";
|
||||||
|
import { INTEGRATION_CONFIGS } from "@/components/settings/integration-configs";
|
||||||
|
import { SectionHeader } from "@/components/ui/section-header";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { orpc } from "@/lib/orpc";
|
||||||
|
|
||||||
|
export function IntegrationsSection() {
|
||||||
|
const integrations = useQuery(orpc.integrations.list.queryOptions());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="mb-6">
|
||||||
|
<SectionHeader title="Integrations" icon={IconWebhook} />
|
||||||
|
|
||||||
|
{integrations.isPending ? (
|
||||||
|
<View className="items-center py-4">
|
||||||
|
<Spinner colorClassName="accent-primary" />
|
||||||
|
</View>
|
||||||
|
) : integrations.isError ? (
|
||||||
|
<View className="items-center gap-2 py-4">
|
||||||
|
<Text className="text-[13px] text-muted-foreground">
|
||||||
|
Could not load integrations
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => integrations.refetch()}
|
||||||
|
className="flex-row items-center gap-1.5 rounded-lg bg-secondary px-3 py-1.5"
|
||||||
|
style={{ borderCurve: "continuous" }}
|
||||||
|
>
|
||||||
|
<IconRefresh size={14} className="accent-primary" />
|
||||||
|
<Text className="font-sans-medium text-[13px] text-primary">
|
||||||
|
Retry
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
INTEGRATION_CONFIGS.map((config) => {
|
||||||
|
const connection =
|
||||||
|
integrations.data?.integrations?.find(
|
||||||
|
(i) => i.provider === config.provider,
|
||||||
|
) ?? null;
|
||||||
|
return (
|
||||||
|
<IntegrationCard
|
||||||
|
key={config.provider}
|
||||||
|
config={config}
|
||||||
|
connection={connection}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Icon } from "@tabler/icons-react-native";
|
||||||
|
import { IconChevronRight } from "@tabler/icons-react-native";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Pressable } from "react-native";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
export function SettingsRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onPress,
|
||||||
|
icon: IconComponent,
|
||||||
|
destructive,
|
||||||
|
disabled,
|
||||||
|
right,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
icon?: Icon;
|
||||||
|
destructive?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
right?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const destructiveColor = useCSSVariable("--color-destructive") as string;
|
||||||
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled || !onPress}
|
||||||
|
className="flex-row items-center py-3.5"
|
||||||
|
style={disabled ? { opacity: 0.5 } : undefined}
|
||||||
|
>
|
||||||
|
{IconComponent && (
|
||||||
|
<IconComponent
|
||||||
|
size={18}
|
||||||
|
color={destructive ? destructiveColor : mutedFgColor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
className={`flex-1 text-[15px] ${destructive ? "text-destructive" : "text-foreground"} ${IconComponent ? "ml-2" : ""}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{right}
|
||||||
|
{!right && value ? (
|
||||||
|
<Text
|
||||||
|
selectable
|
||||||
|
className="mr-1 max-w-[160px] text-[14px] text-muted-foreground"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{!right && onPress && <IconChevronRight size={16} color={mutedFgColor} />}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { Icon } from "@tabler/icons-react-native";
|
||||||
|
import { Children, Fragment, isValidElement, type ReactNode } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { SectionHeader } from "@/components/ui/section-header";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
function flattenChildren(node: ReactNode): ReactNode[] {
|
||||||
|
const result: ReactNode[] = [];
|
||||||
|
Children.forEach(node, (child) => {
|
||||||
|
if (isValidElement(child) && child.type === Fragment) {
|
||||||
|
result.push(
|
||||||
|
...flattenChildren((child.props as { children?: ReactNode }).children),
|
||||||
|
);
|
||||||
|
} else if (child != null && child !== false) {
|
||||||
|
result.push(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsSection({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
badge,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon?: Icon;
|
||||||
|
badge?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const items = flattenChildren(children);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="mb-6">
|
||||||
|
<View className="mb-2 flex-row items-center gap-2">
|
||||||
|
<SectionHeader title={title} icon={icon} />
|
||||||
|
{badge ? (
|
||||||
|
<View className="mb-2.5 rounded-full bg-primary/10 px-2 py-0.5">
|
||||||
|
<Text className="font-sans-medium text-[10px] text-primary">
|
||||||
|
{badge}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className="rounded-xl border border-white/[0.06] bg-card px-3"
|
||||||
|
style={{ borderCurve: "continuous" }}
|
||||||
|
>
|
||||||
|
{items.map((child, i) => {
|
||||||
|
const key =
|
||||||
|
isValidElement(child) && child.key != null
|
||||||
|
? String(child.key)
|
||||||
|
: `settings-item-${i}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={key}>
|
||||||
|
{i > 0 && (
|
||||||
|
<View
|
||||||
|
className="border-border border-t"
|
||||||
|
style={{ borderTopWidth: 0.5 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{child}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Link } from "expo-router";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
import Animated, {
|
||||||
|
interpolate,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withSpring,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { Image } from "@/components/ui/image";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
export function CastCard({
|
||||||
|
person,
|
||||||
|
}: {
|
||||||
|
person: {
|
||||||
|
id: string;
|
||||||
|
personId: string;
|
||||||
|
name: string;
|
||||||
|
character: string | null;
|
||||||
|
profilePath: string | null;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const pressed = useSharedValue(0);
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.95]) }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tapGesture = Gesture.Tap()
|
||||||
|
.onBegin(() => {
|
||||||
|
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
|
||||||
|
})
|
||||||
|
.onFinalize(() => {
|
||||||
|
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/person/${person.personId}` as `/person/${string}`}>
|
||||||
|
<Link.Trigger>
|
||||||
|
<GestureDetector gesture={tapGesture}>
|
||||||
|
<Animated.View className="w-20 items-center" style={animatedStyle}>
|
||||||
|
<View className="mb-2 h-16 w-16 overflow-hidden rounded-full bg-secondary">
|
||||||
|
{person.profilePath && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: person.profilePath }}
|
||||||
|
className="h-full w-full"
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className="text-center font-sans-medium text-[11px] text-foreground"
|
||||||
|
>
|
||||||
|
{person.name}
|
||||||
|
</Text>
|
||||||
|
{person.character ? (
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className="text-center text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{person.character}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
</Link.Trigger>
|
||||||
|
<Link.Preview />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { Season } from "@sofa/api/schemas";
|
||||||
|
import { getNextEpisode } from "@sofa/api/utils";
|
||||||
|
import { IconPlayerPlay } from "@tabler/icons-react-native";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||||
|
import { Image } from "@/components/ui/image";
|
||||||
|
import { SectionHeader } from "@/components/ui/section-header";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
export function ContinueWatchingBanner({
|
||||||
|
seasons,
|
||||||
|
watchedEpisodeIds,
|
||||||
|
userStatus,
|
||||||
|
backdropPath,
|
||||||
|
}: {
|
||||||
|
seasons: Season[];
|
||||||
|
watchedEpisodeIds: Set<string>;
|
||||||
|
userStatus: string | null;
|
||||||
|
backdropPath: string | null;
|
||||||
|
}) {
|
||||||
|
const { nextEpisode, totalEpisodes, watchedEpisodes } = useMemo(
|
||||||
|
() => getNextEpisode(seasons, watchedEpisodeIds),
|
||||||
|
[seasons, watchedEpisodeIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userStatus !== "in_progress" || !nextEpisode) return null;
|
||||||
|
|
||||||
|
const stillUrl = nextEpisode.stillPath ?? backdropPath ?? null;
|
||||||
|
const progress =
|
||||||
|
totalEpisodes > 0 ? (watchedEpisodes / totalEpisodes) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.duration(300).delay(350)}
|
||||||
|
className="mt-6 px-4"
|
||||||
|
>
|
||||||
|
<SectionHeader title="Next Episode" icon={IconPlayerPlay} />
|
||||||
|
<View
|
||||||
|
className="overflow-hidden rounded-[12px] border bg-card"
|
||||||
|
style={{
|
||||||
|
borderColor: "rgba(255,255,255,0.06)",
|
||||||
|
borderCurve: "continuous",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="h-28">
|
||||||
|
{stillUrl && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: stillUrl }}
|
||||||
|
className="h-full w-full"
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgba(0,0,0,0.45)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View className="absolute right-3 bottom-2.5 left-3">
|
||||||
|
<View className="flex-row items-center gap-1.5">
|
||||||
|
<View className="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||||
|
<Text className="font-sans-medium text-[10px] text-primary uppercase tracking-wider">
|
||||||
|
Up next
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className="mt-0.5 font-sans-medium text-[13px] text-white"
|
||||||
|
>
|
||||||
|
<Text className="font-sans-medium text-[11px] text-white/60">
|
||||||
|
S{nextEpisode.seasonNumber} E{nextEpisode.episodeNumber}
|
||||||
|
</Text>
|
||||||
|
{" "}
|
||||||
|
{nextEpisode.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className="absolute right-0 bottom-0 left-0 h-[3px]"
|
||||||
|
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="h-full bg-status-watching"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
IconCircleCheckFilled,
|
||||||
|
IconCircleDashed,
|
||||||
|
} from "@tabler/icons-react-native";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
export function EpisodeRow({
|
||||||
|
episode,
|
||||||
|
isWatched,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
episode: {
|
||||||
|
id: string;
|
||||||
|
episodeNumber: number;
|
||||||
|
name: string | null;
|
||||||
|
airDate: string | null;
|
||||||
|
};
|
||||||
|
isWatched: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const completedColor = useCSSVariable("--color-status-completed") as string;
|
||||||
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onToggle}
|
||||||
|
className="flex-row items-center border-border border-b px-4 py-3"
|
||||||
|
style={{ borderBottomWidth: 0.5 }}
|
||||||
|
>
|
||||||
|
{isWatched ? (
|
||||||
|
<IconCircleCheckFilled size={22} color={completedColor} />
|
||||||
|
) : (
|
||||||
|
<IconCircleDashed size={22} color={mutedFgColor} />
|
||||||
|
)}
|
||||||
|
<View className="ml-3 flex-1">
|
||||||
|
<Text
|
||||||
|
className={`font-sans-medium text-sm ${isWatched ? "text-muted-foreground" : "text-foreground"}`}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{episode.episodeNumber}.{" "}
|
||||||
|
{episode.name ?? `Episode ${episode.episodeNumber}`}
|
||||||
|
</Text>
|
||||||
|
{episode.airDate ? (
|
||||||
|
<Text className="mt-0.5 text-[11px] text-muted-foreground">
|
||||||
|
{episode.airDate}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { IconChevronDown } from "@tabler/icons-react-native";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { InteractionManager, Pressable, View } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
FadeIn,
|
||||||
|
FadeOut,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import { EpisodeRow } from "@/components/titles/episode-row";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { orpc } from "@/lib/orpc";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
|
export function SeasonAccordion({
|
||||||
|
season,
|
||||||
|
episodes,
|
||||||
|
watchedEpisodeIds,
|
||||||
|
}: {
|
||||||
|
season: {
|
||||||
|
id: string;
|
||||||
|
seasonNumber: number;
|
||||||
|
name: string | null;
|
||||||
|
};
|
||||||
|
episodes: Array<{
|
||||||
|
id: string;
|
||||||
|
episodeNumber: number;
|
||||||
|
name: string | null;
|
||||||
|
airDate: string | null;
|
||||||
|
}>;
|
||||||
|
watchedEpisodeIds: Set<string>;
|
||||||
|
}) {
|
||||||
|
const completedColor = useCSSVariable("--color-status-completed") as string;
|
||||||
|
const watchingColor = useCSSVariable("--color-status-watching") as string;
|
||||||
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const chevronRotation = useSharedValue(0);
|
||||||
|
const watchedCount = episodes.filter((e) =>
|
||||||
|
watchedEpisodeIds.has(e.id),
|
||||||
|
).length;
|
||||||
|
const progress = episodes.length > 0 ? watchedCount / episodes.length : 0;
|
||||||
|
|
||||||
|
// Progressive rendering: show first batch immediately, defer rest
|
||||||
|
const INITIAL_BATCH = 10;
|
||||||
|
const [visibleCount, setVisibleCount] = useState(INITIAL_BATCH);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (expanded) {
|
||||||
|
setVisibleCount(INITIAL_BATCH);
|
||||||
|
if (episodes.length > INITIAL_BATCH) {
|
||||||
|
const task = InteractionManager.runAfterInteractions(() => {
|
||||||
|
setVisibleCount(episodes.length);
|
||||||
|
});
|
||||||
|
return () => task.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [expanded, episodes.length]);
|
||||||
|
|
||||||
|
const toggleExpanded = useCallback(() => setExpanded((v) => !v), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chevronRotation.set(withTiming(expanded ? 180 : 0, { duration: 200 }));
|
||||||
|
}, [expanded, chevronRotation]);
|
||||||
|
|
||||||
|
const chevronStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: `${chevronRotation.get()}deg` }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const watchEpisode = useMutation(
|
||||||
|
orpc.episodes.watch.mutationOptions({
|
||||||
|
onSuccess: (_data, { id: epId }) => {
|
||||||
|
const ep = episodes.find((e) => e.id === epId);
|
||||||
|
toast.success(
|
||||||
|
ep
|
||||||
|
? `Watched S${season.seasonNumber} E${ep.episodeNumber}`
|
||||||
|
: "Episode watched",
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to mark episode"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const unwatchEpisode = useMutation(
|
||||||
|
orpc.episodes.unwatch.mutationOptions({
|
||||||
|
onSuccess: (_data, { id: epId }) => {
|
||||||
|
const ep = episodes.find((e) => e.id === epId);
|
||||||
|
toast.success(
|
||||||
|
ep
|
||||||
|
? `Unwatched S${season.seasonNumber} E${ep.episodeNumber}`
|
||||||
|
: "Episode unwatched",
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to unmark episode"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const watchSeason = useMutation(
|
||||||
|
orpc.seasons.watch.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(
|
||||||
|
`Watched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to mark some episodes"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="mb-2 overflow-hidden rounded-xl border bg-card"
|
||||||
|
style={{
|
||||||
|
borderColor: "rgba(255,255,255,0.06)",
|
||||||
|
borderCurve: "continuous",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={toggleExpanded}
|
||||||
|
className="flex-row items-center justify-between p-4"
|
||||||
|
>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="font-sans-medium text-[15px] text-foreground">
|
||||||
|
{season.name ?? `Season ${season.seasonNumber}`}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-0.5 text-muted-foreground text-xs">
|
||||||
|
{watchedCount}/{episodes.length} episodes
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className="mx-3 h-1 w-[60px] overflow-hidden rounded-full"
|
||||||
|
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: `${progress * 100}%`,
|
||||||
|
backgroundColor: progress === 1 ? completedColor : watchingColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Animated.View style={chevronStyle}>
|
||||||
|
<IconChevronDown size={18} color={mutedFgColor} />
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(200)}
|
||||||
|
exiting={FadeOut.duration(150)}
|
||||||
|
>
|
||||||
|
{watchedCount < episodes.length && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => watchSeason.mutate({ id: season.id })}
|
||||||
|
className="mx-4 mb-2 flex-row items-center justify-center rounded-lg bg-secondary py-2"
|
||||||
|
>
|
||||||
|
<Text className="font-sans-medium text-primary text-xs">
|
||||||
|
Mark All Watched
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{episodes.slice(0, visibleCount).map((episode) => (
|
||||||
|
<EpisodeRow
|
||||||
|
key={episode.id}
|
||||||
|
episode={episode}
|
||||||
|
isWatched={watchedEpisodeIds.has(episode.id)}
|
||||||
|
onToggle={() => {
|
||||||
|
if (watchedEpisodeIds.has(episode.id)) {
|
||||||
|
unwatchEpisode.mutate({ id: episode.id });
|
||||||
|
} else {
|
||||||
|
watchEpisode.mutate({ id: episode.id });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
IconBookmark,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconPlayerPlay,
|
||||||
|
} from "@tabler/icons-react-native";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
|
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
||||||
|
|
||||||
|
export function StatusActionButton({
|
||||||
|
currentStatus,
|
||||||
|
onStatusChange,
|
||||||
|
isPending,
|
||||||
|
}: {
|
||||||
|
currentStatus: TitleStatus | null;
|
||||||
|
onStatusChange: (status: TitleStatus | null) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}) {
|
||||||
|
const statuses: Array<{
|
||||||
|
status: TitleStatus;
|
||||||
|
label: string;
|
||||||
|
Icon: typeof IconBookmark;
|
||||||
|
}> = [
|
||||||
|
{ status: "watchlist", label: "Watchlist", Icon: IconBookmark },
|
||||||
|
{ status: "in_progress", label: "Watching", Icon: IconPlayerPlay },
|
||||||
|
{ status: "completed", label: "Completed", Icon: IconCircleCheck },
|
||||||
|
];
|
||||||
|
|
||||||
|
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||||
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-row gap-2">
|
||||||
|
{statuses.map(({ status, label, Icon }) => {
|
||||||
|
const isActive = currentStatus === status;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={status}
|
||||||
|
onPress={() => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
|
onStatusChange(isActive ? null : status);
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
|
className={`flex-row items-center gap-1.5 rounded-full border px-3 py-2 ${
|
||||||
|
isActive
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-border bg-card"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={14} color={isActive ? primaryColor : mutedFgColor} />
|
||||||
|
<Text
|
||||||
|
className={`font-sans-medium text-xs ${isActive ? "text-primary" : "text-foreground"}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg";
|
||||||
|
|
||||||
|
export function TmdbLogo({ height = 12 }: { height?: number }) {
|
||||||
|
const aspectRatio = 190.24 / 81.52;
|
||||||
|
const width = height * aspectRatio;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Svg
|
||||||
|
viewBox="0 0 190.24 81.52"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
accessibilityLabel="TMDB"
|
||||||
|
>
|
||||||
|
<Defs>
|
||||||
|
<LinearGradient
|
||||||
|
id="tmdb-grad"
|
||||||
|
x1="0"
|
||||||
|
x2="190.24"
|
||||||
|
y1="40.76"
|
||||||
|
y2="40.76"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<Stop offset="0" stopColor="#90cea1" />
|
||||||
|
<Stop offset="0.56" stopColor="#3cbec9" />
|
||||||
|
<Stop offset="1" stopColor="#00b3e5" />
|
||||||
|
</LinearGradient>
|
||||||
|
</Defs>
|
||||||
|
<Path
|
||||||
|
fill="url(#tmdb-grad)"
|
||||||
|
d="M105.67 36.06h66.9a17.67 17.67 0 0 0 17.67-17.66A17.67 17.67 0 0 0 172.57.73h-66.9A17.67 17.67 0 0 0 88 18.4a17.67 17.67 0 0 0 17.67 17.66m-88 45h76.9a17.67 17.67 0 0 0 17.67-17.66 17.67 17.67 0 0 0-17.67-17.67h-76.9A17.67 17.67 0 0 0 0 63.4a17.67 17.67 0 0 0 17.67 17.66m-7.26-45.64h7.8V6.92h10.1V0h-28v6.9h10.1Zm28.1 0h7.8V8.25h.1l9 27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2 23.1h-.1L50.31 0h-11.8Zm113.92 20.25a15.1 15.1 0 0 0-4.52-5.52 18.6 18.6 0 0 0-6.68-3.08 33.5 33.5 0 0 0-8.07-1h-11.7v35.4h12.75a24.6 24.6 0 0 0 7.55-1.15 19.3 19.3 0 0 0 6.35-3.32 16.3 16.3 0 0 0 4.37-5.5 16.9 16.9 0 0 0 1.63-7.58 18.5 18.5 0 0 0-1.68-8.25M145 68.6a8.8 8.8 0 0 1-2.64 3.4 10.7 10.7 0 0 1-4 1.82 21.6 21.6 0 0 1-5 .55h-4.05v-21h4.6a17 17 0 0 1 4.67.63 11.7 11.7 0 0 1 3.88 1.87A9.14 9.14 0 0 1 145 59a9.9 9.9 0 0 1 1 4.52 11.9 11.9 0 0 1-1 5.08m44.63-.13a8 8 0 0 0-1.58-2.62 8.4 8.4 0 0 0-2.42-1.85 10.3 10.3 0 0 0-3.17-1v-.1a9.2 9.2 0 0 0 4.42-2.82 7.43 7.43 0 0 0 1.68-5 8.4 8.4 0 0 0-1.15-4.65 8.1 8.1 0 0 0-3-2.72 12.6 12.6 0 0 0-4.18-1.3 33 33 0 0 0-4.62-.33h-13.2v35.4h14.5a22.4 22.4 0 0 0 4.72-.5 13.5 13.5 0 0 0 4.28-1.65 9.4 9.4 0 0 0 3.1-3 8.5 8.5 0 0 0 1.2-4.68 9.4 9.4 0 0 0-.55-3.18Zm-19.42-15.75h5.3a10 10 0 0 1 1.85.18 6.2 6.2 0 0 1 1.7.57 3.4 3.4 0 0 1 1.22 1.13 3.2 3.2 0 0 1 .48 1.82 3.63 3.63 0 0 1-.43 1.8 3.4 3.4 0 0 1-1.12 1.2 4.9 4.9 0 0 1-1.58.65 7.5 7.5 0 0 1-1.77.2h-5.65Zm11.72 20a3.9 3.9 0 0 1-1.22 1.3 4.6 4.6 0 0 1-1.68.7 8.2 8.2 0 0 1-1.82.2h-7v-8h5.9a15 15 0 0 1 2 .15 8.5 8.5 0 0 1 2.05.55 4 4 0 0 1 1.57 1.18 3.1 3.1 0 0 1 .63 2 3.7 3.7 0 0 1-.43 1.92"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { createContext, forwardRef, useContext } from "react";
|
||||||
|
import { Pressable, type PressableProps, type TextProps } from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
import { cn } from "@/utils/cn";
|
||||||
|
|
||||||
|
type ButtonVariant = "default" | "secondary";
|
||||||
|
|
||||||
|
const ButtonVariantContext = createContext<ButtonVariant>("default");
|
||||||
|
|
||||||
|
interface ButtonProps extends PressableProps {
|
||||||
|
size?: "default" | "sm";
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = forwardRef<
|
||||||
|
React.ComponentRef<typeof Pressable>,
|
||||||
|
ButtonProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
size = "default",
|
||||||
|
variant = "default",
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<ButtonVariantContext.Provider value={variant}>
|
||||||
|
<Pressable
|
||||||
|
ref={ref}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"items-center justify-center rounded-xl",
|
||||||
|
size === "sm" ? "h-10 px-4" : "h-12 px-5",
|
||||||
|
variant === "secondary" ? "bg-secondary" : "bg-primary",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={(state) => [
|
||||||
|
{
|
||||||
|
opacity: state.pressed ? 0.8 : disabled ? 0.5 : 1,
|
||||||
|
},
|
||||||
|
typeof style === "function" ? style(state) : style,
|
||||||
|
]}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ButtonVariantContext.Provider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
interface ButtonLabelProps extends TextProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ButtonLabel({ style, className, ...props }: ButtonLabelProps) {
|
||||||
|
const variant = useContext(ButtonVariantContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
className={cn(
|
||||||
|
"text-center font-sans-medium text-[15px]",
|
||||||
|
variant === "secondary"
|
||||||
|
? "text-secondary-foreground"
|
||||||
|
: "text-primary-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Icon } from "@tabler/icons-react-native";
|
||||||
|
import { IconMovie } from "@tabler/icons-react-native";
|
||||||
|
import Animated, { FadeIn } from "react-native-reanimated";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: Icon;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon: IconComponent = IconMovie,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(400)}
|
||||||
|
className="items-center justify-center px-6 py-12"
|
||||||
|
>
|
||||||
|
<IconComponent size={48} color={mutedForeground} />
|
||||||
|
<Text className="mt-3 text-center font-sans-medium text-[16px] text-foreground">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{description && (
|
||||||
|
<Text className="mt-1 text-center text-[13px] text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{actionLabel && onAction && (
|
||||||
|
<Button onPress={onAction} size="sm" className="mt-4 bg-primary">
|
||||||
|
<ButtonLabel className="text-primary-foreground">
|
||||||
|
{actionLabel}
|
||||||
|
</ButtonLabel>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import type { NativeSyntheticEvent, TextLayoutEventData } from "react-native";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
export function ExpandableText({
|
||||||
|
text,
|
||||||
|
maxLines = 3,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
maxLines?: number;
|
||||||
|
}) {
|
||||||
|
const prevTextRef = useRef(text);
|
||||||
|
let expanded: boolean;
|
||||||
|
let needsTruncation: boolean;
|
||||||
|
const [expandedState, setExpanded] = useState(false);
|
||||||
|
const [needsTruncationState, setNeedsTruncation] = useState(false);
|
||||||
|
|
||||||
|
if (prevTextRef.current !== text) {
|
||||||
|
prevTextRef.current = text;
|
||||||
|
setExpanded(false);
|
||||||
|
setNeedsTruncation(false);
|
||||||
|
expanded = false;
|
||||||
|
needsTruncation = false;
|
||||||
|
} else {
|
||||||
|
expanded = expandedState;
|
||||||
|
needsTruncation = needsTruncationState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTextLayout = useCallback(
|
||||||
|
(e: NativeSyntheticEvent<TextLayoutEventData>) => {
|
||||||
|
setNeedsTruncation(e.nativeEvent.lines.length > maxLines);
|
||||||
|
},
|
||||||
|
[maxLines],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
selectable
|
||||||
|
numberOfLines={expanded ? undefined : maxLines}
|
||||||
|
onTextLayout={onTextLayout}
|
||||||
|
className="text-[14px] text-foreground leading-[22px]"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
{needsTruncation && (
|
||||||
|
<Pressable onPress={() => setExpanded(!expanded)} className="mt-1">
|
||||||
|
<Text className="font-sans-medium text-[13px] text-primary">
|
||||||
|
{expanded ? "Show less" : "Show more"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Image as ExpoImage, type ImageProps } from "expo-image";
|
||||||
|
import { useResolveClassNames } from "uniwind";
|
||||||
|
import { resolveUrl } from "@/lib/server-url";
|
||||||
|
|
||||||
|
export function Image({
|
||||||
|
source,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ImageProps & { className?: string }) {
|
||||||
|
const resolved =
|
||||||
|
source && typeof source === "object" && "uri" in source && source.uri
|
||||||
|
? { ...source, uri: resolveUrl(source.uri) ?? undefined }
|
||||||
|
: source;
|
||||||
|
|
||||||
|
const style = useResolveClassNames(className ?? "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ExpoImage source={resolved} style={[style, props.style]} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { IconWifiOff } from "@tabler/icons-react-native";
|
||||||
|
import * as Network from "expo-network";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
|
export function OfflineBanner() {
|
||||||
|
const [isOffline, setIsOffline] = useState(false);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const wasOnline = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const handleState = (state: Network.NetworkState) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
const offline = !state.isConnected || !state.isInternetReachable;
|
||||||
|
setIsOffline(offline);
|
||||||
|
if (offline && wasOnline.current) {
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
||||||
|
}
|
||||||
|
wasOnline.current = !offline;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check initial state
|
||||||
|
Network.getNetworkStateAsync().then(handleState);
|
||||||
|
|
||||||
|
// Listen for changes
|
||||||
|
const subscription = Network.addNetworkStateListener(handleState);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isOffline) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={SlideInUp.duration(300).springify().damping(18)}
|
||||||
|
exiting={SlideOutUp.duration(250)}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: insets.top,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-destructive px-4 py-2.5">
|
||||||
|
<IconWifiOff size={16} color="white" />
|
||||||
|
<Text className="font-sans-medium text-[13px] text-white">
|
||||||
|
No internet connection
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import {
|
||||||
|
IconBookmarkFilled,
|
||||||
|
IconCheckbox,
|
||||||
|
IconDeviceTv,
|
||||||
|
IconLoader,
|
||||||
|
IconMovie,
|
||||||
|
IconPlayerPlayFilled,
|
||||||
|
IconPlus,
|
||||||
|
IconStarFilled,
|
||||||
|
} from "@tabler/icons-react-native";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
import Animated, {
|
||||||
|
interpolate,
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withSpring,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import * as ContextMenu from "zeego/context-menu";
|
||||||
|
import { Image } from "@/components/ui/image";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { client, orpc } from "@/lib/orpc";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
|
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
||||||
|
|
||||||
|
interface PosterCardProps {
|
||||||
|
id?: string;
|
||||||
|
tmdbId: number;
|
||||||
|
title: string;
|
||||||
|
type: "movie" | "tv";
|
||||||
|
posterPath: string | null;
|
||||||
|
releaseDate?: string | null;
|
||||||
|
voteAverage?: number | null;
|
||||||
|
userStatus?: TitleStatus | null;
|
||||||
|
episodeProgress?: { watched: number; total: number } | null;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PosterCard({
|
||||||
|
id,
|
||||||
|
tmdbId,
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
posterPath,
|
||||||
|
releaseDate,
|
||||||
|
voteAverage,
|
||||||
|
userStatus,
|
||||||
|
episodeProgress,
|
||||||
|
width = 140,
|
||||||
|
}: PosterCardProps) {
|
||||||
|
const { navigate } = useRouter();
|
||||||
|
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||||
|
const watchlistColor = useCSSVariable("--color-status-watchlist") as string;
|
||||||
|
const watchingColor = useCSSVariable("--color-status-watching") as string;
|
||||||
|
const completedColor = useCSSVariable("--color-status-completed") as string;
|
||||||
|
|
||||||
|
const statusColors: Record<TitleStatus, string> = {
|
||||||
|
watchlist: watchlistColor,
|
||||||
|
in_progress: watchingColor,
|
||||||
|
completed: completedColor,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pressed = useSharedValue(0);
|
||||||
|
const [localStatus, setLocalStatus] = useState<TitleStatus | null>(
|
||||||
|
userStatus ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalStatus(userStatus ?? null);
|
||||||
|
}, [userStatus]);
|
||||||
|
|
||||||
|
const resolveMutation = useMutation(
|
||||||
|
orpc.titles.resolve.mutationOptions({
|
||||||
|
onSuccess: ({ id: resolvedId }) => {
|
||||||
|
if (resolvedId) {
|
||||||
|
navigate(`/title/${resolvedId}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to load title"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const quickAddMutation = useMutation(
|
||||||
|
orpc.titles.quickAdd.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
setLocalStatus("watchlist");
|
||||||
|
toast.success("Added to watchlist");
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to add to watchlist"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
scale: interpolate(pressed.get(), [0, 1], [1, 0.97]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleResolve = useCallback(() => {
|
||||||
|
resolveMutation.mutate({ tmdbId, type });
|
||||||
|
}, [tmdbId, type, resolveMutation]);
|
||||||
|
|
||||||
|
const handleQuickAdd = useCallback(() => {
|
||||||
|
if (localStatus || quickAddMutation.isPending) return;
|
||||||
|
quickAddMutation.mutate({ tmdbId, type });
|
||||||
|
}, [localStatus, quickAddMutation, tmdbId, type]);
|
||||||
|
|
||||||
|
const year = releaseDate?.slice(0, 4);
|
||||||
|
const imageHeight = width * 1.5;
|
||||||
|
|
||||||
|
const cardContent = (
|
||||||
|
<View
|
||||||
|
className={`overflow-hidden rounded-xl border bg-card ${
|
||||||
|
localStatus ? "border-primary/25" : "border-white/[0.06]"
|
||||||
|
}`}
|
||||||
|
style={{ borderCurve: "continuous" }}
|
||||||
|
>
|
||||||
|
{/* Poster image */}
|
||||||
|
<View style={{ width, height: imageHeight }}>
|
||||||
|
{posterPath ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: posterPath }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit="cover"
|
||||||
|
recyclingKey={`poster-${tmdbId}`}
|
||||||
|
transition={200}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className="flex-1 items-center justify-center bg-secondary p-3">
|
||||||
|
<Text className="text-center font-display text-[13px] text-foreground/[0.44]">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick-add button */}
|
||||||
|
{!localStatus && (
|
||||||
|
<Pressable
|
||||||
|
onPress={handleQuickAdd}
|
||||||
|
className="absolute top-2 right-2 size-[30px] items-center justify-center rounded-full"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||||
|
>
|
||||||
|
{quickAddMutation.isPending ? (
|
||||||
|
<IconLoader size={16} color="white" />
|
||||||
|
) : (
|
||||||
|
<IconPlus size={16} color="white" />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status indicator */}
|
||||||
|
{localStatus && (
|
||||||
|
<View
|
||||||
|
className="absolute top-2 right-2 size-[30px] items-center justify-center rounded-full"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||||
|
>
|
||||||
|
{localStatus === "completed" ? (
|
||||||
|
<IconCheckbox size={16} color="white" />
|
||||||
|
) : localStatus === "in_progress" ? (
|
||||||
|
<IconPlayerPlayFilled size={16} color="white" />
|
||||||
|
) : (
|
||||||
|
<IconBookmarkFilled size={16} color="white" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Episode progress bar */}
|
||||||
|
{episodeProgress &&
|
||||||
|
episodeProgress.total > 0 &&
|
||||||
|
episodeProgress.watched > 0 && (
|
||||||
|
<View
|
||||||
|
className="absolute right-0 bottom-0 left-0 h-[3px]"
|
||||||
|
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="h-full bg-status-watching"
|
||||||
|
style={{
|
||||||
|
width: `${episodeProgress.total > 0 ? (episodeProgress.watched / episodeProgress.total) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<View className="px-2.5 pt-2 pb-2.5">
|
||||||
|
<View className="flex-row items-center gap-1.5">
|
||||||
|
{localStatus && (
|
||||||
|
<View
|
||||||
|
className="size-1.5 rounded-full"
|
||||||
|
style={{ backgroundColor: statusColors[localStatus] }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
className="flex-1 font-sans-medium text-[13px] text-foreground"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="mt-1 flex-row items-center gap-2">
|
||||||
|
{type === "movie" ? (
|
||||||
|
<IconMovie size={12} color={primaryColor} opacity={0.6} />
|
||||||
|
) : (
|
||||||
|
<IconDeviceTv size={12} color={primaryColor} opacity={0.6} />
|
||||||
|
)}
|
||||||
|
{year ? (
|
||||||
|
<Text className="text-[11px] text-muted-foreground">{year}</Text>
|
||||||
|
) : null}
|
||||||
|
{voteAverage != null && voteAverage > 0 && (
|
||||||
|
<View className="ml-auto flex-row items-center gap-0.5">
|
||||||
|
<IconStarFilled size={10} color={primaryColor} opacity={0.8} />
|
||||||
|
<Text className="text-[11px] text-primary/80">
|
||||||
|
{voteAverage.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Gesture for UI-thread press animation (used by both paths)
|
||||||
|
const pressGesture = Gesture.Tap()
|
||||||
|
.onBegin(() => {
|
||||||
|
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
|
||||||
|
})
|
||||||
|
.onFinalize(() => {
|
||||||
|
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cards with id: use context menu with navigation
|
||||||
|
if (id) {
|
||||||
|
return (
|
||||||
|
<ContextMenu.Root>
|
||||||
|
<ContextMenu.Trigger>
|
||||||
|
<GestureDetector gesture={pressGesture}>
|
||||||
|
<Animated.View style={[animatedStyle, { width }]}>
|
||||||
|
<Pressable onPress={() => navigate(`/title/${id}`)}>
|
||||||
|
{cardContent}
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
<ContextMenu.Content>
|
||||||
|
{!localStatus && (
|
||||||
|
<ContextMenu.Item key="watchlist" onSelect={handleQuickAdd}>
|
||||||
|
<ContextMenu.ItemIcon ios={{ name: "bookmark" }} />
|
||||||
|
<ContextMenu.ItemTitle>Add to Watchlist</ContextMenu.ItemTitle>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
)}
|
||||||
|
{localStatus !== "in_progress" && (
|
||||||
|
<ContextMenu.Item
|
||||||
|
key="watching"
|
||||||
|
onSelect={async () => {
|
||||||
|
await client.titles.updateStatus({
|
||||||
|
id,
|
||||||
|
status: "in_progress",
|
||||||
|
});
|
||||||
|
toast.success("Marked as watching");
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: orpc.titles.key(),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: orpc.dashboard.key(),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemIcon ios={{ name: "play.fill" }} />
|
||||||
|
<ContextMenu.ItemTitle>Mark as Watching</ContextMenu.ItemTitle>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
)}
|
||||||
|
{type === "movie" && (
|
||||||
|
<ContextMenu.Item
|
||||||
|
key="watched"
|
||||||
|
onSelect={async () => {
|
||||||
|
await client.titles.watchMovie({ id });
|
||||||
|
toast.success(
|
||||||
|
title ? `Marked "${title}" as watched` : "Marked as watched",
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: orpc.titles.key(),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: orpc.dashboard.key(),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemIcon ios={{ name: "checkmark.circle" }} />
|
||||||
|
<ContextMenu.ItemTitle>Mark as Watched</ContextMenu.ItemTitle>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
)}
|
||||||
|
{localStatus && (
|
||||||
|
<ContextMenu.Item
|
||||||
|
key="remove"
|
||||||
|
destructive
|
||||||
|
onSelect={async () => {
|
||||||
|
await client.titles.updateStatus({ id, status: null });
|
||||||
|
setLocalStatus(null);
|
||||||
|
toast.success("Removed from library");
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: orpc.titles.key(),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: orpc.dashboard.key(),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemIcon ios={{ name: "trash" }} />
|
||||||
|
<ContextMenu.ItemTitle>Remove from Library</ContextMenu.ItemTitle>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
)}
|
||||||
|
</ContextMenu.Content>
|
||||||
|
</ContextMenu.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cards without id: use GestureDetector for resolve-then-navigate
|
||||||
|
const resolveTapGesture = Gesture.Tap()
|
||||||
|
.onBegin(() => {
|
||||||
|
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
|
||||||
|
})
|
||||||
|
.onFinalize(() => {
|
||||||
|
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
runOnJS(handleResolve)();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureDetector gesture={resolveTapGesture}>
|
||||||
|
<Animated.View style={[animatedStyle, { width }]}>
|
||||||
|
{cardContent}
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PosterCardSkeleton({ width = 140 }: { width?: number }) {
|
||||||
|
const imageHeight = width * 1.5;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="overflow-hidden rounded-xl border border-white/[0.06] bg-card"
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
borderCurve: "continuous",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton width={width} height={imageHeight} borderRadius={0} />
|
||||||
|
<View className="px-2.5 pt-2 pb-2.5">
|
||||||
|
<Skeleton width="75%" height={14} />
|
||||||
|
<Skeleton width="50%" height={10} style={{ marginTop: 6 }} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Icon } from "@tabler/icons-react-native";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
interface SectionHeaderProps {
|
||||||
|
title: string;
|
||||||
|
icon?: Icon;
|
||||||
|
iconColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionHeader({
|
||||||
|
title,
|
||||||
|
icon: IconComponent,
|
||||||
|
iconColor,
|
||||||
|
}: SectionHeaderProps) {
|
||||||
|
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||||
|
const resolvedColor = iconColor ?? primaryColor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="mb-3 flex-row items-center gap-2">
|
||||||
|
{IconComponent && <IconComponent size={20} color={resolvedColor} />}
|
||||||
|
<Text className="font-display text-[20px] text-foreground tracking-tight">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import type { ViewStyle } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
|
interface SkeletonProps {
|
||||||
|
width?: number | `${number}%`;
|
||||||
|
height?: number;
|
||||||
|
borderRadius?: number;
|
||||||
|
style?: ViewStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({
|
||||||
|
width,
|
||||||
|
height = 14,
|
||||||
|
borderRadius = 4,
|
||||||
|
style,
|
||||||
|
}: SkeletonProps) {
|
||||||
|
const secondaryColor = useCSSVariable("--color-secondary") as string;
|
||||||
|
const opacity = useSharedValue(0.4);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
opacity.set(withRepeat(withTiming(1, { duration: 800 }), -1, true));
|
||||||
|
}, [opacity]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: opacity.get(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
borderRadius,
|
||||||
|
backgroundColor: secondaryColor,
|
||||||
|
},
|
||||||
|
animatedStyle,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Svg, { Path } from "react-native-svg";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
|
interface SofaLogoProps {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SofaLogo({ size = 24, color }: SofaLogoProps) {
|
||||||
|
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||||
|
const fill = color ?? primaryColor;
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||||
|
<Path
|
||||||
|
fill={fill}
|
||||||
|
d="M7 12v1h10v-1a3 3 0 0 1 2.993-3a4.6 4.6 0 0 0-.07-.78a4 4 0 0 0-3.143-3.143C16.394 5 15.93 5 15 5H9c-.93 0-1.394 0-1.78.077A4 4 0 0 0 4.077 8.22a4.6 4.6 0 0 0-.07.78A3 3 0 0 1 7 12"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
fill={fill}
|
||||||
|
d="M18.444 18H5.556a3.6 3.6 0 0 1-.806-.092V19a.75.75 0 0 1-1.5 0v-1.849A3.55 3.55 0 0 1 2 14.444V12a2 2 0 1 1 4 0v1.2a.8.8 0 0 0 .8.8h10.4a.8.8 0 0 0 .8-.8V12a2 2 0 1 1 4 0v2.444a3.55 3.55 0 0 1-1.25 2.707V19a.75.75 0 0 1-1.5 0v-1.092a3.6 3.6 0 0 1-.806.092"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ActivityIndicator, type ActivityIndicatorProps } from "react-native";
|
||||||
|
|
||||||
|
const SIZES = { sm: "small", default: "large" } as const;
|
||||||
|
|
||||||
|
interface SpinnerProps extends Omit<ActivityIndicatorProps, "size" | "color"> {
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Spinner({ size = "default", ...props }: SpinnerProps) {
|
||||||
|
return (
|
||||||
|
<ActivityIndicator
|
||||||
|
size={SIZES[size]}
|
||||||
|
colorClassName="accent-primary-foreground"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { IconStar, IconStarFilled } from "@tabler/icons-react-native";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
|
interface StarRatingProps {
|
||||||
|
rating: number;
|
||||||
|
onRate?: (rating: number) => void;
|
||||||
|
size?: number;
|
||||||
|
interactive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StarRating({
|
||||||
|
rating,
|
||||||
|
onRate,
|
||||||
|
size = 22,
|
||||||
|
interactive = true,
|
||||||
|
}: StarRatingProps) {
|
||||||
|
const [primary, mutedForeground] = useCSSVariable([
|
||||||
|
"--color-primary",
|
||||||
|
"--color-muted-foreground",
|
||||||
|
]) as [string, string];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Pressable
|
||||||
|
key={star}
|
||||||
|
disabled={!interactive}
|
||||||
|
onPress={() => {
|
||||||
|
if (!onRate) return;
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
onRate(star === rating ? 0 : star);
|
||||||
|
}}
|
||||||
|
hitSlop={6}
|
||||||
|
>
|
||||||
|
{star <= rating ? (
|
||||||
|
<IconStarFilled size={size} color={primary} />
|
||||||
|
) : (
|
||||||
|
<IconStar size={size} color={mutedForeground} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Icon } from "@tabler/icons-react-native";
|
||||||
|
import {
|
||||||
|
IconBookmarkFilled,
|
||||||
|
IconCircleCheckFilled,
|
||||||
|
IconPlayerPlayFilled,
|
||||||
|
} from "@tabler/icons-react-native";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
||||||
|
|
||||||
|
const bgClasses: Record<TitleStatus, string> = {
|
||||||
|
watchlist: "bg-status-watchlist/10",
|
||||||
|
in_progress: "bg-status-watching/10",
|
||||||
|
completed: "bg-status-completed/10",
|
||||||
|
};
|
||||||
|
|
||||||
|
const textClasses: Record<TitleStatus, string> = {
|
||||||
|
watchlist: "text-status-watchlist",
|
||||||
|
in_progress: "text-status-watching",
|
||||||
|
completed: "text-status-completed",
|
||||||
|
};
|
||||||
|
|
||||||
|
const icons: Record<TitleStatus, { label: string; Icon: Icon }> = {
|
||||||
|
watchlist: { label: "Watchlist", Icon: IconBookmarkFilled },
|
||||||
|
in_progress: { label: "Watching", Icon: IconPlayerPlayFilled },
|
||||||
|
completed: { label: "Completed", Icon: IconCircleCheckFilled },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: TitleStatus }) {
|
||||||
|
const watchlistColor = useCSSVariable("--color-status-watchlist") as string;
|
||||||
|
const watchingColor = useCSSVariable("--color-status-watching") as string;
|
||||||
|
const completedColor = useCSSVariable("--color-status-completed") as string;
|
||||||
|
|
||||||
|
const colorMap: Record<TitleStatus, string> = {
|
||||||
|
watchlist: watchlistColor,
|
||||||
|
in_progress: watchingColor,
|
||||||
|
completed: completedColor,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { label, Icon: StatusIcon } = icons[status];
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={`flex-row items-center gap-1.5 rounded-full px-2.5 py-1 ${bgClasses[status]}`}
|
||||||
|
>
|
||||||
|
<StatusIcon size={12} color={colorMap[status]} />
|
||||||
|
<Text className={`font-sans-medium text-[11px] ${textClasses[status]}`}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Pressable } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
interpolateColor,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useDerivedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
|
const TRACK_WIDTH = 40;
|
||||||
|
const TRACK_HEIGHT = 20;
|
||||||
|
const THUMB_SIZE = 14;
|
||||||
|
const THUMB_OFFSET = 3;
|
||||||
|
|
||||||
|
export function Switch({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
value: boolean;
|
||||||
|
onValueChange: (value: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||||
|
const secondaryColor = useCSSVariable("--color-secondary") as string;
|
||||||
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
|
|
||||||
|
const progress = useDerivedValue(() =>
|
||||||
|
withTiming(value ? 1 : 0, { duration: 200 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const trackStyle = useAnimatedStyle(() => ({
|
||||||
|
backgroundColor: interpolateColor(
|
||||||
|
progress.value,
|
||||||
|
[0, 1],
|
||||||
|
[secondaryColor, primaryColor],
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const thumbStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateX:
|
||||||
|
THUMB_OFFSET +
|
||||||
|
progress.value * (TRACK_WIDTH - THUMB_SIZE - THUMB_OFFSET * 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
backgroundColor: interpolateColor(
|
||||||
|
progress.value,
|
||||||
|
[0, 1],
|
||||||
|
[mutedFgColor, "#fff"],
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onValueChange(!value)}
|
||||||
|
disabled={disabled}
|
||||||
|
hitSlop={8}
|
||||||
|
style={disabled ? { opacity: 0.5 } : undefined}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
width: TRACK_WIDTH,
|
||||||
|
height: TRACK_HEIGHT,
|
||||||
|
borderRadius: TRACK_HEIGHT / 2,
|
||||||
|
},
|
||||||
|
trackStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
width: THUMB_SIZE,
|
||||||
|
height: THUMB_SIZE,
|
||||||
|
borderRadius: THUMB_SIZE / 2,
|
||||||
|
position: "absolute",
|
||||||
|
top: (TRACK_HEIGHT - THUMB_SIZE) / 2,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
thumbStyle,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { forwardRef, type PropsWithChildren } from "react";
|
||||||
|
import {
|
||||||
|
TextInput,
|
||||||
|
type TextInputProps,
|
||||||
|
type TextProps,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
import { cn } from "@/utils/cn";
|
||||||
|
|
||||||
|
export function TextField({ children }: PropsWithChildren) {
|
||||||
|
return <View className="gap-1.5">{children}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: TextProps & { className?: string }) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
className={cn("font-sans-medium text-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input = forwardRef<
|
||||||
|
TextInput,
|
||||||
|
TextInputProps & { className?: string }
|
||||||
|
>(({ className, style, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
ref={ref}
|
||||||
|
placeholderTextColorClassName="accent-muted-foreground/50"
|
||||||
|
className={cn(
|
||||||
|
"h-12 rounded-[12px] border border-border bg-input px-3.5 font-sans text-[15px] text-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={[{ borderCurve: "continuous" }, style]}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export function FieldError({
|
||||||
|
isInvalid,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<TextProps & { isInvalid?: boolean; className?: string }>) {
|
||||||
|
if (!isInvalid) return null;
|
||||||
|
return (
|
||||||
|
<Text className={cn("text-[13px] text-destructive", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Text as RNText, type TextProps } from "react-native";
|
||||||
|
|
||||||
|
import { cn } from "@/utils/cn";
|
||||||
|
|
||||||
|
export function Text({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: TextProps & { className?: string }) {
|
||||||
|
return <RNText className={cn("font-sans", className)} {...props} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useEffect, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
import { ToastView } from "@/components/ui/toast";
|
||||||
|
import { dismiss, getSnapshot, subscribe } from "@/lib/toast";
|
||||||
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
|
const hapticMap = {
|
||||||
|
success: () =>
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success),
|
||||||
|
error: () =>
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error),
|
||||||
|
warning: () =>
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning),
|
||||||
|
info: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function ToastProvider() {
|
||||||
|
const queue = useSyncExternalStore(subscribe, getSnapshot);
|
||||||
|
const activeToast = queue[0];
|
||||||
|
|
||||||
|
const activeId = activeToast?.id;
|
||||||
|
const activeType = activeToast?.type;
|
||||||
|
const activeDuration = activeToast?.duration;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeId || !activeType || activeDuration == null) return;
|
||||||
|
|
||||||
|
hapticMap[activeType]();
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
dismiss(activeId);
|
||||||
|
}, activeDuration);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [activeId, activeType, activeDuration]);
|
||||||
|
|
||||||
|
if (!activeToast) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastView key={activeToast.id} toast={activeToast} onDismiss={dismiss} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import {
|
||||||
|
IconAlertOctagon,
|
||||||
|
IconAlertTriangle,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconInfoCircle,
|
||||||
|
} from "@tabler/icons-react-native";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
import Animated, {
|
||||||
|
interpolate,
|
||||||
|
SlideInDown,
|
||||||
|
SlideOutDown,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withSpring,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { scheduleOnRN } from "react-native-worklets";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { ToastItem } from "@/lib/toast";
|
||||||
|
|
||||||
|
const DISMISS_THRESHOLD = 50;
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
success: IconCircleCheck,
|
||||||
|
error: IconAlertOctagon,
|
||||||
|
warning: IconAlertTriangle,
|
||||||
|
info: IconInfoCircle,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const colorVarMap = {
|
||||||
|
success: "--color-status-completed",
|
||||||
|
error: "--color-destructive",
|
||||||
|
warning: "--color-primary",
|
||||||
|
info: "--color-muted-foreground",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface ToastViewProps {
|
||||||
|
toast: ToastItem;
|
||||||
|
onDismiss: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastView({ toast, onDismiss }: ToastViewProps) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const translateY = useSharedValue(0);
|
||||||
|
|
||||||
|
const Icon = iconMap[toast.type];
|
||||||
|
const iconColor = useCSSVariable(colorVarMap[toast.type]);
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
onDismiss(toast.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pan = Gesture.Pan()
|
||||||
|
.onUpdate((e) => {
|
||||||
|
translateY.value = e.translationY;
|
||||||
|
})
|
||||||
|
.onEnd((e) => {
|
||||||
|
if (Math.abs(e.translationY) > DISMISS_THRESHOLD) {
|
||||||
|
const target = e.translationY > 0 ? 200 : -200;
|
||||||
|
translateY.value = withTiming(target, { duration: 200 }, () => {
|
||||||
|
scheduleOnRN(handleDismiss);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
translateY.value = withSpring(0, { damping: 15, stiffness: 300 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateY: translateY.value }],
|
||||||
|
opacity: interpolate(Math.abs(translateY.value), [0, 100], [1, 0]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={SlideInDown.duration(350)}
|
||||||
|
exiting={SlideOutDown.duration(200)}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: insets.bottom + 4,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GestureDetector gesture={pan}>
|
||||||
|
<Animated.View style={animatedStyle}>
|
||||||
|
<View
|
||||||
|
className="mx-4 flex-row items-center gap-3 rounded-xl border border-border bg-card px-4 py-3"
|
||||||
|
style={{ borderCurve: "continuous" }}
|
||||||
|
>
|
||||||
|
<Icon size={18} color={iconColor as string} />
|
||||||
|
|
||||||
|
<View className="flex-1 gap-0.5">
|
||||||
|
<Text className="font-sans-medium text-[14px] text-foreground">
|
||||||
|
{toast.message}
|
||||||
|
</Text>
|
||||||
|
{toast.description ? (
|
||||||
|
<Text className="text-[13px] text-muted-foreground">
|
||||||
|
{toast.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{toast.action ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
toast.action?.onPress();
|
||||||
|
handleDismiss();
|
||||||
|
}}
|
||||||
|
className="rounded-lg bg-secondary px-3 py-1.5"
|
||||||
|
style={{ borderCurve: "continuous" }}
|
||||||
|
>
|
||||||
|
<Text className="font-sans-medium text-[13px] text-primary">
|
||||||
|
{toast.action.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "uniwind";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* Fonts */
|
||||||
|
--font-sans: "DMSans-Regular";
|
||||||
|
--font-sans-medium: "DMSans-Medium";
|
||||||
|
--font-sans-semibold: "DMSans-SemiBold";
|
||||||
|
--font-sans-bold: "DMSans-Bold";
|
||||||
|
--font-display: "DMSerifDisplay-Regular";
|
||||||
|
--font-mono: "GeistMono-Regular";
|
||||||
|
|
||||||
|
/* Sofa — dark cinema theme with warm amber accents */
|
||||||
|
--color-background: oklch(0.13 0.006 55);
|
||||||
|
--color-foreground: oklch(0.93 0.015 80);
|
||||||
|
--color-card: oklch(0.19 0.008 55);
|
||||||
|
--color-card-foreground: oklch(0.93 0.015 80);
|
||||||
|
--color-popover: oklch(0.21 0.008 55);
|
||||||
|
--color-popover-foreground: oklch(0.93 0.015 80);
|
||||||
|
--color-primary: oklch(0.8 0.14 65);
|
||||||
|
--color-primary-foreground: oklch(0.13 0.006 55);
|
||||||
|
--color-secondary: oklch(0.22 0.008 55);
|
||||||
|
--color-secondary-foreground: oklch(0.85 0.02 80);
|
||||||
|
--color-muted: oklch(0.22 0.008 55);
|
||||||
|
--color-muted-foreground: oklch(0.63 0.02 80);
|
||||||
|
--color-accent: oklch(0.25 0.01 55);
|
||||||
|
--color-accent-foreground: oklch(0.93 0.015 80);
|
||||||
|
--color-destructive: oklch(0.65 0.2 25);
|
||||||
|
--color-border: oklch(1 0.015 65 / 10%);
|
||||||
|
--color-input: oklch(1 0 0 / 12%);
|
||||||
|
--color-ring: oklch(0.8 0.14 65);
|
||||||
|
--color-status-watchlist: oklch(0.72 0.12 220);
|
||||||
|
--color-status-watching: oklch(0.78 0.14 65);
|
||||||
|
--color-status-completed: oklch(0.75 0.14 155);
|
||||||
|
|
||||||
|
--radius: 0.625rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [value, delay]);
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { Stack } from "expo-router";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { useCSSVariable, useResolveClassNames } from "uniwind";
|
||||||
|
import { HeaderAvatar } from "@/components/header-avatar";
|
||||||
|
|
||||||
|
type ScreenOptions = NonNullable<
|
||||||
|
Extract<ComponentProps<typeof Stack>["screenOptions"], object>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function useTabScreenOptions() {
|
||||||
|
const headerLargeTitleStyle = useResolveClassNames(
|
||||||
|
"font-display text-foreground",
|
||||||
|
);
|
||||||
|
const headerTitleStyle = useResolveClassNames(
|
||||||
|
"font-display text-lg text-foreground",
|
||||||
|
);
|
||||||
|
const contentStyle = useResolveClassNames("bg-background");
|
||||||
|
|
||||||
|
return {
|
||||||
|
headerLargeTitle: true,
|
||||||
|
headerTransparent: true,
|
||||||
|
headerBlurEffect: "dark" as const,
|
||||||
|
headerLargeStyle: { backgroundColor: "transparent" },
|
||||||
|
headerLargeTitleStyle: headerLargeTitleStyle as Record<string, unknown>,
|
||||||
|
headerTitleStyle: headerTitleStyle as Record<string, unknown>,
|
||||||
|
headerTintColor: useCSSVariable("--color-primary") as string,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLargeTitleShadowVisible: false,
|
||||||
|
headerBackButtonDisplayMode: "minimal" as const,
|
||||||
|
scrollEdgeEffects: {
|
||||||
|
top: "hidden" as const,
|
||||||
|
bottom: "hidden" as const,
|
||||||
|
left: "hidden" as const,
|
||||||
|
right: "hidden" as const,
|
||||||
|
},
|
||||||
|
unstable_headerRightItems: () => [
|
||||||
|
{
|
||||||
|
type: "custom" as const,
|
||||||
|
element: <HeaderAvatar />,
|
||||||
|
hidesSharedBackground: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
contentStyle,
|
||||||
|
} satisfies ScreenOptions;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { expoClient } from "@better-auth/expo/client";
|
||||||
|
import { adminClient, genericOAuthClient } from "better-auth/client/plugins";
|
||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
|
||||||
|
import { QUERY_CACHE_KEY, storage } from "@/lib/mmkv";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
import { getServerUrl, onServerUrlChange } from "@/lib/server-url";
|
||||||
|
|
||||||
|
function buildAuthClient() {
|
||||||
|
return createAuthClient({
|
||||||
|
baseURL: getServerUrl(),
|
||||||
|
plugins: [
|
||||||
|
adminClient(),
|
||||||
|
genericOAuthClient(),
|
||||||
|
expoClient({
|
||||||
|
scheme: "sofa",
|
||||||
|
storagePrefix: "sofa",
|
||||||
|
storage: SecureStore,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export let authClient = buildAuthClient();
|
||||||
|
|
||||||
|
onServerUrlChange(() => {
|
||||||
|
// Fire-and-forget SecureStore cleanup — must not block the
|
||||||
|
// synchronous rebuild so that subsequent listeners (e.g. the root
|
||||||
|
// layout re-render) see the new authClient immediately.
|
||||||
|
Promise.allSettled([
|
||||||
|
SecureStore.deleteItemAsync("sofa_cookie"),
|
||||||
|
SecureStore.deleteItemAsync("sofa_session_token"),
|
||||||
|
SecureStore.deleteItemAsync("sofa_session_data"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
authClient = buildAuthClient();
|
||||||
|
storage.remove(QUERY_CACHE_KEY);
|
||||||
|
queryClient.clear();
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||||
|
import { createMMKV } from "react-native-mmkv";
|
||||||
|
|
||||||
|
export const storage = createMMKV();
|
||||||
|
|
||||||
|
const mmkvStorage = {
|
||||||
|
getItem: (key: string) => storage.getString(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => storage.set(key, value),
|
||||||
|
removeItem: (key: string) => void storage.remove(key),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QUERY_CACHE_KEY = "REACT_QUERY_OFFLINE_CACHE";
|
||||||
|
|
||||||
|
export const queryPersister = createAsyncStoragePersister({
|
||||||
|
storage: mmkvStorage,
|
||||||
|
key: QUERY_CACHE_KEY,
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { createORPCClient } from "@orpc/client";
|
||||||
|
import { RPCLink } from "@orpc/client/fetch";
|
||||||
|
import type { ContractRouterClient } from "@orpc/contract";
|
||||||
|
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
|
||||||
|
import type { contract } from "@sofa/api/contract";
|
||||||
|
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { getServerUrl } from "@/lib/server-url";
|
||||||
|
|
||||||
|
export const link = new RPCLink({
|
||||||
|
url: () => `${getServerUrl()}/rpc`,
|
||||||
|
fetch: (url, options) => {
|
||||||
|
return fetch(url, {
|
||||||
|
...options,
|
||||||
|
credentials: process.env.EXPO_OS === "web" ? "include" : "omit",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
headers() {
|
||||||
|
if (process.env.EXPO_OS === "web") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const headers = new Map<string, string>();
|
||||||
|
const cookies = authClient.getCookie();
|
||||||
|
if (cookies) {
|
||||||
|
headers.set("Cookie", cookies);
|
||||||
|
}
|
||||||
|
return Object.fromEntries(headers);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const client: ContractRouterClient<typeof contract> =
|
||||||
|
createORPCClient(link);
|
||||||
|
|
||||||
|
export const orpc = createTanstackQueryUtils(client);
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { PostHogCustomStorage } from "posthog-react-native";
|
||||||
|
import { PostHog } from "posthog-react-native";
|
||||||
|
|
||||||
|
import { storage } from "@/lib/mmkv";
|
||||||
|
|
||||||
|
const posthogApiKey = process.env.EXPO_PUBLIC_POSTHOG_KEY ?? "";
|
||||||
|
const host = process.env.EXPO_PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com";
|
||||||
|
|
||||||
|
const ANALYTICS_ENABLED_KEY = "sofa_analytics_enabled";
|
||||||
|
|
||||||
|
const posthogStorage: PostHogCustomStorage = {
|
||||||
|
getItem: (key: string) => storage.getString(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => storage.set(key, value),
|
||||||
|
};
|
||||||
|
|
||||||
|
// PostHog throws if apiKey is empty, so only construct when configured.
|
||||||
|
export const posthog: PostHog | null = posthogApiKey
|
||||||
|
? new PostHog(posthogApiKey, {
|
||||||
|
host,
|
||||||
|
defaultOptIn: storage.getBoolean(ANALYTICS_ENABLED_KEY) ?? true,
|
||||||
|
customStorage: posthogStorage,
|
||||||
|
captureAppLifecycleEvents: true,
|
||||||
|
personProfiles: "never",
|
||||||
|
errorTracking: {
|
||||||
|
autocapture: {
|
||||||
|
uncaughtExceptions: true,
|
||||||
|
unhandledRejections: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
export function isAnalyticsEnabled(): boolean {
|
||||||
|
return storage.getBoolean(ANALYTICS_ENABLED_KEY) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAnalyticsEnabled(enabled: boolean): void {
|
||||||
|
storage.set(ANALYTICS_ENABLED_KEY, enabled);
|
||||||
|
if (enabled) {
|
||||||
|
posthog?.optIn();
|
||||||
|
} else {
|
||||||
|
posthog?.optOut();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { posthog } from "@/lib/posthog";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
|
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
gcTime: ONE_DAY_MS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
queryCache: new QueryCache({
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Something went wrong\u2026", {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
posthog?.captureException(error, { source: "react-query" });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { storage } from "@/lib/mmkv";
|
||||||
|
|
||||||
|
const SERVER_URL_KEY = "sofa_server_url";
|
||||||
|
const DEFAULT_URL =
|
||||||
|
process.env.EXPO_PUBLIC_SERVER_URL ?? "https://sofa.example.com";
|
||||||
|
|
||||||
|
const serverUrlListeners: Array<() => void> = [];
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
export type ValidationResult =
|
||||||
|
| { status: "success" }
|
||||||
|
| { status: "error"; error: ValidationError };
|
||||||
|
|
||||||
|
export type ValidationError =
|
||||||
|
| "network_unreachable"
|
||||||
|
| "timeout"
|
||||||
|
| "not_sofa_server"
|
||||||
|
| "server_unhealthy"
|
||||||
|
| "invalid_url";
|
||||||
|
|
||||||
|
// --- URL helpers ---
|
||||||
|
|
||||||
|
export function normalizeUrl(input: string): string {
|
||||||
|
let url = input.trim().replace(/\/+$/, "");
|
||||||
|
if (url && !url.includes("://")) {
|
||||||
|
url = `http://${url}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitUrl(input: string): { protocol: string; host: string } {
|
||||||
|
const match = input.match(/^(https?:\/\/)(.*)/);
|
||||||
|
if (match) {
|
||||||
|
return { protocol: match[1], host: match[2] };
|
||||||
|
}
|
||||||
|
return { protocol: "http://", host: input };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUrl(path: string | null): string | null {
|
||||||
|
if (!path) return null;
|
||||||
|
if (!path.startsWith("/")) return path;
|
||||||
|
return `${getServerUrl()}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Server URL storage ---
|
||||||
|
|
||||||
|
export function getServerUrl(): string {
|
||||||
|
return storage.getString(SERVER_URL_KEY) ?? DEFAULT_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setServerUrl(url: string): void {
|
||||||
|
const normalized = url.replace(/\/+$/, "");
|
||||||
|
storage.set(SERVER_URL_KEY, normalized);
|
||||||
|
for (const listener of serverUrlListeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onServerUrlChange(callback: () => void): () => void {
|
||||||
|
serverUrlListeners.push(callback);
|
||||||
|
return () => {
|
||||||
|
const idx = serverUrlListeners.indexOf(callback);
|
||||||
|
if (idx !== -1) serverUrlListeners.splice(idx, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasStoredServerUrl(): boolean {
|
||||||
|
return storage.contains(SERVER_URL_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Validation ---
|
||||||
|
|
||||||
|
export async function validateServerUrl(
|
||||||
|
url: string,
|
||||||
|
): Promise<ValidationResult> {
|
||||||
|
const normalized = normalizeUrl(url);
|
||||||
|
|
||||||
|
if (!normalized || !normalized.includes("://")) {
|
||||||
|
return { status: "error", error: "invalid_url" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new URL(normalized);
|
||||||
|
} catch {
|
||||||
|
return { status: "error", error: "invalid_url" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
|
const response = await fetch(`${normalized}/api/health`, {
|
||||||
|
method: "GET",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { status: "error", error: "server_unhealthy" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data || typeof data !== "object" || !("status" in data)) {
|
||||||
|
return { status: "error", error: "not_sofa_server" };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { status: "error", error: "not_sofa_server" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "success" };
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof Error &&
|
||||||
|
(err.name === "TimeoutError" || err.name === "AbortError")
|
||||||
|
) {
|
||||||
|
return { status: "error", error: "timeout" };
|
||||||
|
}
|
||||||
|
return { status: "error", error: "network_unreachable" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
type ToastType = "success" | "error" | "info" | "warning";
|
||||||
|
|
||||||
|
interface ToastAction {
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastOptions {
|
||||||
|
description?: string;
|
||||||
|
duration?: number;
|
||||||
|
action?: ToastAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastItem {
|
||||||
|
id: string;
|
||||||
|
type: ToastType;
|
||||||
|
message: string;
|
||||||
|
description?: string;
|
||||||
|
duration: number;
|
||||||
|
action?: ToastAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
let queue: ToastItem[] = [];
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSnapshot(): ToastItem[] {
|
||||||
|
return queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToast(type: ToastType, message: string, options?: ToastOptions) {
|
||||||
|
const item: ToastItem = {
|
||||||
|
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||||
|
type,
|
||||||
|
message,
|
||||||
|
description: options?.description,
|
||||||
|
duration: options?.duration ?? 4000,
|
||||||
|
action: options?.action,
|
||||||
|
};
|
||||||
|
queue = [...queue, item];
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismiss(id: string) {
|
||||||
|
queue = queue.filter((t) => t.id !== id);
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toast = {
|
||||||
|
success: (message: string, options?: ToastOptions) =>
|
||||||
|
addToast("success", message, options),
|
||||||
|
error: (message: string, options?: ToastOptions) =>
|
||||||
|
addToast("error", message, options),
|
||||||
|
info: (message: string, options?: ToastOptions) =>
|
||||||
|
addToast("info", message, options),
|
||||||
|
warning: (message: string, options?: ToastOptions) =>
|
||||||
|
addToast("warning", message, options),
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: (string | undefined | null | false)[]) {
|
||||||
|
return twMerge(inputs.filter(Boolean).join(" "));
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
impactAsync as _impactAsync,
|
||||||
|
notificationAsync as _notificationAsync,
|
||||||
|
AndroidHaptics,
|
||||||
|
ImpactFeedbackStyle,
|
||||||
|
NotificationFeedbackType,
|
||||||
|
performAndroidHapticsAsync,
|
||||||
|
} from "expo-haptics";
|
||||||
|
|
||||||
|
export { ImpactFeedbackStyle, NotificationFeedbackType };
|
||||||
|
|
||||||
|
// See https://docs.expo.dev/versions/latest/sdk/haptics/#androidhaptics
|
||||||
|
const impactStyleToAndroid: Record<ImpactFeedbackStyle, AndroidHaptics> = {
|
||||||
|
[ImpactFeedbackStyle.Light]: AndroidHaptics.Clock_Tick,
|
||||||
|
[ImpactFeedbackStyle.Medium]: AndroidHaptics.Context_Click,
|
||||||
|
[ImpactFeedbackStyle.Heavy]: AndroidHaptics.Long_Press,
|
||||||
|
[ImpactFeedbackStyle.Soft]: AndroidHaptics.Keyboard_Tap,
|
||||||
|
[ImpactFeedbackStyle.Rigid]: AndroidHaptics.Virtual_Key,
|
||||||
|
};
|
||||||
|
|
||||||
|
const notificationTypeToAndroid: Record<
|
||||||
|
NotificationFeedbackType,
|
||||||
|
AndroidHaptics
|
||||||
|
> = {
|
||||||
|
[NotificationFeedbackType.Success]: AndroidHaptics.Confirm,
|
||||||
|
[NotificationFeedbackType.Warning]: AndroidHaptics.Segment_Tick,
|
||||||
|
[NotificationFeedbackType.Error]: AndroidHaptics.Reject,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function impactAsync(
|
||||||
|
style: ImpactFeedbackStyle = ImpactFeedbackStyle.Medium,
|
||||||
|
) {
|
||||||
|
if (process.env.EXPO_OS === "ios") {
|
||||||
|
return _impactAsync(style);
|
||||||
|
}
|
||||||
|
if (process.env.EXPO_OS === "android") {
|
||||||
|
return performAndroidHapticsAsync(impactStyleToAndroid[style]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notificationAsync(
|
||||||
|
type: NotificationFeedbackType = NotificationFeedbackType.Success,
|
||||||
|
) {
|
||||||
|
if (process.env.EXPO_OS === "ios") {
|
||||||
|
return _notificationAsync(type);
|
||||||
|
}
|
||||||
|
if (process.env.EXPO_OS === "android") {
|
||||||
|
return performAndroidHapticsAsync(notificationTypeToAndroid[type]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const MINUTE = 60;
|
||||||
|
const HOUR = 3600;
|
||||||
|
const DAY = 86400;
|
||||||
|
const WEEK = 604800;
|
||||||
|
const MONTH = 2592000;
|
||||||
|
const YEAR = 31536000;
|
||||||
|
|
||||||
|
export function timeAgo(dateString: string): string {
|
||||||
|
const seconds = Math.floor(
|
||||||
|
(Date.now() - new Date(dateString).getTime()) / 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (seconds < MINUTE) return "just now";
|
||||||
|
if (seconds < HOUR) {
|
||||||
|
const m = Math.floor(seconds / MINUTE);
|
||||||
|
return `${m} ${m === 1 ? "minute" : "minutes"} ago`;
|
||||||
|
}
|
||||||
|
if (seconds < DAY) {
|
||||||
|
const h = Math.floor(seconds / HOUR);
|
||||||
|
return `${h} ${h === 1 ? "hour" : "hours"} ago`;
|
||||||
|
}
|
||||||
|
if (seconds < WEEK) {
|
||||||
|
const d = Math.floor(seconds / DAY);
|
||||||
|
return `${d} ${d === 1 ? "day" : "days"} ago`;
|
||||||
|
}
|
||||||
|
if (seconds < MONTH) {
|
||||||
|
const w = Math.floor(seconds / WEEK);
|
||||||
|
return `${w} ${w === 1 ? "week" : "weeks"} ago`;
|
||||||
|
}
|
||||||
|
if (seconds < YEAR) {
|
||||||
|
const mo = Math.floor(seconds / MONTH);
|
||||||
|
return `${mo} ${mo === 1 ? "month" : "months"} ago`;
|
||||||
|
}
|
||||||
|
const y = Math.floor(seconds / YEAR);
|
||||||
|
return `${y} ${y === 1 ? "year" : "years"} ago`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts",
|
||||||
|
"uniwind-types.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// NOTE: This file is generated by uniwind and it should not be edited manually.
|
||||||
|
/// <reference types="uniwind/types" />
|
||||||
|
|
||||||
|
declare module 'uniwind' {
|
||||||
|
export interface UniwindConfig {
|
||||||
|
themes: readonly ['light', 'dark']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
getCachedUpdateCheck,
|
getCachedUpdateCheck,
|
||||||
isUpdateCheckEnabled,
|
isUpdateCheckEnabled,
|
||||||
} from "@sofa/core/update-check";
|
} from "@sofa/core/update-check";
|
||||||
import { rescheduleBackup, triggerJob } from "../../cron";
|
import { rescheduleBackup, triggerJob as triggerCronJob } from "../../cron";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { admin } from "../middleware";
|
import { admin } from "../middleware";
|
||||||
|
|
||||||
@@ -35,7 +35,16 @@ export const backupsCreate = os.admin.backups.create
|
|||||||
export const backupsDelete = os.admin.backups.delete
|
export const backupsDelete = os.admin.backups.delete
|
||||||
.use(admin)
|
.use(admin)
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input }) => {
|
||||||
await deleteBackup(input.filename);
|
try {
|
||||||
|
await deleteBackup(input.filename);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ORPCError) throw err;
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.includes("not found")) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: msg });
|
||||||
|
}
|
||||||
|
throw new ORPCError("BAD_REQUEST", { message: msg });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const backupsRestore = os.admin.backups.restore
|
export const backupsRestore = os.admin.backups.restore
|
||||||
@@ -54,7 +63,9 @@ export const backupsRestore = os.admin.backups.restore
|
|||||||
// Clean up the upload file if restoreFromBackup didn't consume it
|
// Clean up the upload file if restoreFromBackup didn't consume it
|
||||||
const f = Bun.file(tmpPath);
|
const f = Bun.file(tmpPath);
|
||||||
if (await f.exists()) await f.delete();
|
if (await f.exists()) await f.delete();
|
||||||
throw err;
|
if (err instanceof ORPCError) throw err;
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new ORPCError("BAD_REQUEST", { message: msg });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,7 +78,11 @@ export const backupsSchedule = os.admin.backups.schedule
|
|||||||
getSetting("maxBackupRetention") ?? "7",
|
getSetting("maxBackupRetention") ?? "7",
|
||||||
10,
|
10,
|
||||||
),
|
),
|
||||||
frequency: getSetting("backupScheduleFrequency") ?? "1d",
|
frequency: (getSetting("backupScheduleFrequency") ?? "1d") as
|
||||||
|
| "6h"
|
||||||
|
| "12h"
|
||||||
|
| "1d"
|
||||||
|
| "7d",
|
||||||
time: getSetting("backupScheduleTime") ?? "02:00",
|
time: getSetting("backupScheduleTime") ?? "02:00",
|
||||||
dayOfWeek: Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10),
|
dayOfWeek: Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10),
|
||||||
};
|
};
|
||||||
@@ -119,10 +134,10 @@ export const toggleUpdateCheck = os.admin.toggleUpdateCheck
|
|||||||
|
|
||||||
// ─── Jobs ──────────────────────────────────────────────────────
|
// ─── Jobs ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const triggerJobProcedure = os.admin.triggerJob
|
export const triggerJob = os.admin.triggerJob
|
||||||
.use(admin)
|
.use(admin)
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input }) => {
|
||||||
const triggered = await triggerJob(input.name);
|
const triggered = await triggerCronJob(input.name);
|
||||||
if (!triggered) {
|
if (!triggered) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Job not found" });
|
throw new ORPCError("NOT_FOUND", { message: "Job not found" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ import {
|
|||||||
getNewAvailableFeed,
|
getNewAvailableFeed,
|
||||||
getRecommendationsFeed,
|
getRecommendationsFeed,
|
||||||
getUserStats,
|
getUserStats,
|
||||||
|
getWatchCount,
|
||||||
|
getWatchHistory,
|
||||||
} from "@sofa/core/discovery";
|
} from "@sofa/core/discovery";
|
||||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
import { authed } from "../middleware";
|
||||||
|
|
||||||
|
const watchHistoryTypeMap = { movie: "movies", episode: "episodes" } as const;
|
||||||
|
|
||||||
export const stats = os.dashboard.stats.use(authed).handler(({ context }) => {
|
export const stats = os.dashboard.stats.use(authed).handler(({ context }) => {
|
||||||
return getUserStats(context.user.id);
|
return getUserStats(context.user.id);
|
||||||
});
|
});
|
||||||
@@ -46,7 +50,8 @@ export const library = os.dashboard.library
|
|||||||
type: t.type,
|
type: t.type,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
posterPath: tmdbImageUrl(t.posterPath, "posters"),
|
posterPath: tmdbImageUrl(t.posterPath, "posters"),
|
||||||
releaseDate: t.releaseDate ?? t.firstAirDate ?? null,
|
releaseDate: t.releaseDate ?? null,
|
||||||
|
firstAirDate: t.firstAirDate ?? null,
|
||||||
voteAverage: t.voteAverage,
|
voteAverage: t.voteAverage,
|
||||||
userStatus: t.userStatus,
|
userStatus: t.userStatus,
|
||||||
}));
|
}));
|
||||||
@@ -66,8 +71,18 @@ export const recommendations = os.dashboard.recommendations
|
|||||||
type: t.type,
|
type: t.type,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
posterPath: tmdbImageUrl(t.posterPath, "posters"),
|
posterPath: tmdbImageUrl(t.posterPath, "posters"),
|
||||||
releaseDate: t.releaseDate ?? t.firstAirDate ?? null,
|
releaseDate: t.releaseDate ?? null,
|
||||||
|
firstAirDate: t.firstAirDate ?? null,
|
||||||
voteAverage: t.voteAverage,
|
voteAverage: t.voteAverage,
|
||||||
}));
|
}));
|
||||||
return { items };
|
return { items };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const watchHistory = os.dashboard.watchHistory
|
||||||
|
.use(authed)
|
||||||
|
.handler(({ input, context }) => {
|
||||||
|
const coreType = watchHistoryTypeMap[input.type];
|
||||||
|
const count = getWatchCount(context.user.id, coreType, input.period);
|
||||||
|
const history = getWatchHistory(context.user.id, coreType, input.period);
|
||||||
|
return { count, history };
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ export const discover = os.discover
|
|||||||
.handler(async ({ input, context }) => {
|
.handler(async ({ input, context }) => {
|
||||||
if (!isTmdbConfigured()) {
|
if (!isTmdbConfigured()) {
|
||||||
throw new ORPCError("PRECONDITION_FAILED", {
|
throw new ORPCError("PRECONDITION_FAILED", {
|
||||||
message: "TMDB API key is not configured.",
|
message: "TMDB API key is not configured",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await discoverTmdb(input.mediaType, {
|
const results = await discoverTmdb(input.type, {
|
||||||
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),
|
||||||
@@ -35,11 +35,12 @@ export const discover = os.discover
|
|||||||
.filter((r) => r.poster_path)
|
.filter((r) => r.poster_path)
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
tmdbId: r.id,
|
tmdbId: r.id,
|
||||||
type: input.mediaType,
|
type: input.type,
|
||||||
title: r.title ?? r.name ?? "",
|
title: r.title ?? r.name ?? "",
|
||||||
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
|
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
|
||||||
releaseDate: r.release_date ?? r.first_air_date ?? null,
|
releaseDate: (r.release_date as string | undefined) ?? null,
|
||||||
voteAverage: r.vote_average,
|
firstAirDate: (r.first_air_date as string | undefined) ?? null,
|
||||||
|
voteAverage: r.vote_average ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
|
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
|
||||||
|
|||||||