Add @sofa/native Expo React Native app (#7)
@@ -13,3 +13,4 @@ coverage
|
||||
.idea
|
||||
.turbo
|
||||
.tanstack
|
||||
apps/native
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: EAS Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
description: Platform to build
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- ios
|
||||
- android
|
||||
default: all
|
||||
profile:
|
||||
description: Build profile
|
||||
type: choice
|
||||
options:
|
||||
- production
|
||||
- development
|
||||
default: production
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- apps/native/**
|
||||
- packages/api/**
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: EAS Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Setup EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
working-directory: apps/native
|
||||
run: eas build --platform ${{ inputs.platform || 'all' }} --profile ${{ inputs.profile || 'production' }} --non-interactive --no-wait
|
||||
@@ -0,0 +1,39 @@
|
||||
name: EAS Submit
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
description: Platform to submit
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- ios
|
||||
- android
|
||||
default: all
|
||||
|
||||
jobs:
|
||||
submit:
|
||||
name: EAS Submit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Setup EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Submit
|
||||
working-directory: apps/native
|
||||
run: eas submit --platform ${{ inputs.platform || 'all' }} --latest --non-interactive
|
||||
@@ -0,0 +1,33 @@
|
||||
name: EAS Update
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- apps/native/**
|
||||
- packages/api/**
|
||||
|
||||
jobs:
|
||||
update:
|
||||
name: EAS Update
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Setup EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Publish update
|
||||
working-directory: apps/native
|
||||
run: eas update --branch ${{ github.head_ref }} --message "${{ github.event.pull_request.title }}" --non-interactive
|
||||
@@ -18,6 +18,10 @@ jobs:
|
||||
- name: Install dependencies
|
||||
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
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"recommendations": [
|
||||
"biomejs.biome",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"vitest.explorer",
|
||||
"vercel.turbo-vsc"
|
||||
"expo.vscode-expo-tools",
|
||||
"vercel.turbo-vsc",
|
||||
"vitest.explorer"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Root commands (via Turborepo)
|
||||
bun run dev # Start API server + Vite dev server
|
||||
bun run build # Production build (both apps)
|
||||
bun run lint # Biome lint check
|
||||
bun run format # Biome format (auto-fix)
|
||||
bun run check-types # TypeScript type check
|
||||
bun run test # Run tests
|
||||
|
||||
# Database commands (run from packages/db/)
|
||||
cd packages/db && bun run db:push # Push schema changes to SQLite database
|
||||
cd packages/db && bun run db:generate # Generate Drizzle migration files
|
||||
cd packages/db && bun run db:migrate # Run Drizzle migrations
|
||||
cd packages/db && bun run db:studio # Open Drizzle Studio (visual DB browser)
|
||||
```
|
||||
|
||||
**Use Bun, not Node.js** — `bun <file>`, `bun test`, `bun install`, `bun run <script>`, `bunx <pkg>`. Bun auto-loads `.env`.
|
||||
|
||||
## Pre-commit checks
|
||||
|
||||
All three must pass with zero warnings or errors:
|
||||
|
||||
```bash
|
||||
bun run lint
|
||||
bun run check-types
|
||||
bun run test
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Sofa** is a self-hosted movie & TV tracking app built as a Turborepo monorepo.
|
||||
|
||||
```
|
||||
couch-potato/
|
||||
├── apps/
|
||||
│ ├── native/ # @sofa/native — Expo 55 React Native app (Expo Router, UniWind)
|
||||
│ ├── server/ # @sofa/server — Hono API server (oRPC, auth, cron, webhooks)
|
||||
│ └── web/ # @sofa/web — Vite SPA (TanStack Router, TanStack Query)
|
||||
├── packages/
|
||||
│ ├── api/ # @sofa/api — oRPC contract + Zod schemas (shared, JIT)
|
||||
│ ├── auth/ # @sofa/auth — Better Auth server config (JIT)
|
||||
│ ├── config/ # @sofa/config — Path constants + TMDB URLs from env (JIT)
|
||||
│ ├── core/ # @sofa/core — Business logic services (JIT)
|
||||
│ ├── db/ # @sofa/db — Drizzle schema, client, migrations (JIT)
|
||||
│ ├── logger/ # @sofa/logger — Pino-based structured logging (JIT)
|
||||
│ └── tmdb/ # @sofa/tmdb — TMDB API client + image URL helper (JIT)
|
||||
├── turbo.json
|
||||
├── biome.json
|
||||
├── Dockerfile
|
||||
└── package.json
|
||||
```
|
||||
|
||||
All shared packages are JIT (raw TypeScript exports, no build step).
|
||||
|
||||
### Apps
|
||||
|
||||
- **`@sofa/server`** — Hono API server. Hosts oRPC procedures, Better Auth, webhook/image/backup routes, and cron jobs. Runs DB migrations on startup. Dev: port 3001. Prod: serves SPA static files too, port 3000.
|
||||
- **`@sofa/web`** — Vite SPA with TanStack Router (file-based routing). No SSR, no DB. All data via oRPC. Vite dev server proxies `/api/*` and `/rpc/*` to the API server.
|
||||
- **`@sofa/native`** — Expo Router app with 4-tab layout (Home, Explore, Search, Settings). UniWind for styling, `@better-auth/expo` with SecureStore for auth, oRPC client for API calls. Dark-only cinema theme matching web.
|
||||
|
||||
### Stack
|
||||
|
||||
- **Frontend**: Vite 7 SPA, React 19, TanStack Router (file-based), Tailwind CSS v4, shadcn, Jotai
|
||||
- **Mobile**: Expo 55, Expo Router, UniWind (Tailwind for RN), `@tabler/icons-react-native`
|
||||
- **API**: Hono on Bun, oRPC (contract-first) with `@orpc/tanstack-query`
|
||||
- **Database**: SQLite via `bun:sqlite` + Drizzle ORM (WAL mode, sync queries, auto-migrations)
|
||||
- **Auth**: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO
|
||||
- **Monorepo**: Turborepo with Bun workspaces
|
||||
- **Linting**: Biome (2-space indent, organized imports)
|
||||
- **External API**: TMDB (The Movie Database)
|
||||
|
||||
### Package imports
|
||||
|
||||
Path aliases: `@/*` maps to `src/` in both `apps/web/` and `apps/native/`.
|
||||
|
||||
Cross-package imports:
|
||||
- `@sofa/api/contract`, `@sofa/api/schemas` — Contract and Zod types
|
||||
- `@sofa/db/client`, `@sofa/db/schema`, `@sofa/db/helpers`, `@sofa/db/migrate`, `@sofa/db/test-utils`
|
||||
- `@sofa/tmdb/client`, `@sofa/tmdb/image`
|
||||
- `@sofa/core/metadata`, `@sofa/core/tracking`, etc.
|
||||
- `@sofa/auth/server`, `@sofa/auth/config`
|
||||
- `@sofa/config` — Path constants (`DATA_DIR`, `DATABASE_URL`, `CACHE_DIR`, `BACKUP_DIR`, `AVATAR_DIR`) and TMDB URLs
|
||||
- `@sofa/logger` — `createLogger(name)` for structured logging
|
||||
|
||||
### Key patterns
|
||||
|
||||
**oRPC queries** use `orpc.*.queryOptions()` with TanStack Query. **Mutations** use `client.*()` directly. **Route loaders** prefetch via `queryClient.ensureQueryData()`.
|
||||
|
||||
**Auth guards** — Web routes use `beforeLoad` + `authClient.getSession()`. API procedures use auth middleware that calls `auth.api.getSession()`.
|
||||
|
||||
**TMDB images** — Only paths stored in DB. The API server resolves full URLs via `tmdbImageUrl()`. When `IMAGE_CACHE_ENABLED` (default), images are downloaded to disk and served via `/images/:category/:filename`.
|
||||
|
||||
**Database IDs** — All app tables use UUIDv7 text PKs via `Bun.randomUUIDv7()`.
|
||||
|
||||
### Environment variables
|
||||
|
||||
Required: `TMDB_API_READ_ACCESS_TOKEN`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`.
|
||||
|
||||
Optional:
|
||||
- `DATA_DIR` — Root for DB + cache (default `./data`). `DATABASE_URL` and `CACHE_DIR` derived from it but overridable.
|
||||
- `TMDB_API_BASE_URL`, `TMDB_IMAGE_BASE_URL` — Override TMDB endpoints.
|
||||
- `IMAGE_CACHE_ENABLED` — Default `true`. Set `false` for direct TMDB CDN URLs.
|
||||
- `LOG_LEVEL` — `error`/`warn`/`info`/`debug` (default: `info`).
|
||||
- OIDC: `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_ISSUER_URL`, `OIDC_PROVIDER_NAME`, `OIDC_AUTO_REGISTER`, `DISABLE_PASSWORD_LOGIN`.
|
||||
|
||||
### Testing
|
||||
|
||||
Tests live in `packages/core/test/`. `preload.ts` mocks `@sofa/db/client` with in-memory SQLite. Never mock a service module with stubs — `bun`'s `mock.module` persists across test files.
|
||||
|
||||
### Docker
|
||||
|
||||
Single container, single process. Hono serves API + SPA on port 3000. API routes (`/rpc/*`, `/api/*`) mounted first; unmatched routes fall back to `index.html`.
|
||||
|
||||
## Browser Automation
|
||||
|
||||
Use `agent-browser` for web automation (`agent-browser --help` for all commands). Auth credentials: `demo@sofa.watch` / `password`.
|
||||
@@ -1,285 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Root commands (via Turborepo)
|
||||
bun run dev # Start API server + Vite dev server
|
||||
bun run build # Production build (both apps)
|
||||
bun run lint # Biome lint check
|
||||
bun run format # Biome format (auto-fix)
|
||||
bun run check-types # TypeScript type check
|
||||
bun run test # Run tests
|
||||
|
||||
# Database commands (run from packages/db/)
|
||||
cd packages/db && bun run db:push # Push schema changes to SQLite database
|
||||
cd packages/db && bun run db:generate # Generate Drizzle migration files
|
||||
cd packages/db && bun run db:migrate # Run Drizzle migrations
|
||||
cd packages/db && bun run db:studio # Open Drizzle Studio (visual DB browser)
|
||||
```
|
||||
|
||||
IMPORTANT: Default to using Bun instead of Node.js:
|
||||
|
||||
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
||||
- Use `bun test` instead of `jest` or `vitest`
|
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||
- Use `bunx <package> <command>` instead of `npx <package> <command>`
|
||||
- Bun automatically loads .env, so don't use dotenv.
|
||||
|
||||
For more information, read the Bun API docs in node_modules/bun-types/docs/**.mdx.
|
||||
|
||||
## Pre-commit checks
|
||||
|
||||
Before committing, all three of these must pass with zero warnings or errors:
|
||||
|
||||
```bash
|
||||
bun run lint
|
||||
bun run check-types
|
||||
bun run test
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Sofa** is a self-hosted movie & TV tracking app (like Trakt/TVTime) built as a Turborepo monorepo with a standalone Hono API server, a Vite + TanStack Router SPA frontend, and shared packages.
|
||||
|
||||
### Monorepo structure
|
||||
|
||||
```
|
||||
couch-potato/
|
||||
├── apps/
|
||||
│ ├── server/ # @sofa/server — Hono API server (oRPC, auth, cron, webhooks)
|
||||
│ └── web/ # @sofa/web — Vite SPA (TanStack Router, TanStack Query)
|
||||
├── packages/
|
||||
│ ├── api/ # @sofa/api — oRPC contract + Zod schemas (shared)
|
||||
│ ├── auth/ # @sofa/auth — Better Auth server config
|
||||
│ ├── core/ # @sofa/core — Business logic services
|
||||
│ ├── db/ # @sofa/db — Database client, schema, migrations, constants, logger
|
||||
│ └── tmdb/ # @sofa/tmdb — TMDB API client + image URL helper
|
||||
├── turbo.json # Turborepo task configuration
|
||||
├── biome.json # Shared Biome config
|
||||
├── Dockerfile # Multi-stage Docker build (turbo prune, single process)
|
||||
└── package.json # Root workspace config
|
||||
```
|
||||
|
||||
- **`@sofa/server`** (`apps/server/`) — Hono API server. Hosts oRPC procedures, Better Auth, webhook/image/backup routes, and cron jobs. Runs DB migrations on startup. In production, also serves the SPA static files on port 3000. In dev, runs on port 3001.
|
||||
- **`@sofa/web`** (`apps/web/`) — Vite SPA with TanStack Router (file-based routing). No SSR, no DB access, no services. All data fetched via oRPC client calls. Vite dev server proxies `/api/*` and `/rpc/*` to the API server.
|
||||
- **`@sofa/api`** (`packages/api/`) — JIT package with the oRPC contract and Zod schemas. No build step.
|
||||
- **`@sofa/db`** (`packages/db/`) — Database layer: Drizzle schema, client, migrations, constants, logger.
|
||||
- **`@sofa/tmdb`** (`packages/tmdb/`) — TMDB API client (openapi-fetch) and image URL construction.
|
||||
- **`@sofa/core`** (`packages/core/`) — All business logic services (metadata, tracking, discovery, etc.).
|
||||
- **`@sofa/auth`** (`packages/auth/`) — Better Auth server config + environment checks.
|
||||
|
||||
All shared packages are JIT (raw TypeScript exports, no build step). Consumers transpile via their own bundlers.
|
||||
|
||||
### Stack
|
||||
|
||||
- **Frontend**: Vite 7 SPA, React 19, TypeScript, TanStack Router (file-based routing)
|
||||
- **API Server**: Hono (standalone Bun server)
|
||||
- **Monorepo**: Turborepo with Bun workspaces
|
||||
- **Database**: SQLite via bun:sqlite + Drizzle ORM (WAL mode, singleton via `globalThis`, sync queries, auto-migrations on startup)
|
||||
- **Auth**: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO via `genericOAuth` plugin
|
||||
- **Styling**: Tailwind CSS v4 (via `@tailwindcss/vite`), shadcn components, dark cinema theme with warm primary accents
|
||||
- **Fonts**: DM Serif Display (display), DM Sans (body), Geist Mono (mono) — self-hosted via `@fontsource`
|
||||
- **API**: oRPC (contract-first, type-safe RPC) with `@orpc/tanstack-query` for TanStack Query integration
|
||||
- **State**: Jotai (client), TanStack Query (data fetching & mutations via oRPC)
|
||||
- **Linting**: Biome (2-space indent, organized imports, React recommended rules)
|
||||
- **External API**: TMDB (The Movie Database) with Bearer token auth
|
||||
|
||||
### Package imports
|
||||
|
||||
Within `apps/web/`, `@/*` maps to `apps/web/src/`, e.g. `@/components/nav-bar` → `apps/web/src/components/nav-bar`.
|
||||
|
||||
Cross-package imports use the package name:
|
||||
- `@sofa/api/contract`, `@sofa/api/schemas` — Contract and types
|
||||
- `@sofa/db/client`, `@sofa/db/schema`, `@sofa/db/constants`, `@sofa/db/logger` — Database layer
|
||||
- `@sofa/tmdb/client`, `@sofa/tmdb/image` — TMDB client
|
||||
- `@sofa/core/metadata`, `@sofa/core/tracking`, etc. — Business logic
|
||||
- `@sofa/auth/server`, `@sofa/auth/config` — Auth config
|
||||
|
||||
### Key directories
|
||||
|
||||
**API server** (`apps/server/src/`):
|
||||
- `index.ts` — Hono app entry point, startup, CORS, route mounting, graceful shutdown
|
||||
- `cron.ts` — Background job scheduler (croner)
|
||||
- `orpc/` — oRPC server layer (context, middleware, router, handler, procedures/)
|
||||
- `routes/` — Non-RPC Hono routes (auth, images, avatars, backups, webhooks, lists, health)
|
||||
|
||||
**Web app** (`apps/web/src/`):
|
||||
- `main.tsx` — React root: creates router + renders `<RouterProvider />`
|
||||
- `routes/` — TanStack Router file-based routes (auto-generates `routeTree.gen.ts`)
|
||||
- `__root.tsx` — Root layout (providers, head meta, global error/not-found)
|
||||
- `_app.tsx` — Authenticated layout (auth guard via `beforeLoad`, navbar, shell)
|
||||
- `_app/dashboard.tsx`, `_app/explore.tsx`, `_app/settings.tsx`, `_app/titles.$id.tsx`, `_app/people.$id.tsx`
|
||||
- `_auth.tsx` — Auth layout (centering wrapper)
|
||||
- `_auth/login.tsx`, `_auth/register.tsx`
|
||||
- `index.tsx`, `setup.tsx`
|
||||
- `components/` — App components + `components/ui/` for shadcn primitives
|
||||
- `lib/orpc/client.ts` — oRPC client (always same-origin via Vite proxy or Hono static serving)
|
||||
- `lib/orpc/tanstack.ts` — `orpc` utils for TanStack Query
|
||||
- `lib/auth/client.ts` — Better Auth client hooks
|
||||
- `lib/theme.ts` — Color theme CSS properties from title palettes
|
||||
- `styles/globals.css` — Tailwind + font imports
|
||||
|
||||
**Shared packages**:
|
||||
- `packages/api/src/contract.ts` — Full oRPC API contract definition (~30 procedures)
|
||||
- `packages/api/src/schemas.ts` — Shared Zod schemas and inferred TypeScript types
|
||||
- `packages/db/src/schema.ts` — All Drizzle table definitions
|
||||
- `packages/db/drizzle/` — Migration files
|
||||
- `packages/core/src/` — All service files (metadata, tracking, discovery, availability, credits, person, webhooks, backup, settings, colors, update-check, system-health, lists, image-cache)
|
||||
- `packages/core/test/` — Service tests with in-memory SQLite
|
||||
|
||||
### Auth pattern
|
||||
|
||||
**Web app** — Route `beforeLoad` guards check session via Better Auth client SDK:
|
||||
|
||||
```typescript
|
||||
// In route file (e.g. _app.tsx)
|
||||
beforeLoad: async () => {
|
||||
const { data: session } = await authClient.getSession();
|
||||
if (!session) throw redirect({ to: "/login" });
|
||||
return { session };
|
||||
},
|
||||
```
|
||||
|
||||
Session is available to child routes via `Route.useRouteContext()`.
|
||||
|
||||
**API server** — oRPC procedures use auth middleware that calls Better Auth:
|
||||
|
||||
```typescript
|
||||
// apps/server/src/orpc/middleware.ts
|
||||
export const authed = base.middleware(async ({ context, next }) => {
|
||||
const session = await auth.api.getSession({ headers: context.headers });
|
||||
if (!session) throw new ORPCError("UNAUTHORIZED");
|
||||
return next({ context: { user: session.user, session: session.session } });
|
||||
});
|
||||
```
|
||||
|
||||
### oRPC API layer
|
||||
|
||||
All API procedures use oRPC with a contract-first approach. The contract is defined in `packages/api/src/contract.ts`, procedures implement it in `apps/server/src/orpc/procedures/`, and the client consumes it with full type safety.
|
||||
|
||||
**Pattern — importing types from the shared package:**
|
||||
```typescript
|
||||
import type { ResolvedTitle, Season } from "@sofa/api/schemas";
|
||||
import { contract } from "@sofa/api/contract";
|
||||
```
|
||||
|
||||
**Pattern — query in component:**
|
||||
```typescript
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
const { data } = useQuery(orpc.dashboard.stats.queryOptions());
|
||||
```
|
||||
|
||||
**Pattern — mutation:**
|
||||
```typescript
|
||||
import { client } from "@/lib/orpc/client";
|
||||
|
||||
await client.titles.updateStatus({ id: titleId, status: "in_progress" });
|
||||
```
|
||||
|
||||
**Pattern — route loader with TanStack Query prefetch:**
|
||||
```typescript
|
||||
export const Route = createFileRoute("/_app/titles/$id")({
|
||||
loader: async ({ params, context }) => {
|
||||
await context.queryClient.ensureQueryData(
|
||||
orpc.titles.detail.queryOptions({ input: { id: params.id } }),
|
||||
);
|
||||
},
|
||||
component: TitlePage,
|
||||
});
|
||||
```
|
||||
|
||||
**Page patterns:**
|
||||
- **Route loaders** (titles, people): `beforeLoad`/`loader` prefetch data into TanStack Query cache via `queryClient.ensureQueryData()`. Components read via `useQuery()`/`useSuspenseQuery()`.
|
||||
- **Full client-side** (dashboard, explore, settings): Components fetch all data via `orpc.*.queryOptions()` with skeleton loading states.
|
||||
- **Error/loading states**: Routes use `pendingComponent`, `errorComponent`, `notFoundComponent`.
|
||||
|
||||
### Non-RPC routes (API server)
|
||||
|
||||
Hono route handlers in `apps/server/src/routes/`:
|
||||
- `/images/:category/:filename` — Image proxy/cache serving
|
||||
- `/api/auth/*` — Better Auth catch-all
|
||||
- `/api/avatars/:userId` — Avatar file serving
|
||||
- `/api/backup/:filename` — Backup file download
|
||||
- `/api/webhooks/:token` — Plex/Jellyfin/Emby webhooks
|
||||
- `/api/lists/:token` — External list feeds (Sonarr/Radarr)
|
||||
- `/api/health` — Simple health check (no auth)
|
||||
|
||||
In dev, Vite proxies these to the API server. In production, Hono serves both API and SPA — the browser only sees port 3000.
|
||||
|
||||
### Database schema
|
||||
|
||||
All app tables use UUIDv7 text primary keys generated via `Bun.randomUUIDv7()`. Better Auth tables use their own ID format. Key relationships:
|
||||
|
||||
- `titles` → `seasons` → `episodes` (TV hierarchy)
|
||||
- `persons` + `titleCast` (cast/crew linked to titles)
|
||||
- `userTitleStatus` links users to titles with status (watchlist/in_progress/completed)
|
||||
- `userMovieWatches` / `userEpisodeWatches` track watch history (source: manual/import/plex/jellyfin/emby)
|
||||
- `userRatings` stores 1-5 star ratings
|
||||
- `availabilityOffers` caches streaming provider data per title
|
||||
- `titleRecommendations` stores TMDB recommendations and similar titles
|
||||
- `webhookConnections` / `webhookEventLog` — Plex/Jellyfin/Emby webhook integrations
|
||||
- `cronRuns` — Background job execution history
|
||||
- `appSettings` — Key-value store for runtime app configuration
|
||||
|
||||
### Service layer (`packages/core/src/`)
|
||||
|
||||
- **metadata.ts**: `importTitle()` fetches from TMDB, inserts into DB, fire-and-forgets availability + recommendations + credits + image caching. `refreshTvChildren()` fetches all seasons/episodes with 250ms rate limiting.
|
||||
- **image-cache.ts**: Downloads and caches TMDB images to local disk.
|
||||
- **tracking.ts**: Auto-transitions — logging a movie watch sets status to `completed`; logging an episode watch sets `in_progress`; all episodes watched auto-completes the series.
|
||||
- **discovery.ts**: Feed generators — continue watching, library with availability, personalized recommendations.
|
||||
- **availability.ts**: Caches US streaming providers from TMDB watch/providers endpoint.
|
||||
- **credits.ts**: Fetches and caches cast/crew data from TMDB.
|
||||
- **person.ts**: Person detail and filmography lookups.
|
||||
- **webhooks.ts**: Processes incoming Plex/Jellyfin/Emby webhook events.
|
||||
- **backup.ts**: Database backup/restore with configurable scheduled backups.
|
||||
- **settings.ts**: Runtime app settings via `appSettings` table.
|
||||
- **colors.ts**: Extracts dominant color palettes from title posters via node-vibrant.
|
||||
- **update-check.ts**: Checks for new Sofa releases.
|
||||
- **system-health.ts**: System health diagnostics.
|
||||
|
||||
### TMDB images
|
||||
|
||||
Only paths are stored in DB. Image URLs are resolved by the API server — `tmdbImageUrl()` from `@sofa/tmdb/image` is called before sending data to clients. Client components receive ready-to-use URLs.
|
||||
|
||||
When `IMAGE_CACHE_ENABLED` is set (default), images are downloaded to local disk and served via `/images/:category/:filename`. When disabled, `tmdbImageUrl()` returns direct TMDB CDN URLs.
|
||||
|
||||
### Background jobs
|
||||
|
||||
Defined in `apps/server/src/cron.ts` using croner, started when the API server boots. Jobs (all use `protect: true` to prevent overlapping runs, 300ms delay between TMDB calls):
|
||||
- `nightlyRefreshLibrary` (`0 3 * * *`) — refreshes stale library titles (7d) and non-library titles (30d)
|
||||
- `refreshAvailability` (`0 */6 * * *`) — streaming provider data
|
||||
- `refreshRecommendations` (`0 */12 * * *`)
|
||||
- `refreshTvChildren` (`30 */12 * * *`) — episodes for returning/in-production TV shows
|
||||
- `cacheImages` (`0 1,13 * * *`) — posters, backdrops, stills, logos, profile photos
|
||||
- `refreshCredits` (`0 2 * * *`) — cast/crew data for library titles
|
||||
- `scheduledBackup` (configurable via settings) — database backups with retention pruning
|
||||
- `updateCheck` (`0 */6 * * *`) — checks for new Sofa releases
|
||||
|
||||
### Environment variables
|
||||
|
||||
`DATA_DIR` (root for DB + cache, default `./data`), `TMDB_API_READ_ACCESS_TOKEN`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`. `DATABASE_URL` and `CACHE_DIR` are derived from `DATA_DIR` but can be overridden individually.
|
||||
|
||||
Optional: `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_ISSUER_URL`, `OIDC_PROVIDER_NAME`, `OIDC_AUTO_REGISTER`, `DISABLE_PASSWORD_LOGIN` (OIDC/SSO support). `LOG_LEVEL` (error/warn/info/debug, default: info). `IMAGE_CACHE_ENABLED` (default: true).
|
||||
|
||||
### Docker deployment
|
||||
|
||||
Single container, single process. Hono serves both the API and the Vite-built SPA static files on port 3000. API routes (`/rpc/*`, `/api/*`) are mounted first; unmatched routes fall back to `index.html` for SPA routing.
|
||||
|
||||
## Browser Automation
|
||||
|
||||
Use `agent-browser` for web automation. Run `agent-browser --help` for all commands.
|
||||
|
||||
Core workflow:
|
||||
1. `agent-browser open <url>` - Navigate to page
|
||||
2. `agent-browser snapshot -i` - Get interactive elements with refs (@e1, @e2)
|
||||
3. `agent-browser click @e1` / `fill @e2 "text"` - Interact using refs
|
||||
4. Re-snapshot after page changes
|
||||
|
||||
When encountering auth, use `demo@sofa.watch` for the username/email and `password` for the password.
|
||||
@@ -128,9 +128,8 @@ Useful commands:
|
||||
- `bun run test`
|
||||
- `bun run 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
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
EXPO_PUBLIC_POSTHOG_KEY=
|
||||
EXPO_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
@@ -0,0 +1,33 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Expo prebuild
|
||||
/android
|
||||
/ios
|
||||
|
||||
# Google Play service account key
|
||||
google-service-account.json
|
||||
|
||||
# Temporary files created by Metro to check the health of the file watcher
|
||||
.metro-health-check*
|
||||
|
||||
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||
# The following patterns were generated by expo-cli
|
||||
|
||||
expo-env.d.ts
|
||||
# @end expo-cli
|
||||
|
||||
credentials.json
|
||||
credentials/
|
||||
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Sofa",
|
||||
"slug": "sofa",
|
||||
"owner": "jakejarvis",
|
||||
"scheme": "sofa",
|
||||
"orientation": "portrait",
|
||||
"userInterfaceStyle": "dark",
|
||||
"ios": {
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
},
|
||||
"icon": "./assets/images/sofa.icon",
|
||||
"bundleIdentifier": "com.jakejarvis.sofa",
|
||||
"appleTeamId": "B5ZWKBCUTU"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"package": "com.jakejarvis.sofa"
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-router",
|
||||
{
|
||||
"root": "./src/app"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-secure-store",
|
||||
{
|
||||
"configureAndroidBackup": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-font",
|
||||
{
|
||||
"ios": {
|
||||
"fonts": [
|
||||
"../../node_modules/@expo-google-fonts/dm-sans/400Regular/DMSans_400Regular.ttf",
|
||||
"../../node_modules/@expo-google-fonts/dm-sans/500Medium/DMSans_500Medium.ttf",
|
||||
"../../node_modules/@expo-google-fonts/dm-sans/600SemiBold/DMSans_600SemiBold.ttf",
|
||||
"../../node_modules/@expo-google-fonts/dm-sans/700Bold/DMSans_700Bold.ttf",
|
||||
"../../node_modules/@expo-google-fonts/dm-serif-display/400Regular/DMSerifDisplay_400Regular.ttf",
|
||||
"../../node_modules/@expo-google-fonts/geist-mono/400Regular/GeistMono_400Regular.ttf"
|
||||
]
|
||||
},
|
||||
"android": {
|
||||
"fonts": [
|
||||
{
|
||||
"fontFamily": "DMSans-Regular",
|
||||
"fontDefinitions": [
|
||||
{
|
||||
"path": "../../node_modules/@expo-google-fonts/dm-sans/400Regular/DMSans_400Regular.ttf",
|
||||
"weight": 400
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"fontFamily": "DMSans-Medium",
|
||||
"fontDefinitions": [
|
||||
{
|
||||
"path": "../../node_modules/@expo-google-fonts/dm-sans/500Medium/DMSans_500Medium.ttf",
|
||||
"weight": 500
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"fontFamily": "DMSans-SemiBold",
|
||||
"fontDefinitions": [
|
||||
{
|
||||
"path": "../../node_modules/@expo-google-fonts/dm-sans/600SemiBold/DMSans_600SemiBold.ttf",
|
||||
"weight": 600
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"fontFamily": "DMSans-Bold",
|
||||
"fontDefinitions": [
|
||||
{
|
||||
"path": "../../node_modules/@expo-google-fonts/dm-sans/700Bold/DMSans_700Bold.ttf",
|
||||
"weight": 700
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"fontFamily": "DMSerifDisplay-Regular",
|
||||
"fontDefinitions": [
|
||||
{
|
||||
"path": "../../node_modules/@expo-google-fonts/dm-serif-display/400Regular/DMSerifDisplay_400Regular.ttf",
|
||||
"weight": 400
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"fontFamily": "GeistMono-Regular",
|
||||
"fontDefinitions": [
|
||||
{
|
||||
"path": "../../node_modules/@expo-google-fonts/geist-mono/400Regular/GeistMono_400Regular.ttf",
|
||||
"weight": 400
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"backgroundColor": "#101010",
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200
|
||||
}
|
||||
],
|
||||
["expo-system-ui"],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"microphonePermission": false
|
||||
}
|
||||
],
|
||||
["expo-localization"]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
},
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "a9f021eb-f58d-49af-b291-d72f7dc34051"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 764 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 719 KiB |
|
After Width: | Height: | Size: 719 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,37 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
|
||||
<defs>
|
||||
<radialGradient id="b" cx=".5" cy=".33" r=".4">
|
||||
<stop offset="0%" stop-color="#e8a87c" stop-opacity=".2"/>
|
||||
<stop offset="50%" stop-color="#c48060" stop-opacity=".06"/>
|
||||
<stop offset="100%" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="f" cx=".5" cy=".5" r=".7">
|
||||
<stop offset="55%" stop-opacity="0"/>
|
||||
<stop offset="100%" stop-opacity=".3"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="c" cx=".5" cy=".5" r=".5">
|
||||
<stop offset="0%" stop-opacity=".25"/>
|
||||
<stop offset="100%" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="a" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#151210"/>
|
||||
<stop offset="100%" stop-color="#080605"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="d" x1="12" y1="5" x2="12" y2="13" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#f0c9a4"/>
|
||||
<stop offset="100%" stop-color="#dda278"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="e" x1="12" y1="11" x2="12" y2="19.5" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#e8a87c"/>
|
||||
<stop offset="100%" stop-color="#ba7d52"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g clip-path="url(#m)">
|
||||
<path fill="url(#a)" d="M0 0h1024v1024H0z"/>
|
||||
<path fill="url(#b)" d="M0 0h1024v1024H0z"/>
|
||||
<ellipse cx="512" cy="710" rx="220" ry="22" fill="url(#c)"/>
|
||||
<path fill="url(#d)" d="M7 12v1h10v-1a3 3 0 0 1 2.993-3 4.6 4.6 0 0 0-.07-.78 4 4 0 0 0-3.143-3.143C16.394 5 15.93 5 15 5H9c-.93 0-1.394 0-1.78.077A4 4 0 0 0 4.077 8.22a4.6 4.6 0 0 0-.07.78A3 3 0 0 1 7 12" transform="matrix(28 0 0 28 176 172)"/>
|
||||
<path fill="url(#e)" d="M18.444 18H5.556a3.6 3.6 0 0 1-.806-.092V19a.75.75 0 0 1-1.5 0v-1.849A3.55 3.55 0 0 1 2 14.444V12a2 2 0 1 1 4 0v1.2a.8.8 0 0 0 .8.8h10.4a.8.8 0 0 0 .8-.8V12a2 2 0 1 1 4 0v2.444a3.55 3.55 0 0 1-1.25 2.707V19a.75.75 0 0 1-1.5 0v-1.092a3.6 3.6 0 0 1-.806.092" transform="matrix(28 0 0 28 176 172)"/>
|
||||
<path fill="url(#f)" d="M0 0h1024v1024H0z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"fill": {
|
||||
"linear-gradient": [
|
||||
"display-p3:0.89697,0.77637,0.64404,1.00000",
|
||||
"display-p3:0.07726,0.07726,0.07726,1.00000"
|
||||
],
|
||||
"orientation": {
|
||||
"start": {
|
||||
"x": 0.5,
|
||||
"y": 0
|
||||
},
|
||||
"stop": {
|
||||
"x": 0.5,
|
||||
"y": 0.7
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups": [
|
||||
{
|
||||
"blend-mode": "normal",
|
||||
"blur-material": null,
|
||||
"hidden": false,
|
||||
"layers": [
|
||||
{
|
||||
"blend-mode": "normal",
|
||||
"fill": "none",
|
||||
"glass": true,
|
||||
"image-name": "couch-with-glow.svg",
|
||||
"name": "couch-with-glow"
|
||||
}
|
||||
],
|
||||
"lighting": "individual",
|
||||
"shadow": {
|
||||
"kind": "neutral",
|
||||
"opacity": 0.5
|
||||
},
|
||||
"specular": true,
|
||||
"translucency": {
|
||||
"enabled": true,
|
||||
"value": 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms": {
|
||||
"squares": ["iOS"]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 129 KiB |
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 18.3.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"production": {
|
||||
"autoIncrement": true,
|
||||
"ios": {
|
||||
"resourceClass": "m-medium"
|
||||
}
|
||||
},
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"ascAppId": "6760432427"
|
||||
},
|
||||
"android": {
|
||||
"serviceAccountKeyPath": "./google-service-account.json",
|
||||
"track": "internal"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { withUniwindConfig } = require("uniwind/metro");
|
||||
const {
|
||||
wrapWithReanimatedMetroConfig,
|
||||
} = require("react-native-reanimated/metro-config");
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
const uniwindConfig = withUniwindConfig(wrapWithReanimatedMetroConfig(config), {
|
||||
cssEntryFile: "./src/global.css",
|
||||
dtsFile: "./uniwind-types.d.ts",
|
||||
});
|
||||
|
||||
module.exports = uniwindConfig;
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "@sofa/native",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"dev": "expo start --clear",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"prebuild": "expo prebuild",
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/expo": "catalog:",
|
||||
"@expo-google-fonts/dm-sans": "0.4.2",
|
||||
"@expo-google-fonts/dm-serif-display": "0.4.2",
|
||||
"@expo-google-fonts/geist-mono": "0.4.1",
|
||||
"@expo/metro-runtime": "55.0.6",
|
||||
"@gorhom/bottom-sheet": "5.2.8",
|
||||
"@orpc/client": "catalog:",
|
||||
"@orpc/contract": "catalog:",
|
||||
"@orpc/tanstack-query": "catalog:",
|
||||
"@react-native-menu/menu": "2.0.0",
|
||||
"@react-navigation/elements": "2.9.10",
|
||||
"@sofa/api": "workspace:*",
|
||||
"@sofa/tmdb": "workspace:*",
|
||||
"@tabler/icons-react-native": "3.40.0",
|
||||
"@tanstack/query-async-storage-persister": "5.90.24",
|
||||
"@tanstack/react-form": "1.28.5",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-persist-client": "5.90.24",
|
||||
"better-auth": "catalog:",
|
||||
"expo": "55.0.6",
|
||||
"expo-application": "55.0.9",
|
||||
"expo-clipboard": "55.0.8",
|
||||
"expo-constants": "55.0.7",
|
||||
"expo-dev-client": "55.0.16",
|
||||
"expo-device": "55.0.9",
|
||||
"expo-font": "55.0.4",
|
||||
"expo-haptics": "55.0.8",
|
||||
"expo-image": "55.0.6",
|
||||
"expo-image-picker": "55.0.12",
|
||||
"expo-linking": "55.0.7",
|
||||
"expo-localization": "55.0.8",
|
||||
"expo-network": "55.0.8",
|
||||
"expo-router": "55.0.5",
|
||||
"expo-secure-store": "55.0.8",
|
||||
"expo-splash-screen": "55.0.10",
|
||||
"expo-status-bar": "55.0.4",
|
||||
"expo-system-ui": "55.0.9",
|
||||
"expo-web-browser": "55.0.9",
|
||||
"posthog-react-native": "4.37.2",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.2",
|
||||
"react-native-gesture-handler": "2.30.0",
|
||||
"react-native-ios-context-menu": "3.2.0",
|
||||
"react-native-ios-utilities": "5.2.0",
|
||||
"react-native-keyboard-controller": "1.20.7",
|
||||
"react-native-mmkv": "4.2.0",
|
||||
"react-native-nitro-modules": "0.35.0",
|
||||
"react-native-reanimated": "4.2.1",
|
||||
"react-native-safe-area-context": "5.6.2",
|
||||
"react-native-screens": "4.23.0",
|
||||
"react-native-svg": "15.15.3",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"tailwind-merge": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"uniwind": "1.5.0",
|
||||
"zeego": "3.0.6",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { useResolveClassNames } from "uniwind";
|
||||
import { hasStoredServerUrl } from "@/lib/server-url";
|
||||
|
||||
export const unstable_settings = {
|
||||
initialRouteName:
|
||||
process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl()
|
||||
? "login"
|
||||
: "server-url",
|
||||
};
|
||||
|
||||
export default function AuthLayout() {
|
||||
const contentStyle = useResolveClassNames("bg-background");
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle,
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "expo-router";
|
||||
import { useRef } from "react";
|
||||
import { Alert, Pressable, type TextInput, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||
import { z } from "zod";
|
||||
import { AuthScreen } from "@/components/auth-screen";
|
||||
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
FieldError,
|
||||
Input,
|
||||
Label,
|
||||
TextField,
|
||||
} from "@/components/ui/text-field";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
const signInSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Email is required")
|
||||
.email("Enter a valid email address"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
});
|
||||
|
||||
function formatFormErrors(errors: unknown): string | null {
|
||||
if (!errors) return null;
|
||||
if (typeof errors === "string") return errors;
|
||||
if (typeof errors === "object") {
|
||||
const first = Object.values(errors as Record<string, { message: string }[]>)
|
||||
.flat()
|
||||
.find((e) => e.message);
|
||||
if (first) return first.message;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function LoginScreen() {
|
||||
const passwordRef = useRef<TextInput>(null);
|
||||
|
||||
const authConfig = useQuery(orpc.system.authConfig.queryOptions());
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: { email: "", password: "" },
|
||||
validators: { onSubmit: signInSchema },
|
||||
onSubmit: async ({ value, formApi }) => {
|
||||
await authClient.signIn.email(
|
||||
{ email: value.email.trim(), password: value.password },
|
||||
{
|
||||
onError(error) {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
Alert.alert("Error", error.error?.message || "Failed to sign in");
|
||||
},
|
||||
onSuccess() {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
formApi.reset();
|
||||
queryClient.invalidateQueries();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const showPasswordLogin = !authConfig.data?.passwordLoginDisabled;
|
||||
const showOidc = authConfig.data?.oidcEnabled;
|
||||
const showRegister = authConfig.data?.registrationOpen;
|
||||
|
||||
return (
|
||||
<AuthScreen title="Sofa" subtitle="Sign in to continue">
|
||||
{showOidc && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300).delay(100)}
|
||||
className="mb-4"
|
||||
>
|
||||
<Button
|
||||
onPress={() => {
|
||||
authClient.signIn.oauth2({
|
||||
providerId: "oidc",
|
||||
callbackURL: "/(tabs)/(home)",
|
||||
});
|
||||
}}
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
>
|
||||
<ButtonLabel>
|
||||
Sign in with {authConfig.data?.oidcProviderName ?? "SSO"}
|
||||
</ButtonLabel>
|
||||
</Button>
|
||||
|
||||
{showPasswordLogin && (
|
||||
<View className="my-4 flex-row items-center">
|
||||
<View className="h-px flex-1 bg-border" />
|
||||
<Text className="px-3 text-muted-foreground text-xs">OR</Text>
|
||||
<View className="h-px flex-1 bg-border" />
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{showPasswordLogin && (
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
isSubmitting: state.isSubmitting,
|
||||
validationError: formatFormErrors(state.errorMap.onSubmit),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting, validationError }) => (
|
||||
<View className="gap-3">
|
||||
{validationError && (
|
||||
<FieldError isInvalid className="mb-1">
|
||||
{validationError}
|
||||
</FieldError>
|
||||
)}
|
||||
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="email@example.com"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
textContentType="emailAddress"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => passwordRef.current?.focus()}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
ref={passwordRef}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="••••••••"
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
textContentType="password"
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={form.handleSubmit}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
||||
<Button
|
||||
onPress={form.handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="mt-1 bg-primary"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<ButtonLabel>Sign In</ButtonLabel>
|
||||
)}
|
||||
</Button>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
)}
|
||||
|
||||
{showRegister && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(300).delay(500)}
|
||||
className="mt-6 items-center"
|
||||
>
|
||||
<Link href="/(auth)/register" asChild>
|
||||
<Pressable>
|
||||
<Text className="text-primary text-sm">Create an account</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(300).delay(500)}
|
||||
className="mt-4 items-center"
|
||||
>
|
||||
<Link href="/(auth)/server-url" asChild>
|
||||
<Pressable>
|
||||
<Text className="text-muted-foreground text-xs">Change server</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</Animated.View>
|
||||
</AuthScreen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "expo-router";
|
||||
import { useRef } from "react";
|
||||
import { Alert, Pressable, type TextInput, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||
import { z } from "zod";
|
||||
import { AuthScreen } from "@/components/auth-screen";
|
||||
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
FieldError,
|
||||
Input,
|
||||
Label,
|
||||
TextField,
|
||||
} from "@/components/ui/text-field";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
const signUpSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Name is required")
|
||||
.min(2, "Name must be at least 2 characters"),
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Email is required")
|
||||
.email("Enter a valid email address"),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, "Password is required")
|
||||
.min(8, "Use at least 8 characters"),
|
||||
});
|
||||
|
||||
function formatFormErrors(errors: unknown): string | null {
|
||||
if (!errors) return null;
|
||||
if (typeof errors === "string") return errors;
|
||||
if (typeof errors === "object") {
|
||||
const first = Object.values(errors as Record<string, { message: string }[]>)
|
||||
.flat()
|
||||
.find((e) => e.message);
|
||||
if (first) return first.message;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const emailRef = useRef<TextInput>(null);
|
||||
const passwordRef = useRef<TextInput>(null);
|
||||
|
||||
const publicInfo = useQuery(orpc.system.publicInfo.queryOptions());
|
||||
const registrationOpen = publicInfo.data?.registrationOpen ?? false;
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: { name: "", email: "", password: "" },
|
||||
validators: { onSubmit: signUpSchema },
|
||||
onSubmit: async ({ value, formApi }) => {
|
||||
await authClient.signUp.email(
|
||||
{
|
||||
name: value.name.trim(),
|
||||
email: value.email.trim(),
|
||||
password: value.password,
|
||||
},
|
||||
{
|
||||
onError(error) {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
error.error?.message || "Failed to create account",
|
||||
);
|
||||
},
|
||||
onSuccess() {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
formApi.reset();
|
||||
queryClient.invalidateQueries();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (!registrationOpen && !publicInfo.isPending) {
|
||||
return (
|
||||
<AuthScreen
|
||||
title="Registration Closed"
|
||||
subtitle="New account creation is currently disabled."
|
||||
>
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||
<Link href="/(auth)/login" asChild>
|
||||
<Button className="mt-6 bg-primary">
|
||||
<ButtonLabel className="text-primary-foreground">
|
||||
Back to Login
|
||||
</ButtonLabel>
|
||||
</Button>
|
||||
</Link>
|
||||
</Animated.View>
|
||||
</AuthScreen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthScreen title="Create Account">
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
isSubmitting: state.isSubmitting,
|
||||
validationError: formatFormErrors(state.errorMap.onSubmit),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting, validationError }) => (
|
||||
<View className="gap-3">
|
||||
{validationError && (
|
||||
<FieldError isInvalid className="mb-1">
|
||||
{validationError}
|
||||
</FieldError>
|
||||
)}
|
||||
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="Your name"
|
||||
autoComplete="name"
|
||||
textContentType="name"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => emailRef.current?.focus()}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
ref={emailRef}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="email@example.com"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
textContentType="emailAddress"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => passwordRef.current?.focus()}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
ref={passwordRef}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="••••••••"
|
||||
secureTextEntry
|
||||
autoComplete="new-password"
|
||||
textContentType="newPassword"
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={form.handleSubmit}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
||||
<Button
|
||||
onPress={form.handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="mt-1 bg-primary"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<ButtonLabel>Create Account</ButtonLabel>
|
||||
)}
|
||||
</Button>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(300).delay(500)}
|
||||
className="mt-6 items-center"
|
||||
>
|
||||
<Link href="/(auth)/login" asChild>
|
||||
<Pressable>
|
||||
<Text className="text-primary text-sm">
|
||||
Already have an account? Sign in
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</Animated.View>
|
||||
</AuthScreen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconCircleCheck,
|
||||
IconInfoCircle,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Linking, Pressable, TextInput, View } from "react-native";
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeInDown,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { AuthScreen } from "@/components/auth-screen";
|
||||
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
getServerUrl,
|
||||
hasStoredServerUrl,
|
||||
normalizeUrl,
|
||||
setServerUrl,
|
||||
type ValidationError,
|
||||
validateServerUrl,
|
||||
} from "@/lib/server-url";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
type ConnectionState =
|
||||
| { phase: "idle" }
|
||||
| { phase: "connecting" }
|
||||
| { phase: "success" }
|
||||
| { phase: "error"; error: ValidationError };
|
||||
|
||||
const ERROR_MESSAGES: Record<ValidationError, string> = {
|
||||
network_unreachable:
|
||||
"Could not reach the server. Check your connection and the URL.",
|
||||
timeout: "Connection timed out. The server might be down or unreachable.",
|
||||
not_sofa_server:
|
||||
"This doesn't appear to be a Sofa server. Double-check the URL.",
|
||||
server_unhealthy:
|
||||
"Server found but reporting an issue. Try again in a moment.",
|
||||
invalid_url: "That URL doesn't look right. Include the full server address.",
|
||||
};
|
||||
|
||||
export default function ServerUrlScreen() {
|
||||
const { replace } = useRouter();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const successTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const [url, setUrl] = useState(() =>
|
||||
hasStoredServerUrl() ? getServerUrl() : "",
|
||||
);
|
||||
const [connection, setConnection] = useState<ConnectionState>({
|
||||
phase: "idle",
|
||||
});
|
||||
|
||||
const statusCompletedColor = useCSSVariable(
|
||||
"--color-status-completed",
|
||||
) as string;
|
||||
const destructiveColor = useCSSVariable("--color-destructive") as string;
|
||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
const [isFirstLaunch] = useState(() => !hasStoredServerUrl());
|
||||
|
||||
// Icon pulse animation
|
||||
const iconOpacity = useSharedValue(1);
|
||||
const iconAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: iconOpacity.get(),
|
||||
}));
|
||||
|
||||
// Pulsing dot animation for connecting state
|
||||
const dotOpacity = useSharedValue(0.4);
|
||||
const dotAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: dotOpacity.get(),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (connection.phase === "connecting") {
|
||||
iconOpacity.set(withRepeat(withTiming(0.4, { duration: 600 }), -1, true));
|
||||
dotOpacity.set(withRepeat(withTiming(1, { duration: 600 }), -1, true));
|
||||
} else {
|
||||
iconOpacity.set(withTiming(1, { duration: 200 }));
|
||||
dotOpacity.set(0.4);
|
||||
}
|
||||
}, [connection.phase, iconOpacity, dotOpacity]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (successTimeout.current !== null) clearTimeout(successTimeout.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-focus on first launch
|
||||
useEffect(() => {
|
||||
if (isFirstLaunch) {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isFirstLaunch]);
|
||||
|
||||
const handleChangeText = (text: string) => {
|
||||
setUrl(text);
|
||||
if (connection.phase === "error") {
|
||||
setConnection({ phase: "idle" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async () => {
|
||||
const trimmed = url.trim().replace(/\/+$/, "");
|
||||
if (!trimmed) return;
|
||||
|
||||
const fullUrl = normalizeUrl(trimmed);
|
||||
setConnection({ phase: "connecting" });
|
||||
|
||||
const result = await validateServerUrl(fullUrl);
|
||||
|
||||
if (result.status === "success") {
|
||||
setConnection({ phase: "success" });
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setServerUrl(fullUrl);
|
||||
successTimeout.current = setTimeout(() => {
|
||||
replace("/(auth)/login");
|
||||
}, 800);
|
||||
} else {
|
||||
setConnection({ phase: "error", error: result.error });
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
}
|
||||
};
|
||||
|
||||
const isConnecting = connection.phase === "connecting";
|
||||
const isSuccess = connection.phase === "success";
|
||||
const isDisabled = isConnecting || isSuccess;
|
||||
|
||||
return (
|
||||
<AuthScreen
|
||||
title="Sofa"
|
||||
subtitle="Enter your Sofa server URL to get started"
|
||||
logoStyle={iconAnimatedStyle}
|
||||
>
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||
<View
|
||||
className="h-12 flex-row items-center rounded-[12px] border border-border bg-input px-3.5"
|
||||
style={{ borderCurve: "continuous" }}
|
||||
>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={url}
|
||||
onChangeText={handleChangeText}
|
||||
placeholder="https://sofa.example.com"
|
||||
placeholderTextColorClassName="accent-muted-foreground/50"
|
||||
keyboardType="url"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
textContentType="URL"
|
||||
returnKeyType="go"
|
||||
editable={!isDisabled}
|
||||
onSubmitEditing={handleConnect}
|
||||
className="flex-1 py-0 font-mono text-[15px] text-foreground"
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Connect Button / Status */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300).delay(300)}
|
||||
className="mt-4"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<View className="h-12 flex-row items-center justify-center gap-2">
|
||||
<Animated.View
|
||||
className="size-1.5 rounded-full bg-primary"
|
||||
style={dotAnimatedStyle}
|
||||
/>
|
||||
<Text className="text-[13px] text-muted-foreground">
|
||||
Connecting to server...
|
||||
</Text>
|
||||
</View>
|
||||
) : isSuccess ? (
|
||||
<View className="h-12 flex-row items-center justify-center gap-1.5">
|
||||
<IconCircleCheck size={16} color={statusCompletedColor} />
|
||||
<Text className="font-sans-medium text-[13px] text-status-completed">
|
||||
Connected
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button onPress={handleConnect} className="bg-primary">
|
||||
<ButtonLabel>Connect</ButtonLabel>
|
||||
</Button>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
<View className="mt-3 min-h-10">
|
||||
{connection.phase === "error" && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
className="flex-row items-start gap-2 px-1"
|
||||
>
|
||||
<IconAlertCircle
|
||||
size={16}
|
||||
color={destructiveColor}
|
||||
style={{ marginTop: 1 }}
|
||||
/>
|
||||
<Text className="flex-1 text-[13px] text-destructive">
|
||||
{ERROR_MESSAGES[connection.error]}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300).delay(400)}
|
||||
className="mt-4 flex items-center justify-center gap-1"
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => Linking.openURL("https://sofa.watch")}
|
||||
className="flex-row items-center gap-1.5"
|
||||
>
|
||||
<IconInfoCircle size={16} color={mutedFgColor} />
|
||||
<Text className="font-sans-medium text-[13px] text-primary">
|
||||
Don't have a server?
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</AuthScreen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
|
||||
|
||||
export default function ExploreLayout() {
|
||||
return <Stack screenOptions={useTabScreenOptions()} />;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Stack } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { RefreshControl, ScrollView, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||
|
||||
import { FilterableTitleRow } from "@/components/explore/filterable-title-row";
|
||||
import { HeroBanner } from "@/components/explore/hero-banner";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
|
||||
export default function ExploreScreen() {
|
||||
const trending = useQuery(
|
||||
orpc.explore.trending.queryOptions({ input: { type: "all" } }),
|
||||
);
|
||||
const popularMovies = useQuery(
|
||||
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
|
||||
);
|
||||
const popularTv = useQuery(
|
||||
orpc.explore.popular.queryOptions({ input: { type: "tv" } }),
|
||||
);
|
||||
const movieGenres = useQuery(
|
||||
orpc.explore.genres.queryOptions({ input: { type: "movie" } }),
|
||||
);
|
||||
const tvGenres = useQuery(
|
||||
orpc.explore.genres.queryOptions({ input: { type: "tv" } }),
|
||||
);
|
||||
|
||||
const isRefreshing =
|
||||
trending.isRefetching ||
|
||||
popularMovies.isRefetching ||
|
||||
popularTv.isRefetching;
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: orpc.explore.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.discover.key() });
|
||||
}, []);
|
||||
|
||||
const heroItem = trending.data?.hero;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="bg-background"
|
||||
contentContainerStyle={{
|
||||
paddingTop: 8,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColorClassName="accent-primary"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Stack.Screen options={{ title: "Explore" }} />
|
||||
<View className="gap-8">
|
||||
{heroItem && (
|
||||
<Animated.View entering={FadeIn.duration(400)}>
|
||||
<HeroBanner item={heroItem} />
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||
<FilterableTitleRow
|
||||
title="Trending Today"
|
||||
icon={IconFlame}
|
||||
mediaType="movie"
|
||||
defaultItems={trending.data?.items ?? []}
|
||||
defaultUserStatuses={trending.data?.userStatuses ?? {}}
|
||||
defaultEpisodeProgress={trending.data?.episodeProgress ?? {}}
|
||||
isLoading={trending.isPending}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||
<FilterableTitleRow
|
||||
title="Popular Movies"
|
||||
icon={IconMovie}
|
||||
mediaType="movie"
|
||||
defaultItems={popularMovies.data?.items ?? []}
|
||||
defaultUserStatuses={popularMovies.data?.userStatuses ?? {}}
|
||||
defaultEpisodeProgress={popularMovies.data?.episodeProgress ?? {}}
|
||||
genres={movieGenres.data?.genres}
|
||||
isLoading={popularMovies.isPending}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
|
||||
<FilterableTitleRow
|
||||
title="Popular TV Shows"
|
||||
icon={IconDeviceTv}
|
||||
mediaType="tv"
|
||||
defaultItems={popularTv.data?.items ?? []}
|
||||
defaultUserStatuses={popularTv.data?.userStatuses ?? {}}
|
||||
defaultEpisodeProgress={popularTv.data?.episodeProgress ?? {}}
|
||||
genres={tvGenres.data?.genres}
|
||||
isLoading={popularTv.isPending}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
|
||||
|
||||
export default function HomeLayout() {
|
||||
return <Stack screenOptions={useTabScreenOptions()} />;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
IconBooks,
|
||||
IconPlayerPlay,
|
||||
IconThumbUp,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { FlatList, RefreshControl, ScrollView, View } from "react-native";
|
||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||
|
||||
import { ContinueWatchingCard } from "@/components/dashboard/continue-watching-card";
|
||||
import { HorizontalPosterRow } from "@/components/dashboard/horizontal-poster-row";
|
||||
import { StatsCard } from "@/components/dashboard/stats-card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { SectionHeader } from "@/components/ui/section-header";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
|
||||
export default function DashboardScreen() {
|
||||
const { push } = useRouter();
|
||||
authClient.useSession();
|
||||
|
||||
const stats = useQuery(orpc.dashboard.stats.queryOptions());
|
||||
const continueWatching = useQuery(
|
||||
orpc.dashboard.continueWatching.queryOptions(),
|
||||
);
|
||||
const library = useQuery(orpc.dashboard.library.queryOptions());
|
||||
const recommendations = useQuery(
|
||||
orpc.dashboard.recommendations.queryOptions(),
|
||||
);
|
||||
|
||||
const isRefreshing =
|
||||
stats.isRefetching || continueWatching.isRefetching || library.isRefetching;
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||
}, []);
|
||||
|
||||
const hasLibrary = (library.data?.items?.length ?? 0) > 0;
|
||||
const hasContinueWatching = (continueWatching.data?.items?.length ?? 0) > 0;
|
||||
const hasRecommendations = (recommendations.data?.items?.length ?? 0) > 0;
|
||||
|
||||
const statsData = useMemo(
|
||||
() => [
|
||||
{ label: "Movies this month", value: stats.data?.moviesThisMonth },
|
||||
{ label: "Episodes this week", value: stats.data?.episodesThisWeek },
|
||||
{ label: "In library", value: stats.data?.librarySize },
|
||||
{ label: "Completed", value: stats.data?.completed },
|
||||
],
|
||||
[stats.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="bg-background"
|
||||
contentContainerStyle={{
|
||||
paddingTop: 8,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColorClassName="accent-primary"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Stack.Screen options={{ title: "Home" }} />
|
||||
<View className="gap-8">
|
||||
{/* Stats */}
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={statsData}
|
||||
keyExtractor={(item) => item.label}
|
||||
renderItem={({ item }) => (
|
||||
<StatsCard label={item.label} value={item.value} />
|
||||
)}
|
||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||
style={{ overflow: "visible" }}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Continue Watching */}
|
||||
{hasContinueWatching && (
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||
<View className="px-4">
|
||||
<SectionHeader title="Continue Watching" icon={IconPlayerPlay} />
|
||||
</View>
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={continueWatching.data?.items ?? []}
|
||||
keyExtractor={(item) => item.title.id}
|
||||
renderItem={({ item }) => <ContinueWatchingCard item={item} />}
|
||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||
style={{ overflow: "visible" }}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Library */}
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
|
||||
<View className="px-4">
|
||||
<SectionHeader title="In Your Library" icon={IconBooks} />
|
||||
</View>
|
||||
{library.isPending ? (
|
||||
<HorizontalPosterRow items={[]} isLoading />
|
||||
) : hasLibrary ? (
|
||||
<HorizontalPosterRow items={library.data?.items ?? []} />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Your library is empty"
|
||||
description="Start tracking movies and shows"
|
||||
actionLabel="Explore"
|
||||
onAction={() => push("/(tabs)/(explore)")}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* Recommendations */}
|
||||
{hasRecommendations && (
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
||||
<View className="px-4">
|
||||
<SectionHeader title="Recommended for You" icon={IconThumbUp} />
|
||||
</View>
|
||||
<HorizontalPosterRow
|
||||
items={recommendations.data?.items ?? []}
|
||||
isLoading={recommendations.isPending}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
|
||||
|
||||
export default function SearchLayout() {
|
||||
return (
|
||||
<Stack screenOptions={useTabScreenOptions()}>
|
||||
<Stack.Screen name="index" options={{ title: "Search" }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { IconSearch } from "@tabler/icons-react-native";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { FlatList, View } from "react-native";
|
||||
import Animated, { FadeIn } from "react-native-reanimated";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import {
|
||||
type SearchResultItem,
|
||||
SearchResultRow,
|
||||
} from "@/components/search/search-result-row";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
export default function SearchScreen() {
|
||||
const { navigate } = useRouter();
|
||||
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
||||
const [query, setQuery] = useState("");
|
||||
const debouncedQuery = useDebounce(query.trim(), 300);
|
||||
|
||||
const searchResults = useQuery({
|
||||
...orpc.search.queryOptions({ input: { query: debouncedQuery } }),
|
||||
enabled: debouncedQuery.length > 0,
|
||||
});
|
||||
|
||||
// Track which item is currently being resolved/added
|
||||
const [resolvingId, setResolvingId] = useState<string | null>(null);
|
||||
const [addingId, setAddingId] = useState<string | null>(null);
|
||||
|
||||
const resolveTitleMutation = useMutation(
|
||||
orpc.titles.resolve.mutationOptions({
|
||||
onSuccess: ({ id }) => {
|
||||
setResolvingId(null);
|
||||
if (id) navigate(`/title/${id}`);
|
||||
},
|
||||
onError: () => {
|
||||
setResolvingId(null);
|
||||
toast.error("Failed to load title");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const resolvePersonMutation = useMutation(
|
||||
orpc.people.resolve.mutationOptions({
|
||||
onSuccess: ({ id }) => {
|
||||
setResolvingId(null);
|
||||
if (id) navigate(`/person/${id}`);
|
||||
},
|
||||
onError: () => {
|
||||
setResolvingId(null);
|
||||
toast.error("Failed to load person");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const quickAddMutation = useMutation(
|
||||
orpc.titles.quickAdd.mutationOptions({
|
||||
onSuccess: () => {
|
||||
setAddingId(null);
|
||||
toast.success("Added to watchlist");
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||
},
|
||||
onError: () => {
|
||||
setAddingId(null);
|
||||
toast.error("Failed to add to watchlist");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Use refs for mutation.mutate to keep callbacks stable across renders
|
||||
const resolveTitleMutateRef = useRef(resolveTitleMutation.mutate);
|
||||
resolveTitleMutateRef.current = resolveTitleMutation.mutate;
|
||||
const resolvePersonMutateRef = useRef(resolvePersonMutation.mutate);
|
||||
resolvePersonMutateRef.current = resolvePersonMutation.mutate;
|
||||
const quickAddMutateRef = useRef(quickAddMutation.mutate);
|
||||
quickAddMutateRef.current = quickAddMutation.mutate;
|
||||
|
||||
const handleResolve = useCallback((item: SearchResultItem) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setResolvingId(`${item.type}-${item.tmdbId}`);
|
||||
if (item.type === "person") {
|
||||
resolvePersonMutateRef.current({ tmdbId: item.tmdbId });
|
||||
} else {
|
||||
resolveTitleMutateRef.current({ tmdbId: item.tmdbId, type: item.type });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleQuickAdd = useCallback((tmdbId: number, type: "movie" | "tv") => {
|
||||
setAddingId(`${type}-${tmdbId}`);
|
||||
quickAddMutateRef.current({ tmdbId, type });
|
||||
}, []);
|
||||
|
||||
// Memoize mapped results to maintain stable references
|
||||
const allResults = useMemo<SearchResultItem[]>(
|
||||
() =>
|
||||
searchResults.data?.results?.map((r) => ({
|
||||
tmdbId: r.tmdbId,
|
||||
title: r.title,
|
||||
type: r.type,
|
||||
posterPath: r.posterPath,
|
||||
profilePath: r.profilePath,
|
||||
releaseDate: r.releaseDate,
|
||||
})) ?? [],
|
||||
[searchResults.data?.results],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: SearchResultItem }) => (
|
||||
<SearchResultRow
|
||||
item={item}
|
||||
onResolve={handleResolve}
|
||||
onQuickAdd={handleQuickAdd}
|
||||
isResolving={resolvingId === `${item.type}-${item.tmdbId}`}
|
||||
isAdding={addingId === `${item.type}-${item.tmdbId}`}
|
||||
/>
|
||||
),
|
||||
[handleResolve, handleQuickAdd, resolvingId, addingId],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback(
|
||||
(item: SearchResultItem) => `${item.type}-${item.tmdbId}`,
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-background">
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search movies, shows, people...",
|
||||
onChangeText: (e) => setQuery(e.nativeEvent.text),
|
||||
hideWhenScrolling: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{debouncedQuery.length === 0 ? (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(400)}
|
||||
className="flex-1 items-center justify-center"
|
||||
>
|
||||
<IconSearch size={64} color={mutedForeground} />
|
||||
<Text className="mt-3 text-[15px] text-muted-foreground">
|
||||
Search for movies, shows, or people
|
||||
</Text>
|
||||
</Animated.View>
|
||||
) : searchResults.isPending ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Spinner colorClassName="accent-primary" />
|
||||
</View>
|
||||
) : allResults.length === 0 ? (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(300)}
|
||||
className="flex-1 items-center justify-center"
|
||||
>
|
||||
<Text className="text-[15px] text-muted-foreground">
|
||||
No results for "{debouncedQuery}"
|
||||
</Text>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={allResults}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
|
||||
|
||||
export default function SettingsLayout() {
|
||||
return <Stack screenOptions={useTabScreenOptions()} />;
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
import {
|
||||
IconArrowUpRight,
|
||||
IconBrandGithub,
|
||||
IconCamera,
|
||||
IconChartBar,
|
||||
IconCloud,
|
||||
IconDatabase,
|
||||
IconDeviceMobileCog,
|
||||
IconDots,
|
||||
IconLink,
|
||||
IconLogout,
|
||||
IconPhoto,
|
||||
IconServer,
|
||||
IconShield,
|
||||
IconUser,
|
||||
IconUserPlus,
|
||||
IconWorld,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import * as Application from "expo-application";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Linking,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { IntegrationsSection } from "@/components/settings/integrations-section";
|
||||
import { SettingsRow } from "@/components/settings/settings-row";
|
||||
import { SettingsSection } from "@/components/settings/settings-section";
|
||||
import { TmdbLogo } from "@/components/tmdb-logo";
|
||||
import { Image } from "@/components/ui/image";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { getServerUrl } from "@/lib/server-url";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { push } = useRouter();
|
||||
const { data: session, refetch: refetchSession } = authClient.useSession();
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.name) setNameInput(session.user.name);
|
||||
}, [session?.user?.name]);
|
||||
|
||||
const [analyticsEnabled, setAnalyticsToggle] = useState(isAnalyticsEnabled);
|
||||
const isAdmin = session?.user?.role === "admin";
|
||||
const serverUrl = getServerUrl();
|
||||
|
||||
const systemHealth = useQuery({
|
||||
...orpc.system.health.queryOptions(),
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
const updateName = useMutation(
|
||||
orpc.account.updateName.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success("Name updated");
|
||||
setIsEditingName(false);
|
||||
queryClient.invalidateQueries({ queryKey: orpc.account.key() });
|
||||
refetchSession();
|
||||
},
|
||||
onError: () => toast.error("Failed to update name"),
|
||||
}),
|
||||
);
|
||||
|
||||
const uploadAvatar = useMutation(
|
||||
orpc.account.uploadAvatar.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success("Profile picture updated");
|
||||
queryClient.invalidateQueries({ queryKey: orpc.account.key() });
|
||||
refetchSession();
|
||||
},
|
||||
onError: () => toast.error("Failed to upload avatar"),
|
||||
}),
|
||||
);
|
||||
|
||||
const removeAvatar = useMutation(
|
||||
orpc.account.removeAvatar.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success("Profile picture removed");
|
||||
queryClient.invalidateQueries({ queryKey: orpc.account.key() });
|
||||
refetchSession();
|
||||
},
|
||||
onError: () => toast.error("Failed to remove profile picture"),
|
||||
}),
|
||||
);
|
||||
|
||||
const pickAvatar = useCallback(async () => {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ["images"],
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (result.canceled || !result.assets[0]) return;
|
||||
|
||||
const asset = result.assets[0];
|
||||
const response = await fetch(asset.uri);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], asset.fileName ?? "avatar.jpg", {
|
||||
type: asset.mimeType ?? "image/jpeg",
|
||||
});
|
||||
uploadAvatar.mutate(file);
|
||||
}, [uploadAvatar]);
|
||||
|
||||
const hasAvatarImage = !!session?.user?.image;
|
||||
|
||||
const registration = useQuery({
|
||||
...orpc.admin.registration.queryOptions(),
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
const toggleRegistration = useMutation(
|
||||
orpc.admin.toggleRegistration.mutationOptions({
|
||||
onSuccess: (_data, { open }) => {
|
||||
toast.success(open ? "Registration opened" : "Registration closed");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.admin.registration.key(),
|
||||
});
|
||||
},
|
||||
onError: () => toast.error("Failed to update registration setting"),
|
||||
}),
|
||||
);
|
||||
|
||||
const updateCheck = useQuery({
|
||||
...orpc.admin.updateCheck.queryOptions(),
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
const toggleUpdateCheck = useMutation(
|
||||
orpc.admin.toggleUpdateCheck.mutationOptions({
|
||||
onSuccess: (_data, { enabled }) => {
|
||||
toast.success(
|
||||
enabled ? "Update checks enabled" : "Update checks disabled",
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.admin.updateCheck.key(),
|
||||
});
|
||||
},
|
||||
onError: () => toast.error("Failed to update setting"),
|
||||
}),
|
||||
);
|
||||
|
||||
const primaryFgColor = useCSSVariable("--color-primary-foreground") as string;
|
||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await queryClient.invalidateQueries();
|
||||
setRefreshing(false);
|
||||
}, []);
|
||||
|
||||
const handleSignOut = () => {
|
||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Sign Out",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
authClient.signOut();
|
||||
queryClient.clear();
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="bg-background"
|
||||
contentContainerStyle={{
|
||||
paddingTop: 8,
|
||||
paddingBottom: 32,
|
||||
paddingHorizontal: 16,
|
||||
}}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColorClassName="accent-primary"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Stack.Screen options={{ title: "Settings" }} />
|
||||
|
||||
{/* Account */}
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||
<SettingsSection title="Account" icon={IconUser}>
|
||||
<View className="flex-row items-center py-3.5">
|
||||
{hasAvatarImage ? (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Pressable className="mr-3">
|
||||
<View className="size-11 overflow-hidden rounded-full bg-secondary">
|
||||
{uploadAvatar.isPending ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Spinner size="sm" colorClassName="accent-primary" />
|
||||
</View>
|
||||
) : (
|
||||
<Image
|
||||
source={{ uri: session.user.image ?? undefined }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View className="absolute right-0 bottom-0 size-[18px] items-center justify-center rounded-full bg-primary">
|
||||
<IconCamera size={10} color={primaryFgColor} />
|
||||
</View>
|
||||
</Pressable>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item key="change" onSelect={() => pickAvatar()}>
|
||||
<DropdownMenu.ItemIcon
|
||||
ios={{ name: "photo.on.rectangle.angled" }}
|
||||
/>
|
||||
<DropdownMenu.ItemTitle>
|
||||
Change Photo
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="remove"
|
||||
destructive
|
||||
onSelect={() => removeAvatar.mutate()}
|
||||
>
|
||||
<DropdownMenu.ItemIcon ios={{ name: "trash" }} />
|
||||
<DropdownMenu.ItemTitle>
|
||||
Remove Photo
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
) : (
|
||||
<Pressable onPress={pickAvatar} className="mr-3">
|
||||
<View className="size-11 overflow-hidden rounded-full bg-secondary">
|
||||
{uploadAvatar.isPending ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Spinner size="sm" colorClassName="accent-primary" />
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center bg-primary/[0.08]">
|
||||
<Text className="font-sans-medium text-lg text-primary">
|
||||
{session?.user?.name?.charAt(0)?.toUpperCase() ?? "?"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className="absolute right-0 bottom-0 size-[18px] items-center justify-center rounded-full bg-primary">
|
||||
<IconCamera size={10} color={primaryFgColor} />
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
<View className="flex-1">
|
||||
{isEditingName ? (
|
||||
<View className="flex-row items-center gap-2">
|
||||
<TextInput
|
||||
value={nameInput}
|
||||
onChangeText={setNameInput}
|
||||
className="flex-1 border-primary border-b py-0.5 font-sans text-[15px] text-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => updateName.mutate({ name: nameInput })}
|
||||
>
|
||||
<Text className="text-primary text-sm">Save</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setNameInput(session?.user?.name ?? "");
|
||||
setIsEditingName(false);
|
||||
}}
|
||||
>
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
Cancel
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable onPress={() => setIsEditingName(true)}>
|
||||
<Text className="font-sans-medium text-base text-foreground">
|
||||
{session?.user?.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Text
|
||||
selectable
|
||||
className="mt-0.5 text-[13px] text-muted-foreground"
|
||||
>
|
||||
{session?.user?.email}
|
||||
</Text>
|
||||
</View>
|
||||
{isAdmin && (
|
||||
<View className="rounded-full bg-primary/10 px-2 py-0.5">
|
||||
<Text className="font-sans-medium text-[10px] text-primary">
|
||||
Admin
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<SettingsRow
|
||||
label="Sign out"
|
||||
icon={IconLogout}
|
||||
onPress={handleSignOut}
|
||||
destructive
|
||||
/>
|
||||
</SettingsSection>
|
||||
</Animated.View>
|
||||
|
||||
{/* Server */}
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||
<SettingsSection title="Application" icon={IconDeviceMobileCog}>
|
||||
<SettingsRow
|
||||
label="Server URL"
|
||||
value={serverUrl}
|
||||
icon={IconLink}
|
||||
onPress={() => {
|
||||
Alert.alert(
|
||||
"Change Server",
|
||||
"You'll be signed out to change the server URL.",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Continue",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await authClient.signOut();
|
||||
queryClient.clear();
|
||||
push("/(auth)/server-url");
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<SettingsRow
|
||||
label="Anonymous usage reporting"
|
||||
icon={IconChartBar}
|
||||
right={
|
||||
<Switch
|
||||
value={analyticsEnabled}
|
||||
onValueChange={(enabled) => {
|
||||
setAnalyticsToggle(enabled);
|
||||
setAnalyticsEnabled(enabled);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</Animated.View>
|
||||
|
||||
{/* Integrations */}
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
|
||||
<IntegrationsSection />
|
||||
</Animated.View>
|
||||
|
||||
{/* Admin: Server Health */}
|
||||
{isAdmin && (
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
||||
<SettingsSection
|
||||
title="Server Health"
|
||||
icon={IconServer}
|
||||
badge="Admin"
|
||||
>
|
||||
{systemHealth.isPending ? (
|
||||
<View className="items-center py-4">
|
||||
<Spinner colorClassName="accent-primary" />
|
||||
</View>
|
||||
) : systemHealth.data ? (
|
||||
<>
|
||||
<SettingsRow
|
||||
label="Database"
|
||||
value={
|
||||
systemHealth.data?.database
|
||||
? `${systemHealth.data.database.titleCount} titles`
|
||||
: "—"
|
||||
}
|
||||
icon={IconDatabase}
|
||||
/>
|
||||
<SettingsRow
|
||||
label="TMDB"
|
||||
value={systemHealth.data?.tmdb?.connected ? "Connected" : "—"}
|
||||
icon={IconCloud}
|
||||
/>
|
||||
<SettingsRow
|
||||
label="Image Cache"
|
||||
value={
|
||||
systemHealth.data?.imageCache
|
||||
? `${systemHealth.data.imageCache.imageCount} images`
|
||||
: "—"
|
||||
}
|
||||
icon={IconPhoto}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Admin: Security */}
|
||||
{isAdmin && (
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(500)}>
|
||||
<SettingsSection title="Security" icon={IconShield} badge="Admin">
|
||||
<SettingsRow
|
||||
label="Open registration"
|
||||
icon={IconUserPlus}
|
||||
right={
|
||||
<Switch
|
||||
value={registration.data?.open ?? false}
|
||||
onValueChange={(open) => toggleRegistration.mutate({ open })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
label="Check for updates"
|
||||
icon={IconCloud}
|
||||
right={
|
||||
<Switch
|
||||
value={updateCheck.data?.enabled ?? false}
|
||||
onValueChange={(enabled) =>
|
||||
toggleUpdateCheck.mutate({ enabled })
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{updateCheck.data?.updateCheck?.updateAvailable && (
|
||||
<View className="py-3.5">
|
||||
<Text className="font-sans-medium text-[13px] text-status-completed">
|
||||
Update available: {updateCheck.data.updateCheck.latestVersion}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* More Settings */}
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
||||
<SettingsSection title="More Settings" icon={IconDots}>
|
||||
<Pressable
|
||||
onPress={() => Linking.openURL(`${serverUrl}/settings`)}
|
||||
className="flex-row items-center justify-center py-3.5 active:opacity-70"
|
||||
>
|
||||
<IconWorld size={18} color={mutedFgColor} />
|
||||
<Text className="ml-2 flex-1 text-[15px] text-foreground">
|
||||
Open in browser…
|
||||
</Text>
|
||||
<IconArrowUpRight size={16} color={mutedFgColor} />
|
||||
</Pressable>
|
||||
</SettingsSection>
|
||||
</Animated.View>
|
||||
|
||||
{/* Version */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300).delay(400)}
|
||||
className="mt-6 items-center"
|
||||
>
|
||||
<Text className="text-muted-foreground text-xs">
|
||||
Native
|
||||
{Application.nativeApplicationVersion
|
||||
? ` v${Application.nativeApplicationVersion}`
|
||||
: ""}
|
||||
{Application.nativeBuildVersion
|
||||
? ` (${Application.nativeBuildVersion})`
|
||||
: ""}
|
||||
{updateCheck.data?.updateCheck?.currentVersion
|
||||
? ` · Server v${updateCheck.data.updateCheck.currentVersion}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* GitHub */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300).delay(450)}
|
||||
className="mt-3 items-center"
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => Linking.openURL("https://github.com/jakejarvis/sofa")}
|
||||
className="flex-row items-center gap-1.5 active:opacity-70"
|
||||
>
|
||||
<IconBrandGithub size={14} color={mutedFgColor} />
|
||||
<Text className="text-muted-foreground text-xs">GitHub</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
|
||||
{/* TMDB Attribution */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300).delay(500)}
|
||||
className="mt-4 items-center gap-2"
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => Linking.openURL("https://www.themoviedb.org/")}
|
||||
className="items-center gap-2 active:opacity-70"
|
||||
>
|
||||
<TmdbLogo height={12} />
|
||||
<Text className="text-center text-[10px] text-muted-foreground leading-relaxed">
|
||||
This product uses the TMDB API but is not endorsed or certified by
|
||||
TMDB.
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
export default function TabLayout() {
|
||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||
|
||||
return (
|
||||
<NativeTabs
|
||||
iconColor={{
|
||||
default: mutedFgColor,
|
||||
selected: primaryColor,
|
||||
}}
|
||||
labelStyle={{
|
||||
default: { color: mutedFgColor },
|
||||
selected: { color: primaryColor },
|
||||
}}
|
||||
screenListeners={{
|
||||
tabPress: () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<NativeTabs.Trigger name="(home)">
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="(explore)">
|
||||
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf="safari" md="explore" />
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="(search)" role="search" />
|
||||
<NativeTabs.Trigger name="(settings)">
|
||||
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { IconAlertTriangle } from "@tabler/icons-react-native";
|
||||
import { Link, Stack } from "expo-router";
|
||||
import { View } from "react-native";
|
||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { Container } from "@/components/container";
|
||||
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Not Found" }} />
|
||||
<Container>
|
||||
<View className="flex-1 items-center justify-center p-4">
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(400)}
|
||||
className="items-center"
|
||||
>
|
||||
<IconAlertTriangle size={48} color={mutedForeground} />
|
||||
<Text className="mt-3 font-display text-foreground text-xl">
|
||||
Page Not Found
|
||||
</Text>
|
||||
<Text className="mt-1 mb-4 text-center text-muted-foreground text-sm">
|
||||
The page you're looking for doesn't exist.
|
||||
</Text>
|
||||
</Animated.View>
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||
<Link href="/" asChild>
|
||||
<Button size="sm" className="bg-primary">
|
||||
<ButtonLabel className="text-primary-foreground">
|
||||
Go Home
|
||||
</ButtonLabel>
|
||||
</Button>
|
||||
</Link>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import "@/global.css";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { PostHogErrorBoundary, PostHogProvider } from "posthog-react-native";
|
||||
import { useEffect, useState } from "react";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { Uniwind, useResolveClassNames } from "uniwind";
|
||||
|
||||
import { OfflineBanner } from "@/components/ui/offline-banner";
|
||||
import { ToastProvider } from "@/components/ui/toast-provider";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { queryPersister } from "@/lib/mmkv";
|
||||
import { posthog } from "@/lib/posthog";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { hasStoredServerUrl, onServerUrlChange } from "@/lib/server-url";
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
export const unstable_settings = {
|
||||
initialRouteName: "(tabs)",
|
||||
};
|
||||
|
||||
function AppContent() {
|
||||
const contentStyle = useResolveClassNames("bg-background");
|
||||
|
||||
// Force re-render when server URL changes so useSession()
|
||||
// re-subscribes to the rebuilt authClient's session atom.
|
||||
const [, setUrlVersion] = useState(0);
|
||||
useEffect(() => onServerUrlChange(() => setUrlVersion((n) => n + 1)), []);
|
||||
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const hasServerUrl =
|
||||
!!process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl();
|
||||
|
||||
// --- PostHog screen tracking ---
|
||||
const pathname = usePathname();
|
||||
const params = useGlobalSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (posthog && pathname) {
|
||||
posthog.screen(pathname, params);
|
||||
}
|
||||
}, [pathname, params]);
|
||||
|
||||
useEffect(() => {
|
||||
Uniwind.setTheme("dark");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending || !hasServerUrl) {
|
||||
SplashScreen.hideAsync();
|
||||
}
|
||||
}, [isPending, hasServerUrl]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="light" />
|
||||
<OfflineBanner />
|
||||
<ToastProvider />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle,
|
||||
animation: "slide_from_right",
|
||||
}}
|
||||
>
|
||||
<Stack.Protected guard={!session}>
|
||||
<Stack.Screen name="(auth)" />
|
||||
</Stack.Protected>
|
||||
|
||||
<Stack.Protected guard={!!session}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen
|
||||
name="title/[id]"
|
||||
dangerouslySingular
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: true,
|
||||
headerBlurEffect: "none",
|
||||
headerTintColor: "white",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
headerTitle: "",
|
||||
animation: "slide_from_right",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="person/[id]"
|
||||
dangerouslySingular
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: true,
|
||||
headerBlurEffect: "none",
|
||||
headerTintColor: "white",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
headerTitle: "",
|
||||
animation: "slide_from_right",
|
||||
}}
|
||||
/>
|
||||
</Stack.Protected>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const inner = (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{ persister: queryPersister }}
|
||||
>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<KeyboardProvider>
|
||||
<AppContent />
|
||||
</KeyboardProvider>
|
||||
</GestureHandlerRootView>
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
|
||||
if (!posthog) return inner;
|
||||
|
||||
return (
|
||||
<PostHogProvider client={posthog} autocapture={{ captureScreens: false }}>
|
||||
<PostHogErrorBoundary>{inner}</PostHogErrorBoundary>
|
||||
</PostHogProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconMovie,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { FlatList, Pressable, useWindowDimensions, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { ExpandableText } from "@/components/ui/expandable-text";
|
||||
import { Image } from "@/components/ui/image";
|
||||
import { PosterCard } from "@/components/ui/poster-card";
|
||||
import { SectionHeader } from "@/components/ui/section-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
|
||||
const FILMOGRAPHY_GAP = 12;
|
||||
const FILMOGRAPHY_PADDING = 16;
|
||||
|
||||
export default function PersonDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { back } = useRouter();
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const columnWidth =
|
||||
(screenWidth - FILMOGRAPHY_PADDING * 2 - FILMOGRAPHY_GAP) / 2;
|
||||
|
||||
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
const { data, isPending, isError } = useQuery(
|
||||
orpc.people.detail.queryOptions({ input: { id } }),
|
||||
);
|
||||
|
||||
const person = data?.person;
|
||||
const filmography = data?.filmography ?? [];
|
||||
|
||||
const renderFilmographyItem = useCallback(
|
||||
({ item: credit }: { item: (typeof filmography)[number] }) => (
|
||||
<View style={{ width: columnWidth }}>
|
||||
<PosterCard
|
||||
id={credit.titleId}
|
||||
tmdbId={credit.tmdbId}
|
||||
title={credit.title}
|
||||
type={credit.type}
|
||||
posterPath={credit.posterPath}
|
||||
releaseDate={credit.releaseDate ?? credit.firstAirDate}
|
||||
voteAverage={credit.voteAverage}
|
||||
userStatus={data?.userStatuses?.[credit.titleId] ?? null}
|
||||
width={undefined}
|
||||
/>
|
||||
{credit.character ? (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="mt-1 text-center text-[11px] text-muted-foreground"
|
||||
>
|
||||
as {credit.character}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
),
|
||||
[columnWidth, data?.userStatuses],
|
||||
);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<View
|
||||
className="flex-1 items-center bg-background"
|
||||
style={{ paddingTop: insets.top + 56 }}
|
||||
>
|
||||
{/* Profile photo skeleton */}
|
||||
<Skeleton width={120} height={120} borderRadius={60} />
|
||||
{/* Name skeleton */}
|
||||
<Skeleton
|
||||
width={180}
|
||||
height={28}
|
||||
borderRadius={6}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
{/* Department badge skeleton */}
|
||||
<Skeleton
|
||||
width={80}
|
||||
height={24}
|
||||
borderRadius={12}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
{/* Bio skeleton */}
|
||||
<View className="mt-6 gap-2 self-stretch px-4">
|
||||
<Skeleton width="100%" height={14} />
|
||||
<Skeleton width="100%" height={14} />
|
||||
<Skeleton width="60%" height={14} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError && !data) {
|
||||
return (
|
||||
<View
|
||||
className="flex-1 items-center justify-center bg-background"
|
||||
style={{ paddingTop: insets.top }}
|
||||
>
|
||||
<Animated.View entering={FadeIn.duration(400)} className="items-center">
|
||||
<IconAlertTriangle size={48} color={mutedForeground} />
|
||||
<Text className="mt-3 font-display text-foreground text-xl">
|
||||
Something went wrong
|
||||
</Text>
|
||||
<Text className="mt-1 text-center text-muted-foreground text-sm">
|
||||
Could not load person details
|
||||
</Text>
|
||||
<Pressable onPress={() => back()} className="mt-4">
|
||||
<Text className="text-primary">Go back</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!person) {
|
||||
return (
|
||||
<View
|
||||
className="flex-1 items-center justify-center bg-background"
|
||||
style={{ paddingTop: insets.top }}
|
||||
>
|
||||
<Animated.View entering={FadeIn.duration(400)} className="items-center">
|
||||
<IconUser size={48} color={mutedForeground} />
|
||||
<Text className="mt-3 font-display text-foreground text-xl">
|
||||
Person not found
|
||||
</Text>
|
||||
<Pressable onPress={() => back()} className="mt-4">
|
||||
<Text className="text-primary">Go back</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const listHeader = (
|
||||
<>
|
||||
{/* Profile hero */}
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(400)}
|
||||
className="items-center"
|
||||
style={{ paddingTop: insets.top + 56, paddingBottom: 24 }}
|
||||
>
|
||||
<View className="size-[120px] overflow-hidden rounded-full bg-secondary">
|
||||
{person.profilePath && (
|
||||
<Image
|
||||
source={{ uri: person.profilePath }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text
|
||||
selectable
|
||||
className="mt-4 text-center font-display text-[28px] text-foreground"
|
||||
>
|
||||
{person.name}
|
||||
</Text>
|
||||
|
||||
{person.knownForDepartment ? (
|
||||
<View className="mt-2 rounded-full bg-secondary px-3 py-1">
|
||||
<Text className="text-muted-foreground text-xs">
|
||||
{person.knownForDepartment}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{person.birthday || person.deathday ? (
|
||||
<Text selectable className="mt-2 text-[13px] text-muted-foreground">
|
||||
{person.birthday?.slice(0, 4)}
|
||||
{person.deathday ? ` — ${person.deathday.slice(0, 4)}` : ""}
|
||||
</Text>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
|
||||
{/* Biography */}
|
||||
{person.biography ? (
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||
<View className="mb-6 px-4">
|
||||
<ExpandableText text={person.biography} maxLines={4} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
) : null}
|
||||
|
||||
{/* Filmography section header */}
|
||||
{filmography.length > 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300).delay(200)}
|
||||
className="px-4"
|
||||
>
|
||||
<SectionHeader title="Filmography" icon={IconMovie} />
|
||||
</Animated.View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: person?.name ?? "",
|
||||
headerTransparent: true,
|
||||
headerBlurEffect: "none",
|
||||
headerTintColor: "white",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
headerTitle: "",
|
||||
}}
|
||||
/>
|
||||
<FlatList
|
||||
data={filmography}
|
||||
keyExtractor={(item) => item.titleId}
|
||||
renderItem={renderFilmographyItem}
|
||||
numColumns={2}
|
||||
columnWrapperStyle={{
|
||||
gap: FILMOGRAPHY_GAP,
|
||||
paddingHorizontal: FILMOGRAPHY_PADDING,
|
||||
}}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 32,
|
||||
gap: FILMOGRAPHY_GAP,
|
||||
}}
|
||||
ListHeaderComponent={listHeader}
|
||||
className="bg-background"
|
||||
initialNumToRender={6}
|
||||
maxToRenderPerBatch={8}
|
||||
windowSize={5}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
import {
|
||||
IconCheck,
|
||||
IconList,
|
||||
IconMovie,
|
||||
IconPlayerPlay,
|
||||
IconStarFilled,
|
||||
IconThumbUp,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
FlatList,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { CastCard } from "@/components/titles/cast-card";
|
||||
import { ContinueWatchingBanner } from "@/components/titles/continue-watching-banner";
|
||||
import { SeasonAccordion } from "@/components/titles/season-accordion";
|
||||
import { StatusActionButton } from "@/components/titles/status-action-button";
|
||||
import { ExpandableText } from "@/components/ui/expandable-text";
|
||||
import { Image } from "@/components/ui/image";
|
||||
import { PosterCard } from "@/components/ui/poster-card";
|
||||
import { SectionHeader } from "@/components/ui/section-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { StarRating } from "@/components/ui/star-rating";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
export default function TitleDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { back } = useRouter();
|
||||
|
||||
const [primary, mutedForeground, primaryForeground] = useCSSVariable([
|
||||
"--color-primary",
|
||||
"--color-muted-foreground",
|
||||
"--color-primary-foreground",
|
||||
]) as [string, string, string];
|
||||
|
||||
const detail = useQuery(orpc.titles.detail.queryOptions({ input: { id } }));
|
||||
const userInfo = useQuery(
|
||||
orpc.titles.userInfo.queryOptions({ input: { id } }),
|
||||
);
|
||||
const recommendations = useQuery(
|
||||
orpc.titles.recommendations.queryOptions({ input: { id } }),
|
||||
);
|
||||
|
||||
const updateStatus = useMutation(
|
||||
orpc.titles.updateStatus.mutationOptions({
|
||||
onSuccess: (_data, { status }) => {
|
||||
const statusMessages: Record<string, string> = {
|
||||
watchlist: "Added to watchlist",
|
||||
in_progress: "Marked as watching",
|
||||
completed: "Marked as completed",
|
||||
};
|
||||
toast.success(
|
||||
status
|
||||
? (statusMessages[status] ?? "Status updated")
|
||||
: "Removed from library",
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||
},
|
||||
onError: () => toast.error("Failed to update status"),
|
||||
}),
|
||||
);
|
||||
|
||||
const updateRating = useMutation(
|
||||
orpc.titles.updateRating.mutationOptions({
|
||||
onSuccess: (_data, { stars }) => {
|
||||
toast.success(
|
||||
stars > 0
|
||||
? `Rated ${stars} star${stars > 1 ? "s" : ""}`
|
||||
: "Rating removed",
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
},
|
||||
onError: () => toast.error("Failed to update rating"),
|
||||
}),
|
||||
);
|
||||
|
||||
const watchMovie = useMutation(
|
||||
orpc.titles.watchMovie.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
title?.title
|
||||
? `Marked "${title.title}" as watched`
|
||||
: "Marked as watched",
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||
},
|
||||
onError: () => toast.error("Failed to mark as watched"),
|
||||
}),
|
||||
);
|
||||
|
||||
const watchAll = useMutation(
|
||||
orpc.titles.watchAll.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success("Marked all episodes as watched");
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||
},
|
||||
onError: () => toast.error("Failed to mark all episodes as watched"),
|
||||
}),
|
||||
);
|
||||
|
||||
const quickAddMutation = useMutation(
|
||||
orpc.titles.quickAdd.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success("Added to watchlist");
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||
},
|
||||
onError: () => toast.error("Failed to add to watchlist"),
|
||||
}),
|
||||
);
|
||||
|
||||
const hydrateMutation = useMutation(
|
||||
orpc.titles.hydrateSeasons.mutationOptions({
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
setRefreshing(false);
|
||||
}, []);
|
||||
|
||||
const title = detail.data?.title;
|
||||
const seasons = detail.data?.seasons ?? [];
|
||||
const cast = detail.data?.cast ?? [];
|
||||
const availability = detail.data?.availability ?? [];
|
||||
const watchedEpisodeIds = useMemo(
|
||||
() => new Set(userInfo.data?.episodeWatches ?? []),
|
||||
[userInfo.data?.episodeWatches],
|
||||
);
|
||||
|
||||
const hydratedTitleId = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (
|
||||
detail.data?.needsHydration &&
|
||||
title?.type === "tv" &&
|
||||
hydratedTitleId.current !== id
|
||||
) {
|
||||
hydratedTitleId.current = id;
|
||||
hydrateMutation.mutate({ id, tmdbId: title.tmdbId });
|
||||
}
|
||||
}, [
|
||||
detail.data?.needsHydration,
|
||||
title?.type,
|
||||
title?.tmdbId,
|
||||
id,
|
||||
hydrateMutation.mutate,
|
||||
]);
|
||||
|
||||
if (detail.isPending) {
|
||||
return (
|
||||
<View className="flex-1 bg-background">
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
headerBlurEffect: "none",
|
||||
headerTintColor: "white",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
headerTitle: "",
|
||||
}}
|
||||
/>
|
||||
{/* Hero skeleton */}
|
||||
<Skeleton width="100%" height={300} borderRadius={0} />
|
||||
{/* Genre chips skeleton */}
|
||||
<View className="mt-3 flex-row gap-2 px-4">
|
||||
<Skeleton width={60} height={24} borderRadius={12} />
|
||||
<Skeleton width={80} height={24} borderRadius={12} />
|
||||
<Skeleton width={50} height={24} borderRadius={12} />
|
||||
</View>
|
||||
{/* Actions skeleton */}
|
||||
<View className="mt-4 flex-row gap-2 px-4">
|
||||
<Skeleton width={100} height={36} borderRadius={18} />
|
||||
<Skeleton width={90} height={36} borderRadius={18} />
|
||||
<Skeleton width={105} height={36} borderRadius={18} />
|
||||
</View>
|
||||
{/* Overview skeleton */}
|
||||
<View className="mt-5 gap-2 px-4">
|
||||
<Skeleton width="100%" height={14} />
|
||||
<Skeleton width="100%" height={14} />
|
||||
<Skeleton width="70%" height={14} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-background px-6">
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
headerBlurEffect: "none",
|
||||
headerTintColor: "white",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
headerTitle: "",
|
||||
}}
|
||||
/>
|
||||
<IconMovie size={48} color={mutedForeground} />
|
||||
<Text className="mt-3 font-display text-foreground text-xl">
|
||||
Title not found
|
||||
</Text>
|
||||
<Pressable onPress={() => back()} className="mt-4">
|
||||
<Text className="text-primary">Go back</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const year = (title.releaseDate ?? title.firstAirDate)?.slice(0, 4);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="bg-background"
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 32 }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColorClassName="accent-primary"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: title?.title ?? "",
|
||||
headerTransparent: true,
|
||||
headerBlurEffect: "none",
|
||||
headerTintColor: "white",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
headerTitle: "",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Hero */}
|
||||
<View className="h-[300px]">
|
||||
{title.backdropPath && (
|
||||
<Image
|
||||
source={{ uri: title.backdropPath }}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
}}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
className="absolute inset-0"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.55)" }}
|
||||
/>
|
||||
|
||||
{title.trailerVideoKey && (
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
WebBrowser.openBrowserAsync(
|
||||
`https://www.youtube.com/watch?v=${title.trailerVideoKey}`,
|
||||
)
|
||||
}
|
||||
className="absolute inset-0 items-center justify-center"
|
||||
>
|
||||
<View
|
||||
className="h-14 w-14 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
|
||||
>
|
||||
<IconPlayerPlay size={28} color="white" fill="white" />
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<View className="absolute right-0 bottom-0 left-0 flex-row items-end p-4">
|
||||
{title.posterPath && (
|
||||
<View
|
||||
className="mr-3 h-[150px] w-[100px] overflow-hidden rounded-lg"
|
||||
style={{ borderCurve: "continuous" }}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: title.posterPath }}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
contentFit="cover"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View className="flex-1 pb-1">
|
||||
<Text
|
||||
selectable
|
||||
className="font-display text-2xl text-white"
|
||||
numberOfLines={2}
|
||||
>
|
||||
{title.title}
|
||||
</Text>
|
||||
<View className="mt-1.5 flex-row flex-wrap items-center gap-2">
|
||||
<View className="rounded-full bg-primary px-2 py-0.5">
|
||||
<Text className="font-sans-medium text-[10px] text-primary-foreground">
|
||||
{title.type === "movie" ? "Movie" : "TV"}
|
||||
</Text>
|
||||
</View>
|
||||
{year ? (
|
||||
<Text className="text-[13px] text-white/70">{year}</Text>
|
||||
) : null}
|
||||
{title.contentRating ? (
|
||||
<Text className="text-white/50 text-xs">
|
||||
{title.contentRating}
|
||||
</Text>
|
||||
) : null}
|
||||
{title.voteAverage != null && title.voteAverage > 0 && (
|
||||
<View className="flex-row items-center gap-0.5">
|
||||
<IconStarFilled size={12} color={primary} />
|
||||
<Text className="text-primary text-xs">
|
||||
{title.voteAverage.toFixed(1)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Genres */}
|
||||
{title.genres && title.genres.length > 0 && (
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
className="mt-3"
|
||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||
>
|
||||
{title.genres.map((genre: string) => (
|
||||
<View
|
||||
key={genre}
|
||||
className="mr-2 rounded-full bg-secondary px-2.5 py-1"
|
||||
>
|
||||
<Text className="text-[11px] text-muted-foreground">
|
||||
{genre}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300).delay(200)}
|
||||
className="mt-4 px-4"
|
||||
>
|
||||
<StatusActionButton
|
||||
currentStatus={userInfo.data?.status ?? null}
|
||||
onStatusChange={(status) => {
|
||||
if (status === null) {
|
||||
updateStatus.mutate({ id, status: null });
|
||||
} else if (status === "watchlist") {
|
||||
quickAddMutation.mutate({
|
||||
tmdbId: title.tmdbId,
|
||||
type: title.type,
|
||||
});
|
||||
} else if (status === "completed" && title.type === "movie") {
|
||||
watchMovie.mutate({ id });
|
||||
} else if (status === "completed" && title.type === "tv") {
|
||||
watchAll.mutate({ id });
|
||||
} else {
|
||||
updateStatus.mutate({ id, status });
|
||||
}
|
||||
}}
|
||||
isPending={
|
||||
updateStatus.isPending ||
|
||||
quickAddMutation.isPending ||
|
||||
watchMovie.isPending ||
|
||||
watchAll.isPending
|
||||
}
|
||||
/>
|
||||
|
||||
<View className="mt-4 flex-row items-center justify-between">
|
||||
<StarRating
|
||||
rating={userInfo.data?.rating ?? 0}
|
||||
onRate={(stars) => updateRating.mutate({ id, stars })}
|
||||
/>
|
||||
|
||||
{title.type === "movie" && (
|
||||
<Pressable
|
||||
onPress={() => watchMovie.mutate({ id })}
|
||||
disabled={watchMovie.isPending}
|
||||
className="flex-row items-center gap-1.5 rounded-full bg-primary px-4 py-2"
|
||||
>
|
||||
{watchMovie.isPending ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<>
|
||||
<IconCheck size={16} color={primaryForeground} />
|
||||
<Text className="font-sans-medium text-[13px] text-primary-foreground">
|
||||
Mark Watched
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Overview */}
|
||||
{title.overview ? (
|
||||
<Animated.View entering={FadeIn.duration(300).delay(300)}>
|
||||
<View className="mt-5 px-4">
|
||||
<ExpandableText text={title.overview} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
) : null}
|
||||
|
||||
{/* Availability */}
|
||||
{availability.length > 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300).delay(400)}
|
||||
className="mt-6"
|
||||
>
|
||||
<View className="px-4">
|
||||
<SectionHeader title="Where to Watch" icon={IconPlayerPlay} />
|
||||
</View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ gap: 8, paddingHorizontal: 16 }}
|
||||
>
|
||||
{availability.map((offer) => (
|
||||
<View
|
||||
key={`${offer.providerId}-${offer.offerType}`}
|
||||
className="items-center"
|
||||
>
|
||||
{offer.logoPath && (
|
||||
<Image
|
||||
source={{ uri: offer.logoPath }}
|
||||
style={{ width: 44, height: 44, borderRadius: 10 }}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
className="mt-1 max-w-[60px] text-center text-[10px] text-muted-foreground"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{offer.providerName}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Continue Watching */}
|
||||
{title.type === "tv" && (
|
||||
<ContinueWatchingBanner
|
||||
seasons={seasons}
|
||||
watchedEpisodeIds={watchedEpisodeIds}
|
||||
userStatus={userInfo.data?.status ?? null}
|
||||
backdropPath={title.backdropPath}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Seasons & Episodes */}
|
||||
{title.type === "tv" && seasons.length > 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300).delay(400)}
|
||||
className="mt-6 px-4"
|
||||
>
|
||||
<SectionHeader title="Seasons" icon={IconList} />
|
||||
{seasons.map((season) => (
|
||||
<SeasonAccordion
|
||||
key={season.id}
|
||||
season={season}
|
||||
episodes={season.episodes ?? []}
|
||||
watchedEpisodeIds={watchedEpisodeIds}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{hydrateMutation.isPending && (
|
||||
<View className="items-center py-6">
|
||||
<Spinner colorClassName="accent-primary" />
|
||||
<Text className="mt-2 text-[13px] text-muted-foreground">
|
||||
Loading season data...
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Cast */}
|
||||
{cast.length > 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300).delay(500)}
|
||||
className="mt-6"
|
||||
>
|
||||
<View className="px-4">
|
||||
<SectionHeader title="Cast" icon={IconUsers} />
|
||||
</View>
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={cast}
|
||||
keyExtractor={(item, index) => `${item.id}-${index}`}
|
||||
renderItem={({ item }) => <CastCard person={item} />}
|
||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||
style={{ overflow: "visible" }}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{recommendations.data &&
|
||||
recommendations.data.recommendations?.length > 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300).delay(600)}
|
||||
className="mt-6"
|
||||
>
|
||||
<View className="px-4">
|
||||
<SectionHeader title="More Like This" icon={IconThumbUp} />
|
||||
</View>
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={recommendations.data.recommendations}
|
||||
keyExtractor={(item) => item.id ?? String(item.tmdbId)}
|
||||
renderItem={({ item }) => (
|
||||
<PosterCard
|
||||
id={item.id}
|
||||
tmdbId={item.tmdbId}
|
||||
title={item.title}
|
||||
type={item.type}
|
||||
posterPath={item.posterPath}
|
||||
releaseDate={item.releaseDate ?? item.firstAirDate}
|
||||
voteAverage={item.voteAverage}
|
||||
userStatus={
|
||||
item.id
|
||||
? (recommendations.data?.userStatuses?.[item.id] ?? null)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||
style={{ overflow: "visible" }}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import { ScrollView } from "react-native";
|
||||
import Animated, { FadeIn } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { SofaLogo } from "@/components/ui/sofa-logo";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
interface AuthScreenProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
logoStyle?: StyleProp<ViewStyle>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthScreen({
|
||||
title,
|
||||
subtitle,
|
||||
logoStyle,
|
||||
children,
|
||||
}: AuthScreenProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
bounces={false}
|
||||
className="bg-background"
|
||||
>
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(400)}
|
||||
className="mb-4 items-center"
|
||||
>
|
||||
{logoStyle ? (
|
||||
<Animated.View style={logoStyle}>
|
||||
<SofaLogo size={48} />
|
||||
</Animated.View>
|
||||
) : (
|
||||
<SofaLogo size={48} />
|
||||
)}
|
||||
<Text className="mt-1 font-display text-[32px] text-foreground">
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text className="mt-2 text-muted-foreground text-sm">{subtitle}</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import {
|
||||
ScrollView,
|
||||
type ScrollViewProps,
|
||||
View,
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import Animated, { type AnimatedProps } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
const AnimatedView = Animated.createAnimatedComponent(View);
|
||||
|
||||
type Props = AnimatedProps<ViewProps> & {
|
||||
className?: string;
|
||||
isScrollable?: boolean;
|
||||
scrollViewProps?: Omit<ScrollViewProps, "contentContainerStyle">;
|
||||
};
|
||||
|
||||
export function Container({
|
||||
children,
|
||||
className,
|
||||
isScrollable = true,
|
||||
scrollViewProps,
|
||||
...props
|
||||
}: PropsWithChildren<Props>) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<AnimatedView
|
||||
className={cn("flex-1 bg-background", className)}
|
||||
{...props}
|
||||
style={[{ paddingBottom: insets.bottom }, props.style]}
|
||||
>
|
||||
{isScrollable ? (
|
||||
<ScrollView
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
{...scrollViewProps}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View className="flex-1">{children}</View>
|
||||
)}
|
||||
</AnimatedView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Link } from "expo-router";
|
||||
import { View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
import { Image } from "@/components/ui/image";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
export interface ContinueWatchingItem {
|
||||
title: {
|
||||
id: string;
|
||||
title: string;
|
||||
backdropPath: string | null;
|
||||
};
|
||||
watchedEpisodes: number;
|
||||
totalEpisodes: number;
|
||||
nextEpisode?: {
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
name: string | null;
|
||||
stillPath: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
||||
const pressed = useSharedValue(0);
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.97]) }],
|
||||
}));
|
||||
|
||||
const tapGesture = Gesture.Tap()
|
||||
.onBegin(() => {
|
||||
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
|
||||
})
|
||||
.onFinalize(() => {
|
||||
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
|
||||
});
|
||||
|
||||
return (
|
||||
<Link href={`/title/${item.title.id}` as `/title/${string}`}>
|
||||
<Link.Trigger>
|
||||
<GestureDetector gesture={tapGesture}>
|
||||
<Animated.View
|
||||
className="w-[200px] overflow-hidden rounded-[12px] border bg-card"
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
borderColor: "rgba(255,255,255,0.06)",
|
||||
borderCurve: "continuous",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View className="h-28 w-[200px]">
|
||||
{(item.nextEpisode?.stillPath || item.title.backdropPath) && (
|
||||
<Image
|
||||
source={{
|
||||
uri: (item.nextEpisode?.stillPath ??
|
||||
item.title.backdropPath) as string,
|
||||
}}
|
||||
className="h-full w-full"
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
className="absolute inset-0"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.4)" }}
|
||||
/>
|
||||
{item.nextEpisode && (
|
||||
<View className="absolute right-2.5 bottom-3 left-2.5">
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="font-sans-medium text-[11px] text-white/60"
|
||||
>
|
||||
S{item.nextEpisode.seasonNumber} E
|
||||
{item.nextEpisode.episodeNumber}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View
|
||||
className="absolute right-0 bottom-0 left-0 h-[3px]"
|
||||
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
|
||||
>
|
||||
<View
|
||||
className="h-full bg-status-watching"
|
||||
style={{
|
||||
width: `${item.totalEpisodes > 0 ? (item.watchedEpisodes / item.totalEpisodes) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View className="p-2.5">
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="font-sans-medium text-[13px] text-foreground"
|
||||
>
|
||||
{item.title.title}
|
||||
</Text>
|
||||
{item.nextEpisode && (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="mt-0.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{item.nextEpisode.name}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { FlatList } from "react-native";
|
||||
|
||||
import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
|
||||
|
||||
export interface PosterRowItem {
|
||||
id: string;
|
||||
tmdbId: number;
|
||||
title: string;
|
||||
type: string;
|
||||
posterPath: string | null;
|
||||
releaseDate?: string | null;
|
||||
firstAirDate?: string | null;
|
||||
voteAverage?: number | null;
|
||||
userStatus?: "watchlist" | "in_progress" | "completed" | null;
|
||||
episodeProgress?: { watched: number; total: number } | null;
|
||||
}
|
||||
|
||||
export function HorizontalPosterRow({
|
||||
items,
|
||||
isLoading,
|
||||
}: {
|
||||
items: PosterRowItem[];
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={[1, 2, 3, 4]}
|
||||
keyExtractor={(item) => String(item)}
|
||||
renderItem={() => <PosterCardSkeleton />}
|
||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||
style={{ overflow: "visible" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={items}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<PosterCard
|
||||
id={item.id}
|
||||
tmdbId={item.tmdbId}
|
||||
title={item.title}
|
||||
type={item.type as "movie" | "tv"}
|
||||
posterPath={item.posterPath}
|
||||
releaseDate={item.releaseDate ?? item.firstAirDate}
|
||||
voteAverage={item.voteAverage}
|
||||
userStatus={item.userStatus}
|
||||
episodeProgress={item.episodeProgress}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||
style={{ overflow: "visible" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
export function StatsCard({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | undefined;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
className="min-w-[120px] rounded-xl border border-white/[0.06] bg-card px-4 py-3"
|
||||
style={{ borderCurve: "continuous" }}
|
||||
>
|
||||
<Text
|
||||
className="font-sans-bold text-[24px] text-primary"
|
||||
style={{ fontVariant: ["tabular-nums"] }}
|
||||
>
|
||||
{value ?? "—"}
|
||||
</Text>
|
||||
<Text className="mt-0.5 text-[11px] text-muted-foreground">{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Icon } from "@tabler/icons-react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { FlatList, ScrollView, View } from "react-native";
|
||||
import { GenreChip } from "@/components/explore/genre-chip";
|
||||
import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
|
||||
import { SectionHeader } from "@/components/ui/section-header";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
|
||||
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
||||
|
||||
export function FilterableTitleRow({
|
||||
title,
|
||||
icon,
|
||||
mediaType,
|
||||
defaultItems,
|
||||
defaultUserStatuses,
|
||||
defaultEpisodeProgress,
|
||||
genres,
|
||||
isLoading,
|
||||
}: {
|
||||
title: string;
|
||||
icon: Icon;
|
||||
mediaType: "movie" | "tv";
|
||||
defaultItems: Array<{
|
||||
tmdbId: number;
|
||||
title: string;
|
||||
type: string;
|
||||
posterPath: string | null;
|
||||
releaseDate?: string | null;
|
||||
firstAirDate?: string | null;
|
||||
voteAverage?: number | null;
|
||||
}>;
|
||||
defaultUserStatuses: Record<string, TitleStatus>;
|
||||
defaultEpisodeProgress: Record<string, { watched: number; total: number }>;
|
||||
genres?: Array<{ id: number; name: string }>;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
|
||||
|
||||
const discover = useQuery({
|
||||
...orpc.discover.queryOptions({
|
||||
input: { type: mediaType, genreId: selectedGenre ?? 0 },
|
||||
}),
|
||||
enabled: selectedGenre !== null,
|
||||
});
|
||||
|
||||
const items =
|
||||
selectedGenre === null ? defaultItems : (discover.data?.items ?? []);
|
||||
const userStatuses =
|
||||
selectedGenre === null
|
||||
? defaultUserStatuses
|
||||
: (discover.data?.userStatuses ?? {});
|
||||
const episodeProgress =
|
||||
selectedGenre === null
|
||||
? defaultEpisodeProgress
|
||||
: (discover.data?.episodeProgress ?? {});
|
||||
const showLoading =
|
||||
isLoading || (selectedGenre !== null && discover.isPending);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View className="px-4">
|
||||
<SectionHeader title={title} icon={icon} />
|
||||
</View>
|
||||
|
||||
{genres && genres.length > 0 && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
className="mb-3"
|
||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||
>
|
||||
<GenreChip
|
||||
label="All"
|
||||
isSelected={selectedGenre === null}
|
||||
onPress={() => setSelectedGenre(null)}
|
||||
/>
|
||||
{genres.map((genre) => (
|
||||
<GenreChip
|
||||
key={genre.id}
|
||||
label={genre.name}
|
||||
isSelected={selectedGenre === genre.id}
|
||||
onPress={() =>
|
||||
setSelectedGenre(selectedGenre === genre.id ? null : genre.id)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{showLoading ? (
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={[1, 2, 3, 4]}
|
||||
keyExtractor={(item) => String(item)}
|
||||
renderItem={() => <PosterCardSkeleton />}
|
||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||
style={{ overflow: "visible" }}
|
||||
/>
|
||||
) : items.length === 0 && selectedGenre !== null ? (
|
||||
<View className="items-center py-6">
|
||||
<Text className="text-[13px] text-muted-foreground">
|
||||
No titles found for this genre.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={items}
|
||||
keyExtractor={(item) => `${item.tmdbId}-${item.type}`}
|
||||
renderItem={({ item }) => (
|
||||
<PosterCard
|
||||
tmdbId={item.tmdbId}
|
||||
title={item.title}
|
||||
type={item.type as "movie" | "tv"}
|
||||
posterPath={item.posterPath}
|
||||
releaseDate={item.releaseDate ?? item.firstAirDate}
|
||||
voteAverage={item.voteAverage}
|
||||
userStatus={userStatuses[`${item.tmdbId}-${item.type}`] ?? null}
|
||||
episodeProgress={
|
||||
episodeProgress[`${item.tmdbId}-${item.type}`] ?? null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||
style={{ overflow: "visible" }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Pressable } from "react-native";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
export function GenreChip({
|
||||
label,
|
||||
isSelected,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
isSelected: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
onPress();
|
||||
}}
|
||||
className={`mr-2 rounded-full px-3 py-1.5 ${isSelected ? "bg-primary" : "bg-secondary"}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-sans-medium text-[12px] ${isSelected ? "text-primary-foreground" : "text-foreground"}`}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { IconStarFilled } from "@tabler/icons-react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { Image } from "@/components/ui/image";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
export interface HeroBannerItem {
|
||||
tmdbId: number;
|
||||
title: string;
|
||||
type: string;
|
||||
backdropPath?: string | null;
|
||||
overview?: string | null;
|
||||
voteAverage?: number | null;
|
||||
releaseDate?: string | null;
|
||||
}
|
||||
|
||||
export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
||||
const { navigate } = useRouter();
|
||||
const primary = useCSSVariable("--color-primary") as string;
|
||||
const pressed = useSharedValue(0);
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.98]) }],
|
||||
}));
|
||||
|
||||
const resolveMutation = useMutation(
|
||||
orpc.titles.resolve.mutationOptions({
|
||||
onSuccess: ({ id }) => {
|
||||
navigate(`/title/${id}`);
|
||||
},
|
||||
onError: () => toast.error("Failed to load title"),
|
||||
}),
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
resolveMutation.mutate({
|
||||
tmdbId: item.tmdbId,
|
||||
type: item.type as "movie" | "tv",
|
||||
});
|
||||
}, [item.tmdbId, item.type, resolveMutation]);
|
||||
|
||||
const tapGesture = Gesture.Tap()
|
||||
.onBegin(() => {
|
||||
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
|
||||
})
|
||||
.onFinalize(() => {
|
||||
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(handlePress)();
|
||||
});
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={tapGesture}>
|
||||
<Animated.View
|
||||
className="mx-4 overflow-hidden rounded-2xl"
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
height: 220,
|
||||
opacity: resolveMutation.isPending ? 0.7 : 1,
|
||||
borderCurve: "continuous",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{item.backdropPath && (
|
||||
<Image
|
||||
source={{ uri: item.backdropPath }}
|
||||
className="absolute h-full w-full"
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
className="absolute inset-0"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
/>
|
||||
<View className="flex-1 justify-end p-4">
|
||||
<Text className="font-display text-2xl text-white" numberOfLines={2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
{item.overview ? (
|
||||
<Text className="mt-1 text-white/70 text-xs" numberOfLines={2}>
|
||||
{item.overview}
|
||||
</Text>
|
||||
) : null}
|
||||
<View className="mt-2 flex-row items-center gap-2">
|
||||
{item.voteAverage != null && item.voteAverage > 0 && (
|
||||
<View className="flex-row items-center gap-1">
|
||||
<IconStarFilled size={12} color={primary} />
|
||||
<Text className="text-primary text-xs">
|
||||
{item.voteAverage.toFixed(1)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text className="text-white/50 text-xs">
|
||||
{item.releaseDate?.slice(0, 4)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { Alert, Pressable, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Image } from "@/components/ui/image";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
export function HeaderAvatar() {
|
||||
const { data: session } = authClient.useSession();
|
||||
const router = useRouter();
|
||||
|
||||
if (!session?.user) return null;
|
||||
|
||||
const { user } = session;
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Pressable
|
||||
onPress={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)}
|
||||
>
|
||||
<View className="size-8 overflow-hidden rounded-full">
|
||||
{user.image ? (
|
||||
<Image
|
||||
source={{ uri: user.image }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center bg-primary/[0.08]">
|
||||
<Text className="font-sans-medium text-[13px] text-primary">
|
||||
{user.name?.charAt(0)?.toUpperCase() ?? "?"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item
|
||||
key="settings"
|
||||
onSelect={() => router.navigate("/(tabs)/(settings)")}
|
||||
>
|
||||
<DropdownMenu.ItemIcon
|
||||
ios={{ name: "gear" }}
|
||||
androidIconName="ic_menu_preferences"
|
||||
/>
|
||||
<DropdownMenu.ItemTitle>Settings</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
key="sign-out"
|
||||
destructive
|
||||
onSelect={() => {
|
||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Sign Out",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
authClient.signOut();
|
||||
queryClient.clear();
|
||||
},
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIcon
|
||||
ios={{ name: "rectangle.portrait.and.arrow.right" }}
|
||||
androidIconName="ic_menu_close_clear_cancel"
|
||||
/>
|
||||
<DropdownMenu.ItemTitle>Sign out</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { IconLoader, IconPlus } from "@tabler/icons-react-native";
|
||||
import { memo } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { Image } from "@/components/ui/image";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
export interface SearchResultItem {
|
||||
tmdbId: number;
|
||||
title: string;
|
||||
type: "movie" | "tv" | "person";
|
||||
posterPath?: string | null;
|
||||
profilePath?: string | null;
|
||||
releaseDate?: string | null;
|
||||
}
|
||||
|
||||
export const SearchResultRow = memo(function SearchResultRow({
|
||||
item,
|
||||
onResolve,
|
||||
onQuickAdd,
|
||||
isResolving,
|
||||
isAdding,
|
||||
}: {
|
||||
item: SearchResultItem;
|
||||
onResolve: (item: SearchResultItem) => void;
|
||||
onQuickAdd: (tmdbId: number, type: "movie" | "tv") => void;
|
||||
isResolving: boolean;
|
||||
isAdding: boolean;
|
||||
}) {
|
||||
const primary = useCSSVariable("--color-primary") as string;
|
||||
const imageSrc = item.posterPath ?? item.profilePath;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => onResolve(item)}
|
||||
disabled={isResolving}
|
||||
className="flex-row items-center border-border border-b px-4 py-3"
|
||||
style={{
|
||||
borderBottomWidth: 0.5,
|
||||
opacity: isResolving ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className="mr-3 overflow-hidden bg-secondary"
|
||||
style={{
|
||||
width: 44,
|
||||
height: item.type === "person" ? 44 : 66,
|
||||
borderRadius: item.type === "person" ? 22 : 8,
|
||||
borderCurve: item.type === "person" ? undefined : "continuous",
|
||||
}}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<Image
|
||||
source={{ uri: imageSrc }}
|
||||
className="h-full w-full"
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="font-sans-medium text-[15px] text-foreground"
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
<View className="mt-1 flex-row items-center gap-2">
|
||||
<View className="rounded-full bg-secondary px-2 py-0.5">
|
||||
<Text className="text-[10px] text-muted-foreground">
|
||||
{item.type === "movie"
|
||||
? "Movie"
|
||||
: item.type === "tv"
|
||||
? "TV"
|
||||
: "Person"}
|
||||
</Text>
|
||||
</View>
|
||||
{item.releaseDate ? (
|
||||
<Text className="text-muted-foreground text-xs">
|
||||
{item.releaseDate.slice(0, 4)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{item.type !== "person" && (
|
||||
<Pressable
|
||||
onPress={() => onQuickAdd(item.tmdbId, item.type as "movie" | "tv")}
|
||||
disabled={isAdding}
|
||||
hitSlop={8}
|
||||
className="ml-2"
|
||||
>
|
||||
{isAdding ? (
|
||||
<IconLoader size={22} color={primary} />
|
||||
) : (
|
||||
<IconPlus size={22} color={primary} />
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{isResolving && (
|
||||
<Spinner size="sm" colorClassName="accent-primary" className="ml-2" />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { SvgProps } from "react-native-svg";
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
export function PlexIcon(props: SvgProps) {
|
||||
return (
|
||||
<Svg width={24} height={24} viewBox="0 0 32 32" {...props}>
|
||||
{/* Icon from CoreUI Brands by creativeLabs Łukasz Holeczek - https://creativecommons.org/publicdomain/zero/1.0/ */}
|
||||
<Path
|
||||
fill={props.color ?? "currentColor"}
|
||||
d="M15.527 0H6.24l10.239 16L6.24 32h9.287L25.76 16z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function JellyfinIcon(props: SvgProps) {
|
||||
return (
|
||||
<Svg width={24} height={24} viewBox="0 0 24 24" {...props}>
|
||||
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
|
||||
<Path
|
||||
fill={props.color ?? "currentColor"}
|
||||
d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0S15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189c.518 1.04 7.348 1.027 7.86 0c.511-1.027-2.874-7.19-3.93-7.19z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmbyIcon(props: SvgProps) {
|
||||
return (
|
||||
<Svg width={24} height={24} viewBox="0 0 24 24" {...props}>
|
||||
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
|
||||
<Path
|
||||
fill={props.color ?? "currentColor"}
|
||||
d="M11.041 0c-.007 0-1.456 1.43-3.219 3.176L4.615 6.352l.512.513l.512.512l-2.819 2.791L0 12.961l1.83 1.848l3.182 3.209l1.351 1.359l.508-.496c.28-.273.515-.498.524-.498c.008 0 1.266 1.264 2.794 2.808L12.97 24l.187-.182c.23-.225 5.007-4.95 5.717-5.656l.52-.516l-.502-.513c-.276-.282-.5-.52-.496-.53c.003-.009 1.264-1.26 2.802-2.783s2.8-2.776 2.803-2.785c.005-.012-3.617-3.684-6.107-6.193L17.65 4.6l-.505.505c-.279.278-.517.501-.53.497s-1.27-1.267-2.793-2.805A450 450 0 0 0 11.041 0M9.223 7.367c.091.038 7.951 4.608 7.957 4.627c.003.013-1.781 1.056-3.965 2.32a1000 1000 0 0 1-3.996 2.307c-.019.006-.026-1.266-.026-4.629c0-3.7.007-4.634.03-4.625"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SonarrIcon(props: SvgProps) {
|
||||
return (
|
||||
<Svg width={24} height={24} viewBox="0 0 24 24" {...props}>
|
||||
{/* Icon from Custom Brand Icons by Emanuele & rchiileea - https://github.com/elax46/custom-brand-icons/blob/main/LICENSE */}
|
||||
<Path
|
||||
fill={props.color ?? "currentColor"}
|
||||
d="m7.338 16.322l.165.159l-2.491 2.495l.13.129l2.493-2.498l.164.159l1.531-1.59l-.461-.444zm.106-8.651l-.161.161L8.855 9.4l.452-.453l-1.572-1.568l-.162.162L5 4.976q-.064.065-.127.132ZM5 4.976l-.128.131c.043-.043.083-.088.128-.131m0-.001l-.129.13l.128-.131ZM16.631 16.24l-1.648-1.64l-.451.453l1.647 1.64l.161-.162l2.533 2.621l.007-.006l.053-.052q.035-.035.067-.073L16.469 16.4ZM19 19.025q-.032.038-.067.073zm-.127.127l.007-.006zm-2.397-11.69l.062.065zl-.163-.162l-1.549 1.575l.455.449l1.549-1.572l-.163-.16l2.544-2.476q-.062-.067-.126-.132Zm2.672-2.346l-.127-.132q.065.065.127.132m.024-.023l-.128-.131l-.022.021l.127.132zm-7.156 4.139a2.66 2.66 0 0 0-1.941.8a2.62 2.62 0 0 0-.795 1.745v.384a3 3 0 0 0 .037.325a2.6 2.6 0 0 0 .763 1.434a2.4 2.4 0 0 0 .342.292a2.76 2.76 0 0 0 3.2 0a2.4 2.4 0 0 0 .279-.233l.059-.059a2.76 2.76 0 0 0 0-3.888a2.65 2.65 0 0 0-1.944-.8m6.917 9.862l-.053.052q.029-.025.053-.052M5.823 4.238l-.008.007Zm-.822.736l-.002.002zm.307 14.333l-.02-.018ZM7.505 12.1a5.64 5.64 0 0 0-1.426-4.257c-.806-.806-1.92-1.916-1.923-1.919a9.3 9.3 0 0 0-2.024 5.35a.13.13 0 0 0-.018.064Q2.1 11.653 2.1 12c0 .219 0 .439.014.658a10 10 0 0 0 .132 1.169a9.3 9.3 0 0 0 2.038 4.4c.007-.007.9-.9 1.75-1.754A5.63 5.63 0 0 0 7.505 12.1m4.527 4.587c-1.806 0-3.036.167-4.358 1.49a432 432 0 0 0-1.694 1.7q.125.098.255.189a9.43 9.43 0 0 0 5.774 1.846a9.5 9.5 0 0 0 5.784-1.846c.1-.068.189-.139.282-.211l-1.6-1.6c-1.428-1.431-2.56-1.568-4.443-1.568m-6.113 3.142L5.9 19.81Zm-.31-.252l-.023-.021Zm6.423-11.986a5.86 5.86 0 0 0 4.441-1.562c.753-.753 1.744-1.74 1.762-1.758a9.52 9.52 0 0 0-6.226-2.18a9.56 9.56 0 0 0-6.186 2.147L7.683 6.1a5.8 5.8 0 0 0 4.349 1.491m6.99-2.607v-.001l-.009-.009l-.002-.002l.002.002Zm-1.183 3.037c-1.2 1.2-1.3 2.238-1.3 4.075a5.7 5.7 0 0 0 1.48 4.358c.879.879 1.712 1.708 1.734 1.73A9.55 9.55 0 0 0 21.9 12a9.6 9.6 0 0 0-2.429-6.531q.217.25.414.5zm1.525 10.616l-.022.023zM19.233 5.2l-.084-.089z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RadarrIcon(props: SvgProps) {
|
||||
return (
|
||||
<Svg width={24} height={24} viewBox="0 0 24 24" {...props}>
|
||||
{/* Icon from Custom Brand Icons by Emanuele & rchiileea - https://github.com/elax46/custom-brand-icons/blob/main/LICENSE */}
|
||||
<Path
|
||||
fill={props.color ?? "currentColor"}
|
||||
d="m8.06 16.01l7.199-4.113l-7.052-3.966Zm-1.028 3.82a2.96 2.96 0 0 1-2.5.294A3.37 3.37 0 0 0 8.648 21.3l10.136-5.876a1.73 1.73 0 0 0 .294-2.645zM19.225 9.106L8.8 3.083C6.738 1.614 3.359 2.5 3.359 6.168l.147 11.605c0 1.175.882 1.763 2.057 1.616L5.416 5.433c0-1.322.735-1.469 1.616-.881l11.752 6.61a2.9 2.9 0 0 1 1.47 2.2a3.307 3.307 0 0 0-1.029-4.256"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import {
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconCopy,
|
||||
IconInfoCircle,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Alert, Pressable, View } from "react-native";
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import type { IntegrationConfig } from "@/components/settings/integration-configs";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
interface IntegrationCardProps {
|
||||
config: IntegrationConfig;
|
||||
connection: {
|
||||
provider: string;
|
||||
type: string;
|
||||
token: string;
|
||||
enabled: boolean;
|
||||
lastEventAt: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
||||
const { provider, label } = config;
|
||||
const providerInput = provider as
|
||||
| "plex"
|
||||
| "jellyfin"
|
||||
| "emby"
|
||||
| "sonarr"
|
||||
| "radarr";
|
||||
|
||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const chevronRotation = useSharedValue(0);
|
||||
const setupChevronRotation = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
chevronRotation.set(withTiming(expanded ? 180 : 0, { duration: 200 }));
|
||||
}, [expanded, chevronRotation]);
|
||||
|
||||
useEffect(() => {
|
||||
setupChevronRotation.set(
|
||||
withTiming(setupOpen ? 180 : 0, { duration: 200 }),
|
||||
);
|
||||
}, [setupOpen, setupChevronRotation]);
|
||||
|
||||
const chevronStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${chevronRotation.get()}deg` }],
|
||||
}));
|
||||
|
||||
const setupChevronStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${setupChevronRotation.get()}deg` }],
|
||||
}));
|
||||
|
||||
const toggleExpanded = useCallback(() => setExpanded((v) => !v), []);
|
||||
const toggleSetup = useCallback(() => setSetupOpen((v) => !v), []);
|
||||
|
||||
const connectMutation = useMutation(
|
||||
orpc.integrations.create.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success(`${label} connected`);
|
||||
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
|
||||
},
|
||||
onError: () => toast.error(`Failed to connect ${label}`),
|
||||
}),
|
||||
);
|
||||
|
||||
const deleteMutation = useMutation(
|
||||
orpc.integrations.delete.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success(`${label} disconnected`);
|
||||
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
|
||||
},
|
||||
onError: () => toast.error(`Failed to disconnect ${label}`),
|
||||
}),
|
||||
);
|
||||
|
||||
const regenerateMutation = useMutation(
|
||||
orpc.integrations.regenerateToken.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success(`${label} URL regenerated`);
|
||||
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
|
||||
},
|
||||
onError: () => toast.error(`Failed to regenerate ${label} URL`),
|
||||
}),
|
||||
);
|
||||
|
||||
const url = connection ? config.buildUrl(connection.token) : null;
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!url) return;
|
||||
await Clipboard.setStringAsync(url);
|
||||
setCopied(true);
|
||||
toast.success("URL copied to clipboard");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [url]);
|
||||
|
||||
const handleRegenerate = useCallback(() => {
|
||||
Alert.alert(
|
||||
"Regenerate URL",
|
||||
`This will invalidate the current ${label} URL. You'll need to update it in ${label}.`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Regenerate",
|
||||
style: "destructive",
|
||||
onPress: () => regenerateMutation.mutate({ provider: providerInput }),
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [label, providerInput, regenerateMutation]);
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
Alert.alert(
|
||||
`Disconnect ${label}`,
|
||||
`Are you sure you want to disconnect ${label}? The current URL will stop working.`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Disconnect",
|
||||
style: "destructive",
|
||||
onPress: () => deleteMutation.mutate({ provider: providerInput }),
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [label, providerInput, deleteMutation]);
|
||||
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<View
|
||||
className="mb-2 overflow-hidden rounded-xl border bg-card"
|
||||
style={{
|
||||
borderColor: "rgba(255,255,255,0.06)",
|
||||
borderCurve: "continuous",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Pressable
|
||||
onPress={toggleExpanded}
|
||||
className="flex-row items-center justify-between p-3"
|
||||
>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Icon
|
||||
width={18}
|
||||
height={18}
|
||||
color={connection ? primaryColor : mutedFgColor}
|
||||
/>
|
||||
<View>
|
||||
<Text className="font-sans-medium text-[15px] text-foreground">
|
||||
{label}
|
||||
</Text>
|
||||
<Text className="mt-0.5 text-muted-foreground text-xs">
|
||||
{connection
|
||||
? config.connectedStatus(connection.lastEventAt)
|
||||
: "Not configured"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Animated.View style={chevronStyle}>
|
||||
<IconChevronDown size={18} color={mutedFgColor} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(150)}
|
||||
className="border-white/[0.06] border-t px-4 pt-3 pb-4"
|
||||
>
|
||||
{/* Requirement note */}
|
||||
{config.requirementNote && (
|
||||
<View className="mb-3 flex-row items-start gap-2 rounded-lg bg-primary/[0.06] px-3 py-2.5">
|
||||
<IconInfoCircle
|
||||
size={14}
|
||||
color={primaryColor}
|
||||
className="mt-px"
|
||||
/>
|
||||
<Text className="flex-1 text-[12px] text-foreground/80">
|
||||
{config.requirementNote}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!connection ? (
|
||||
/* Connect button */
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
connectMutation.mutate({ provider: providerInput })
|
||||
}
|
||||
disabled={connectMutation.isPending}
|
||||
className="items-center rounded-lg bg-primary py-2.5 active:opacity-80"
|
||||
>
|
||||
<Text className="font-sans-medium text-primary-foreground">
|
||||
{connectMutation.isPending ? "Connecting…" : `Connect ${label}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : (
|
||||
<View className="gap-3">
|
||||
{/* URL display */}
|
||||
<View>
|
||||
<Text className="mb-1.5 text-muted-foreground text-xs">
|
||||
{config.urlLabel}
|
||||
</Text>
|
||||
<View className="flex-row items-center rounded-lg bg-secondary/50 px-3 py-2.5">
|
||||
<Text
|
||||
className="flex-1 font-mono text-[11px] text-muted-foreground"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{url}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={handleCopy}
|
||||
className="ml-2 active:opacity-60"
|
||||
hitSlop={8}
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck size={16} color="#4ade80" />
|
||||
) : (
|
||||
<IconCopy size={16} color={mutedFgColor} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action buttons */}
|
||||
<View className="flex-row gap-2">
|
||||
<Pressable
|
||||
onPress={handleRegenerate}
|
||||
disabled={regenerateMutation.isPending}
|
||||
className="flex-1 flex-row items-center justify-center gap-1.5 rounded-lg bg-secondary py-2.5 active:opacity-80"
|
||||
>
|
||||
<IconRefresh size={14} color={mutedFgColor} />
|
||||
<Text className="font-sans-medium text-[13px] text-foreground">
|
||||
Regenerate
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleDisconnect}
|
||||
className="flex-1 flex-row items-center justify-center gap-1.5 rounded-lg bg-destructive/10 py-2.5 active:opacity-80"
|
||||
>
|
||||
<IconTrash size={14} color="#ef4444" />
|
||||
<Text className="font-sans-medium text-[13px] text-destructive">
|
||||
Disconnect
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Setup instructions (nested accordion) */}
|
||||
<View className="mt-3">
|
||||
<Pressable
|
||||
onPress={toggleSetup}
|
||||
className="flex-row items-center gap-1.5"
|
||||
>
|
||||
<Animated.View style={setupChevronStyle}>
|
||||
<IconChevronDown size={12} color={mutedFgColor} />
|
||||
</Animated.View>
|
||||
<Text className="text-muted-foreground text-xs">
|
||||
Setup instructions
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{setupOpen && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(150)}
|
||||
className="mt-2 rounded-lg border border-white/[0.04] bg-secondary/30 px-3 py-2.5"
|
||||
>
|
||||
{config.setupSteps.map((step, i) => (
|
||||
<View key={step} className="flex-row py-1">
|
||||
<Text className="w-5 text-muted-foreground text-xs">
|
||||
{i + 1}.
|
||||
</Text>
|
||||
<Text className="flex-1 text-muted-foreground text-xs leading-[18px]">
|
||||
{step}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { SvgProps } from "react-native-svg";
|
||||
import {
|
||||
EmbyIcon,
|
||||
JellyfinIcon,
|
||||
PlexIcon,
|
||||
RadarrIcon,
|
||||
SonarrIcon,
|
||||
} from "@/components/settings/icons";
|
||||
import { getServerUrl } from "@/lib/server-url";
|
||||
import { timeAgo } from "@/utils/time-ago";
|
||||
|
||||
export interface IntegrationConfig {
|
||||
provider: "plex" | "jellyfin" | "emby" | "sonarr" | "radarr";
|
||||
label: string;
|
||||
icon: React.ComponentType<SvgProps>;
|
||||
type: "webhook" | "list";
|
||||
buildUrl: (token: string) => string;
|
||||
urlLabel: string;
|
||||
connectedStatus: (lastEventAt: string | null) => string;
|
||||
requirementNote?: string;
|
||||
setupSteps: string[];
|
||||
}
|
||||
|
||||
function webhookStatus(lastEventAt: string | null): string {
|
||||
return lastEventAt
|
||||
? `Last event ${timeAgo(lastEventAt)}`
|
||||
: "Ready — nothing received yet";
|
||||
}
|
||||
|
||||
function listStatus(lastEventAt: string | null): string {
|
||||
return lastEventAt
|
||||
? `Last polled ${timeAgo(lastEventAt)}`
|
||||
: "Ready — not polled yet";
|
||||
}
|
||||
|
||||
export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
|
||||
{
|
||||
provider: "plex",
|
||||
label: "Plex",
|
||||
icon: PlexIcon,
|
||||
type: "webhook",
|
||||
buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`,
|
||||
urlLabel: "Webhook URL",
|
||||
connectedStatus: webhookStatus,
|
||||
requirementNote: "Requires an active Plex Pass subscription.",
|
||||
setupSteps: [
|
||||
"Open Plex, go to Settings > Webhooks",
|
||||
'Click "Add Webhook" and paste the URL above',
|
||||
"Sofa will automatically log movies and episodes when you finish watching them",
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "jellyfin",
|
||||
label: "Jellyfin",
|
||||
icon: JellyfinIcon,
|
||||
type: "webhook",
|
||||
buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`,
|
||||
urlLabel: "Webhook URL",
|
||||
connectedStatus: webhookStatus,
|
||||
setupSteps: [
|
||||
"Install the Webhook plugin from Jellyfin's plugin catalog",
|
||||
"Go to Dashboard > Plugins > Webhook",
|
||||
'Add a "Generic Destination" and paste the URL above',
|
||||
'Enable the "Playback Stop" notification type',
|
||||
"Sofa will automatically log movies and episodes when you finish watching them",
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "emby",
|
||||
label: "Emby",
|
||||
icon: EmbyIcon,
|
||||
type: "webhook",
|
||||
buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`,
|
||||
urlLabel: "Webhook URL",
|
||||
connectedStatus: webhookStatus,
|
||||
requirementNote:
|
||||
"Requires Emby Server 4.7.9+ and an active Emby Premiere license.",
|
||||
setupSteps: [
|
||||
"Open Emby, go to Settings > Webhooks",
|
||||
"Add a new webhook and paste the URL above",
|
||||
'Enable the "Playback" event category',
|
||||
"Sofa will automatically log movies and episodes when you finish watching them",
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "sonarr",
|
||||
label: "Sonarr",
|
||||
icon: SonarrIcon,
|
||||
type: "list",
|
||||
buildUrl: (token) => `${getServerUrl()}/api/lists/${token}`,
|
||||
urlLabel: "Sonarr List URL",
|
||||
connectedStatus: listStatus,
|
||||
setupSteps: [
|
||||
"Open Sonarr, go to Settings > Import Lists",
|
||||
'Click "+" and select "Custom Lists"',
|
||||
"Paste the Sonarr URL above into the List URL field",
|
||||
"Set your preferred quality profile and root folder",
|
||||
"Titles on your Sofa watchlist will be automatically added for download when Sonarr polls this list (every 6 hours by default)",
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "radarr",
|
||||
label: "Radarr",
|
||||
icon: RadarrIcon,
|
||||
type: "list",
|
||||
buildUrl: (token) => `${getServerUrl()}/api/lists/${token}`,
|
||||
urlLabel: "Radarr List URL",
|
||||
connectedStatus: listStatus,
|
||||
setupSteps: [
|
||||
"Open Radarr, go to Settings > Import Lists",
|
||||
'Click "+" and select "Custom Lists"',
|
||||
"Paste the Radarr URL above into the List URL field",
|
||||
"Set your preferred quality profile and root folder",
|
||||
"Titles on your Sofa watchlist will be automatically added for download when Radarr polls this list (every 12 hours by default)",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,55 @@
|
||||
import { IconRefresh, IconWebhook } from "@tabler/icons-react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { IntegrationCard } from "@/components/settings/integration-card";
|
||||
import { INTEGRATION_CONFIGS } from "@/components/settings/integration-configs";
|
||||
import { SectionHeader } from "@/components/ui/section-header";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
|
||||
export function IntegrationsSection() {
|
||||
const integrations = useQuery(orpc.integrations.list.queryOptions());
|
||||
|
||||
return (
|
||||
<View className="mb-6">
|
||||
<SectionHeader title="Integrations" icon={IconWebhook} />
|
||||
|
||||
{integrations.isPending ? (
|
||||
<View className="items-center py-4">
|
||||
<Spinner colorClassName="accent-primary" />
|
||||
</View>
|
||||
) : integrations.isError ? (
|
||||
<View className="items-center gap-2 py-4">
|
||||
<Text className="text-[13px] text-muted-foreground">
|
||||
Could not load integrations
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => integrations.refetch()}
|
||||
className="flex-row items-center gap-1.5 rounded-lg bg-secondary px-3 py-1.5"
|
||||
style={{ borderCurve: "continuous" }}
|
||||
>
|
||||
<IconRefresh size={14} className="accent-primary" />
|
||||
<Text className="font-sans-medium text-[13px] text-primary">
|
||||
Retry
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
INTEGRATION_CONFIGS.map((config) => {
|
||||
const connection =
|
||||
integrations.data?.integrations?.find(
|
||||
(i) => i.provider === config.provider,
|
||||
) ?? null;
|
||||
return (
|
||||
<IntegrationCard
|
||||
key={config.provider}
|
||||
config={config}
|
||||
connection={connection}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Icon } from "@tabler/icons-react-native";
|
||||
import { IconChevronRight } from "@tabler/icons-react-native";
|
||||
import type { ReactNode } from "react";
|
||||
import { Pressable } from "react-native";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
export function SettingsRow({
|
||||
label,
|
||||
value,
|
||||
onPress,
|
||||
icon: IconComponent,
|
||||
destructive,
|
||||
disabled,
|
||||
right,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
onPress?: () => void;
|
||||
icon?: Icon;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
right?: ReactNode;
|
||||
}) {
|
||||
const destructiveColor = useCSSVariable("--color-destructive") as string;
|
||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled || !onPress}
|
||||
className="flex-row items-center py-3.5"
|
||||
style={disabled ? { opacity: 0.5 } : undefined}
|
||||
>
|
||||
{IconComponent && (
|
||||
<IconComponent
|
||||
size={18}
|
||||
color={destructive ? destructiveColor : mutedFgColor}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
className={`flex-1 text-[15px] ${destructive ? "text-destructive" : "text-foreground"} ${IconComponent ? "ml-2" : ""}`}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{right}
|
||||
{!right && value ? (
|
||||
<Text
|
||||
selectable
|
||||
className="mr-1 max-w-[160px] text-[14px] text-muted-foreground"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
) : null}
|
||||
{!right && onPress && <IconChevronRight size={16} color={mutedFgColor} />}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Icon } from "@tabler/icons-react-native";
|
||||
import { Children, Fragment, isValidElement, type ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { SectionHeader } from "@/components/ui/section-header";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
function flattenChildren(node: ReactNode): ReactNode[] {
|
||||
const result: ReactNode[] = [];
|
||||
Children.forEach(node, (child) => {
|
||||
if (isValidElement(child) && child.type === Fragment) {
|
||||
result.push(
|
||||
...flattenChildren((child.props as { children?: ReactNode }).children),
|
||||
);
|
||||
} else if (child != null && child !== false) {
|
||||
result.push(child);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function SettingsSection({
|
||||
title,
|
||||
icon,
|
||||
badge,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon?: Icon;
|
||||
badge?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const items = flattenChildren(children);
|
||||
|
||||
return (
|
||||
<View className="mb-6">
|
||||
<View className="mb-2 flex-row items-center gap-2">
|
||||
<SectionHeader title={title} icon={icon} />
|
||||
{badge ? (
|
||||
<View className="mb-2.5 rounded-full bg-primary/10 px-2 py-0.5">
|
||||
<Text className="font-sans-medium text-[10px] text-primary">
|
||||
{badge}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<View
|
||||
className="rounded-xl border border-white/[0.06] bg-card px-3"
|
||||
style={{ borderCurve: "continuous" }}
|
||||
>
|
||||
{items.map((child, i) => {
|
||||
const key =
|
||||
isValidElement(child) && child.key != null
|
||||
? String(child.key)
|
||||
: `settings-item-${i}`;
|
||||
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
{i > 0 && (
|
||||
<View
|
||||
className="border-border border-t"
|
||||
style={{ borderTopWidth: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
{child}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Link } from "expo-router";
|
||||
import { View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
import { Image } from "@/components/ui/image";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
export function CastCard({
|
||||
person,
|
||||
}: {
|
||||
person: {
|
||||
id: string;
|
||||
personId: string;
|
||||
name: string;
|
||||
character: string | null;
|
||||
profilePath: string | null;
|
||||
};
|
||||
}) {
|
||||
const pressed = useSharedValue(0);
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.95]) }],
|
||||
}));
|
||||
|
||||
const tapGesture = Gesture.Tap()
|
||||
.onBegin(() => {
|
||||
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
|
||||
})
|
||||
.onFinalize(() => {
|
||||
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
|
||||
});
|
||||
|
||||
return (
|
||||
<Link href={`/person/${person.personId}` as `/person/${string}`}>
|
||||
<Link.Trigger>
|
||||
<GestureDetector gesture={tapGesture}>
|
||||
<Animated.View className="w-20 items-center" style={animatedStyle}>
|
||||
<View className="mb-2 h-16 w-16 overflow-hidden rounded-full bg-secondary">
|
||||
{person.profilePath && (
|
||||
<Image
|
||||
source={{ uri: person.profilePath }}
|
||||
className="h-full w-full"
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="text-center font-sans-medium text-[11px] text-foreground"
|
||||
>
|
||||
{person.name}
|
||||
</Text>
|
||||
{person.character ? (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="text-center text-[10px] text-muted-foreground"
|
||||
>
|
||||
{person.character}
|
||||
</Text>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Season } from "@sofa/api/schemas";
|
||||
import { getNextEpisode } from "@sofa/api/utils";
|
||||
import { IconPlayerPlay } from "@tabler/icons-react-native";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||
import { Image } from "@/components/ui/image";
|
||||
import { SectionHeader } from "@/components/ui/section-header";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
export function ContinueWatchingBanner({
|
||||
seasons,
|
||||
watchedEpisodeIds,
|
||||
userStatus,
|
||||
backdropPath,
|
||||
}: {
|
||||
seasons: Season[];
|
||||
watchedEpisodeIds: Set<string>;
|
||||
userStatus: string | null;
|
||||
backdropPath: string | null;
|
||||
}) {
|
||||
const { nextEpisode, totalEpisodes, watchedEpisodes } = useMemo(
|
||||
() => getNextEpisode(seasons, watchedEpisodeIds),
|
||||
[seasons, watchedEpisodeIds],
|
||||
);
|
||||
|
||||
if (userStatus !== "in_progress" || !nextEpisode) return null;
|
||||
|
||||
const stillUrl = nextEpisode.stillPath ?? backdropPath ?? null;
|
||||
const progress =
|
||||
totalEpisodes > 0 ? (watchedEpisodes / totalEpisodes) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300).delay(350)}
|
||||
className="mt-6 px-4"
|
||||
>
|
||||
<SectionHeader title="Next Episode" icon={IconPlayerPlay} />
|
||||
<View
|
||||
className="overflow-hidden rounded-[12px] border bg-card"
|
||||
style={{
|
||||
borderColor: "rgba(255,255,255,0.06)",
|
||||
borderCurve: "continuous",
|
||||
}}
|
||||
>
|
||||
<View className="h-28">
|
||||
{stillUrl && (
|
||||
<Image
|
||||
source={{ uri: stillUrl }}
|
||||
className="h-full w-full"
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundColor: "rgba(0,0,0,0.45)",
|
||||
}}
|
||||
/>
|
||||
<View className="absolute right-3 bottom-2.5 left-3">
|
||||
<View className="flex-row items-center gap-1.5">
|
||||
<View className="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
<Text className="font-sans-medium text-[10px] text-primary uppercase tracking-wider">
|
||||
Up next
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="mt-0.5 font-sans-medium text-[13px] text-white"
|
||||
>
|
||||
<Text className="font-sans-medium text-[11px] text-white/60">
|
||||
S{nextEpisode.seasonNumber} E{nextEpisode.episodeNumber}
|
||||
</Text>
|
||||
{" "}
|
||||
{nextEpisode.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className="absolute right-0 bottom-0 left-0 h-[3px]"
|
||||
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
|
||||
>
|
||||
<View
|
||||
className="h-full bg-status-watching"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
IconCircleCheckFilled,
|
||||
IconCircleDashed,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
export function EpisodeRow({
|
||||
episode,
|
||||
isWatched,
|
||||
onToggle,
|
||||
}: {
|
||||
episode: {
|
||||
id: string;
|
||||
episodeNumber: number;
|
||||
name: string | null;
|
||||
airDate: string | null;
|
||||
};
|
||||
isWatched: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const completedColor = useCSSVariable("--color-status-completed") as string;
|
||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onToggle}
|
||||
className="flex-row items-center border-border border-b px-4 py-3"
|
||||
style={{ borderBottomWidth: 0.5 }}
|
||||
>
|
||||
{isWatched ? (
|
||||
<IconCircleCheckFilled size={22} color={completedColor} />
|
||||
) : (
|
||||
<IconCircleDashed size={22} color={mutedFgColor} />
|
||||
)}
|
||||
<View className="ml-3 flex-1">
|
||||
<Text
|
||||
className={`font-sans-medium text-sm ${isWatched ? "text-muted-foreground" : "text-foreground"}`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{episode.episodeNumber}.{" "}
|
||||
{episode.name ?? `Episode ${episode.episodeNumber}`}
|
||||
</Text>
|
||||
{episode.airDate ? (
|
||||
<Text className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{episode.airDate}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { IconChevronDown } from "@tabler/icons-react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { InteractionManager, Pressable, View } from "react-native";
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { EpisodeRow } from "@/components/titles/episode-row";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
export function SeasonAccordion({
|
||||
season,
|
||||
episodes,
|
||||
watchedEpisodeIds,
|
||||
}: {
|
||||
season: {
|
||||
id: string;
|
||||
seasonNumber: number;
|
||||
name: string | null;
|
||||
};
|
||||
episodes: Array<{
|
||||
id: string;
|
||||
episodeNumber: number;
|
||||
name: string | null;
|
||||
airDate: string | null;
|
||||
}>;
|
||||
watchedEpisodeIds: Set<string>;
|
||||
}) {
|
||||
const completedColor = useCSSVariable("--color-status-completed") as string;
|
||||
const watchingColor = useCSSVariable("--color-status-watching") as string;
|
||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const chevronRotation = useSharedValue(0);
|
||||
const watchedCount = episodes.filter((e) =>
|
||||
watchedEpisodeIds.has(e.id),
|
||||
).length;
|
||||
const progress = episodes.length > 0 ? watchedCount / episodes.length : 0;
|
||||
|
||||
// Progressive rendering: show first batch immediately, defer rest
|
||||
const INITIAL_BATCH = 10;
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_BATCH);
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded) {
|
||||
setVisibleCount(INITIAL_BATCH);
|
||||
if (episodes.length > INITIAL_BATCH) {
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
setVisibleCount(episodes.length);
|
||||
});
|
||||
return () => task.cancel();
|
||||
}
|
||||
}
|
||||
}, [expanded, episodes.length]);
|
||||
|
||||
const toggleExpanded = useCallback(() => setExpanded((v) => !v), []);
|
||||
|
||||
useEffect(() => {
|
||||
chevronRotation.set(withTiming(expanded ? 180 : 0, { duration: 200 }));
|
||||
}, [expanded, chevronRotation]);
|
||||
|
||||
const chevronStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${chevronRotation.get()}deg` }],
|
||||
}));
|
||||
|
||||
const watchEpisode = useMutation(
|
||||
orpc.episodes.watch.mutationOptions({
|
||||
onSuccess: (_data, { id: epId }) => {
|
||||
const ep = episodes.find((e) => e.id === epId);
|
||||
toast.success(
|
||||
ep
|
||||
? `Watched S${season.seasonNumber} E${ep.episodeNumber}`
|
||||
: "Episode watched",
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||
},
|
||||
onError: () => toast.error("Failed to mark episode"),
|
||||
}),
|
||||
);
|
||||
|
||||
const unwatchEpisode = useMutation(
|
||||
orpc.episodes.unwatch.mutationOptions({
|
||||
onSuccess: (_data, { id: epId }) => {
|
||||
const ep = episodes.find((e) => e.id === epId);
|
||||
toast.success(
|
||||
ep
|
||||
? `Unwatched S${season.seasonNumber} E${ep.episodeNumber}`
|
||||
: "Episode unwatched",
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||
},
|
||||
onError: () => toast.error("Failed to unmark episode"),
|
||||
}),
|
||||
);
|
||||
|
||||
const watchSeason = useMutation(
|
||||
orpc.seasons.watch.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
`Watched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||
},
|
||||
onError: () => toast.error("Failed to mark some episodes"),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="mb-2 overflow-hidden rounded-xl border bg-card"
|
||||
style={{
|
||||
borderColor: "rgba(255,255,255,0.06)",
|
||||
borderCurve: "continuous",
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
onPress={toggleExpanded}
|
||||
className="flex-row items-center justify-between p-4"
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="font-sans-medium text-[15px] text-foreground">
|
||||
{season.name ?? `Season ${season.seasonNumber}`}
|
||||
</Text>
|
||||
<Text className="mt-0.5 text-muted-foreground text-xs">
|
||||
{watchedCount}/{episodes.length} episodes
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className="mx-3 h-1 w-[60px] overflow-hidden rounded-full"
|
||||
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progress * 100}%`,
|
||||
backgroundColor: progress === 1 ? completedColor : watchingColor,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Animated.View style={chevronStyle}>
|
||||
<IconChevronDown size={18} color={mutedFgColor} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
|
||||
{expanded && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(150)}
|
||||
>
|
||||
{watchedCount < episodes.length && (
|
||||
<Pressable
|
||||
onPress={() => watchSeason.mutate({ id: season.id })}
|
||||
className="mx-4 mb-2 flex-row items-center justify-center rounded-lg bg-secondary py-2"
|
||||
>
|
||||
<Text className="font-sans-medium text-primary text-xs">
|
||||
Mark All Watched
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{episodes.slice(0, visibleCount).map((episode) => (
|
||||
<EpisodeRow
|
||||
key={episode.id}
|
||||
episode={episode}
|
||||
isWatched={watchedEpisodeIds.has(episode.id)}
|
||||
onToggle={() => {
|
||||
if (watchedEpisodeIds.has(episode.id)) {
|
||||
unwatchEpisode.mutate({ id: episode.id });
|
||||
} else {
|
||||
watchEpisode.mutate({ id: episode.id });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
IconBookmark,
|
||||
IconCircleCheck,
|
||||
IconPlayerPlay,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
||||
|
||||
export function StatusActionButton({
|
||||
currentStatus,
|
||||
onStatusChange,
|
||||
isPending,
|
||||
}: {
|
||||
currentStatus: TitleStatus | null;
|
||||
onStatusChange: (status: TitleStatus | null) => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const statuses: Array<{
|
||||
status: TitleStatus;
|
||||
label: string;
|
||||
Icon: typeof IconBookmark;
|
||||
}> = [
|
||||
{ status: "watchlist", label: "Watchlist", Icon: IconBookmark },
|
||||
{ status: "in_progress", label: "Watching", Icon: IconPlayerPlay },
|
||||
{ status: "completed", label: "Completed", Icon: IconCircleCheck },
|
||||
];
|
||||
|
||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
return (
|
||||
<View className="flex-row gap-2">
|
||||
{statuses.map(({ status, label, Icon }) => {
|
||||
const isActive = currentStatus === status;
|
||||
return (
|
||||
<Pressable
|
||||
key={status}
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
onStatusChange(isActive ? null : status);
|
||||
}}
|
||||
disabled={isPending}
|
||||
className={`flex-row items-center gap-1.5 rounded-full border px-3 py-2 ${
|
||||
isActive
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border bg-card"
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} color={isActive ? primaryColor : mutedFgColor} />
|
||||
<Text
|
||||
className={`font-sans-medium text-xs ${isActive ? "text-primary" : "text-foreground"}`}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg";
|
||||
|
||||
export function TmdbLogo({ height = 12 }: { height?: number }) {
|
||||
const aspectRatio = 190.24 / 81.52;
|
||||
const width = height * aspectRatio;
|
||||
|
||||
return (
|
||||
<Svg
|
||||
viewBox="0 0 190.24 81.52"
|
||||
width={width}
|
||||
height={height}
|
||||
accessibilityLabel="TMDB"
|
||||
>
|
||||
<Defs>
|
||||
<LinearGradient
|
||||
id="tmdb-grad"
|
||||
x1="0"
|
||||
x2="190.24"
|
||||
y1="40.76"
|
||||
y2="40.76"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<Stop offset="0" stopColor="#90cea1" />
|
||||
<Stop offset="0.56" stopColor="#3cbec9" />
|
||||
<Stop offset="1" stopColor="#00b3e5" />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
<Path
|
||||
fill="url(#tmdb-grad)"
|
||||
d="M105.67 36.06h66.9a17.67 17.67 0 0 0 17.67-17.66A17.67 17.67 0 0 0 172.57.73h-66.9A17.67 17.67 0 0 0 88 18.4a17.67 17.67 0 0 0 17.67 17.66m-88 45h76.9a17.67 17.67 0 0 0 17.67-17.66 17.67 17.67 0 0 0-17.67-17.67h-76.9A17.67 17.67 0 0 0 0 63.4a17.67 17.67 0 0 0 17.67 17.66m-7.26-45.64h7.8V6.92h10.1V0h-28v6.9h10.1Zm28.1 0h7.8V8.25h.1l9 27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2 23.1h-.1L50.31 0h-11.8Zm113.92 20.25a15.1 15.1 0 0 0-4.52-5.52 18.6 18.6 0 0 0-6.68-3.08 33.5 33.5 0 0 0-8.07-1h-11.7v35.4h12.75a24.6 24.6 0 0 0 7.55-1.15 19.3 19.3 0 0 0 6.35-3.32 16.3 16.3 0 0 0 4.37-5.5 16.9 16.9 0 0 0 1.63-7.58 18.5 18.5 0 0 0-1.68-8.25M145 68.6a8.8 8.8 0 0 1-2.64 3.4 10.7 10.7 0 0 1-4 1.82 21.6 21.6 0 0 1-5 .55h-4.05v-21h4.6a17 17 0 0 1 4.67.63 11.7 11.7 0 0 1 3.88 1.87A9.14 9.14 0 0 1 145 59a9.9 9.9 0 0 1 1 4.52 11.9 11.9 0 0 1-1 5.08m44.63-.13a8 8 0 0 0-1.58-2.62 8.4 8.4 0 0 0-2.42-1.85 10.3 10.3 0 0 0-3.17-1v-.1a9.2 9.2 0 0 0 4.42-2.82 7.43 7.43 0 0 0 1.68-5 8.4 8.4 0 0 0-1.15-4.65 8.1 8.1 0 0 0-3-2.72 12.6 12.6 0 0 0-4.18-1.3 33 33 0 0 0-4.62-.33h-13.2v35.4h14.5a22.4 22.4 0 0 0 4.72-.5 13.5 13.5 0 0 0 4.28-1.65 9.4 9.4 0 0 0 3.1-3 8.5 8.5 0 0 0 1.2-4.68 9.4 9.4 0 0 0-.55-3.18Zm-19.42-15.75h5.3a10 10 0 0 1 1.85.18 6.2 6.2 0 0 1 1.7.57 3.4 3.4 0 0 1 1.22 1.13 3.2 3.2 0 0 1 .48 1.82 3.63 3.63 0 0 1-.43 1.8 3.4 3.4 0 0 1-1.12 1.2 4.9 4.9 0 0 1-1.58.65 7.5 7.5 0 0 1-1.77.2h-5.65Zm11.72 20a3.9 3.9 0 0 1-1.22 1.3 4.6 4.6 0 0 1-1.68.7 8.2 8.2 0 0 1-1.82.2h-7v-8h5.9a15 15 0 0 1 2 .15 8.5 8.5 0 0 1 2.05.55 4 4 0 0 1 1.57 1.18 3.1 3.1 0 0 1 .63 2 3.7 3.7 0 0 1-.43 1.92"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createContext, forwardRef, useContext } from "react";
|
||||
import { Pressable, type PressableProps, type TextProps } from "react-native";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
type ButtonVariant = "default" | "secondary";
|
||||
|
||||
const ButtonVariantContext = createContext<ButtonVariant>("default");
|
||||
|
||||
interface ButtonProps extends PressableProps {
|
||||
size?: "default" | "sm";
|
||||
variant?: ButtonVariant;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<
|
||||
React.ComponentRef<typeof Pressable>,
|
||||
ButtonProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
size = "default",
|
||||
variant = "default",
|
||||
className,
|
||||
style,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<ButtonVariantContext.Provider value={variant}>
|
||||
<Pressable
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"items-center justify-center rounded-xl",
|
||||
size === "sm" ? "h-10 px-4" : "h-12 px-5",
|
||||
variant === "secondary" ? "bg-secondary" : "bg-primary",
|
||||
className,
|
||||
)}
|
||||
style={(state) => [
|
||||
{
|
||||
opacity: state.pressed ? 0.8 : disabled ? 0.5 : 1,
|
||||
},
|
||||
typeof style === "function" ? style(state) : style,
|
||||
]}
|
||||
{...props}
|
||||
/>
|
||||
</ButtonVariantContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
interface ButtonLabelProps extends TextProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ButtonLabel({ style, className, ...props }: ButtonLabelProps) {
|
||||
const variant = useContext(ButtonVariantContext);
|
||||
|
||||
return (
|
||||
<Text
|
||||
className={cn(
|
||||
"text-center font-sans-medium text-[15px]",
|
||||
variant === "secondary"
|
||||
? "text-secondary-foreground"
|
||||
: "text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Icon } from "@tabler/icons-react-native";
|
||||
import { IconMovie } from "@tabler/icons-react-native";
|
||||
import Animated, { FadeIn } from "react-native-reanimated";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: Icon;
|
||||
title: string;
|
||||
description?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: IconComponent = IconMovie,
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: EmptyStateProps) {
|
||||
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(400)}
|
||||
className="items-center justify-center px-6 py-12"
|
||||
>
|
||||
<IconComponent size={48} color={mutedForeground} />
|
||||
<Text className="mt-3 text-center font-sans-medium text-[16px] text-foreground">
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text className="mt-1 text-center text-[13px] text-muted-foreground">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{actionLabel && onAction && (
|
||||
<Button onPress={onAction} size="sm" className="mt-4 bg-primary">
|
||||
<ButtonLabel className="text-primary-foreground">
|
||||
{actionLabel}
|
||||
</ButtonLabel>
|
||||
</Button>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { NativeSyntheticEvent, TextLayoutEventData } from "react-native";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
export function ExpandableText({
|
||||
text,
|
||||
maxLines = 3,
|
||||
}: {
|
||||
text: string;
|
||||
maxLines?: number;
|
||||
}) {
|
||||
const prevTextRef = useRef(text);
|
||||
let expanded: boolean;
|
||||
let needsTruncation: boolean;
|
||||
const [expandedState, setExpanded] = useState(false);
|
||||
const [needsTruncationState, setNeedsTruncation] = useState(false);
|
||||
|
||||
if (prevTextRef.current !== text) {
|
||||
prevTextRef.current = text;
|
||||
setExpanded(false);
|
||||
setNeedsTruncation(false);
|
||||
expanded = false;
|
||||
needsTruncation = false;
|
||||
} else {
|
||||
expanded = expandedState;
|
||||
needsTruncation = needsTruncationState;
|
||||
}
|
||||
|
||||
const onTextLayout = useCallback(
|
||||
(e: NativeSyntheticEvent<TextLayoutEventData>) => {
|
||||
setNeedsTruncation(e.nativeEvent.lines.length > maxLines);
|
||||
},
|
||||
[maxLines],
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text
|
||||
selectable
|
||||
numberOfLines={expanded ? undefined : maxLines}
|
||||
onTextLayout={onTextLayout}
|
||||
className="text-[14px] text-foreground leading-[22px]"
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
{needsTruncation && (
|
||||
<Pressable onPress={() => setExpanded(!expanded)} className="mt-1">
|
||||
<Text className="font-sans-medium text-[13px] text-primary">
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Image as ExpoImage, type ImageProps } from "expo-image";
|
||||
import { useResolveClassNames } from "uniwind";
|
||||
import { resolveUrl } from "@/lib/server-url";
|
||||
|
||||
export function Image({
|
||||
source,
|
||||
className,
|
||||
...props
|
||||
}: ImageProps & { className?: string }) {
|
||||
const resolved =
|
||||
source && typeof source === "object" && "uri" in source && source.uri
|
||||
? { ...source, uri: resolveUrl(source.uri) ?? undefined }
|
||||
: source;
|
||||
|
||||
const style = useResolveClassNames(className ?? "");
|
||||
|
||||
return (
|
||||
<ExpoImage source={resolved} style={[style, props.style]} {...props} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { IconWifiOff } from "@tabler/icons-react-native";
|
||||
import * as Network from "expo-network";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
export function OfflineBanner() {
|
||||
const [isOffline, setIsOffline] = useState(false);
|
||||
const insets = useSafeAreaInsets();
|
||||
const wasOnline = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const handleState = (state: Network.NetworkState) => {
|
||||
if (!mounted) return;
|
||||
const offline = !state.isConnected || !state.isInternetReachable;
|
||||
setIsOffline(offline);
|
||||
if (offline && wasOnline.current) {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
||||
}
|
||||
wasOnline.current = !offline;
|
||||
};
|
||||
|
||||
// Check initial state
|
||||
Network.getNetworkStateAsync().then(handleState);
|
||||
|
||||
// Listen for changes
|
||||
const subscription = Network.addNetworkStateListener(handleState);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isOffline) return null;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={SlideInUp.duration(300).springify().damping(18)}
|
||||
exiting={SlideOutUp.duration(250)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: insets.top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-destructive px-4 py-2.5">
|
||||
<IconWifiOff size={16} color="white" />
|
||||
<Text className="font-sans-medium text-[13px] text-white">
|
||||
No internet connection
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import {
|
||||
IconBookmarkFilled,
|
||||
IconCheckbox,
|
||||
IconDeviceTv,
|
||||
IconLoader,
|
||||
IconMovie,
|
||||
IconPlayerPlayFilled,
|
||||
IconPlus,
|
||||
IconStarFilled,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import { Image } from "@/components/ui/image";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { client, orpc } from "@/lib/orpc";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
||||
|
||||
interface PosterCardProps {
|
||||
id?: string;
|
||||
tmdbId: number;
|
||||
title: string;
|
||||
type: "movie" | "tv";
|
||||
posterPath: string | null;
|
||||
releaseDate?: string | null;
|
||||
voteAverage?: number | null;
|
||||
userStatus?: TitleStatus | null;
|
||||
episodeProgress?: { watched: number; total: number } | null;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export function PosterCard({
|
||||
id,
|
||||
tmdbId,
|
||||
title,
|
||||
type,
|
||||
posterPath,
|
||||
releaseDate,
|
||||
voteAverage,
|
||||
userStatus,
|
||||
episodeProgress,
|
||||
width = 140,
|
||||
}: PosterCardProps) {
|
||||
const { navigate } = useRouter();
|
||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||
const watchlistColor = useCSSVariable("--color-status-watchlist") as string;
|
||||
const watchingColor = useCSSVariable("--color-status-watching") as string;
|
||||
const completedColor = useCSSVariable("--color-status-completed") as string;
|
||||
|
||||
const statusColors: Record<TitleStatus, string> = {
|
||||
watchlist: watchlistColor,
|
||||
in_progress: watchingColor,
|
||||
completed: completedColor,
|
||||
};
|
||||
|
||||
const pressed = useSharedValue(0);
|
||||
const [localStatus, setLocalStatus] = useState<TitleStatus | null>(
|
||||
userStatus ?? null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalStatus(userStatus ?? null);
|
||||
}, [userStatus]);
|
||||
|
||||
const resolveMutation = useMutation(
|
||||
orpc.titles.resolve.mutationOptions({
|
||||
onSuccess: ({ id: resolvedId }) => {
|
||||
if (resolvedId) {
|
||||
navigate(`/title/${resolvedId}`);
|
||||
}
|
||||
},
|
||||
onError: () => toast.error("Failed to load title"),
|
||||
}),
|
||||
);
|
||||
|
||||
const quickAddMutation = useMutation(
|
||||
orpc.titles.quickAdd.mutationOptions({
|
||||
onSuccess: () => {
|
||||
setLocalStatus("watchlist");
|
||||
toast.success("Added to watchlist");
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||
},
|
||||
onError: () => toast.error("Failed to add to watchlist"),
|
||||
}),
|
||||
);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
scale: interpolate(pressed.get(), [0, 1], [1, 0.97]),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const handleResolve = useCallback(() => {
|
||||
resolveMutation.mutate({ tmdbId, type });
|
||||
}, [tmdbId, type, resolveMutation]);
|
||||
|
||||
const handleQuickAdd = useCallback(() => {
|
||||
if (localStatus || quickAddMutation.isPending) return;
|
||||
quickAddMutation.mutate({ tmdbId, type });
|
||||
}, [localStatus, quickAddMutation, tmdbId, type]);
|
||||
|
||||
const year = releaseDate?.slice(0, 4);
|
||||
const imageHeight = width * 1.5;
|
||||
|
||||
const cardContent = (
|
||||
<View
|
||||
className={`overflow-hidden rounded-xl border bg-card ${
|
||||
localStatus ? "border-primary/25" : "border-white/[0.06]"
|
||||
}`}
|
||||
style={{ borderCurve: "continuous" }}
|
||||
>
|
||||
{/* Poster image */}
|
||||
<View style={{ width, height: imageHeight }}>
|
||||
{posterPath ? (
|
||||
<Image
|
||||
source={{ uri: posterPath }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit="cover"
|
||||
recyclingKey={`poster-${tmdbId}`}
|
||||
transition={200}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center bg-secondary p-3">
|
||||
<Text className="text-center font-display text-[13px] text-foreground/[0.44]">
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Quick-add button */}
|
||||
{!localStatus && (
|
||||
<Pressable
|
||||
onPress={handleQuickAdd}
|
||||
className="absolute top-2 right-2 size-[30px] items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
>
|
||||
{quickAddMutation.isPending ? (
|
||||
<IconLoader size={16} color="white" />
|
||||
) : (
|
||||
<IconPlus size={16} color="white" />
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Status indicator */}
|
||||
{localStatus && (
|
||||
<View
|
||||
className="absolute top-2 right-2 size-[30px] items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
>
|
||||
{localStatus === "completed" ? (
|
||||
<IconCheckbox size={16} color="white" />
|
||||
) : localStatus === "in_progress" ? (
|
||||
<IconPlayerPlayFilled size={16} color="white" />
|
||||
) : (
|
||||
<IconBookmarkFilled size={16} color="white" />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Episode progress bar */}
|
||||
{episodeProgress &&
|
||||
episodeProgress.total > 0 &&
|
||||
episodeProgress.watched > 0 && (
|
||||
<View
|
||||
className="absolute right-0 bottom-0 left-0 h-[3px]"
|
||||
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
|
||||
>
|
||||
<View
|
||||
className="h-full bg-status-watching"
|
||||
style={{
|
||||
width: `${episodeProgress.total > 0 ? (episodeProgress.watched / episodeProgress.total) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Metadata */}
|
||||
<View className="px-2.5 pt-2 pb-2.5">
|
||||
<View className="flex-row items-center gap-1.5">
|
||||
{localStatus && (
|
||||
<View
|
||||
className="size-1.5 rounded-full"
|
||||
style={{ backgroundColor: statusColors[localStatus] }}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
className="flex-1 font-sans-medium text-[13px] text-foreground"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="mt-1 flex-row items-center gap-2">
|
||||
{type === "movie" ? (
|
||||
<IconMovie size={12} color={primaryColor} opacity={0.6} />
|
||||
) : (
|
||||
<IconDeviceTv size={12} color={primaryColor} opacity={0.6} />
|
||||
)}
|
||||
{year ? (
|
||||
<Text className="text-[11px] text-muted-foreground">{year}</Text>
|
||||
) : null}
|
||||
{voteAverage != null && voteAverage > 0 && (
|
||||
<View className="ml-auto flex-row items-center gap-0.5">
|
||||
<IconStarFilled size={10} color={primaryColor} opacity={0.8} />
|
||||
<Text className="text-[11px] text-primary/80">
|
||||
{voteAverage.toFixed(1)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// Gesture for UI-thread press animation (used by both paths)
|
||||
const pressGesture = Gesture.Tap()
|
||||
.onBegin(() => {
|
||||
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
|
||||
})
|
||||
.onFinalize(() => {
|
||||
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
|
||||
});
|
||||
|
||||
// Cards with id: use context menu with navigation
|
||||
if (id) {
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<GestureDetector gesture={pressGesture}>
|
||||
<Animated.View style={[animatedStyle, { width }]}>
|
||||
<Pressable onPress={() => navigate(`/title/${id}`)}>
|
||||
{cardContent}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content>
|
||||
{!localStatus && (
|
||||
<ContextMenu.Item key="watchlist" onSelect={handleQuickAdd}>
|
||||
<ContextMenu.ItemIcon ios={{ name: "bookmark" }} />
|
||||
<ContextMenu.ItemTitle>Add to Watchlist</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
{localStatus !== "in_progress" && (
|
||||
<ContextMenu.Item
|
||||
key="watching"
|
||||
onSelect={async () => {
|
||||
await client.titles.updateStatus({
|
||||
id,
|
||||
status: "in_progress",
|
||||
});
|
||||
toast.success("Marked as watching");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.titles.key(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.dashboard.key(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemIcon ios={{ name: "play.fill" }} />
|
||||
<ContextMenu.ItemTitle>Mark as Watching</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
{type === "movie" && (
|
||||
<ContextMenu.Item
|
||||
key="watched"
|
||||
onSelect={async () => {
|
||||
await client.titles.watchMovie({ id });
|
||||
toast.success(
|
||||
title ? `Marked "${title}" as watched` : "Marked as watched",
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.titles.key(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.dashboard.key(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemIcon ios={{ name: "checkmark.circle" }} />
|
||||
<ContextMenu.ItemTitle>Mark as Watched</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
{localStatus && (
|
||||
<ContextMenu.Item
|
||||
key="remove"
|
||||
destructive
|
||||
onSelect={async () => {
|
||||
await client.titles.updateStatus({ id, status: null });
|
||||
setLocalStatus(null);
|
||||
toast.success("Removed from library");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.titles.key(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.dashboard.key(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemIcon ios={{ name: "trash" }} />
|
||||
<ContextMenu.ItemTitle>Remove from Library</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
}
|
||||
|
||||
// Cards without id: use GestureDetector for resolve-then-navigate
|
||||
const resolveTapGesture = Gesture.Tap()
|
||||
.onBegin(() => {
|
||||
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
|
||||
})
|
||||
.onFinalize(() => {
|
||||
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(handleResolve)();
|
||||
});
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={resolveTapGesture}>
|
||||
<Animated.View style={[animatedStyle, { width }]}>
|
||||
{cardContent}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
export function PosterCardSkeleton({ width = 140 }: { width?: number }) {
|
||||
const imageHeight = width * 1.5;
|
||||
return (
|
||||
<View
|
||||
className="overflow-hidden rounded-xl border border-white/[0.06] bg-card"
|
||||
style={{
|
||||
width,
|
||||
borderCurve: "continuous",
|
||||
}}
|
||||
>
|
||||
<Skeleton width={width} height={imageHeight} borderRadius={0} />
|
||||
<View className="px-2.5 pt-2 pb-2.5">
|
||||
<Skeleton width="75%" height={14} />
|
||||
<Skeleton width="50%" height={10} style={{ marginTop: 6 }} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Icon } from "@tabler/icons-react-native";
|
||||
import { View } from "react-native";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
icon?: Icon;
|
||||
iconColor?: string;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
icon: IconComponent,
|
||||
iconColor,
|
||||
}: SectionHeaderProps) {
|
||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||
const resolvedColor = iconColor ?? primaryColor;
|
||||
|
||||
return (
|
||||
<View className="mb-3 flex-row items-center gap-2">
|
||||
{IconComponent && <IconComponent size={20} color={resolvedColor} />}
|
||||
<Text className="font-display text-[20px] text-foreground tracking-tight">
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect } from "react";
|
||||
import type { ViewStyle } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
import { useCSSVariable } from "uniwind";
|
||||
|
||||
interface SkeletonProps {
|
||||
width?: number | `${number}%`;
|
||||
height?: number;
|
||||
borderRadius?: number;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
export function Skeleton({
|
||||
width,
|
||||
height = 14,
|
||||
borderRadius = 4,
|
||||
style,
|
||||
}: SkeletonProps) {
|
||||
const secondaryColor = useCSSVariable("--color-secondary") as string;
|
||||
const opacity = useSharedValue(0.4);
|
||||
|
||||
useEffect(() => {
|
||||
opacity.set(withRepeat(withTiming(1, { duration: 800 }), -1, true));
|
||||
}, [opacity]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.get(),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
width,
|
||||
height,
|
||||
borderRadius,
|
||||
backgroundColor: secondaryColor,
|
||||
},
|
||||
animatedStyle,
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
|
||||
interface SofaLogoProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function SofaLogo({ size = 24, color }: SofaLogoProps) {
|
||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||
const fill = color ?? primaryColor;
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
fill={fill}
|
||||
d="M7 12v1h10v-1a3 3 0 0 1 2.993-3a4.6 4.6 0 0 0-.07-.78a4 4 0 0 0-3.143-3.143C16.394 5 15.93 5 15 5H9c-.93 0-1.394 0-1.78.077A4 4 0 0 0 4.077 8.22a4.6 4.6 0 0 0-.07.78A3 3 0 0 1 7 12"
|
||||
/>
|
||||
<Path
|
||||
fill={fill}
|
||||
d="M18.444 18H5.556a3.6 3.6 0 0 1-.806-.092V19a.75.75 0 0 1-1.5 0v-1.849A3.55 3.55 0 0 1 2 14.444V12a2 2 0 1 1 4 0v1.2a.8.8 0 0 0 .8.8h10.4a.8.8 0 0 0 .8-.8V12a2 2 0 1 1 4 0v2.444a3.55 3.55 0 0 1-1.25 2.707V19a.75.75 0 0 1-1.5 0v-1.092a3.6 3.6 0 0 1-.806.092"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ActivityIndicator, type ActivityIndicatorProps } from "react-native";
|
||||
|
||||
const SIZES = { sm: "small", default: "large" } as const;
|
||||
|
||||
interface SpinnerProps extends Omit<ActivityIndicatorProps, "size" | "color"> {
|
||||
size?: "sm" | "default";
|
||||
}
|
||||
|
||||
export function Spinner({ size = "default", ...props }: SpinnerProps) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
size={SIZES[size]}
|
||||
colorClassName="accent-primary-foreground"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { IconStar, IconStarFilled } from "@tabler/icons-react-native";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
interface StarRatingProps {
|
||||
rating: number;
|
||||
onRate?: (rating: number) => void;
|
||||
size?: number;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export function StarRating({
|
||||
rating,
|
||||
onRate,
|
||||
size = 22,
|
||||
interactive = true,
|
||||
}: StarRatingProps) {
|
||||
const [primary, mutedForeground] = useCSSVariable([
|
||||
"--color-primary",
|
||||
"--color-muted-foreground",
|
||||
]) as [string, string];
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Pressable
|
||||
key={star}
|
||||
disabled={!interactive}
|
||||
onPress={() => {
|
||||
if (!onRate) return;
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
onRate(star === rating ? 0 : star);
|
||||
}}
|
||||
hitSlop={6}
|
||||
>
|
||||
{star <= rating ? (
|
||||
<IconStarFilled size={size} color={primary} />
|
||||
) : (
|
||||
<IconStar size={size} color={mutedForeground} />
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Icon } from "@tabler/icons-react-native";
|
||||
import {
|
||||
IconBookmarkFilled,
|
||||
IconCircleCheckFilled,
|
||||
IconPlayerPlayFilled,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { View } from "react-native";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
||||
|
||||
const bgClasses: Record<TitleStatus, string> = {
|
||||
watchlist: "bg-status-watchlist/10",
|
||||
in_progress: "bg-status-watching/10",
|
||||
completed: "bg-status-completed/10",
|
||||
};
|
||||
|
||||
const textClasses: Record<TitleStatus, string> = {
|
||||
watchlist: "text-status-watchlist",
|
||||
in_progress: "text-status-watching",
|
||||
completed: "text-status-completed",
|
||||
};
|
||||
|
||||
const icons: Record<TitleStatus, { label: string; Icon: Icon }> = {
|
||||
watchlist: { label: "Watchlist", Icon: IconBookmarkFilled },
|
||||
in_progress: { label: "Watching", Icon: IconPlayerPlayFilled },
|
||||
completed: { label: "Completed", Icon: IconCircleCheckFilled },
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: TitleStatus }) {
|
||||
const watchlistColor = useCSSVariable("--color-status-watchlist") as string;
|
||||
const watchingColor = useCSSVariable("--color-status-watching") as string;
|
||||
const completedColor = useCSSVariable("--color-status-completed") as string;
|
||||
|
||||
const colorMap: Record<TitleStatus, string> = {
|
||||
watchlist: watchlistColor,
|
||||
in_progress: watchingColor,
|
||||
completed: completedColor,
|
||||
};
|
||||
|
||||
const { label, Icon: StatusIcon } = icons[status];
|
||||
return (
|
||||
<View
|
||||
className={`flex-row items-center gap-1.5 rounded-full px-2.5 py-1 ${bgClasses[status]}`}
|
||||
>
|
||||
<StatusIcon size={12} color={colorMap[status]} />
|
||||
<Text className={`font-sans-medium text-[11px] ${textClasses[status]}`}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Pressable } from "react-native";
|
||||
import Animated, {
|
||||
interpolateColor,
|
||||
useAnimatedStyle,
|
||||
useDerivedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
|
||||
const TRACK_WIDTH = 40;
|
||||
const TRACK_HEIGHT = 20;
|
||||
const THUMB_SIZE = 14;
|
||||
const THUMB_OFFSET = 3;
|
||||
|
||||
export function Switch({
|
||||
value,
|
||||
onValueChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: boolean;
|
||||
onValueChange: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||
const secondaryColor = useCSSVariable("--color-secondary") as string;
|
||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
const progress = useDerivedValue(() =>
|
||||
withTiming(value ? 1 : 0, { duration: 200 }),
|
||||
);
|
||||
|
||||
const trackStyle = useAnimatedStyle(() => ({
|
||||
backgroundColor: interpolateColor(
|
||||
progress.value,
|
||||
[0, 1],
|
||||
[secondaryColor, primaryColor],
|
||||
),
|
||||
}));
|
||||
|
||||
const thumbStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
translateX:
|
||||
THUMB_OFFSET +
|
||||
progress.value * (TRACK_WIDTH - THUMB_SIZE - THUMB_OFFSET * 2),
|
||||
},
|
||||
],
|
||||
backgroundColor: interpolateColor(
|
||||
progress.value,
|
||||
[0, 1],
|
||||
[mutedFgColor, "#fff"],
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => onValueChange(!value)}
|
||||
disabled={disabled}
|
||||
hitSlop={8}
|
||||
style={disabled ? { opacity: 0.5 } : undefined}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
width: TRACK_WIDTH,
|
||||
height: TRACK_HEIGHT,
|
||||
borderRadius: TRACK_HEIGHT / 2,
|
||||
},
|
||||
trackStyle,
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
width: THUMB_SIZE,
|
||||
height: THUMB_SIZE,
|
||||
borderRadius: THUMB_SIZE / 2,
|
||||
position: "absolute",
|
||||
top: (TRACK_HEIGHT - THUMB_SIZE) / 2,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 2,
|
||||
elevation: 3,
|
||||
},
|
||||
thumbStyle,
|
||||
]}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { forwardRef, type PropsWithChildren } from "react";
|
||||
import {
|
||||
TextInput,
|
||||
type TextInputProps,
|
||||
type TextProps,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
export function TextField({ children }: PropsWithChildren) {
|
||||
return <View className="gap-1.5">{children}</View>;
|
||||
}
|
||||
|
||||
export function Label({
|
||||
className,
|
||||
...props
|
||||
}: TextProps & { className?: string }) {
|
||||
return (
|
||||
<Text
|
||||
className={cn("font-sans-medium text-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Input = forwardRef<
|
||||
TextInput,
|
||||
TextInputProps & { className?: string }
|
||||
>(({ className, style, ...props }, ref) => {
|
||||
return (
|
||||
<TextInput
|
||||
ref={ref}
|
||||
placeholderTextColorClassName="accent-muted-foreground/50"
|
||||
className={cn(
|
||||
"h-12 rounded-[12px] border border-border bg-input px-3.5 font-sans text-[15px] text-foreground",
|
||||
className,
|
||||
)}
|
||||
style={[{ borderCurve: "continuous" }, style]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
export function FieldError({
|
||||
isInvalid,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: PropsWithChildren<TextProps & { isInvalid?: boolean; className?: string }>) {
|
||||
if (!isInvalid) return null;
|
||||
return (
|
||||
<Text className={cn("text-[13px] text-destructive", className)} {...props}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Text as RNText, type TextProps } from "react-native";
|
||||
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
export function Text({
|
||||
className,
|
||||
...props
|
||||
}: TextProps & { className?: string }) {
|
||||
return <RNText className={cn("font-sans", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
|
||||
import { ToastView } from "@/components/ui/toast";
|
||||
import { dismiss, getSnapshot, subscribe } from "@/lib/toast";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
const hapticMap = {
|
||||
success: () =>
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success),
|
||||
error: () =>
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error),
|
||||
warning: () =>
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning),
|
||||
info: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light),
|
||||
} as const;
|
||||
|
||||
export function ToastProvider() {
|
||||
const queue = useSyncExternalStore(subscribe, getSnapshot);
|
||||
const activeToast = queue[0];
|
||||
|
||||
const activeId = activeToast?.id;
|
||||
const activeType = activeToast?.type;
|
||||
const activeDuration = activeToast?.duration;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeId || !activeType || activeDuration == null) return;
|
||||
|
||||
hapticMap[activeType]();
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
dismiss(activeId);
|
||||
}, activeDuration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeId, activeType, activeDuration]);
|
||||
|
||||
if (!activeToast) return null;
|
||||
|
||||
return (
|
||||
<ToastView key={activeToast.id} toast={activeToast} onDismiss={dismiss} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
IconAlertOctagon,
|
||||
IconAlertTriangle,
|
||||
IconCircleCheck,
|
||||
IconInfoCircle,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
SlideInDown,
|
||||
SlideOutDown,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { ToastItem } from "@/lib/toast";
|
||||
|
||||
const DISMISS_THRESHOLD = 50;
|
||||
|
||||
const iconMap = {
|
||||
success: IconCircleCheck,
|
||||
error: IconAlertOctagon,
|
||||
warning: IconAlertTriangle,
|
||||
info: IconInfoCircle,
|
||||
} as const;
|
||||
|
||||
const colorVarMap = {
|
||||
success: "--color-status-completed",
|
||||
error: "--color-destructive",
|
||||
warning: "--color-primary",
|
||||
info: "--color-muted-foreground",
|
||||
} as const;
|
||||
|
||||
interface ToastViewProps {
|
||||
toast: ToastItem;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ToastView({ toast, onDismiss }: ToastViewProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const translateY = useSharedValue(0);
|
||||
|
||||
const Icon = iconMap[toast.type];
|
||||
const iconColor = useCSSVariable(colorVarMap[toast.type]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
onDismiss(toast.id);
|
||||
};
|
||||
|
||||
const pan = Gesture.Pan()
|
||||
.onUpdate((e) => {
|
||||
translateY.value = e.translationY;
|
||||
})
|
||||
.onEnd((e) => {
|
||||
if (Math.abs(e.translationY) > DISMISS_THRESHOLD) {
|
||||
const target = e.translationY > 0 ? 200 : -200;
|
||||
translateY.value = withTiming(target, { duration: 200 }, () => {
|
||||
scheduleOnRN(handleDismiss);
|
||||
});
|
||||
} else {
|
||||
translateY.value = withSpring(0, { damping: 15, stiffness: 300 });
|
||||
}
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
opacity: interpolate(Math.abs(translateY.value), [0, 100], [1, 0]),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={SlideInDown.duration(350)}
|
||||
exiting={SlideOutDown.duration(200)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: insets.bottom + 4,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 200,
|
||||
}}
|
||||
>
|
||||
<GestureDetector gesture={pan}>
|
||||
<Animated.View style={animatedStyle}>
|
||||
<View
|
||||
className="mx-4 flex-row items-center gap-3 rounded-xl border border-border bg-card px-4 py-3"
|
||||
style={{ borderCurve: "continuous" }}
|
||||
>
|
||||
<Icon size={18} color={iconColor as string} />
|
||||
|
||||
<View className="flex-1 gap-0.5">
|
||||
<Text className="font-sans-medium text-[14px] text-foreground">
|
||||
{toast.message}
|
||||
</Text>
|
||||
{toast.description ? (
|
||||
<Text className="text-[13px] text-muted-foreground">
|
||||
{toast.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{toast.action ? (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
toast.action?.onPress();
|
||||
handleDismiss();
|
||||
}}
|
||||
className="rounded-lg bg-secondary px-3 py-1.5"
|
||||
style={{ borderCurve: "continuous" }}
|
||||
>
|
||||
<Text className="font-sans-medium text-[13px] text-primary">
|
||||
{toast.action.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
@import "tailwindcss";
|
||||
@import "uniwind";
|
||||
|
||||
@theme {
|
||||
/* Fonts */
|
||||
--font-sans: "DMSans-Regular";
|
||||
--font-sans-medium: "DMSans-Medium";
|
||||
--font-sans-semibold: "DMSans-SemiBold";
|
||||
--font-sans-bold: "DMSans-Bold";
|
||||
--font-display: "DMSerifDisplay-Regular";
|
||||
--font-mono: "GeistMono-Regular";
|
||||
|
||||
/* Sofa — dark cinema theme with warm amber accents */
|
||||
--color-background: oklch(0.13 0.006 55);
|
||||
--color-foreground: oklch(0.93 0.015 80);
|
||||
--color-card: oklch(0.19 0.008 55);
|
||||
--color-card-foreground: oklch(0.93 0.015 80);
|
||||
--color-popover: oklch(0.21 0.008 55);
|
||||
--color-popover-foreground: oklch(0.93 0.015 80);
|
||||
--color-primary: oklch(0.8 0.14 65);
|
||||
--color-primary-foreground: oklch(0.13 0.006 55);
|
||||
--color-secondary: oklch(0.22 0.008 55);
|
||||
--color-secondary-foreground: oklch(0.85 0.02 80);
|
||||
--color-muted: oklch(0.22 0.008 55);
|
||||
--color-muted-foreground: oklch(0.63 0.02 80);
|
||||
--color-accent: oklch(0.25 0.01 55);
|
||||
--color-accent-foreground: oklch(0.93 0.015 80);
|
||||
--color-destructive: oklch(0.65 0.2 25);
|
||||
--color-border: oklch(1 0.015 65 / 10%);
|
||||
--color-input: oklch(1 0 0 / 12%);
|
||||
--color-ring: oklch(0.8 0.14 65);
|
||||
--color-status-watchlist: oklch(0.72 0.12 220);
|
||||
--color-status-watching: oklch(0.78 0.14 65);
|
||||
--color-status-completed: oklch(0.75 0.14 155);
|
||||
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Stack } from "expo-router";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCSSVariable, useResolveClassNames } from "uniwind";
|
||||
import { HeaderAvatar } from "@/components/header-avatar";
|
||||
|
||||
type ScreenOptions = NonNullable<
|
||||
Extract<ComponentProps<typeof Stack>["screenOptions"], object>
|
||||
>;
|
||||
|
||||
export function useTabScreenOptions() {
|
||||
const headerLargeTitleStyle = useResolveClassNames(
|
||||
"font-display text-foreground",
|
||||
);
|
||||
const headerTitleStyle = useResolveClassNames(
|
||||
"font-display text-lg text-foreground",
|
||||
);
|
||||
const contentStyle = useResolveClassNames("bg-background");
|
||||
|
||||
return {
|
||||
headerLargeTitle: true,
|
||||
headerTransparent: true,
|
||||
headerBlurEffect: "dark" as const,
|
||||
headerLargeStyle: { backgroundColor: "transparent" },
|
||||
headerLargeTitleStyle: headerLargeTitleStyle as Record<string, unknown>,
|
||||
headerTitleStyle: headerTitleStyle as Record<string, unknown>,
|
||||
headerTintColor: useCSSVariable("--color-primary") as string,
|
||||
headerShadowVisible: false,
|
||||
headerLargeTitleShadowVisible: false,
|
||||
headerBackButtonDisplayMode: "minimal" as const,
|
||||
scrollEdgeEffects: {
|
||||
top: "hidden" as const,
|
||||
bottom: "hidden" as const,
|
||||
left: "hidden" as const,
|
||||
right: "hidden" as const,
|
||||
},
|
||||
unstable_headerRightItems: () => [
|
||||
{
|
||||
type: "custom" as const,
|
||||
element: <HeaderAvatar />,
|
||||
hidesSharedBackground: true,
|
||||
},
|
||||
],
|
||||
contentStyle,
|
||||
} satisfies ScreenOptions;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expoClient } from "@better-auth/expo/client";
|
||||
import { adminClient, genericOAuthClient } from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
|
||||
import { QUERY_CACHE_KEY, storage } from "@/lib/mmkv";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { getServerUrl, onServerUrlChange } from "@/lib/server-url";
|
||||
|
||||
function buildAuthClient() {
|
||||
return createAuthClient({
|
||||
baseURL: getServerUrl(),
|
||||
plugins: [
|
||||
adminClient(),
|
||||
genericOAuthClient(),
|
||||
expoClient({
|
||||
scheme: "sofa",
|
||||
storagePrefix: "sofa",
|
||||
storage: SecureStore,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export let authClient = buildAuthClient();
|
||||
|
||||
onServerUrlChange(() => {
|
||||
// Fire-and-forget SecureStore cleanup — must not block the
|
||||
// synchronous rebuild so that subsequent listeners (e.g. the root
|
||||
// layout re-render) see the new authClient immediately.
|
||||
Promise.allSettled([
|
||||
SecureStore.deleteItemAsync("sofa_cookie"),
|
||||
SecureStore.deleteItemAsync("sofa_session_token"),
|
||||
SecureStore.deleteItemAsync("sofa_session_data"),
|
||||
]);
|
||||
|
||||
authClient = buildAuthClient();
|
||||
storage.remove(QUERY_CACHE_KEY);
|
||||
queryClient.clear();
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
import { createMMKV } from "react-native-mmkv";
|
||||
|
||||
export const storage = createMMKV();
|
||||
|
||||
const mmkvStorage = {
|
||||
getItem: (key: string) => storage.getString(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => void storage.remove(key),
|
||||
};
|
||||
|
||||
export const QUERY_CACHE_KEY = "REACT_QUERY_OFFLINE_CACHE";
|
||||
|
||||
export const queryPersister = createAsyncStoragePersister({
|
||||
storage: mmkvStorage,
|
||||
key: QUERY_CACHE_KEY,
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createORPCClient } from "@orpc/client";
|
||||
import { RPCLink } from "@orpc/client/fetch";
|
||||
import type { ContractRouterClient } from "@orpc/contract";
|
||||
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
|
||||
import type { contract } from "@sofa/api/contract";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { getServerUrl } from "@/lib/server-url";
|
||||
|
||||
export const link = new RPCLink({
|
||||
url: () => `${getServerUrl()}/rpc`,
|
||||
fetch: (url, options) => {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
credentials: process.env.EXPO_OS === "web" ? "include" : "omit",
|
||||
});
|
||||
},
|
||||
headers() {
|
||||
if (process.env.EXPO_OS === "web") {
|
||||
return {};
|
||||
}
|
||||
const headers = new Map<string, string>();
|
||||
const cookies = authClient.getCookie();
|
||||
if (cookies) {
|
||||
headers.set("Cookie", cookies);
|
||||
}
|
||||
return Object.fromEntries(headers);
|
||||
},
|
||||
});
|
||||
|
||||
export const client: ContractRouterClient<typeof contract> =
|
||||
createORPCClient(link);
|
||||
|
||||
export const orpc = createTanstackQueryUtils(client);
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { PostHogCustomStorage } from "posthog-react-native";
|
||||
import { PostHog } from "posthog-react-native";
|
||||
|
||||
import { storage } from "@/lib/mmkv";
|
||||
|
||||
const posthogApiKey = process.env.EXPO_PUBLIC_POSTHOG_KEY ?? "";
|
||||
const host = process.env.EXPO_PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com";
|
||||
|
||||
const ANALYTICS_ENABLED_KEY = "sofa_analytics_enabled";
|
||||
|
||||
const posthogStorage: PostHogCustomStorage = {
|
||||
getItem: (key: string) => storage.getString(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
};
|
||||
|
||||
// PostHog throws if apiKey is empty, so only construct when configured.
|
||||
export const posthog: PostHog | null = posthogApiKey
|
||||
? new PostHog(posthogApiKey, {
|
||||
host,
|
||||
defaultOptIn: storage.getBoolean(ANALYTICS_ENABLED_KEY) ?? true,
|
||||
customStorage: posthogStorage,
|
||||
captureAppLifecycleEvents: true,
|
||||
personProfiles: "never",
|
||||
errorTracking: {
|
||||
autocapture: {
|
||||
uncaughtExceptions: true,
|
||||
unhandledRejections: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
export function isAnalyticsEnabled(): boolean {
|
||||
return storage.getBoolean(ANALYTICS_ENABLED_KEY) ?? true;
|
||||
}
|
||||
|
||||
export function setAnalyticsEnabled(enabled: boolean): void {
|
||||
storage.set(ANALYTICS_ENABLED_KEY, enabled);
|
||||
if (enabled) {
|
||||
posthog?.optIn();
|
||||
} else {
|
||||
posthog?.optOut();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { posthog } from "@/lib/posthog";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: ONE_DAY_MS,
|
||||
},
|
||||
},
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => {
|
||||
toast.error("Something went wrong\u2026", {
|
||||
description: error.message,
|
||||
});
|
||||
posthog?.captureException(error, { source: "react-query" });
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { storage } from "@/lib/mmkv";
|
||||
|
||||
const SERVER_URL_KEY = "sofa_server_url";
|
||||
const DEFAULT_URL =
|
||||
process.env.EXPO_PUBLIC_SERVER_URL ?? "https://sofa.example.com";
|
||||
|
||||
const serverUrlListeners: Array<() => void> = [];
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type ValidationResult =
|
||||
| { status: "success" }
|
||||
| { status: "error"; error: ValidationError };
|
||||
|
||||
export type ValidationError =
|
||||
| "network_unreachable"
|
||||
| "timeout"
|
||||
| "not_sofa_server"
|
||||
| "server_unhealthy"
|
||||
| "invalid_url";
|
||||
|
||||
// --- URL helpers ---
|
||||
|
||||
export function normalizeUrl(input: string): string {
|
||||
let url = input.trim().replace(/\/+$/, "");
|
||||
if (url && !url.includes("://")) {
|
||||
url = `http://${url}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function splitUrl(input: string): { protocol: string; host: string } {
|
||||
const match = input.match(/^(https?:\/\/)(.*)/);
|
||||
if (match) {
|
||||
return { protocol: match[1], host: match[2] };
|
||||
}
|
||||
return { protocol: "http://", host: input };
|
||||
}
|
||||
|
||||
export function resolveUrl(path: string | null): string | null {
|
||||
if (!path) return null;
|
||||
if (!path.startsWith("/")) return path;
|
||||
return `${getServerUrl()}${path}`;
|
||||
}
|
||||
|
||||
// --- Server URL storage ---
|
||||
|
||||
export function getServerUrl(): string {
|
||||
return storage.getString(SERVER_URL_KEY) ?? DEFAULT_URL;
|
||||
}
|
||||
|
||||
export function setServerUrl(url: string): void {
|
||||
const normalized = url.replace(/\/+$/, "");
|
||||
storage.set(SERVER_URL_KEY, normalized);
|
||||
for (const listener of serverUrlListeners) listener();
|
||||
}
|
||||
|
||||
export function onServerUrlChange(callback: () => void): () => void {
|
||||
serverUrlListeners.push(callback);
|
||||
return () => {
|
||||
const idx = serverUrlListeners.indexOf(callback);
|
||||
if (idx !== -1) serverUrlListeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
export function hasStoredServerUrl(): boolean {
|
||||
return storage.contains(SERVER_URL_KEY);
|
||||
}
|
||||
|
||||
// --- Validation ---
|
||||
|
||||
export async function validateServerUrl(
|
||||
url: string,
|
||||
): Promise<ValidationResult> {
|
||||
const normalized = normalizeUrl(url);
|
||||
|
||||
if (!normalized || !normalized.includes("://")) {
|
||||
return { status: "error", error: "invalid_url" };
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(normalized);
|
||||
} catch {
|
||||
return { status: "error", error: "invalid_url" };
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const response = await fetch(`${normalized}/api/health`, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!response.ok) {
|
||||
return { status: "error", error: "server_unhealthy" };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await response.json();
|
||||
if (!data || typeof data !== "object" || !("status" in data)) {
|
||||
return { status: "error", error: "not_sofa_server" };
|
||||
}
|
||||
} catch {
|
||||
return { status: "error", error: "not_sofa_server" };
|
||||
}
|
||||
|
||||
return { status: "success" };
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err.name === "TimeoutError" || err.name === "AbortError")
|
||||
) {
|
||||
return { status: "error", error: "timeout" };
|
||||
}
|
||||
return { status: "error", error: "network_unreachable" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
type ToastType = "success" | "error" | "info" | "warning";
|
||||
|
||||
interface ToastAction {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
interface ToastOptions {
|
||||
description?: string;
|
||||
duration?: number;
|
||||
action?: ToastAction;
|
||||
}
|
||||
|
||||
export interface ToastItem {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
description?: string;
|
||||
duration: number;
|
||||
action?: ToastAction;
|
||||
}
|
||||
|
||||
let queue: ToastItem[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function getSnapshot(): ToastItem[] {
|
||||
return queue;
|
||||
}
|
||||
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function addToast(type: ToastType, message: string, options?: ToastOptions) {
|
||||
const item: ToastItem = {
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||
type,
|
||||
message,
|
||||
description: options?.description,
|
||||
duration: options?.duration ?? 4000,
|
||||
action: options?.action,
|
||||
};
|
||||
queue = [...queue, item];
|
||||
emit();
|
||||
}
|
||||
|
||||
export function dismiss(id: string) {
|
||||
queue = queue.filter((t) => t.id !== id);
|
||||
emit();
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (message: string, options?: ToastOptions) =>
|
||||
addToast("success", message, options),
|
||||
error: (message: string, options?: ToastOptions) =>
|
||||
addToast("error", message, options),
|
||||
info: (message: string, options?: ToastOptions) =>
|
||||
addToast("info", message, options),
|
||||
warning: (message: string, options?: ToastOptions) =>
|
||||
addToast("warning", message, options),
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: (string | undefined | null | false)[]) {
|
||||
return twMerge(inputs.filter(Boolean).join(" "));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
impactAsync as _impactAsync,
|
||||
notificationAsync as _notificationAsync,
|
||||
AndroidHaptics,
|
||||
ImpactFeedbackStyle,
|
||||
NotificationFeedbackType,
|
||||
performAndroidHapticsAsync,
|
||||
} from "expo-haptics";
|
||||
|
||||
export { ImpactFeedbackStyle, NotificationFeedbackType };
|
||||
|
||||
// See https://docs.expo.dev/versions/latest/sdk/haptics/#androidhaptics
|
||||
const impactStyleToAndroid: Record<ImpactFeedbackStyle, AndroidHaptics> = {
|
||||
[ImpactFeedbackStyle.Light]: AndroidHaptics.Clock_Tick,
|
||||
[ImpactFeedbackStyle.Medium]: AndroidHaptics.Context_Click,
|
||||
[ImpactFeedbackStyle.Heavy]: AndroidHaptics.Long_Press,
|
||||
[ImpactFeedbackStyle.Soft]: AndroidHaptics.Keyboard_Tap,
|
||||
[ImpactFeedbackStyle.Rigid]: AndroidHaptics.Virtual_Key,
|
||||
};
|
||||
|
||||
const notificationTypeToAndroid: Record<
|
||||
NotificationFeedbackType,
|
||||
AndroidHaptics
|
||||
> = {
|
||||
[NotificationFeedbackType.Success]: AndroidHaptics.Confirm,
|
||||
[NotificationFeedbackType.Warning]: AndroidHaptics.Segment_Tick,
|
||||
[NotificationFeedbackType.Error]: AndroidHaptics.Reject,
|
||||
};
|
||||
|
||||
export async function impactAsync(
|
||||
style: ImpactFeedbackStyle = ImpactFeedbackStyle.Medium,
|
||||
) {
|
||||
if (process.env.EXPO_OS === "ios") {
|
||||
return _impactAsync(style);
|
||||
}
|
||||
if (process.env.EXPO_OS === "android") {
|
||||
return performAndroidHapticsAsync(impactStyleToAndroid[style]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function notificationAsync(
|
||||
type: NotificationFeedbackType = NotificationFeedbackType.Success,
|
||||
) {
|
||||
if (process.env.EXPO_OS === "ios") {
|
||||
return _notificationAsync(type);
|
||||
}
|
||||
if (process.env.EXPO_OS === "android") {
|
||||
return performAndroidHapticsAsync(notificationTypeToAndroid[type]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
const MINUTE = 60;
|
||||
const HOUR = 3600;
|
||||
const DAY = 86400;
|
||||
const WEEK = 604800;
|
||||
const MONTH = 2592000;
|
||||
const YEAR = 31536000;
|
||||
|
||||
export function timeAgo(dateString: string): string {
|
||||
const seconds = Math.floor(
|
||||
(Date.now() - new Date(dateString).getTime()) / 1000,
|
||||
);
|
||||
|
||||
if (seconds < MINUTE) return "just now";
|
||||
if (seconds < HOUR) {
|
||||
const m = Math.floor(seconds / MINUTE);
|
||||
return `${m} ${m === 1 ? "minute" : "minutes"} ago`;
|
||||
}
|
||||
if (seconds < DAY) {
|
||||
const h = Math.floor(seconds / HOUR);
|
||||
return `${h} ${h === 1 ? "hour" : "hours"} ago`;
|
||||
}
|
||||
if (seconds < WEEK) {
|
||||
const d = Math.floor(seconds / DAY);
|
||||
return `${d} ${d === 1 ? "day" : "days"} ago`;
|
||||
}
|
||||
if (seconds < MONTH) {
|
||||
const w = Math.floor(seconds / WEEK);
|
||||
return `${w} ${w === 1 ? "week" : "weeks"} ago`;
|
||||
}
|
||||
if (seconds < YEAR) {
|
||||
const mo = Math.floor(seconds / MONTH);
|
||||
return `${mo} ${mo === 1 ? "month" : "months"} ago`;
|
||||
}
|
||||
const y = Math.floor(seconds / YEAR);
|
||||
return `${y} ${y === 1 ? "year" : "years"} ago`;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts",
|
||||
"uniwind-types.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// NOTE: This file is generated by uniwind and it should not be edited manually.
|
||||
/// <reference types="uniwind/types" />
|
||||
|
||||
declare module 'uniwind' {
|
||||
export interface UniwindConfig {
|
||||
themes: readonly ['light', 'dark']
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getCachedUpdateCheck,
|
||||
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" });
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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 }));
|
||||
|
||||