Add @sofa/native Expo React Native app (#7)

This commit is contained in:
2026-03-12 19:39:54 -04:00
committed by GitHub
parent a326c968b7
commit 83839a0cd7
130 changed files with 9015 additions and 971 deletions
+1
View File
@@ -13,3 +13,4 @@ coverage
.idea
.turbo
.tanstack
apps/native
+51
View File
@@ -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
+39
View File
@@ -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
+33
View File
@@ -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
+4
View File
@@ -18,6 +18,10 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Generate Expo types
run: bunx expo customize tsconfig.json
working-directory: ./apps/native
- name: Lint
run: bunx turbo run lint
+3 -2
View File
@@ -2,7 +2,8 @@
"recommendations": [
"biomejs.biome",
"bradlc.vscode-tailwindcss",
"vitest.explorer",
"vercel.turbo-vsc"
"expo.vscode-expo-tools",
"vercel.turbo-vsc",
"vitest.explorer"
]
}
+120
View File
@@ -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`.
-285
View File
@@ -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.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+2 -3
View File
@@ -128,9 +128,8 @@ Useful commands:
- `bun run test`
- `bun run lint`
- `bun run check-types`
- `bun run db:generate`
- `bun run db:migrate`
- `bun run db:seed`
- `cd packages/db && bun run db:generate`
- `cd packages/db && bun run db:migrate`
## TMDB notice
+2
View File
@@ -0,0 +1,2 @@
EXPO_PUBLIC_POSTHOG_KEY=
EXPO_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
+33
View File
@@ -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/
+138
View File
@@ -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"
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 764 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

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"]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

+29
View File
@@ -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"
}
}
}
}
+15
View File
@@ -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;
+81
View File
@@ -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:"
}
}
+24
View File
@@ -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",
}}
/>
);
}
+209
View File
@@ -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>
);
}
+220
View File
@@ -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>
);
}
+230
View File
@@ -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()} />;
}
+140
View File
@@ -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>
);
}
+40
View File
@@ -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>
);
}
+43
View File
@@ -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>
</>
);
}
+129
View File
@@ -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>
);
}
+236
View File
@@ -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}
/>
</>
);
}
+569
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+34
View File
@@ -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>
);
}
+78
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+17
View File
@@ -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>
);
}
+92
View File
@@ -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>
);
}
+10
View File
@@ -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} />
);
}
+127
View File
@@ -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>
);
}
+37
View File
@@ -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;
}
+10
View File
@@ -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;
}
+40
View File
@@ -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();
});
+17
View File
@@ -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,
});
+34
View File
@@ -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);
+44
View File
@@ -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();
}
}
+22
View File
@@ -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" });
},
}),
});
+121
View File
@@ -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" };
}
}
+68
View File
@@ -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),
};
+5
View File
@@ -0,0 +1,5 @@
import { twMerge } from "tailwind-merge";
export function cn(...inputs: (string | undefined | null | false)[]) {
return twMerge(inputs.filter(Boolean).join(" "));
}
+50
View File
@@ -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]);
}
}
+36
View File
@@ -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`;
}
+17
View File
@@ -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"
]
}
+10
View File
@@ -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 {}
+20 -5
View File
@@ -13,7 +13,7 @@ import {
getCachedUpdateCheck,
isUpdateCheckEnabled,
} from "@sofa/core/update-check";
import { rescheduleBackup, triggerJob } from "../../cron";
import { rescheduleBackup, triggerJob as triggerCronJob } from "../../cron";
import { os } from "../context";
import { admin } from "../middleware";
@@ -35,7 +35,16 @@ export const backupsCreate = os.admin.backups.create
export const backupsDelete = os.admin.backups.delete
.use(admin)
.handler(async ({ input }) => {
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
@@ -54,7 +63,9 @@ export const backupsRestore = os.admin.backups.restore
// Clean up the upload file if restoreFromBackup didn't consume it
const f = Bun.file(tmpPath);
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",
10,
),
frequency: getSetting("backupScheduleFrequency") ?? "1d",
frequency: (getSetting("backupScheduleFrequency") ?? "1d") as
| "6h"
| "12h"
| "1d"
| "7d",
time: getSetting("backupScheduleTime") ?? "02:00",
dayOfWeek: Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10),
};
@@ -119,10 +134,10 @@ export const toggleUpdateCheck = os.admin.toggleUpdateCheck
// ─── Jobs ──────────────────────────────────────────────────────
export const triggerJobProcedure = os.admin.triggerJob
export const triggerJob = os.admin.triggerJob
.use(admin)
.handler(async ({ input }) => {
const triggered = await triggerJob(input.name);
const triggered = await triggerCronJob(input.name);
if (!triggered) {
throw new ORPCError("NOT_FOUND", { message: "Job not found" });
}
+17 -2
View File
@@ -3,11 +3,15 @@ import {
getNewAvailableFeed,
getRecommendationsFeed,
getUserStats,
getWatchCount,
getWatchHistory,
} from "@sofa/core/discovery";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
import { authed } from "../middleware";
const watchHistoryTypeMap = { movie: "movies", episode: "episodes" } as const;
export const stats = os.dashboard.stats.use(authed).handler(({ context }) => {
return getUserStats(context.user.id);
});
@@ -46,7 +50,8 @@ export const library = os.dashboard.library
type: t.type,
title: t.title,
posterPath: tmdbImageUrl(t.posterPath, "posters"),
releaseDate: t.releaseDate ?? t.firstAirDate ?? null,
releaseDate: t.releaseDate ?? null,
firstAirDate: t.firstAirDate ?? null,
voteAverage: t.voteAverage,
userStatus: t.userStatus,
}));
@@ -66,8 +71,18 @@ export const recommendations = os.dashboard.recommendations
type: t.type,
title: t.title,
posterPath: tmdbImageUrl(t.posterPath, "posters"),
releaseDate: t.releaseDate ?? t.firstAirDate ?? null,
releaseDate: t.releaseDate ?? null,
firstAirDate: t.firstAirDate ?? null,
voteAverage: t.voteAverage,
}));
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 };
});
+6 -5
View File
@@ -14,11 +14,11 @@ export const discover = os.discover
.handler(async ({ input, context }) => {
if (!isTmdbConfigured()) {
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",
"vote_count.gte": "50",
with_genres: String(input.genreId),
@@ -35,11 +35,12 @@ export const discover = os.discover
.filter((r) => r.poster_path)
.map((r) => ({
tmdbId: r.id,
type: input.mediaType,
type: input.type,
title: r.title ?? r.name ?? "",
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
releaseDate: r.release_date ?? r.first_air_date ?? null,
voteAverage: r.vote_average,
releaseDate: (r.release_date as string | undefined) ?? null,
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 }));

Some files were not shown because too many files have changed in this diff Show More