mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
refactor: replace Biome with oxlint + oxfmt
Migrate the entire monorepo from Biome 2.4.7 to oxlint 1.56.0 (linter) and oxfmt 0.41.0 (formatter) for faster lint/format and broader rule coverage. - Add `.oxlintrc.json` with React, TypeScript, unicorn, import plugins and correctness/suspicious categories - Add `.oxfmtrc.json` with 2-space indent, import sorting, and Tailwind class sorting (all 30+ custom className attributes migrated) - Add `docs/.oxlintrc.json` and `docs/.oxfmtrc.json` with Next.js plugin - Update all 12 workspace package.json scripts: `oxlint`, `oxfmt`, `oxfmt --check` - Add `format:check` turbo task and CI step - Update VS Code settings/extensions to use `oxc.oxc-vscode` - Update CI path triggers from `biome.json` to new config files - Remove all `biome-ignore` comments and fix shadowed variables - Delete `biome.json` and `docs/biome.json` - Reformat entire codebase with oxfmt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,9 +36,8 @@ jobs:
|
|||||||
uses: anthropics/claude-code-action@v1
|
uses: anthropics/claude-code-action@v1
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
plugin_marketplaces: "https://github.com/anthropics/claude-code.git"
|
||||||
plugins: 'code-review@claude-code-plugins'
|
plugins: "code-review@claude-code-plugins"
|
||||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
prompt: "/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||||
|
|
||||||
|
|||||||
@@ -47,4 +47,3 @@ jobs:
|
|||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ on:
|
|||||||
- "turbo.json"
|
- "turbo.json"
|
||||||
- "package.json"
|
- "package.json"
|
||||||
- "bun.lock"
|
- "bun.lock"
|
||||||
- "biome.json"
|
- ".oxlintrc.json"
|
||||||
|
- ".oxfmtrc.json"
|
||||||
- "tsconfig.json"
|
- "tsconfig.json"
|
||||||
- ".github/workflows/docker.yml"
|
- ".github/workflows/docker.yml"
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
@@ -25,6 +30,9 @@ jobs:
|
|||||||
- name: Lint
|
- name: Lint
|
||||||
run: bunx turbo run lint
|
run: bunx turbo run lint
|
||||||
|
|
||||||
|
- name: Format check
|
||||||
|
run: bunx turbo run format:check
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: bunx turbo run check-types
|
run: bunx turbo run check-types
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
|
"sortImports": {
|
||||||
|
"groups": ["builtin", "external", "internal", "parent", "sibling", "index"],
|
||||||
|
"internalPattern": ["@/", "@sofa/"],
|
||||||
|
"newlinesBetween": true,
|
||||||
|
"order": "asc"
|
||||||
|
},
|
||||||
|
"sortTailwindcss": {
|
||||||
|
"attributes": [
|
||||||
|
"classList",
|
||||||
|
"headerClassName",
|
||||||
|
"contentContainerClassName",
|
||||||
|
"columnWrapperClassName",
|
||||||
|
"endFillColorClassName",
|
||||||
|
"imageClassName",
|
||||||
|
"tintColorClassName",
|
||||||
|
"ios_backgroundColorClassName",
|
||||||
|
"thumbColorClassName",
|
||||||
|
"trackColorOnClassName",
|
||||||
|
"trackColorOffClassName",
|
||||||
|
"selectionColorClassName",
|
||||||
|
"cursorColorClassName",
|
||||||
|
"underlineColorAndroidClassName",
|
||||||
|
"placeholderTextColorClassName",
|
||||||
|
"selectionHandleColorClassName",
|
||||||
|
"colorsClassName",
|
||||||
|
"progressBackgroundColorClassName",
|
||||||
|
"titleColorClassName",
|
||||||
|
"underlayColorClassName",
|
||||||
|
"colorClassName",
|
||||||
|
"backdropColorClassName",
|
||||||
|
"backgroundColorClassName",
|
||||||
|
"statusBarBackgroundColorClassName",
|
||||||
|
"drawerBackgroundColorClassName",
|
||||||
|
"ListFooterComponentClassName",
|
||||||
|
"ListHeaderComponentClassName"
|
||||||
|
],
|
||||||
|
"functions": ["cn", "clsx", "cva", "useResolveClassNames"]
|
||||||
|
},
|
||||||
|
"ignorePatterns": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"packages/db/drizzle",
|
||||||
|
"packages/i18n/src/po/*.ts",
|
||||||
|
"packages/tmdb/src/schema.d.ts",
|
||||||
|
"**/routeTree.gen.ts",
|
||||||
|
"apps/native/.expo/types/**/*.ts",
|
||||||
|
"apps/native/expo-env.d.ts",
|
||||||
|
"apps/native/uniwind-types.d.ts",
|
||||||
|
"docs"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["eslint", "typescript", "unicorn", "oxc", "react", "import"],
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error",
|
||||||
|
"suspicious": "warn"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"react/style-prop-object": "off",
|
||||||
|
"import/no-unassigned-import": "off",
|
||||||
|
"import/no-named-as-default-member": "off",
|
||||||
|
"unicorn/no-array-sort": "off",
|
||||||
|
"unicorn/consistent-function-scoping": "off",
|
||||||
|
"no-new": "off"
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["node_modules", "dist", "build", "docs", "**/routeTree.gen.ts"]
|
||||||
|
}
|
||||||
Vendored
+1
-1
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"biomejs.biome",
|
|
||||||
"bradlc.vscode-tailwindcss",
|
"bradlc.vscode-tailwindcss",
|
||||||
"expo.vscode-expo-tools",
|
"expo.vscode-expo-tools",
|
||||||
|
"oxc.oxc-vscode",
|
||||||
"vercel.turbo-vsc",
|
"vercel.turbo-vsc",
|
||||||
"vitest.explorer"
|
"vitest.explorer"
|
||||||
]
|
]
|
||||||
|
|||||||
Vendored
+9
-10
@@ -1,11 +1,10 @@
|
|||||||
{
|
{
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.formatOnPaste": true,
|
"editor.formatOnPaste": true,
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.biome": "explicit",
|
"source.fixAll.oxlint": "explicit"
|
||||||
"source.organizeImports.biome": "explicit"
|
|
||||||
},
|
},
|
||||||
"files.readonlyInclude": {
|
"files.readonlyInclude": {
|
||||||
"**/routeTree.gen.ts": true
|
"**/routeTree.gen.ts": true
|
||||||
@@ -18,25 +17,25 @@
|
|||||||
},
|
},
|
||||||
"emmet.showExpandedAbbreviation": "never",
|
"emmet.showExpandedAbbreviation": "never",
|
||||||
"[javascript]": {
|
"[javascript]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||||
},
|
},
|
||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||||
},
|
},
|
||||||
"[javascriptreact]": {
|
"[javascriptreact]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||||
},
|
},
|
||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||||
},
|
},
|
||||||
"[jsonc]": {
|
"[jsonc]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||||
},
|
},
|
||||||
"[css]": {
|
"[css]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||||
},
|
},
|
||||||
"tailwindCSS.classAttributes": [
|
"tailwindCSS.classAttributes": [
|
||||||
"class",
|
"class",
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
# Root commands (via Turborepo)
|
# Root commands (via Turborepo)
|
||||||
bun run dev # Start API server + Vite dev server
|
bun run dev # Start API server + Vite dev server
|
||||||
bun run build # Production build (both apps)
|
bun run build # Production build (both apps)
|
||||||
bun run lint # Biome lint check
|
bun run lint # Oxlint lint check
|
||||||
bun run format # Biome format (auto-fix)
|
bun run format # Oxfmt format (auto-fix)
|
||||||
bun run check-types # TypeScript type check
|
bun run check-types # TypeScript type check
|
||||||
bun run test # Run tests
|
bun run test # Run tests
|
||||||
bun run generate:openapi # Regenerate OpenAPI spec + docs API pages (run after contract/schema changes)
|
bun run generate:openapi # Regenerate OpenAPI spec + docs API pages (run after contract/schema changes)
|
||||||
@@ -51,8 +51,9 @@ couch-potato/
|
|||||||
│ ├── db/ # @sofa/db — Drizzle schema, client, migrations (JIT)
|
│ ├── db/ # @sofa/db — Drizzle schema, client, migrations (JIT)
|
||||||
│ ├── logger/ # @sofa/logger — Pino-based structured logging (JIT)
|
│ ├── logger/ # @sofa/logger — Pino-based structured logging (JIT)
|
||||||
│ └── tmdb/ # @sofa/tmdb — TMDB API client + image URL helper (JIT)
|
│ └── tmdb/ # @sofa/tmdb — TMDB API client + image URL helper (JIT)
|
||||||
|
├── .oxlintrc.json
|
||||||
|
├── .oxfmtrc.json
|
||||||
├── turbo.json
|
├── turbo.json
|
||||||
├── biome.json
|
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
└── package.json
|
└── package.json
|
||||||
```
|
```
|
||||||
@@ -76,7 +77,7 @@ All shared packages are JIT (raw TypeScript exports, no build step).
|
|||||||
- **Auth**: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO
|
- **Auth**: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO
|
||||||
- **Monorepo**: Turborepo with Bun workspaces
|
- **Monorepo**: Turborepo with Bun workspaces
|
||||||
- **Docs**: Fumadocs (Next.js), fumadocs-openapi for API reference
|
- **Docs**: Fumadocs (Next.js), fumadocs-openapi for API reference
|
||||||
- **Linting**: Biome (2-space indent, organized imports)
|
- **Linting**: Oxlint + Oxfmt (2-space indent, organized imports, Tailwind class sorting)
|
||||||
- **External API**: TMDB (The Movie Database)
|
- **External API**: TMDB (The Movie Database)
|
||||||
|
|
||||||
### Package imports
|
### Package imports
|
||||||
@@ -84,6 +85,7 @@ All shared packages are JIT (raw TypeScript exports, no build step).
|
|||||||
Path aliases: `@/*` maps to `src/` in both `apps/web/` and `apps/native/`.
|
Path aliases: `@/*` maps to `src/` in both `apps/web/` and `apps/native/`.
|
||||||
|
|
||||||
Cross-package imports:
|
Cross-package imports:
|
||||||
|
|
||||||
- `@sofa/api/contract`, `@sofa/api/schemas` — Contract and Zod types
|
- `@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/db/client`, `@sofa/db/schema`, `@sofa/db/helpers`, `@sofa/db/migrate`, `@sofa/db/test-utils`
|
||||||
- `@sofa/tmdb/client`, `@sofa/tmdb/image`
|
- `@sofa/tmdb/client`, `@sofa/tmdb/image`
|
||||||
@@ -107,6 +109,7 @@ Cross-package imports:
|
|||||||
Required: `TMDB_API_READ_ACCESS_TOKEN`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`.
|
Required: `TMDB_API_READ_ACCESS_TOKEN`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`.
|
||||||
|
|
||||||
Optional:
|
Optional:
|
||||||
|
|
||||||
- `DATA_DIR` — Root for DB + cache (default `./data`). `DATABASE_URL` and `CACHE_DIR` derived from it but overridable.
|
- `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.
|
- `TMDB_API_BASE_URL`, `TMDB_IMAGE_BASE_URL` — Override TMDB endpoints.
|
||||||
- `PUBLIC_API_URL` — Base URL for centralized public API (default: `https://public-api.sofa.watch`). Used for update checks and OAuth import proxy.
|
- `PUBLIC_API_URL` — Base URL for centralized public API (default: `https://public-api.sofa.watch`). Used for update checks and OAuth import proxy.
|
||||||
|
|||||||
@@ -82,20 +82,20 @@ Set `BETTER_AUTH_URL` to the real external URL of your instance. This especially
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
| Variable | Required | Notes |
|
| Variable | Required | Notes |
|
||||||
| --- | --- | --- |
|
| ---------------------------- | -------- | ----------------------------------------------------------------------------------------------- |
|
||||||
| `TMDB_API_READ_ACCESS_TOKEN` | Yes | TMDB metadata and discovery |
|
| `TMDB_API_READ_ACCESS_TOKEN` | Yes | TMDB metadata and discovery |
|
||||||
| `BETTER_AUTH_SECRET` | Yes | Session and auth secret |
|
| `BETTER_AUTH_SECRET` | Yes | Session and auth secret |
|
||||||
| `BETTER_AUTH_URL` | Yes | Public base URL of the app |
|
| `BETTER_AUTH_URL` | Yes | Public base URL of the app |
|
||||||
| `DATA_DIR` | No | Root data directory. Defaults to `/data` in the container |
|
| `DATA_DIR` | No | Root data directory. Defaults to `/data` in the container |
|
||||||
| `IMAGE_CACHE_ENABLED` | No | Defaults to enabled. Set to `false` to use TMDB images directly instead of caching them locally |
|
| `IMAGE_CACHE_ENABLED` | No | Defaults to enabled. Set to `false` to use TMDB images directly instead of caching them locally |
|
||||||
| `LOG_LEVEL` | No | `error`, `warn`, `info`, or `debug` |
|
| `LOG_LEVEL` | No | `error`, `warn`, `info`, or `debug` |
|
||||||
| `OIDC_CLIENT_ID` | No | Enable OIDC when set with the matching secret and issuer |
|
| `OIDC_CLIENT_ID` | No | Enable OIDC when set with the matching secret and issuer |
|
||||||
| `OIDC_CLIENT_SECRET` | No | OIDC client secret |
|
| `OIDC_CLIENT_SECRET` | No | OIDC client secret |
|
||||||
| `OIDC_ISSUER_URL` | No | OIDC issuer URL |
|
| `OIDC_ISSUER_URL` | No | OIDC issuer URL |
|
||||||
| `OIDC_PROVIDER_NAME` | No | Login button label. Defaults to `SSO` |
|
| `OIDC_PROVIDER_NAME` | No | Login button label. Defaults to `SSO` |
|
||||||
| `OIDC_AUTO_REGISTER` | No | Defaults to `true` |
|
| `OIDC_AUTO_REGISTER` | No | Defaults to `true` |
|
||||||
| `DISABLE_PASSWORD_LOGIN` | No | Set to `true` to hide email/password login when OIDC is configured |
|
| `DISABLE_PASSWORD_LOGIN` | No | Set to `true` to hide email/password login when OIDC is configured |
|
||||||
|
|
||||||
See [`.env.example`](./.env.example) for the full list.
|
See [`.env.example`](./.env.example) for the full list.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
||||||
|
"extends": ["../../.oxlintrc.json"],
|
||||||
|
"ignorePatterns": ["node_modules", ".expo/types/**/*.ts", "expo-env.d.ts", "uniwind-types.d.ts"]
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
const { getDefaultConfig } = require("expo/metro-config");
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
const { withUniwindConfig } = require("uniwind/metro");
|
const { withUniwindConfig } = require("uniwind/metro");
|
||||||
const {
|
const { wrapWithReanimatedMetroConfig } = require("react-native-reanimated/metro-config");
|
||||||
wrapWithReanimatedMetroConfig,
|
|
||||||
} = require("react-native-reanimated/metro-config");
|
|
||||||
|
|
||||||
/** @type {import('expo/metro-config').MetroConfig} */
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
const config = getDefaultConfig(__dirname);
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"prebuild": "expo prebuild",
|
"prebuild": "expo prebuild",
|
||||||
"lint": "biome check",
|
"lint": "oxlint",
|
||||||
"format": "biome format --write",
|
"format": "oxfmt --config ../../.oxfmtrc.json",
|
||||||
|
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
|
||||||
"check-types": "tsc --noEmit"
|
"check-types": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -35,10 +36,10 @@
|
|||||||
"@sofa/api": "workspace:*",
|
"@sofa/api": "workspace:*",
|
||||||
"@sofa/i18n": "workspace:*",
|
"@sofa/i18n": "workspace:*",
|
||||||
"@tabler/icons-react-native": "3.40.0",
|
"@tabler/icons-react-native": "3.40.0",
|
||||||
"@tanstack/query-async-storage-persister": "5.90.24",
|
"@tanstack/query-async-storage-persister": "5.90.25",
|
||||||
"@tanstack/react-form": "1.28.5",
|
"@tanstack/react-form": "1.28.5",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-query-persist-client": "5.90.24",
|
"@tanstack/react-query-persist-client": "5.90.25",
|
||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
"burnt": "0.13.0",
|
"burnt": "0.13.0",
|
||||||
"expo": "55.0.7",
|
"expo": "55.0.7",
|
||||||
@@ -63,7 +64,7 @@
|
|||||||
"expo-system-ui": "55.0.10",
|
"expo-system-ui": "55.0.10",
|
||||||
"expo-tracking-transparency": "55.0.9",
|
"expo-tracking-transparency": "55.0.9",
|
||||||
"expo-web-browser": "55.0.10",
|
"expo-web-browser": "55.0.10",
|
||||||
"posthog-react-native": "4.37.3",
|
"posthog-react-native": "4.37.4",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-native": "0.83.2",
|
"react-native": "0.83.2",
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useResolveClassNames } from "uniwind";
|
import { useResolveClassNames } from "uniwind";
|
||||||
|
|
||||||
import { hasStoredServerUrl } from "@/lib/server";
|
import { hasStoredServerUrl } from "@/lib/server";
|
||||||
|
|
||||||
export const unstable_settings = {
|
export const unstable_settings = {
|
||||||
initialRouteName:
|
initialRouteName:
|
||||||
process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl()
|
process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl() ? "login" : "server-url",
|
||||||
? "login"
|
|
||||||
: "server-url",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AuthLayout() {
|
export default function AuthLayout() {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Pressable, type TextInput, View } from "react-native";
|
|||||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { AuthScreen } from "@/components/auth-screen";
|
import { AuthScreen } from "@/components/auth-screen";
|
||||||
import { Button, ButtonLabel } from "@/components/ui/button";
|
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
@@ -22,11 +23,7 @@ import { getFormErrors } from "@/utils/form-errors";
|
|||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
const signInSchema = z.object({
|
const signInSchema = z.object({
|
||||||
email: z
|
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1, "Email is required")
|
|
||||||
.email("Enter a valid email address"),
|
|
||||||
password: z.string().min(1, "Password is required"),
|
password: z.string().min(1, "Password is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,9 +65,7 @@ export default function LoginScreen() {
|
|||||||
const isSubmitting = useStore(form.store, (s) => s.isSubmitting);
|
const isSubmitting = useStore(form.store, (s) => s.isSubmitting);
|
||||||
const busy = isSubmitting || isSignedIn;
|
const busy = isSubmitting || isSignedIn;
|
||||||
|
|
||||||
const statusCompletedColor = useCSSVariable(
|
const statusCompletedColor = useCSSVariable("--color-status-completed") as string;
|
||||||
"--color-status-completed",
|
|
||||||
) as string;
|
|
||||||
const serverHost = splitUrl(getServerUrl()).host;
|
const serverHost = splitUrl(getServerUrl()).host;
|
||||||
|
|
||||||
const showPasswordLogin = !authConfig.data?.passwordLoginDisabled;
|
const showPasswordLogin = !authConfig.data?.passwordLoginDisabled;
|
||||||
@@ -90,10 +85,7 @@ export default function LoginScreen() {
|
|||||||
return (
|
return (
|
||||||
<AuthScreen title="Sofa" subtitle={t`Sign in to continue`}>
|
<AuthScreen title="Sofa" subtitle={t`Sign in to continue`}>
|
||||||
{showOidc && (
|
{showOidc && (
|
||||||
<Animated.View
|
<Animated.View entering={FadeInDown.duration(300).delay(100)} className="mb-4">
|
||||||
entering={FadeInDown.duration(300).delay(100)}
|
|
||||||
className="mb-4"
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
authClient.signIn.oauth2({
|
authClient.signIn.oauth2({
|
||||||
@@ -105,19 +97,17 @@ export default function LoginScreen() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<ButtonLabel>
|
<ButtonLabel>
|
||||||
<Trans>
|
<Trans>Sign in with {authConfig.data?.oidcProviderName ?? "SSO"}</Trans>
|
||||||
Sign in with {authConfig.data?.oidcProviderName ?? "SSO"}
|
|
||||||
</Trans>
|
|
||||||
</ButtonLabel>
|
</ButtonLabel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{showPasswordLogin && (
|
{showPasswordLogin && (
|
||||||
<View className="my-4 flex-row items-center">
|
<View className="my-4 flex-row items-center">
|
||||||
<View className="h-px flex-1 bg-border" />
|
<View className="bg-border h-px flex-1" />
|
||||||
<Text className="px-3 text-muted-foreground text-xs">
|
<Text className="text-muted-foreground px-3 text-xs">
|
||||||
<Trans>OR</Trans>
|
<Trans>OR</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<View className="h-px flex-1 bg-border" />
|
<View className="bg-border h-px flex-1" />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -148,11 +138,7 @@ export default function LoginScreen() {
|
|||||||
returnKeyType="next"
|
returnKeyType="next"
|
||||||
blurOnSubmit={false}
|
blurOnSubmit={false}
|
||||||
onSubmitEditing={() => passwordRef.current?.focus()}
|
onSubmitEditing={() => passwordRef.current?.focus()}
|
||||||
className={
|
className={errorFields.has("email") ? "border-destructive" : undefined}
|
||||||
errorFields.has("email")
|
|
||||||
? "border-destructive"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</TextField>
|
</TextField>
|
||||||
)}
|
)}
|
||||||
@@ -181,11 +167,7 @@ export default function LoginScreen() {
|
|||||||
textContentType="password"
|
textContentType="password"
|
||||||
returnKeyType="go"
|
returnKeyType="go"
|
||||||
onSubmitEditing={form.handleSubmit}
|
onSubmitEditing={form.handleSubmit}
|
||||||
className={
|
className={errorFields.has("password") ? "border-destructive" : undefined}
|
||||||
errorFields.has("password")
|
|
||||||
? "border-destructive"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</TextField>
|
</TextField>
|
||||||
)}
|
)}
|
||||||
@@ -193,11 +175,7 @@ export default function LoginScreen() {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
||||||
<Button
|
<Button onPress={form.handleSubmit} disabled={busy} className="mt-2">
|
||||||
onPress={form.handleSubmit}
|
|
||||||
disabled={busy}
|
|
||||||
className="mt-2"
|
|
||||||
>
|
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
) : (
|
) : (
|
||||||
@@ -220,10 +198,7 @@ export default function LoginScreen() {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Animated.View
|
<Animated.View entering={FadeIn.duration(300).delay(500)} className="mt-8 items-center">
|
||||||
entering={FadeIn.duration(300).delay(500)}
|
|
||||||
className="mt-8 items-center"
|
|
||||||
>
|
|
||||||
<Link href="/(auth)/server-url" replace asChild>
|
<Link href="/(auth)/server-url" replace asChild>
|
||||||
<Pressable
|
<Pressable
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -231,16 +206,10 @@ export default function LoginScreen() {
|
|||||||
accessibilityState={{ disabled: busy }}
|
accessibilityState={{ disabled: busy }}
|
||||||
className="flex-row items-center gap-1.5"
|
className="flex-row items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<ScaledIcon
|
<ScaledIcon icon={IconServer2} size={14} color={statusCompletedColor} />
|
||||||
icon={IconServer2}
|
<Text className="text-muted-foreground font-sans text-xs">
|
||||||
size={14}
|
|
||||||
color={statusCompletedColor}
|
|
||||||
/>
|
|
||||||
<Text className="font-sans text-muted-foreground text-xs">
|
|
||||||
<Trans>
|
<Trans>
|
||||||
Connected to{" "}
|
Connected to <Text className="font-medium">{serverHost}</Text>. Tap to change.
|
||||||
<Text className="font-medium">{serverHost}</Text>. Tap to
|
|
||||||
change.
|
|
||||||
</Trans>
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useRef, useState } from "react";
|
|||||||
import { Pressable, type TextInput, View } from "react-native";
|
import { Pressable, type TextInput, View } from "react-native";
|
||||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { AuthScreen } from "@/components/auth-screen";
|
import { AuthScreen } from "@/components/auth-screen";
|
||||||
import { Button, ButtonLabel } from "@/components/ui/button";
|
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
@@ -19,20 +20,9 @@ import { getFormErrors } from "@/utils/form-errors";
|
|||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
const signUpSchema = z.object({
|
const signUpSchema = z.object({
|
||||||
name: z
|
name: z.string().trim().min(1, "Name is required").min(2, "Name must be at least 2 characters"),
|
||||||
.string()
|
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
|
||||||
.trim()
|
password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"),
|
||||||
.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"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function RegisterScreen() {
|
export default function RegisterScreen() {
|
||||||
@@ -99,7 +89,7 @@ export default function RegisterScreen() {
|
|||||||
>
|
>
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||||
<Link href="/(auth)/login" asChild>
|
<Link href="/(auth)/login" asChild>
|
||||||
<Button className="mt-6 bg-primary">
|
<Button className="bg-primary mt-6">
|
||||||
<ButtonLabel className="text-primary-foreground">
|
<ButtonLabel className="text-primary-foreground">
|
||||||
<Trans>Back to Login</Trans>
|
<Trans>Back to Login</Trans>
|
||||||
</ButtonLabel>
|
</ButtonLabel>
|
||||||
@@ -111,10 +101,7 @@ export default function RegisterScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthScreen
|
<AuthScreen title={t`Create Account`} subtitle={t`Registering on ${serverHost}`}>
|
||||||
title={t`Create Account`}
|
|
||||||
subtitle={t`Registering on ${serverHost}`}
|
|
||||||
>
|
|
||||||
<View className="gap-3">
|
<View className="gap-3">
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||||
<form.Field name="name">
|
<form.Field name="name">
|
||||||
@@ -137,9 +124,7 @@ export default function RegisterScreen() {
|
|||||||
returnKeyType="next"
|
returnKeyType="next"
|
||||||
blurOnSubmit={false}
|
blurOnSubmit={false}
|
||||||
onSubmitEditing={() => emailRef.current?.focus()}
|
onSubmitEditing={() => emailRef.current?.focus()}
|
||||||
className={
|
className={errorFields.has("name") ? "border-destructive" : undefined}
|
||||||
errorFields.has("name") ? "border-destructive" : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</TextField>
|
</TextField>
|
||||||
)}
|
)}
|
||||||
@@ -170,9 +155,7 @@ export default function RegisterScreen() {
|
|||||||
returnKeyType="next"
|
returnKeyType="next"
|
||||||
blurOnSubmit={false}
|
blurOnSubmit={false}
|
||||||
onSubmitEditing={() => passwordRef.current?.focus()}
|
onSubmitEditing={() => passwordRef.current?.focus()}
|
||||||
className={
|
className={errorFields.has("email") ? "border-destructive" : undefined}
|
||||||
errorFields.has("email") ? "border-destructive" : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</TextField>
|
</TextField>
|
||||||
)}
|
)}
|
||||||
@@ -201,11 +184,7 @@ export default function RegisterScreen() {
|
|||||||
textContentType="newPassword"
|
textContentType="newPassword"
|
||||||
returnKeyType="go"
|
returnKeyType="go"
|
||||||
onSubmitEditing={form.handleSubmit}
|
onSubmitEditing={form.handleSubmit}
|
||||||
className={
|
className={errorFields.has("password") ? "border-destructive" : undefined}
|
||||||
errorFields.has("password")
|
|
||||||
? "border-destructive"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</TextField>
|
</TextField>
|
||||||
)}
|
)}
|
||||||
@@ -213,11 +192,7 @@ export default function RegisterScreen() {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
||||||
<Button
|
<Button onPress={form.handleSubmit} disabled={busy} className="bg-primary mt-1">
|
||||||
onPress={form.handleSubmit}
|
|
||||||
disabled={busy}
|
|
||||||
className="mt-1 bg-primary"
|
|
||||||
>
|
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
) : (
|
) : (
|
||||||
@@ -229,10 +204,7 @@ export default function RegisterScreen() {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Animated.View
|
<Animated.View entering={FadeIn.duration(300).delay(500)} className="mt-6 items-center">
|
||||||
entering={FadeIn.duration(300).delay(500)}
|
|
||||||
className="mt-6 items-center"
|
|
||||||
>
|
|
||||||
<Link href="/(auth)/login" asChild>
|
<Link href="/(auth)/login" asChild>
|
||||||
<Pressable disabled={busy}>
|
<Pressable disabled={busy}>
|
||||||
<Text className="text-primary text-sm">
|
<Text className="text-primary text-sm">
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import {
|
import { IconAlertCircle, IconCircleCheck, IconInfoCircle } from "@tabler/icons-react-native";
|
||||||
IconAlertCircle,
|
|
||||||
IconCircleCheck,
|
|
||||||
IconInfoCircle,
|
|
||||||
} from "@tabler/icons-react-native";
|
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Linking, Pressable, type TextInput, View } from "react-native";
|
import { Linking, Pressable, type TextInput, View } from "react-native";
|
||||||
@@ -16,16 +12,13 @@ import Animated, {
|
|||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import { AuthScreen } from "@/components/auth-screen";
|
import { AuthScreen } from "@/components/auth-screen";
|
||||||
import { Button, ButtonLabel } from "@/components/ui/button";
|
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Input } from "@/components/ui/text-field";
|
import { Input } from "@/components/ui/text-field";
|
||||||
import {
|
import { getServerUrl, serverManager, type ValidationError } from "@/lib/server";
|
||||||
getServerUrl,
|
|
||||||
serverManager,
|
|
||||||
type ValidationError,
|
|
||||||
} from "@/lib/server";
|
|
||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
type ConnectionState =
|
type ConnectionState =
|
||||||
@@ -52,16 +45,12 @@ export default function ServerUrlScreen() {
|
|||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const successTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const successTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const [url, setUrl] = useState(() =>
|
const [url, setUrl] = useState(() => (serverManager.hasStoredServerUrl() ? getServerUrl() : ""));
|
||||||
serverManager.hasStoredServerUrl() ? getServerUrl() : "",
|
|
||||||
);
|
|
||||||
const [connection, setConnection] = useState<ConnectionState>({
|
const [connection, setConnection] = useState<ConnectionState>({
|
||||||
phase: "idle",
|
phase: "idle",
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusCompletedColor = useCSSVariable(
|
const statusCompletedColor = useCSSVariable("--color-status-completed") as string;
|
||||||
"--color-status-completed",
|
|
||||||
) as string;
|
|
||||||
const destructiveColor = useCSSVariable("--color-destructive") as string;
|
const destructiveColor = useCSSVariable("--color-destructive") as string;
|
||||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
|
|
||||||
@@ -172,37 +161,23 @@ export default function ServerUrlScreen() {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Connect Button / Status */}
|
{/* Connect Button / Status */}
|
||||||
<Animated.View
|
<Animated.View entering={FadeInDown.duration(300).delay(300)} className="mt-4">
|
||||||
entering={FadeInDown.duration(300).delay(300)}
|
|
||||||
className="mt-4"
|
|
||||||
>
|
|
||||||
{isConnecting ? (
|
{isConnecting ? (
|
||||||
<View className="min-h-12 flex-row items-center justify-center gap-2 py-2">
|
<View className="min-h-12 flex-row items-center justify-center gap-2 py-2">
|
||||||
<Animated.View
|
<Animated.View className="bg-primary size-1.5 rounded-full" style={dotAnimatedStyle} />
|
||||||
className="size-1.5 rounded-full bg-primary"
|
|
||||||
style={dotAnimatedStyle}
|
|
||||||
/>
|
|
||||||
<Text className="text-muted-foreground text-sm">
|
<Text className="text-muted-foreground text-sm">
|
||||||
<Trans>Connecting to server...</Trans>
|
<Trans>Connecting to server...</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : isSuccess ? (
|
) : isSuccess ? (
|
||||||
<View className="min-h-12 flex-row items-center justify-center gap-1.5 py-2">
|
<View className="min-h-12 flex-row items-center justify-center gap-1.5 py-2">
|
||||||
<ScaledIcon
|
<ScaledIcon icon={IconCircleCheck} size={16} color={statusCompletedColor} />
|
||||||
icon={IconCircleCheck}
|
<Text className="text-status-completed font-sans text-sm font-medium">
|
||||||
size={16}
|
|
||||||
color={statusCompletedColor}
|
|
||||||
/>
|
|
||||||
<Text className="font-medium font-sans text-sm text-status-completed">
|
|
||||||
<Trans>Connected</Trans>
|
<Trans>Connected</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button onPress={handleConnect} disabled={!isValidUrl} className="bg-primary">
|
||||||
onPress={handleConnect}
|
|
||||||
disabled={!isValidUrl}
|
|
||||||
className="bg-primary"
|
|
||||||
>
|
|
||||||
<ButtonLabel>
|
<ButtonLabel>
|
||||||
<Trans>Connect</Trans>
|
<Trans>Connect</Trans>
|
||||||
</ButtonLabel>
|
</ButtonLabel>
|
||||||
@@ -222,7 +197,7 @@ export default function ServerUrlScreen() {
|
|||||||
color={destructiveColor}
|
color={destructiveColor}
|
||||||
style={{ marginTop: 1 }}
|
style={{ marginTop: 1 }}
|
||||||
/>
|
/>
|
||||||
<Text selectable className="flex-1 text-destructive text-sm">
|
<Text selectable className="text-destructive flex-1 text-sm">
|
||||||
{getErrorMessages(t)[connection.error]}
|
{getErrorMessages(t)[connection.error]}
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -238,7 +213,7 @@ export default function ServerUrlScreen() {
|
|||||||
className="flex-row items-center gap-1.5"
|
className="flex-row items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<ScaledIcon icon={IconInfoCircle} size={16} color={mutedFgColor} />
|
<ScaledIcon icon={IconInfoCircle} size={16} color={mutedFgColor} />
|
||||||
<Text className="font-medium font-sans text-primary text-sm">
|
<Text className="text-primary font-sans text-sm font-medium">
|
||||||
<Trans>Don't have a server?</Trans>
|
<Trans>Don't have a server?</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -25,23 +25,13 @@ export default function ExploreScreen() {
|
|||||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const popularMovies = useQuery(
|
const popularMovies = useQuery(orpc.explore.popular.queryOptions({ input: { type: "movie" } }));
|
||||||
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 popularTv = useQuery(
|
const tvGenres = useQuery(orpc.explore.genres.queryOptions({ input: { type: "tv" } }));
|
||||||
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 =
|
const isRefreshing =
|
||||||
trending.isRefetching ||
|
trending.isRefetching || popularMovies.isRefetching || popularTv.isRefetching;
|
||||||
popularMovies.isRefetching ||
|
|
||||||
popularTv.isRefetching;
|
|
||||||
|
|
||||||
const onRefresh = useCallback(() => {
|
const onRefresh = useCallback(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: orpc.explore.key() });
|
queryClient.invalidateQueries({ queryKey: orpc.explore.key() });
|
||||||
@@ -56,18 +46,18 @@ export default function ExploreScreen() {
|
|||||||
);
|
);
|
||||||
const trendingStatuses = useMemo(
|
const trendingStatuses = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.assign(
|
Object.assign({}, ...(trending.data?.pages.map((p) => p.userStatuses) ?? [])) as Record<
|
||||||
{},
|
string,
|
||||||
...(trending.data?.pages.map((p) => p.userStatuses) ?? []),
|
"watchlist" | "in_progress" | "completed"
|
||||||
) as Record<string, "watchlist" | "in_progress" | "completed">,
|
>,
|
||||||
[trending.data?.pages],
|
[trending.data?.pages],
|
||||||
);
|
);
|
||||||
const trendingProgress = useMemo(
|
const trendingProgress = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.assign(
|
Object.assign({}, ...(trending.data?.pages.map((p) => p.episodeProgress) ?? [])) as Record<
|
||||||
{},
|
string,
|
||||||
...(trending.data?.pages.map((p) => p.episodeProgress) ?? []),
|
{ watched: number; total: number }
|
||||||
) as Record<string, { watched: number; total: number }>,
|
>,
|
||||||
[trending.data?.pages],
|
[trending.data?.pages],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -77,15 +67,11 @@ export default function ExploreScreen() {
|
|||||||
contentContainerStyle={exploreContentContainerStyle}
|
contentContainerStyle={exploreContentContainerStyle}
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
scrollToOverflowEnabled
|
scrollToOverflowEnabled
|
||||||
refreshControl={
|
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />}
|
||||||
<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<View className="gap-8">
|
<View className="gap-8">
|
||||||
{heroItem && (
|
{heroItem && (
|
||||||
<Animated.View
|
<Animated.View entering={FadeIn.duration(400).withInitialValues({ opacity: 0.01 })}>
|
||||||
entering={FadeIn.duration(400).withInitialValues({ opacity: 0.01 })}
|
|
||||||
>
|
|
||||||
<HeroBanner item={heroItem} />
|
<HeroBanner item={heroItem} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { useLingui } from "@lingui/react/macro";
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import {
|
import { IconBooks, IconPlayerPlay, IconThumbUp } from "@tabler/icons-react-native";
|
||||||
IconBooks,
|
|
||||||
IconPlayerPlay,
|
|
||||||
IconThumbUp,
|
|
||||||
} from "@tabler/icons-react-native";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
@@ -36,16 +32,11 @@ export default function DashboardScreen() {
|
|||||||
authClient.useSession();
|
authClient.useSession();
|
||||||
|
|
||||||
const stats = useQuery(orpc.dashboard.stats.queryOptions());
|
const stats = useQuery(orpc.dashboard.stats.queryOptions());
|
||||||
const continueWatching = useQuery(
|
const continueWatching = useQuery(orpc.dashboard.continueWatching.queryOptions());
|
||||||
orpc.dashboard.continueWatching.queryOptions(),
|
|
||||||
);
|
|
||||||
const library = useQuery(orpc.dashboard.library.queryOptions({ input: {} }));
|
const library = useQuery(orpc.dashboard.library.queryOptions({ input: {} }));
|
||||||
const recommendations = useQuery(
|
const recommendations = useQuery(orpc.dashboard.recommendations.queryOptions());
|
||||||
orpc.dashboard.recommendations.queryOptions(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const isRefreshing =
|
const isRefreshing = stats.isRefetching || continueWatching.isRefetching || library.isRefetching;
|
||||||
stats.isRefetching || continueWatching.isRefetching || library.isRefetching;
|
|
||||||
|
|
||||||
const onRefresh = useCallback(() => {
|
const onRefresh = useCallback(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||||
@@ -72,11 +63,9 @@ export default function DashboardScreen() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const renderContinueWatchingItem = useCallback(
|
const renderContinueWatchingItem = useCallback(
|
||||||
({
|
({ item }: { item: NonNullable<typeof continueWatching.data>["items"][number] }) => (
|
||||||
item,
|
<ContinueWatchingCard item={item} />
|
||||||
}: {
|
),
|
||||||
item: NonNullable<typeof continueWatching.data>["items"][number];
|
|
||||||
}) => <ContinueWatchingCard item={item} />,
|
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -86,9 +75,7 @@ export default function DashboardScreen() {
|
|||||||
contentContainerStyle={dashboardContentContainerStyle}
|
contentContainerStyle={dashboardContentContainerStyle}
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
scrollToOverflowEnabled
|
scrollToOverflowEnabled
|
||||||
refreshControl={
|
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />}
|
||||||
<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<View className="gap-8">
|
<View className="gap-8">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
@@ -109,10 +96,7 @@ export default function DashboardScreen() {
|
|||||||
{hasContinueWatching && (
|
{hasContinueWatching && (
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||||
<View className="px-4">
|
<View className="px-4">
|
||||||
<SectionHeader
|
<SectionHeader title={t`Continue Watching`} icon={IconPlayerPlay} />
|
||||||
title={t`Continue Watching`}
|
|
||||||
icon={IconPlayerPlay}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
<FlashList
|
<FlashList
|
||||||
horizontal
|
horizontal
|
||||||
@@ -150,10 +134,7 @@ export default function DashboardScreen() {
|
|||||||
{hasRecommendations && (
|
{hasRecommendations && (
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
||||||
<View className="px-4">
|
<View className="px-4">
|
||||||
<SectionHeader
|
<SectionHeader title={t`Recommended for You`} icon={IconThumbUp} />
|
||||||
title={t`Recommended for You`}
|
|
||||||
icon={IconThumbUp}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
<HorizontalPosterRow
|
<HorizontalPosterRow
|
||||||
items={recommendations.data?.items ?? []}
|
items={recommendations.data?.items ?? []}
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ import { Stack } from "expo-router";
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { ActivityIndicator, View } from "react-native";
|
import { ActivityIndicator, View } from "react-native";
|
||||||
import Animated, { FadeIn } from "react-native-reanimated";
|
import Animated, { FadeIn } from "react-native-reanimated";
|
||||||
|
|
||||||
import { RecentlyViewedList } from "@/components/search/recently-viewed-list";
|
import { RecentlyViewedList } from "@/components/search/recently-viewed-list";
|
||||||
import {
|
import { type SearchResultItem, SearchResultRow } from "@/components/search/search-result-row";
|
||||||
type SearchResultItem,
|
|
||||||
SearchResultRow,
|
|
||||||
} from "@/components/search/search-result-row";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
@@ -58,28 +56,19 @@ export default function SearchScreen() {
|
|||||||
[searchResults.data?.pages],
|
[searchResults.data?.pages],
|
||||||
);
|
);
|
||||||
|
|
||||||
const addingId = quickAddMutation.isPending
|
const addingId = quickAddMutation.isPending ? (quickAddMutation.variables?.id ?? null) : null;
|
||||||
? (quickAddMutation.variables?.id ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item }: { item: SearchResultItem }) => (
|
({ item }: { item: SearchResultItem }) => (
|
||||||
<SearchResultRow
|
<SearchResultRow item={item} onQuickAdd={handleQuickAdd} isAdding={addingId === item.id} />
|
||||||
item={item}
|
|
||||||
onQuickAdd={handleQuickAdd}
|
|
||||||
isAdding={addingId === item.id}
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
[handleQuickAdd, addingId],
|
[handleQuickAdd, addingId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback(
|
const keyExtractor = useCallback((item: SearchResultItem) => `${item.type}-${item.id}`, []);
|
||||||
(item: SearchResultItem) => `${item.type}-${item.id}`,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-background">
|
<View className="bg-background flex-1">
|
||||||
<Stack.Header
|
<Stack.Header
|
||||||
transparent={false}
|
transparent={false}
|
||||||
style={{ backgroundColor: "#000" }}
|
style={{ backgroundColor: "#000" }}
|
||||||
@@ -93,9 +82,7 @@ export default function SearchScreen() {
|
|||||||
onClose={() => setQuery("")}
|
onClose={() => setQuery("")}
|
||||||
hideWhenScrolling={false}
|
hideWhenScrolling={false}
|
||||||
placement={process.env.EXPO_OS === "ios" ? "integrated" : undefined}
|
placement={process.env.EXPO_OS === "ios" ? "integrated" : undefined}
|
||||||
allowToolbarIntegration={
|
allowToolbarIntegration={process.env.EXPO_OS === "ios" ? true : undefined}
|
||||||
process.env.EXPO_OS === "ios" ? true : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{debouncedQuery.length === 0 ? (
|
{debouncedQuery.length === 0 ? (
|
||||||
@@ -109,7 +96,7 @@ export default function SearchScreen() {
|
|||||||
entering={FadeIn.duration(300)}
|
entering={FadeIn.duration(300)}
|
||||||
className="flex-1 items-center justify-center"
|
className="flex-1 items-center justify-center"
|
||||||
>
|
>
|
||||||
<Text className="text-base text-muted-foreground">
|
<Text className="text-muted-foreground text-base">
|
||||||
<Trans>No results for "{debouncedQuery}"</Trans>
|
<Trans>No results for "{debouncedQuery}"</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -121,10 +108,7 @@ export default function SearchScreen() {
|
|||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (
|
if (searchResults.hasNextPage && !searchResults.isFetchingNextPage) {
|
||||||
searchResults.hasNextPage &&
|
|
||||||
!searchResults.isFetchingNextPage
|
|
||||||
) {
|
|
||||||
searchResults.fetchNextPage();
|
searchResults.fetchNextPage();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { plural } from "@lingui/core/macro";
|
import { plural } from "@lingui/core/macro";
|
||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { activateLocale, type SupportedLocale } from "@sofa/i18n";
|
|
||||||
import { LOCALE_INFO } from "@sofa/i18n/locales";
|
|
||||||
import {
|
import {
|
||||||
IconArrowUpRight,
|
IconArrowUpRight,
|
||||||
IconBrandGithub,
|
IconBrandGithub,
|
||||||
@@ -39,6 +37,7 @@ import {
|
|||||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
|
||||||
import { IntegrationsSection } from "@/components/settings/integrations-section";
|
import { IntegrationsSection } from "@/components/settings/integrations-section";
|
||||||
import { SettingsRow } from "@/components/settings/settings-row";
|
import { SettingsRow } from "@/components/settings/settings-row";
|
||||||
import { SettingsSection } from "@/components/settings/settings-section";
|
import { SettingsSection } from "@/components/settings/settings-section";
|
||||||
@@ -55,6 +54,8 @@ import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
|
|||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
import { authClient, getServerUrl } from "@/lib/server";
|
import { authClient, getServerUrl } from "@/lib/server";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import { activateLocale, type SupportedLocale } from "@sofa/i18n";
|
||||||
|
import { LOCALE_INFO } from "@sofa/i18n/locales";
|
||||||
|
|
||||||
const settingsContentContainerStyle = {
|
const settingsContentContainerStyle = {
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
@@ -74,8 +75,7 @@ export default function SettingsScreen() {
|
|||||||
}, [session?.user?.name, isEditingName]);
|
}, [session?.user?.name, isEditingName]);
|
||||||
|
|
||||||
const [languageModalOpen, setLanguageModalOpen] = useState(false);
|
const [languageModalOpen, setLanguageModalOpen] = useState(false);
|
||||||
const languageLabel =
|
const languageLabel = LOCALE_INFO.find((o) => o.code === i18n.locale)?.nativeName ?? i18n.locale;
|
||||||
LOCALE_INFO.find((o) => o.code === i18n.locale)?.nativeName ?? i18n.locale;
|
|
||||||
const [analyticsEnabled, setAnalyticsToggle] = useState(isAnalyticsEnabled);
|
const [analyticsEnabled, setAnalyticsToggle] = useState(isAnalyticsEnabled);
|
||||||
|
|
||||||
const isAdmin = session?.user?.role === "admin";
|
const isAdmin = session?.user?.role === "admin";
|
||||||
@@ -90,11 +90,8 @@ export default function SettingsScreen() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const hasPassword =
|
const hasPassword =
|
||||||
accounts?.some(
|
accounts?.some((a: { providerId: string }) => a.providerId === "credential") ?? false;
|
||||||
(a: { providerId: string }) => a.providerId === "credential",
|
const showPasswordOption = hasPassword && !(authConfig.data?.passwordLoginDisabled ?? true);
|
||||||
) ?? false;
|
|
||||||
const showPasswordOption =
|
|
||||||
hasPassword && !(authConfig.data?.passwordLoginDisabled ?? true);
|
|
||||||
|
|
||||||
const systemHealth = useQuery({
|
const systemHealth = useQuery({
|
||||||
...orpc.admin.systemHealth.queryOptions(),
|
...orpc.admin.systemHealth.queryOptions(),
|
||||||
@@ -181,9 +178,7 @@ export default function SettingsScreen() {
|
|||||||
const toggleUpdateCheck = useMutation(
|
const toggleUpdateCheck = useMutation(
|
||||||
orpc.admin.toggleUpdateCheck.mutationOptions({
|
orpc.admin.toggleUpdateCheck.mutationOptions({
|
||||||
onSuccess: (_data, { enabled }) => {
|
onSuccess: (_data, { enabled }) => {
|
||||||
toast.success(
|
toast.success(enabled ? t`Update checks enabled` : t`Update checks disabled`);
|
||||||
enabled ? t`Update checks enabled` : t`Update checks disabled`,
|
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: orpc.admin.updateCheck.key(),
|
queryKey: orpc.admin.updateCheck.key(),
|
||||||
});
|
});
|
||||||
@@ -222,9 +217,7 @@ export default function SettingsScreen() {
|
|||||||
contentContainerStyle={settingsContentContainerStyle}
|
contentContainerStyle={settingsContentContainerStyle}
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
scrollToOverflowEnabled
|
scrollToOverflowEnabled
|
||||||
refreshControl={
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{/* Account */}
|
{/* Account */}
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||||
@@ -240,7 +233,7 @@ export default function SettingsScreen() {
|
|||||||
className="mr-3"
|
className="mr-3"
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
<View className="size-11 overflow-hidden rounded-full bg-secondary">
|
<View className="bg-secondary size-11 overflow-hidden rounded-full">
|
||||||
{uploadAvatar.isPending ? (
|
{uploadAvatar.isPending ? (
|
||||||
<View className="flex-1 items-center justify-center">
|
<View className="flex-1 items-center justify-center">
|
||||||
<Spinner size="sm" colorClassName="accent-primary" />
|
<Spinner size="sm" colorClassName="accent-primary" />
|
||||||
@@ -253,16 +246,14 @@ export default function SettingsScreen() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View className="absolute right-0 bottom-0 size-[18px] items-center justify-center rounded-full bg-primary">
|
<View className="bg-primary absolute right-0 bottom-0 size-[18px] items-center justify-center rounded-full">
|
||||||
<IconCamera size={10} color={primaryFgColor} />
|
<IconCamera size={10} color={primaryFgColor} />
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content>
|
<DropdownMenu.Content>
|
||||||
<DropdownMenu.Item key="change" onSelect={() => pickAvatar()}>
|
<DropdownMenu.Item key="change" onSelect={() => pickAvatar()}>
|
||||||
<DropdownMenu.ItemIcon
|
<DropdownMenu.ItemIcon ios={{ name: "photo.on.rectangle.angled" }} />
|
||||||
ios={{ name: "photo.on.rectangle.angled" }}
|
|
||||||
/>
|
|
||||||
<DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
<Trans>Change Photo</Trans>
|
<Trans>Change Photo</Trans>
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
@@ -287,20 +278,20 @@ export default function SettingsScreen() {
|
|||||||
className="mr-3"
|
className="mr-3"
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
<View className="size-11 overflow-hidden rounded-full bg-secondary">
|
<View className="bg-secondary size-11 overflow-hidden rounded-full">
|
||||||
{uploadAvatar.isPending ? (
|
{uploadAvatar.isPending ? (
|
||||||
<View className="flex-1 items-center justify-center">
|
<View className="flex-1 items-center justify-center">
|
||||||
<Spinner size="sm" colorClassName="accent-primary" />
|
<Spinner size="sm" colorClassName="accent-primary" />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className="flex-1 items-center justify-center bg-primary/[0.08]">
|
<View className="bg-primary/[0.08] flex-1 items-center justify-center">
|
||||||
<Text className="font-display font-medium text-lg text-primary">
|
<Text className="font-display text-primary text-lg font-medium">
|
||||||
{session?.user?.name?.charAt(0)?.toUpperCase() ?? "?"}
|
{session?.user?.name?.charAt(0)?.toUpperCase() ?? "?"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View className="absolute right-0 bottom-0 size-[18px] items-center justify-center rounded-full bg-primary">
|
<View className="bg-primary absolute right-0 bottom-0 size-[18px] items-center justify-center rounded-full">
|
||||||
<IconCamera size={10} color={primaryFgColor} />
|
<IconCamera size={10} color={primaryFgColor} />
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -312,12 +303,10 @@ export default function SettingsScreen() {
|
|||||||
value={nameInput}
|
value={nameInput}
|
||||||
accessibilityLabel="Display name"
|
accessibilityLabel="Display name"
|
||||||
onChangeText={setNameInput}
|
onChangeText={setNameInput}
|
||||||
className="min-h-10 flex-1 border-primary border-b py-2 font-sans text-base text-foreground"
|
className="border-primary text-foreground min-h-10 flex-1 border-b py-2 font-sans text-base"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Pressable
|
<Pressable onPress={() => updateName.mutate({ name: nameInput })}>
|
||||||
onPress={() => updateName.mutate({ name: nameInput })}
|
|
||||||
>
|
|
||||||
<Text className="text-primary text-sm">
|
<Text className="text-primary text-sm">
|
||||||
<Trans>Save</Trans>
|
<Trans>Save</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
@@ -335,20 +324,20 @@ export default function SettingsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<Pressable onPress={() => setIsEditingName(true)}>
|
<Pressable onPress={() => setIsEditingName(true)}>
|
||||||
<Text className="font-medium font-sans text-base text-foreground">
|
<Text className="text-foreground font-sans text-base font-medium">
|
||||||
{session?.user?.name}
|
{session?.user?.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
<Text selectable className="mt-0.5 text-muted-foreground text-sm">
|
<Text selectable className="text-muted-foreground mt-0.5 text-sm">
|
||||||
{session?.user?.email}
|
{session?.user?.email}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<View className="rounded-full bg-primary/10 px-2 py-0.5">
|
<View className="bg-primary/10 rounded-full px-2 py-0.5">
|
||||||
<Text
|
<Text
|
||||||
maxFontSizeMultiplier={1.0}
|
maxFontSizeMultiplier={1.0}
|
||||||
className="font-medium font-sans text-primary text-xs"
|
className="text-primary font-sans text-xs font-medium"
|
||||||
>
|
>
|
||||||
<Trans>Admin</Trans>
|
<Trans>Admin</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
@@ -364,12 +353,7 @@ export default function SettingsScreen() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow label={t`Sign out`} icon={IconLogout} onPress={handleSignOut} destructive />
|
||||||
label={t`Sign out`}
|
|
||||||
icon={IconLogout}
|
|
||||||
onPress={handleSignOut}
|
|
||||||
destructive
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
@@ -381,22 +365,18 @@ export default function SettingsScreen() {
|
|||||||
value={serverUrl}
|
value={serverUrl}
|
||||||
icon={IconLink}
|
icon={IconLink}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
Alert.alert(
|
Alert.alert(t`Change Server`, t`You'll be signed out to change the server URL.`, [
|
||||||
t`Change Server`,
|
{ text: t`Cancel`, style: "cancel" },
|
||||||
t`You'll be signed out to change the server URL.`,
|
{
|
||||||
[
|
text: t`Continue`,
|
||||||
{ text: t`Cancel`, style: "cancel" },
|
style: "destructive",
|
||||||
{
|
onPress: async () => {
|
||||||
text: t`Continue`,
|
await authClient.signOut();
|
||||||
style: "destructive",
|
queryClient.clear();
|
||||||
onPress: async () => {
|
push("/(auth)/server-url");
|
||||||
await authClient.signOut();
|
|
||||||
queryClient.clear();
|
|
||||||
push("/(auth)/server-url");
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
},
|
||||||
);
|
]);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
@@ -448,11 +428,7 @@ export default function SettingsScreen() {
|
|||||||
{/* Admin: Server Health */}
|
{/* Admin: Server Health */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
||||||
<SettingsSection
|
<SettingsSection title={t`Server Health`} icon={IconServer} badge={t`Admin`}>
|
||||||
title={t`Server Health`}
|
|
||||||
icon={IconServer}
|
|
||||||
badge={t`Admin`}
|
|
||||||
>
|
|
||||||
{systemHealth.isPending ? (
|
{systemHealth.isPending ? (
|
||||||
<View className="items-center py-4">
|
<View className="items-center py-4">
|
||||||
<Spinner colorClassName="accent-primary" />
|
<Spinner colorClassName="accent-primary" />
|
||||||
@@ -470,9 +446,7 @@ export default function SettingsScreen() {
|
|||||||
/>
|
/>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="TMDB"
|
label="TMDB"
|
||||||
value={
|
value={systemHealth.data?.tmdb?.connected ? t`Connected` : "—"}
|
||||||
systemHealth.data?.tmdb?.connected ? t`Connected` : "—"
|
|
||||||
}
|
|
||||||
icon={IconCloud}
|
icon={IconCloud}
|
||||||
/>
|
/>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
@@ -493,11 +467,7 @@ export default function SettingsScreen() {
|
|||||||
{/* Admin: Security */}
|
{/* Admin: Security */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(500)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(500)}>
|
||||||
<SettingsSection
|
<SettingsSection title={t`Security`} icon={IconShield} badge={t`Admin`}>
|
||||||
title={t`Security`}
|
|
||||||
icon={IconShield}
|
|
||||||
badge={t`Admin`}
|
|
||||||
>
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label={t`Open registration`}
|
label={t`Open registration`}
|
||||||
icon={IconUserPlus}
|
icon={IconUserPlus}
|
||||||
@@ -516,19 +486,14 @@ export default function SettingsScreen() {
|
|||||||
<Switch
|
<Switch
|
||||||
value={updateCheck.data?.enabled ?? false}
|
value={updateCheck.data?.enabled ?? false}
|
||||||
accessibilityLabel="Check for updates"
|
accessibilityLabel="Check for updates"
|
||||||
onValueChange={(enabled) =>
|
onValueChange={(enabled) => toggleUpdateCheck.mutate({ enabled })}
|
||||||
toggleUpdateCheck.mutate({ enabled })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{updateCheck.data?.updateCheck?.updateAvailable && (
|
{updateCheck.data?.updateCheck?.updateAvailable && (
|
||||||
<View className="py-3.5">
|
<View className="py-3.5">
|
||||||
<Text className="font-medium font-sans text-sm text-status-completed">
|
<Text className="text-status-completed font-sans text-sm font-medium">
|
||||||
<Trans>
|
<Trans>Update available: {updateCheck.data.updateCheck.latestVersion}</Trans>
|
||||||
Update available:{" "}
|
|
||||||
{updateCheck.data.updateCheck.latestVersion}
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -544,31 +509,20 @@ export default function SettingsScreen() {
|
|||||||
className="flex-row items-center justify-center py-3.5 active:opacity-70"
|
className="flex-row items-center justify-center py-3.5 active:opacity-70"
|
||||||
>
|
>
|
||||||
<ScaledIcon icon={IconWorld} size={18} color={mutedFgColor} />
|
<ScaledIcon icon={IconWorld} size={18} color={mutedFgColor} />
|
||||||
<Text className="ml-2 flex-1 text-base text-foreground">
|
<Text className="text-foreground ml-2 flex-1 text-base">
|
||||||
<Trans>Open in browser…</Trans>
|
<Trans>Open in browser…</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<ScaledIcon
|
<ScaledIcon icon={IconArrowUpRight} size={16} color={mutedFgColor} />
|
||||||
icon={IconArrowUpRight}
|
|
||||||
size={16}
|
|
||||||
color={mutedFgColor}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Version */}
|
{/* Version */}
|
||||||
<Animated.View
|
<Animated.View entering={FadeInDown.duration(300).delay(400)} className="mt-6 items-center">
|
||||||
entering={FadeInDown.duration(300).delay(400)}
|
|
||||||
className="mt-6 items-center"
|
|
||||||
>
|
|
||||||
<Text className="text-muted-foreground text-xs">
|
<Text className="text-muted-foreground text-xs">
|
||||||
Native
|
Native
|
||||||
{Application.nativeApplicationVersion
|
{Application.nativeApplicationVersion ? ` v${Application.nativeApplicationVersion}` : ""}
|
||||||
? ` v${Application.nativeApplicationVersion}`
|
{Application.nativeBuildVersion ? ` (${Application.nativeBuildVersion})` : ""}
|
||||||
: ""}
|
|
||||||
{Application.nativeBuildVersion
|
|
||||||
? ` (${Application.nativeBuildVersion})`
|
|
||||||
: ""}
|
|
||||||
{updateCheck.data?.updateCheck?.currentVersion
|
{updateCheck.data?.updateCheck?.currentVersion
|
||||||
? ` · Server v${updateCheck.data.updateCheck.currentVersion}`
|
? ` · Server v${updateCheck.data.updateCheck.currentVersion}`
|
||||||
: ""}
|
: ""}
|
||||||
@@ -576,10 +530,7 @@ export default function SettingsScreen() {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* GitHub */}
|
{/* GitHub */}
|
||||||
<Animated.View
|
<Animated.View entering={FadeInDown.duration(300).delay(450)} className="mt-3 items-center">
|
||||||
entering={FadeInDown.duration(300).delay(450)}
|
|
||||||
className="mt-3 items-center"
|
|
||||||
>
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => Linking.openURL("https://github.com/jakejarvis/sofa")}
|
onPress={() => Linking.openURL("https://github.com/jakejarvis/sofa")}
|
||||||
className="flex-row items-center gap-1.5 active:opacity-70"
|
className="flex-row items-center gap-1.5 active:opacity-70"
|
||||||
@@ -601,12 +552,9 @@ export default function SettingsScreen() {
|
|||||||
<TmdbLogo height={12} />
|
<TmdbLogo height={12} />
|
||||||
<Text
|
<Text
|
||||||
maxFontSizeMultiplier={1.0}
|
maxFontSizeMultiplier={1.0}
|
||||||
className="text-center text-muted-foreground text-xs leading-relaxed"
|
className="text-muted-foreground text-center text-xs leading-relaxed"
|
||||||
>
|
>
|
||||||
<Trans>
|
<Trans>This product uses the TMDB API but is not endorsed or certified by TMDB.</Trans>
|
||||||
This product uses the TMDB API but is not endorsed or certified by
|
|
||||||
TMDB.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { NativeTabBar } from "@/components/navigation/native-tab-bar";
|
import { NativeTabBar } from "@/components/navigation/native-tab-bar";
|
||||||
import { orpc } from "@/lib/orpc";
|
import { orpc } from "@/lib/orpc";
|
||||||
import { authClient } from "@/lib/server";
|
import { authClient } from "@/lib/server";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Link, Stack } from "expo-router";
|
|||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import { Container } from "@/components/container";
|
import { Container } from "@/components/container";
|
||||||
import { Button, ButtonLabel } from "@/components/ui/button";
|
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
@@ -18,15 +19,12 @@ export default function NotFoundScreen() {
|
|||||||
</Stack.Screen.Title>
|
</Stack.Screen.Title>
|
||||||
<Container>
|
<Container>
|
||||||
<View className="flex-1 items-center justify-center p-4">
|
<View className="flex-1 items-center justify-center p-4">
|
||||||
<Animated.View
|
<Animated.View entering={FadeIn.duration(400)} className="items-center">
|
||||||
entering={FadeIn.duration(400)}
|
|
||||||
className="items-center"
|
|
||||||
>
|
|
||||||
<IconAlertTriangle size={48} color={mutedForeground} />
|
<IconAlertTriangle size={48} color={mutedForeground} />
|
||||||
<Text className="mt-3 font-display text-foreground text-xl">
|
<Text className="font-display text-foreground mt-3 text-xl">
|
||||||
<Trans>Page Not Found</Trans>
|
<Trans>Page Not Found</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="mt-1 mb-4 text-center text-muted-foreground text-sm">
|
<Text className="text-muted-foreground mt-1 mb-4 text-center text-sm">
|
||||||
<Trans>The page you're looking for doesn't exist.</Trans>
|
<Trans>The page you're looking for doesn't exist.</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import "@/lib/intl-polyfills";
|
|||||||
import "@/global.css";
|
import "@/global.css";
|
||||||
import { I18nProvider } from "@lingui/react";
|
import { I18nProvider } from "@lingui/react";
|
||||||
import { ThemeProvider } from "@react-navigation/native";
|
import { ThemeProvider } from "@react-navigation/native";
|
||||||
import { i18n } from "@sofa/i18n";
|
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
persistQueryClientRestore,
|
persistQueryClientRestore,
|
||||||
@@ -23,19 +22,16 @@ import { KeyboardProvider } from "react-native-keyboard-controller";
|
|||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { enableFreeze } from "react-native-screens";
|
import { enableFreeze } from "react-native-screens";
|
||||||
import { Uniwind, useResolveClassNames } from "uniwind";
|
import { Uniwind, useResolveClassNames } from "uniwind";
|
||||||
|
|
||||||
import { OfflineBanner } from "@/components/ui/offline-banner";
|
import { OfflineBanner } from "@/components/ui/offline-banner";
|
||||||
import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner";
|
import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner";
|
||||||
import { useServerConnection } from "@/hooks/use-server-connection";
|
import { useServerConnection } from "@/hooks/use-server-connection";
|
||||||
import { initLocale } from "@/lib/i18n";
|
import { initLocale } from "@/lib/i18n";
|
||||||
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
|
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
import {
|
import { getScopeKey, initialize, onStorageScopeChange, queryPersister } from "@/lib/server";
|
||||||
getScopeKey,
|
|
||||||
initialize,
|
|
||||||
onStorageScopeChange,
|
|
||||||
queryPersister,
|
|
||||||
} from "@/lib/server";
|
|
||||||
import { sofaTheme } from "@/lib/theme";
|
import { sofaTheme } from "@/lib/theme";
|
||||||
|
import { i18n } from "@sofa/i18n";
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
enableFreeze(true);
|
enableFreeze(true);
|
||||||
@@ -73,9 +69,7 @@ function AppContent() {
|
|||||||
const [isLocaleReady, setLocaleReady] = useState(false);
|
const [isLocaleReady, setLocaleReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localeReady
|
localeReady.then(() => setLocaleReady(true)).catch(() => setLocaleReady(true));
|
||||||
.then(() => setLocaleReady(true))
|
|
||||||
.catch(() => setLocaleReady(true));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// --- App Tracking Transparency (must resolve before screen tracking) ---
|
// --- App Tracking Transparency (must resolve before screen tracking) ---
|
||||||
@@ -149,10 +143,7 @@ function AppContent() {
|
|||||||
|
|
||||||
<Stack.Protected guard={!!session}>
|
<Stack.Protected guard={!!session}>
|
||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen
|
<Stack.Screen name="change-password" options={changePasswordOptions} />
|
||||||
name="change-password"
|
|
||||||
options={changePasswordOptions}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="title/[id]"
|
name="title/[id]"
|
||||||
dangerouslySingular
|
dangerouslySingular
|
||||||
@@ -223,9 +214,7 @@ function QueryProvider({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, [scopeKey]);
|
}, [scopeKey]);
|
||||||
|
|
||||||
return (
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
|
|||||||
@@ -6,16 +6,12 @@ import { Alert, ScrollView, type TextInput, View } from "react-native";
|
|||||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||||
import { useResolveClassNames } from "uniwind";
|
import { useResolveClassNames } from "uniwind";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { Button, ButtonLabel } from "@/components/ui/button";
|
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import {
|
import { FieldError, Input, Label, TextField } from "@/components/ui/text-field";
|
||||||
FieldError,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
TextField,
|
|
||||||
} from "@/components/ui/text-field";
|
|
||||||
import { authClient } from "@/lib/server";
|
import { authClient } from "@/lib/server";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
@@ -85,7 +81,7 @@ export default function ChangePasswordScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="flex-1 bg-background"
|
className="bg-background flex-1"
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingTop: 16,
|
paddingTop: 16,
|
||||||
@@ -156,9 +152,7 @@ export default function ChangePasswordScreen() {
|
|||||||
textContentType="newPassword"
|
textContentType="newPassword"
|
||||||
returnKeyType="next"
|
returnKeyType="next"
|
||||||
blurOnSubmit={false}
|
blurOnSubmit={false}
|
||||||
onSubmitEditing={() =>
|
onSubmitEditing={() => confirmPasswordRef.current?.focus()}
|
||||||
confirmPasswordRef.current?.focus()
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</TextField>
|
</TextField>
|
||||||
)}
|
)}
|
||||||
@@ -192,10 +186,10 @@ export default function ChangePasswordScreen() {
|
|||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.duration(300).delay(400)}
|
entering={FadeInDown.duration(300).delay(400)}
|
||||||
className="flex-row items-center justify-between rounded-xl border border-border bg-input px-3.5 py-3"
|
className="border-border bg-input flex-row items-center justify-between rounded-xl border px-3.5 py-3"
|
||||||
style={{ borderCurve: "continuous" }}
|
style={{ borderCurve: "continuous" }}
|
||||||
>
|
>
|
||||||
<Text className="text-base text-foreground">
|
<Text className="text-foreground text-base">
|
||||||
<Trans>Sign out of other sessions</Trans>
|
<Trans>Sign out of other sessions</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -209,7 +203,7 @@ export default function ChangePasswordScreen() {
|
|||||||
<Button
|
<Button
|
||||||
onPress={form.handleSubmit}
|
onPress={form.handleSubmit}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="mt-2 bg-primary"
|
className="bg-primary mt-2"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { useHeaderHeight } from "@react-navigation/elements";
|
import { useHeaderHeight } from "@react-navigation/elements";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { formatDate } from "@sofa/i18n/format";
|
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
@@ -12,15 +11,11 @@ import {
|
|||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import {
|
import { ActivityIndicator, Pressable, useWindowDimensions, View } from "react-native";
|
||||||
ActivityIndicator,
|
|
||||||
Pressable,
|
|
||||||
useWindowDimensions,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import { DetailStackHeader } from "@/components/navigation/modal-stack-header";
|
import { DetailStackHeader } from "@/components/navigation/modal-stack-header";
|
||||||
import { ExpandableText } from "@/components/ui/expandable-text";
|
import { ExpandableText } from "@/components/ui/expandable-text";
|
||||||
import { Image } from "@/components/ui/image";
|
import { Image } from "@/components/ui/image";
|
||||||
@@ -32,6 +27,7 @@ import { Text } from "@/components/ui/text";
|
|||||||
import { useTitleActions } from "@/hooks/use-title-actions";
|
import { useTitleActions } from "@/hooks/use-title-actions";
|
||||||
import { orpc } from "@/lib/orpc";
|
import { orpc } from "@/lib/orpc";
|
||||||
import { addRecentlyViewed } from "@/lib/recently-viewed";
|
import { addRecentlyViewed } from "@/lib/recently-viewed";
|
||||||
|
import { formatDate } from "@sofa/i18n/format";
|
||||||
|
|
||||||
const FILMOGRAPHY_GAP = 12;
|
const FILMOGRAPHY_GAP = 12;
|
||||||
const FILMOGRAPHY_PADDING = 16;
|
const FILMOGRAPHY_PADDING = 16;
|
||||||
@@ -68,12 +64,9 @@ export default function PersonDetailScreen() {
|
|||||||
const { back } = useRouter();
|
const { back } = useRouter();
|
||||||
const { width: screenWidth } = useWindowDimensions();
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
const useAutomaticInsets = process.env.EXPO_OS === "ios";
|
const useAutomaticInsets = process.env.EXPO_OS === "ios";
|
||||||
const filmographyColumns =
|
const filmographyColumns = screenWidth >= 900 ? 4 : screenWidth >= 600 ? 3 : 2;
|
||||||
screenWidth >= 900 ? 4 : screenWidth >= 600 ? 3 : 2;
|
|
||||||
const columnWidth = Math.floor(
|
const columnWidth = Math.floor(
|
||||||
(screenWidth -
|
(screenWidth - FILMOGRAPHY_PADDING * 2 - FILMOGRAPHY_GAP * (filmographyColumns - 1)) /
|
||||||
FILMOGRAPHY_PADDING * 2 -
|
|
||||||
FILMOGRAPHY_GAP * (filmographyColumns - 1)) /
|
|
||||||
filmographyColumns,
|
filmographyColumns,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -82,40 +75,29 @@ export default function PersonDetailScreen() {
|
|||||||
|
|
||||||
const { quickAdd } = useTitleActions();
|
const { quickAdd } = useTitleActions();
|
||||||
const handleQuickAdd = useCallback(
|
const handleQuickAdd = useCallback(
|
||||||
(id: string) => quickAdd.mutate({ id }),
|
(titleId: string) => quickAdd.mutate({ id: titleId }),
|
||||||
[quickAdd],
|
[quickAdd],
|
||||||
);
|
);
|
||||||
const addingKey = quickAdd.isPending
|
const addingKey = quickAdd.isPending ? (quickAdd.variables?.id ?? null) : null;
|
||||||
? (quickAdd.variables?.id ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const {
|
const { data, isPending, isError, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||||
data,
|
useInfiniteQuery(
|
||||||
isPending,
|
orpc.people.detail.infiniteOptions({
|
||||||
isError,
|
input: (pageParam: number) => ({ id, page: pageParam, limit: 20 }),
|
||||||
fetchNextPage,
|
initialPageParam: 1,
|
||||||
hasNextPage,
|
getNextPageParam: (lastPage) =>
|
||||||
isFetchingNextPage,
|
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||||
} = useInfiniteQuery(
|
}),
|
||||||
orpc.people.detail.infiniteOptions({
|
);
|
||||||
input: (pageParam: number) => ({ id, page: pageParam, limit: 20 }),
|
|
||||||
initialPageParam: 1,
|
|
||||||
getNextPageParam: (lastPage) =>
|
|
||||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const person = data?.pages[0]?.person;
|
const person = data?.pages[0]?.person;
|
||||||
const filmography = useMemo(
|
const filmography = useMemo(() => data?.pages.flatMap((p) => p.filmography) ?? [], [data?.pages]);
|
||||||
() => data?.pages.flatMap((p) => p.filmography) ?? [],
|
|
||||||
[data?.pages],
|
|
||||||
);
|
|
||||||
const userStatuses = useMemo(
|
const userStatuses = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.assign(
|
Object.assign({}, ...(data?.pages.map((p) => p.userStatuses) ?? [])) as Record<
|
||||||
{},
|
string,
|
||||||
...(data?.pages.map((p) => p.userStatuses) ?? []),
|
"watchlist" | "in_progress" | "completed"
|
||||||
) as Record<string, "watchlist" | "in_progress" | "completed">,
|
>,
|
||||||
[data?.pages],
|
[data?.pages],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -166,25 +148,15 @@ export default function PersonDetailScreen() {
|
|||||||
<>
|
<>
|
||||||
<DetailStackHeader />
|
<DetailStackHeader />
|
||||||
<View
|
<View
|
||||||
className="flex-1 items-center bg-background"
|
className="bg-background flex-1 items-center"
|
||||||
style={{ paddingTop: headerHeight + 24 }}
|
style={{ paddingTop: headerHeight + 24 }}
|
||||||
>
|
>
|
||||||
{/* Profile photo skeleton */}
|
{/* Profile photo skeleton */}
|
||||||
<Skeleton width={120} height={120} borderRadius={60} />
|
<Skeleton width={120} height={120} borderRadius={60} />
|
||||||
{/* Name skeleton */}
|
{/* Name skeleton */}
|
||||||
<Skeleton
|
<Skeleton width={180} height={28} borderRadius={6} style={{ marginTop: 16 }} />
|
||||||
width={180}
|
|
||||||
height={28}
|
|
||||||
borderRadius={6}
|
|
||||||
style={{ marginTop: 16 }}
|
|
||||||
/>
|
|
||||||
{/* Department badge skeleton */}
|
{/* Department badge skeleton */}
|
||||||
<Skeleton
|
<Skeleton width={80} height={24} borderRadius={12} style={{ marginTop: 8 }} />
|
||||||
width={80}
|
|
||||||
height={24}
|
|
||||||
borderRadius={12}
|
|
||||||
style={{ marginTop: 8 }}
|
|
||||||
/>
|
|
||||||
{/* Bio skeleton */}
|
{/* Bio skeleton */}
|
||||||
<View className="mt-6 gap-2 self-stretch px-4">
|
<View className="mt-6 gap-2 self-stretch px-4">
|
||||||
<Skeleton width="100%" height={14} />
|
<Skeleton width="100%" height={14} />
|
||||||
@@ -201,18 +173,15 @@ export default function PersonDetailScreen() {
|
|||||||
<>
|
<>
|
||||||
<DetailStackHeader />
|
<DetailStackHeader />
|
||||||
<View
|
<View
|
||||||
className="flex-1 items-center justify-center bg-background"
|
className="bg-background flex-1 items-center justify-center"
|
||||||
style={{ paddingTop: insets.top }}
|
style={{ paddingTop: insets.top }}
|
||||||
>
|
>
|
||||||
<Animated.View
|
<Animated.View entering={FadeIn.duration(400)} className="items-center">
|
||||||
entering={FadeIn.duration(400)}
|
|
||||||
className="items-center"
|
|
||||||
>
|
|
||||||
<IconAlertTriangle size={48} color={mutedForeground} />
|
<IconAlertTriangle size={48} color={mutedForeground} />
|
||||||
<Text className="mt-3 font-display text-foreground text-xl">
|
<Text className="font-display text-foreground mt-3 text-xl">
|
||||||
<Trans>Something went wrong</Trans>
|
<Trans>Something went wrong</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="mt-1 text-center text-muted-foreground text-sm">
|
<Text className="text-muted-foreground mt-1 text-center text-sm">
|
||||||
<Trans>Could not load person details</Trans>
|
<Trans>Could not load person details</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable onPress={() => back()} className="mt-4">
|
<Pressable onPress={() => back()} className="mt-4">
|
||||||
@@ -231,15 +200,12 @@ export default function PersonDetailScreen() {
|
|||||||
<>
|
<>
|
||||||
<DetailStackHeader />
|
<DetailStackHeader />
|
||||||
<View
|
<View
|
||||||
className="flex-1 items-center justify-center bg-background"
|
className="bg-background flex-1 items-center justify-center"
|
||||||
style={{ paddingTop: insets.top }}
|
style={{ paddingTop: insets.top }}
|
||||||
>
|
>
|
||||||
<Animated.View
|
<Animated.View entering={FadeIn.duration(400)} className="items-center">
|
||||||
entering={FadeIn.duration(400)}
|
|
||||||
className="items-center"
|
|
||||||
>
|
|
||||||
<IconUser size={48} color={mutedForeground} />
|
<IconUser size={48} color={mutedForeground} />
|
||||||
<Text className="mt-3 font-display text-foreground text-xl">
|
<Text className="font-display text-foreground mt-3 text-xl">
|
||||||
<Trans>Person not found</Trans>
|
<Trans>Person not found</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable onPress={() => back()} className="mt-4">
|
<Pressable onPress={() => back()} className="mt-4">
|
||||||
@@ -266,7 +232,7 @@ export default function PersonDetailScreen() {
|
|||||||
paddingBottom: 24,
|
paddingBottom: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="size-[120px] overflow-hidden rounded-full bg-secondary">
|
<View className="bg-secondary size-[120px] overflow-hidden rounded-full">
|
||||||
{person.profilePath && (
|
{person.profilePath && (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: person.profilePath }}
|
source={{ uri: person.profilePath }}
|
||||||
@@ -277,15 +243,14 @@ export default function PersonDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className="mt-4 text-center font-display text-3xl text-foreground">
|
<Text className="font-display text-foreground mt-4 text-center text-3xl">
|
||||||
{person.name}
|
{person.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{person.knownForDepartment ? (
|
{person.knownForDepartment ? (
|
||||||
<View className="mt-2 rounded-full bg-secondary px-3 py-1">
|
<View className="bg-secondary mt-2 rounded-full px-3 py-1">
|
||||||
<Text className="text-muted-foreground text-xs uppercase tracking-wider">
|
<Text className="text-muted-foreground text-xs tracking-wider uppercase">
|
||||||
{getDepartmentLabels(t)[person.knownForDepartment] ??
|
{getDepartmentLabels(t)[person.knownForDepartment] ?? person.knownForDepartment}
|
||||||
person.knownForDepartment}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -294,18 +259,12 @@ export default function PersonDetailScreen() {
|
|||||||
<View className="mt-3 items-center gap-1.5">
|
<View className="mt-3 items-center gap-1.5">
|
||||||
{person.birthday ? (
|
{person.birthday ? (
|
||||||
<View className="flex-row items-center gap-1.5">
|
<View className="flex-row items-center gap-1.5">
|
||||||
<ScaledIcon
|
<ScaledIcon icon={IconCalendar} size={14} color={primaryColor} />
|
||||||
icon={IconCalendar}
|
|
||||||
size={14}
|
|
||||||
color={primaryColor}
|
|
||||||
/>
|
|
||||||
<Text selectable className="text-muted-foreground text-sm">
|
<Text selectable className="text-muted-foreground text-sm">
|
||||||
{formatDate(person.birthday)}
|
{formatDate(person.birthday)}
|
||||||
{(() => {
|
{(() => {
|
||||||
const age = calculateAge(person.birthday, person.deathday);
|
const age = calculateAge(person.birthday, person.deathday);
|
||||||
return person.deathday
|
return person.deathday ? ` (${t`died at ${age}`})` : ` (${t`age ${age}`})`;
|
||||||
? ` (${t`died at ${age}`})`
|
|
||||||
: ` (${t`age ${age}`})`;
|
|
||||||
})()}
|
})()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -333,10 +292,7 @@ export default function PersonDetailScreen() {
|
|||||||
|
|
||||||
{/* Filmography section header */}
|
{/* Filmography section header */}
|
||||||
{filmography.length > 0 && (
|
{filmography.length > 0 && (
|
||||||
<Animated.View
|
<Animated.View entering={FadeInDown.duration(300).delay(200)} className="px-4">
|
||||||
entering={FadeInDown.duration(300).delay(200)}
|
|
||||||
className="px-4"
|
|
||||||
>
|
|
||||||
<SectionHeader title={t`Filmography`} icon={IconMovie} />
|
<SectionHeader title={t`Filmography`} icon={IconMovie} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
@@ -344,16 +300,14 @@ export default function PersonDetailScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-background" collapsable={false}>
|
<View className="bg-background flex-1" collapsable={false}>
|
||||||
<FlashList
|
<FlashList
|
||||||
data={filmography}
|
data={filmography}
|
||||||
keyExtractor={(item) => item.titleId}
|
keyExtractor={(item) => item.titleId}
|
||||||
renderItem={renderFilmographyItem}
|
renderItem={renderFilmographyItem}
|
||||||
numColumns={filmographyColumns}
|
numColumns={filmographyColumns}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentInsetAdjustmentBehavior={
|
contentInsetAdjustmentBehavior={useAutomaticInsets ? "automatic" : "never"}
|
||||||
useAutomaticInsets ? "automatic" : "never"
|
|
||||||
}
|
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingBottom: useAutomaticInsets ? 32 : insets.bottom + 32,
|
paddingBottom: useAutomaticInsets ? 32 : insets.bottom + 32,
|
||||||
paddingHorizontal: FILMOGRAPHY_PADDING - FILMOGRAPHY_GUTTER,
|
paddingHorizontal: FILMOGRAPHY_PADDING - FILMOGRAPHY_GUTTER,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Pressable, ScrollView, StyleSheet, View } from "react-native";
|
|||||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
HorizontalPosterRow,
|
HorizontalPosterRow,
|
||||||
type PosterRowItem,
|
type PosterRowItem,
|
||||||
@@ -102,12 +103,8 @@ export default function TitleDetailScreen() {
|
|||||||
]) as [string, string, string];
|
]) as [string, string, string];
|
||||||
|
|
||||||
const detail = useQuery(orpc.titles.detail.queryOptions({ input: { id } }));
|
const detail = useQuery(orpc.titles.detail.queryOptions({ input: { id } }));
|
||||||
const userInfo = useQuery(
|
const userInfo = useQuery(orpc.titles.userInfo.queryOptions({ input: { id } }));
|
||||||
orpc.titles.userInfo.queryOptions({ input: { id } }),
|
const recommendations = useQuery(orpc.titles.recommendations.queryOptions({ input: { id } }));
|
||||||
);
|
|
||||||
const recommendations = useQuery(
|
|
||||||
orpc.titles.recommendations.queryOptions({ input: { id } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
updateStatus,
|
updateStatus,
|
||||||
@@ -116,24 +113,19 @@ export default function TitleDetailScreen() {
|
|||||||
quickAdd: quickAddMutation,
|
quickAdd: quickAddMutation,
|
||||||
} = useTitleActions({
|
} = useTitleActions({
|
||||||
toasts: {
|
toasts: {
|
||||||
watchMovie: () =>
|
watchMovie: () => (title?.title ? `Marked "${title.title}" as watched` : "Marked as watched"),
|
||||||
title?.title
|
|
||||||
? `Marked "${title.title}" as watched`
|
|
||||||
: "Marked as watched",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const title = detail.data?.title;
|
const title = detail.data?.title;
|
||||||
const palette = title?.colorPalette ?? null;
|
const palette = title?.colorPalette ?? null;
|
||||||
useTitleTheme(palette);
|
useTitleTheme(palette);
|
||||||
const providerIcon =
|
const providerIcon = process.env.EXPO_OS === "ios" ? IconBrandAppstore : IconBrandGooglePlay;
|
||||||
process.env.EXPO_OS === "ios" ? IconBrandAppstore : IconBrandGooglePlay;
|
|
||||||
|
|
||||||
const titleName = title?.title;
|
const titleName = title?.title;
|
||||||
const titleType = title?.type;
|
const titleType = title?.type;
|
||||||
const titlePosterPath = title?.posterPath ?? null;
|
const titlePosterPath = title?.posterPath ?? null;
|
||||||
const titleYear =
|
const titleYear = (title?.releaseDate ?? title?.firstAirDate)?.slice(0, 4) ?? null;
|
||||||
(title?.releaseDate ?? title?.firstAirDate)?.slice(0, 4) ?? null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (titleName && titleType) {
|
if (titleName && titleType) {
|
||||||
@@ -166,9 +158,7 @@ export default function TitleDetailScreen() {
|
|||||||
releaseDate: item.releaseDate,
|
releaseDate: item.releaseDate,
|
||||||
firstAirDate: item.firstAirDate,
|
firstAirDate: item.firstAirDate,
|
||||||
voteAverage: item.voteAverage,
|
voteAverage: item.voteAverage,
|
||||||
userStatus: item.id
|
userStatus: item.id ? (recommendations.data?.userStatuses?.[item.id] ?? null) : null,
|
||||||
? (recommendations.data?.userStatuses?.[item.id] ?? null)
|
|
||||||
: null,
|
|
||||||
})),
|
})),
|
||||||
[recommendations.data],
|
[recommendations.data],
|
||||||
);
|
);
|
||||||
@@ -188,17 +178,11 @@ export default function TitleDetailScreen() {
|
|||||||
[useAutomaticInsets, headerHeight],
|
[useAutomaticInsets, headerHeight],
|
||||||
);
|
);
|
||||||
const darkMutedOverlayStyle = useMemo(
|
const darkMutedOverlayStyle = useMemo(
|
||||||
() =>
|
() => (palette?.darkMuted ? { backgroundColor: palette.darkMuted, opacity: 0.2 } : undefined),
|
||||||
palette?.darkMuted
|
|
||||||
? { backgroundColor: palette.darkMuted, opacity: 0.2 }
|
|
||||||
: undefined,
|
|
||||||
[palette?.darkMuted],
|
[palette?.darkMuted],
|
||||||
);
|
);
|
||||||
const vibrantOverlayStyle = useMemo(
|
const vibrantOverlayStyle = useMemo(
|
||||||
() =>
|
() => (palette?.vibrant ? { backgroundColor: palette.vibrant, opacity: 0.06 } : undefined),
|
||||||
palette?.vibrant
|
|
||||||
? { backgroundColor: palette.vibrant, opacity: 0.06 }
|
|
||||||
: undefined,
|
|
||||||
[palette?.vibrant],
|
[palette?.vibrant],
|
||||||
);
|
);
|
||||||
const posterShadowStyle = useMemo(
|
const posterShadowStyle = useMemo(
|
||||||
@@ -215,7 +199,7 @@ export default function TitleDetailScreen() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DetailStackHeader />
|
<DetailStackHeader />
|
||||||
<View className="flex-1 bg-background">
|
<View className="bg-background flex-1">
|
||||||
{/* Hero skeleton */}
|
{/* Hero skeleton */}
|
||||||
<Skeleton width="100%" height={300} borderRadius={0} />
|
<Skeleton width="100%" height={300} borderRadius={0} />
|
||||||
{/* Genre chips skeleton */}
|
{/* Genre chips skeleton */}
|
||||||
@@ -245,9 +229,9 @@ export default function TitleDetailScreen() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DetailStackHeader />
|
<DetailStackHeader />
|
||||||
<View className="flex-1 items-center justify-center bg-background px-6">
|
<View className="bg-background flex-1 items-center justify-center px-6">
|
||||||
<IconMovie size={48} color={mutedForeground} />
|
<IconMovie size={48} color={mutedForeground} />
|
||||||
<Text className="mt-3 font-display text-foreground text-xl">
|
<Text className="font-display text-foreground mt-3 text-xl">
|
||||||
<Trans>Title not found</Trans>
|
<Trans>Title not found</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable onPress={() => back()} className="mt-4">
|
<Pressable onPress={() => back()} className="mt-4">
|
||||||
@@ -265,9 +249,7 @@ export default function TitleDetailScreen() {
|
|||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="bg-background"
|
className="bg-background"
|
||||||
contentInsetAdjustmentBehavior={
|
contentInsetAdjustmentBehavior={useAutomaticInsets ? "automatic" : "never"}
|
||||||
useAutomaticInsets ? "automatic" : "never"
|
|
||||||
}
|
|
||||||
contentContainerStyle={titleScrollContentStyle}
|
contentContainerStyle={titleScrollContentStyle}
|
||||||
>
|
>
|
||||||
<DetailStackHeader />
|
<DetailStackHeader />
|
||||||
@@ -285,12 +267,8 @@ export default function TitleDetailScreen() {
|
|||||||
{/* Base darkening overlay */}
|
{/* Base darkening overlay */}
|
||||||
<View style={titleDetailStyles.heroBaseOverlay} />
|
<View style={titleDetailStyles.heroBaseOverlay} />
|
||||||
{/* Colored tint from palette */}
|
{/* Colored tint from palette */}
|
||||||
{palette?.darkMuted && (
|
{palette?.darkMuted && <View style={[titleDetailStyles.heroFill, darkMutedOverlayStyle]} />}
|
||||||
<View style={[titleDetailStyles.heroFill, darkMutedOverlayStyle]} />
|
{palette?.vibrant && <View style={[titleDetailStyles.heroFill, vibrantOverlayStyle]} />}
|
||||||
)}
|
|
||||||
{palette?.vibrant && (
|
|
||||||
<View style={[titleDetailStyles.heroFill, vibrantOverlayStyle]} />
|
|
||||||
)}
|
|
||||||
{/* Bottom fade to background */}
|
{/* Bottom fade to background */}
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={["transparent", "rgba(0,0,0,0.6)", "rgba(0,0,0,0.95)"]}
|
colors={["transparent", "rgba(0,0,0,0.6)", "rgba(0,0,0,0.95)"]}
|
||||||
@@ -347,39 +325,26 @@ export default function TitleDetailScreen() {
|
|||||||
</Link.AppleZoomTarget>
|
</Link.AppleZoomTarget>
|
||||||
)}
|
)}
|
||||||
<View className="flex-1 pb-1">
|
<View className="flex-1 pb-1">
|
||||||
<Text
|
<Text className="font-display text-2xl text-white" numberOfLines={2}>
|
||||||
className="font-display text-2xl text-white"
|
|
||||||
numberOfLines={2}
|
|
||||||
>
|
|
||||||
{title.title}
|
{title.title}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="mt-1.5 flex-row flex-wrap items-center gap-2">
|
<View className="mt-1.5 flex-row flex-wrap items-center gap-2">
|
||||||
<View className="rounded-full bg-title-accent px-2 py-0.5">
|
<View className="bg-title-accent rounded-full px-2 py-0.5">
|
||||||
<Text
|
<Text
|
||||||
maxFontSizeMultiplier={1.0}
|
maxFontSizeMultiplier={1.0}
|
||||||
className="font-medium font-sans text-title-accent-foreground text-xs"
|
className="text-title-accent-foreground font-sans text-xs font-medium"
|
||||||
>
|
>
|
||||||
{title.type === "movie" ? t`Movie` : t`TV`}
|
{title.type === "movie" ? t`Movie` : t`TV`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{year ? (
|
{year ? <Text className="text-sm text-white/70">{year}</Text> : null}
|
||||||
<Text className="text-sm text-white/70">{year}</Text>
|
|
||||||
) : null}
|
|
||||||
{title.contentRating ? (
|
{title.contentRating ? (
|
||||||
<Text className="text-white/50 text-xs">
|
<Text className="text-xs text-white/50">{title.contentRating}</Text>
|
||||||
{title.contentRating}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
) : null}
|
||||||
{title.voteAverage != null && title.voteAverage > 0 && (
|
{title.voteAverage != null && title.voteAverage > 0 && (
|
||||||
<View className="flex-row items-center gap-0.5">
|
<View className="flex-row items-center gap-0.5">
|
||||||
<ScaledIcon
|
<ScaledIcon icon={IconStarFilled} size={12} color={titleAccent} />
|
||||||
icon={IconStarFilled}
|
<Text className="text-title-accent text-xs">{title.voteAverage.toFixed(1)}</Text>
|
||||||
size={12}
|
|
||||||
color={titleAccent}
|
|
||||||
/>
|
|
||||||
<Text className="text-title-accent text-xs">
|
|
||||||
{title.voteAverage.toFixed(1)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -397,10 +362,7 @@ export default function TitleDetailScreen() {
|
|||||||
contentContainerStyle={titleGenresContentStyle}
|
contentContainerStyle={titleGenresContentStyle}
|
||||||
>
|
>
|
||||||
{title.genres.map((genre: string) => (
|
{title.genres.map((genre: string) => (
|
||||||
<View
|
<View key={genre} className="bg-secondary mr-2 rounded-full px-2.5 py-1">
|
||||||
key={genre}
|
|
||||||
className="mr-2 rounded-full bg-secondary px-2.5 py-1"
|
|
||||||
>
|
|
||||||
<Text className="text-muted-foreground text-xs">{genre}</Text>
|
<Text className="text-muted-foreground text-xs">{genre}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -409,10 +371,7 @@ export default function TitleDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Animated.View
|
<Animated.View entering={FadeInDown.duration(300).delay(200)} className="mt-4 px-4">
|
||||||
entering={FadeInDown.duration(300).delay(200)}
|
|
||||||
className="mt-4 px-4"
|
|
||||||
>
|
|
||||||
<View className="flex-row flex-wrap items-center gap-3">
|
<View className="flex-row flex-wrap items-center gap-3">
|
||||||
<StatusActionButton
|
<StatusActionButton
|
||||||
currentStatus={userInfo.data?.status ?? null}
|
currentStatus={userInfo.data?.status ?? null}
|
||||||
@@ -423,29 +382,21 @@ export default function TitleDetailScreen() {
|
|||||||
updateStatus.mutate({ id, status: null });
|
updateStatus.mutate({ id, status: null });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
isPending={
|
isPending={updateStatus.isPending || quickAddMutation.isPending || watchMovie.isPending}
|
||||||
updateStatus.isPending ||
|
|
||||||
quickAddMutation.isPending ||
|
|
||||||
watchMovie.isPending
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{title.type === "movie" && (
|
{title.type === "movie" && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => watchMovie.mutate({ id })}
|
onPress={() => watchMovie.mutate({ id })}
|
||||||
disabled={watchMovie.isPending}
|
disabled={watchMovie.isPending}
|
||||||
className="flex-row items-center gap-1.5 rounded-lg bg-title-accent px-4 py-2"
|
className="bg-title-accent flex-row items-center gap-1.5 rounded-lg px-4 py-2"
|
||||||
>
|
>
|
||||||
{watchMovie.isPending ? (
|
{watchMovie.isPending ? (
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ScaledIcon
|
<ScaledIcon icon={IconCheck} size={16} color={titleAccentForeground} />
|
||||||
icon={IconCheck}
|
<Text className="text-title-accent-foreground font-sans text-sm font-medium">
|
||||||
size={16}
|
|
||||||
color={titleAccentForeground}
|
|
||||||
/>
|
|
||||||
<Text className="font-medium font-sans text-sm text-title-accent-foreground">
|
|
||||||
<Trans>Mark Watched</Trans>
|
<Trans>Mark Watched</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
@@ -453,7 +404,7 @@ export default function TitleDetailScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View className="h-6 w-px bg-border/50" />
|
<View className="bg-border/50 h-6 w-px" />
|
||||||
|
|
||||||
<StarRating
|
<StarRating
|
||||||
rating={userInfo.data?.rating ?? 0}
|
rating={userInfo.data?.rating ?? 0}
|
||||||
@@ -474,16 +425,9 @@ export default function TitleDetailScreen() {
|
|||||||
|
|
||||||
{/* Availability */}
|
{/* Availability */}
|
||||||
{availability.length > 0 && (
|
{availability.length > 0 && (
|
||||||
<Animated.View
|
<Animated.View entering={FadeInDown.duration(300).delay(400)} className="mt-6">
|
||||||
entering={FadeInDown.duration(300).delay(400)}
|
|
||||||
className="mt-6"
|
|
||||||
>
|
|
||||||
<View className="px-4">
|
<View className="px-4">
|
||||||
<SectionHeader
|
<SectionHeader title={t`Where to Watch`} icon={providerIcon} iconColor={titleAccent} />
|
||||||
title={t`Where to Watch`}
|
|
||||||
icon={providerIcon}
|
|
||||||
iconColor={titleAccent}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
@@ -491,10 +435,7 @@ export default function TitleDetailScreen() {
|
|||||||
contentContainerStyle={titleAvailabilityContentStyle}
|
contentContainerStyle={titleAvailabilityContentStyle}
|
||||||
>
|
>
|
||||||
{availability.map((offer) => (
|
{availability.map((offer) => (
|
||||||
<View
|
<View key={`${offer.providerId}-${offer.offerType}`} className="items-center">
|
||||||
key={`${offer.providerId}-${offer.offerType}`}
|
|
||||||
className="items-center"
|
|
||||||
>
|
|
||||||
{offer.logoPath && (
|
{offer.logoPath && (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: offer.logoPath }}
|
source={{ uri: offer.logoPath }}
|
||||||
@@ -504,7 +445,7 @@ export default function TitleDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
<Text
|
<Text
|
||||||
maxFontSizeMultiplier={1.0}
|
maxFontSizeMultiplier={1.0}
|
||||||
className="mt-1 max-w-[60px] text-center text-muted-foreground text-xs"
|
className="text-muted-foreground mt-1 max-w-[60px] text-center text-xs"
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{offer.providerName}
|
{offer.providerName}
|
||||||
@@ -528,15 +469,8 @@ export default function TitleDetailScreen() {
|
|||||||
|
|
||||||
{/* Seasons & Episodes */}
|
{/* Seasons & Episodes */}
|
||||||
{title.type === "tv" && seasons.length > 0 && (
|
{title.type === "tv" && seasons.length > 0 && (
|
||||||
<Animated.View
|
<Animated.View entering={FadeInDown.duration(300).delay(400)} className="mt-6 px-4">
|
||||||
entering={FadeInDown.duration(300).delay(400)}
|
<SectionHeader title={t`Seasons`} icon={IconList} iconColor={titleAccent} />
|
||||||
className="mt-6 px-4"
|
|
||||||
>
|
|
||||||
<SectionHeader
|
|
||||||
title={t`Seasons`}
|
|
||||||
icon={IconList}
|
|
||||||
iconColor={titleAccent}
|
|
||||||
/>
|
|
||||||
{seasons.map((season) => (
|
{seasons.map((season) => (
|
||||||
<SeasonAccordion
|
<SeasonAccordion
|
||||||
key={season.id}
|
key={season.id}
|
||||||
@@ -550,16 +484,9 @@ export default function TitleDetailScreen() {
|
|||||||
|
|
||||||
{/* Cast */}
|
{/* Cast */}
|
||||||
{cast.length > 0 && (
|
{cast.length > 0 && (
|
||||||
<Animated.View
|
<Animated.View entering={FadeInDown.duration(300).delay(500)} className="mt-6">
|
||||||
entering={FadeInDown.duration(300).delay(500)}
|
|
||||||
className="mt-6"
|
|
||||||
>
|
|
||||||
<View className="px-4">
|
<View className="px-4">
|
||||||
<SectionHeader
|
<SectionHeader title={t`Cast`} icon={IconUsers} iconColor={titleAccent} />
|
||||||
title={t`Cast`}
|
|
||||||
icon={IconUsers}
|
|
||||||
iconColor={titleAccent}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
<FlashList
|
<FlashList
|
||||||
horizontal
|
horizontal
|
||||||
@@ -576,16 +503,9 @@ export default function TitleDetailScreen() {
|
|||||||
|
|
||||||
{/* Recommendations */}
|
{/* Recommendations */}
|
||||||
{recItems.length > 0 && (
|
{recItems.length > 0 && (
|
||||||
<Animated.View
|
<Animated.View entering={FadeInDown.duration(300).delay(600)} className="mt-6">
|
||||||
entering={FadeInDown.duration(300).delay(600)}
|
|
||||||
className="mt-6"
|
|
||||||
>
|
|
||||||
<View className="px-4">
|
<View className="px-4">
|
||||||
<SectionHeader
|
<SectionHeader title={t`More Like This`} icon={IconThumbUp} iconColor={titleAccent} />
|
||||||
title={t`More Like This`}
|
|
||||||
icon={IconThumbUp}
|
|
||||||
iconColor={titleAccent}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
<HorizontalPosterRow items={recItems} />
|
<HorizontalPosterRow items={recItems} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { StyleProp, ViewStyle } from "react-native";
|
|||||||
import { ScrollView } from "react-native";
|
import { ScrollView } from "react-native";
|
||||||
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
|
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
|
||||||
import Animated, { FadeIn } from "react-native-reanimated";
|
import Animated, { FadeIn } from "react-native-reanimated";
|
||||||
|
|
||||||
import { SofaLogo } from "@/components/ui/sofa-logo";
|
import { SofaLogo } from "@/components/ui/sofa-logo";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
@@ -13,12 +14,7 @@ interface AuthScreenProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthScreen({
|
export function AuthScreen({ title, subtitle, logoStyle, children }: AuthScreenProps) {
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
logoStyle,
|
|
||||||
children,
|
|
||||||
}: AuthScreenProps) {
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
|
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -34,10 +30,7 @@ export function AuthScreen({
|
|||||||
bounces={false}
|
bounces={false}
|
||||||
className="bg-background"
|
className="bg-background"
|
||||||
>
|
>
|
||||||
<Animated.View
|
<Animated.View entering={FadeIn.duration(400)} className="mb-4 items-center">
|
||||||
entering={FadeIn.duration(400)}
|
|
||||||
className="mb-4 items-center"
|
|
||||||
>
|
|
||||||
{logoStyle ? (
|
{logoStyle ? (
|
||||||
<Animated.View style={logoStyle}>
|
<Animated.View style={logoStyle}>
|
||||||
<SofaLogo size={48} />
|
<SofaLogo size={48} />
|
||||||
@@ -45,17 +38,10 @@ export function AuthScreen({
|
|||||||
) : (
|
) : (
|
||||||
<SofaLogo size={48} />
|
<SofaLogo size={48} />
|
||||||
)}
|
)}
|
||||||
<Text
|
<Text maxFontSizeMultiplier={1.3} className="font-display text-foreground mt-1 text-3xl">
|
||||||
maxFontSizeMultiplier={1.3}
|
|
||||||
className="mt-1 font-display text-3xl text-foreground"
|
|
||||||
>
|
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{subtitle && (
|
{subtitle && <Text className="text-muted-foreground mt-2 text-sm">{subtitle}</Text>}
|
||||||
<Text className="mt-2 text-muted-foreground text-sm">
|
|
||||||
{subtitle}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
{children}
|
{children}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import {
|
import { ScrollView, type ScrollViewProps, View, type ViewProps } from "react-native";
|
||||||
ScrollView,
|
|
||||||
type ScrollViewProps,
|
|
||||||
View,
|
|
||||||
type ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import Animated, { type AnimatedProps } from "react-native-reanimated";
|
import Animated, { type AnimatedProps } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
import { cn } from "@/utils/cn";
|
import { cn } from "@/utils/cn";
|
||||||
|
|
||||||
const AnimatedView = Animated.createAnimatedComponent(View);
|
const AnimatedView = Animated.createAnimatedComponent(View);
|
||||||
@@ -28,7 +24,7 @@ export function Container({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedView
|
<AnimatedView
|
||||||
className={cn("flex-1 bg-background", className)}
|
className={cn("bg-background flex-1", className)}
|
||||||
{...props}
|
{...props}
|
||||||
style={[{ paddingBottom: insets.bottom }, props.style]}
|
style={[{ paddingBottom: insets.bottom }, props.style]}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from "expo-router";
|
|||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { GestureDetector } from "react-native-gesture-handler";
|
import { GestureDetector } from "react-native-gesture-handler";
|
||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
|
|
||||||
import { Image } from "@/components/ui/image";
|
import { Image } from "@/components/ui/image";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { usePressAnimation } from "@/hooks/use-press-animation";
|
import { usePressAnimation } from "@/hooks/use-press-animation";
|
||||||
@@ -34,21 +35,16 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
|||||||
const nextEpLabel = item.nextEpisode
|
const nextEpLabel = item.nextEpisode
|
||||||
? `Next: Season ${item.nextEpisode.seasonNumber} Episode ${item.nextEpisode.episodeNumber}`
|
? `Next: Season ${item.nextEpisode.seasonNumber} Episode ${item.nextEpisode.episodeNumber}`
|
||||||
: undefined;
|
: undefined;
|
||||||
const cardLabel = [item.title.title, progressLabel, nextEpLabel]
|
const cardLabel = [item.title.title, progressLabel, nextEpLabel].filter(Boolean).join(", ");
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureDetector gesture={tapGesture}>
|
<GestureDetector gesture={tapGesture}>
|
||||||
<Animated.View className="w-[200px]" style={animatedStyle}>
|
<Animated.View className="w-[200px]" style={animatedStyle}>
|
||||||
<Link href={`/title/${item.title.id}` as `/title/${string}`} asChild>
|
<Link href={`/title/${item.title.id}` as `/title/${string}`} asChild>
|
||||||
<Link.Trigger withAppleZoom>
|
<Link.Trigger withAppleZoom>
|
||||||
<Pressable
|
<Pressable accessibilityRole="button" accessibilityLabel={cardLabel}>
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={cardLabel}
|
|
||||||
>
|
|
||||||
<View
|
<View
|
||||||
className="overflow-hidden rounded-[12px] border bg-card"
|
className="bg-card overflow-hidden rounded-[12px] border"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "rgba(255,255,255,0.06)",
|
borderColor: "rgba(255,255,255,0.06)",
|
||||||
borderCurve: "continuous",
|
borderCurve: "continuous",
|
||||||
@@ -58,13 +54,9 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
|||||||
{(item.nextEpisode?.stillPath || item.title.backdropPath) && (
|
{(item.nextEpisode?.stillPath || item.title.backdropPath) && (
|
||||||
<Image
|
<Image
|
||||||
source={{
|
source={{
|
||||||
uri: (item.nextEpisode?.stillPath ??
|
uri: (item.nextEpisode?.stillPath ?? item.title.backdropPath) as string,
|
||||||
item.title.backdropPath) as string,
|
|
||||||
}}
|
}}
|
||||||
thumbHash={
|
thumbHash={item.nextEpisode?.stillThumbHash ?? item.title.backdropThumbHash}
|
||||||
item.nextEpisode?.stillThumbHash ??
|
|
||||||
item.title.backdropThumbHash
|
|
||||||
}
|
|
||||||
recyclingKey={item.title.id}
|
recyclingKey={item.title.id}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
@@ -78,10 +70,9 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
|||||||
<View className="absolute right-2.5 bottom-3 left-2.5">
|
<View className="absolute right-2.5 bottom-3 left-2.5">
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
className="font-medium font-sans text-white/80 text-xs"
|
className="font-sans text-xs font-medium text-white/80"
|
||||||
>
|
>
|
||||||
S{item.nextEpisode.seasonNumber} E
|
S{item.nextEpisode.seasonNumber} E{item.nextEpisode.episodeNumber}
|
||||||
{item.nextEpisode.episodeNumber}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -90,7 +81,7 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
|||||||
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
|
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className="h-full bg-status-watching"
|
className="bg-status-watching h-full"
|
||||||
style={{
|
style={{
|
||||||
width: `${item.totalEpisodes > 0 ? (item.watchedEpisodes / item.totalEpisodes) * 100 : 0}%`,
|
width: `${item.totalEpisodes > 0 ? (item.watchedEpisodes / item.totalEpisodes) * 100 : 0}%`,
|
||||||
}}
|
}}
|
||||||
@@ -98,17 +89,11 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className="p-2.5">
|
<View className="p-2.5">
|
||||||
<Text
|
<Text numberOfLines={1} className="text-foreground font-sans text-sm font-medium">
|
||||||
numberOfLines={1}
|
|
||||||
className="font-medium font-sans text-foreground text-sm"
|
|
||||||
>
|
|
||||||
{item.title.title}
|
{item.title.title}
|
||||||
</Text>
|
</Text>
|
||||||
{item.nextEpisode && (
|
{item.nextEpisode && (
|
||||||
<Text
|
<Text numberOfLines={1} className="text-muted-foreground mt-0.5 text-xs">
|
||||||
numberOfLines={1}
|
|
||||||
className="mt-0.5 text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
{item.nextEpisode.name}
|
{item.nextEpisode.name}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -121,9 +106,7 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
|||||||
<Link.MenuAction
|
<Link.MenuAction
|
||||||
title={t`Mark as Completed`}
|
title={t`Mark as Completed`}
|
||||||
icon="checkmark.circle"
|
icon="checkmark.circle"
|
||||||
onPress={() =>
|
onPress={() => titleActions.markCompleted(item.title.id, item.title.title)}
|
||||||
titleActions.markCompleted(item.title.id, item.title.title)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Link.MenuAction
|
<Link.MenuAction
|
||||||
title={t`Remove from Library`}
|
title={t`Remove from Library`}
|
||||||
|
|||||||
@@ -30,13 +30,8 @@ export function HorizontalPosterRow({
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { quickAdd } = useTitleActions();
|
const { quickAdd } = useTitleActions();
|
||||||
const handleQuickAdd = useCallback(
|
const handleQuickAdd = useCallback((id: string) => quickAdd.mutate({ id }), [quickAdd]);
|
||||||
(id: string) => quickAdd.mutate({ id }),
|
const addingKey = quickAdd.isPending ? (quickAdd.variables?.id ?? null) : null;
|
||||||
[quickAdd],
|
|
||||||
);
|
|
||||||
const addingKey = quickAdd.isPending
|
|
||||||
? (quickAdd.variables?.id ?? null)
|
|
||||||
: null;
|
|
||||||
const keyExtractor = useCallback((item: PosterRowItem) => item.id, []);
|
const keyExtractor = useCallback((item: PosterRowItem) => item.id, []);
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item }: { item: PosterRowItem }) => (
|
({ item }: { item: PosterRowItem }) => (
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
export function StatsCard({
|
export function StatsCard({ label, value }: { label: string; value: number | undefined }) {
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: number | undefined;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="min-w-[120px] rounded-xl border border-white/[0.06] bg-card px-4 py-3"
|
className="bg-card min-w-[120px] rounded-xl border border-white/[0.06] px-4 py-3"
|
||||||
style={{ borderCurve: "continuous" }}
|
style={{ borderCurve: "continuous" }}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
className="font-bold font-sans text-2xl text-primary"
|
className="text-primary font-sans text-2xl font-bold"
|
||||||
style={{ fontVariant: ["tabular-nums"] }}
|
style={{ fontVariant: ["tabular-nums"] }}
|
||||||
>
|
>
|
||||||
{value ?? "—"}
|
{value ?? "—"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="mt-0.5 text-muted-foreground text-xs">{label}</Text>
|
<Text className="text-muted-foreground mt-0.5 text-xs">{label}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Icon } from "@tabler/icons-react-native";
|
|||||||
import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
|
import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
HorizontalPosterRow,
|
HorizontalPosterRow,
|
||||||
type PosterRowItem,
|
type PosterRowItem,
|
||||||
@@ -68,28 +69,25 @@ export function FilterableTitleRow({
|
|||||||
);
|
);
|
||||||
const discoverStatuses = useMemo(
|
const discoverStatuses = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.assign(
|
Object.assign({}, ...(discover.data?.pages.map((p) => p.userStatuses) ?? [])) as Record<
|
||||||
{},
|
string,
|
||||||
...(discover.data?.pages.map((p) => p.userStatuses) ?? []),
|
TitleStatus
|
||||||
) as Record<string, TitleStatus>,
|
>,
|
||||||
[discover.data?.pages],
|
[discover.data?.pages],
|
||||||
);
|
);
|
||||||
const discoverProgress = useMemo(
|
const discoverProgress = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.assign(
|
Object.assign({}, ...(discover.data?.pages.map((p) => p.episodeProgress) ?? [])) as Record<
|
||||||
{},
|
string,
|
||||||
...(discover.data?.pages.map((p) => p.episodeProgress) ?? []),
|
{ watched: number; total: number }
|
||||||
) as Record<string, { watched: number; total: number }>,
|
>,
|
||||||
[discover.data?.pages],
|
[discover.data?.pages],
|
||||||
);
|
);
|
||||||
|
|
||||||
const rawItems = selectedGenre === null ? defaultItems : discoverItems;
|
const rawItems = selectedGenre === null ? defaultItems : discoverItems;
|
||||||
const userStatuses =
|
const userStatuses = selectedGenre === null ? defaultUserStatuses : discoverStatuses;
|
||||||
selectedGenre === null ? defaultUserStatuses : discoverStatuses;
|
const episodeProgress = selectedGenre === null ? defaultEpisodeProgress : discoverProgress;
|
||||||
const episodeProgress =
|
const showLoading = isLoading || (selectedGenre !== null && discover.isPending);
|
||||||
selectedGenre === null ? defaultEpisodeProgress : discoverProgress;
|
|
||||||
const showLoading =
|
|
||||||
isLoading || (selectedGenre !== null && discover.isPending);
|
|
||||||
|
|
||||||
// Map items into PosterRowItem shape with status/progress resolved
|
// Map items into PosterRowItem shape with status/progress resolved
|
||||||
const items = useMemo<PosterRowItem[]>(
|
const items = useMemo<PosterRowItem[]>(
|
||||||
@@ -125,9 +123,7 @@ export function FilterableTitleRow({
|
|||||||
key={genre.id}
|
key={genre.id}
|
||||||
label={genre.name}
|
label={genre.name}
|
||||||
isSelected={selectedGenre === genre.id}
|
isSelected={selectedGenre === genre.id}
|
||||||
onPress={() =>
|
onPress={() => setSelectedGenre(selectedGenre === genre.id ? null : genre.id)}
|
||||||
setSelectedGenre(selectedGenre === genre.id ? null : genre.id)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useLingui } from "@lingui/react/macro";
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { Pressable } from "react-native";
|
import { Pressable } from "react-native";
|
||||||
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
@@ -23,14 +24,12 @@ export function GenreChip({
|
|||||||
accessibilityLabel={label}
|
accessibilityLabel={label}
|
||||||
accessibilityState={{ selected: isSelected }}
|
accessibilityState={{ selected: isSelected }}
|
||||||
accessibilityHint={
|
accessibilityHint={
|
||||||
isSelected
|
isSelected ? t`Double tap to clear this filter` : t`Double tap to filter by ${label}`
|
||||||
? t`Double tap to clear this filter`
|
|
||||||
: t`Double tap to filter by ${label}`
|
|
||||||
}
|
}
|
||||||
className={`mr-2 rounded-full px-3 py-1.5 ${isSelected ? "bg-primary" : "bg-secondary"}`}
|
className={`mr-2 rounded-full px-3 py-1.5 ${isSelected ? "bg-primary" : "bg-secondary"}`}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
className={`font-medium font-sans text-xs ${isSelected ? "text-primary-foreground" : "text-foreground"}`}
|
className={`font-sans text-xs font-medium ${isSelected ? "text-primary-foreground" : "text-foreground"}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Pressable, View } from "react-native";
|
|||||||
import { GestureDetector } from "react-native-gesture-handler";
|
import { GestureDetector } from "react-native-gesture-handler";
|
||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import { Image } from "@/components/ui/image";
|
import { Image } from "@/components/ui/image";
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
@@ -76,36 +77,22 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
|||||||
padding: 16,
|
padding: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text className="font-display text-2xl text-white" numberOfLines={2}>
|
||||||
className="font-display text-2xl text-white"
|
|
||||||
numberOfLines={2}
|
|
||||||
>
|
|
||||||
{item.title}
|
{item.title}
|
||||||
</Text>
|
</Text>
|
||||||
{item.overview ? (
|
{item.overview ? (
|
||||||
<Text
|
<Text className="mt-1 text-xs text-white/70" numberOfLines={2}>
|
||||||
className="mt-1 text-white/70 text-xs"
|
|
||||||
numberOfLines={2}
|
|
||||||
>
|
|
||||||
{item.overview}
|
{item.overview}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<View className="mt-2 flex-row items-center gap-2">
|
<View className="mt-2 flex-row items-center gap-2">
|
||||||
{item.voteAverage != null && item.voteAverage > 0 && (
|
{item.voteAverage != null && item.voteAverage > 0 && (
|
||||||
<View className="flex-row items-center gap-1">
|
<View className="flex-row items-center gap-1">
|
||||||
<ScaledIcon
|
<ScaledIcon icon={IconStarFilled} size={12} color={primary} />
|
||||||
icon={IconStarFilled}
|
<Text className="text-primary text-xs">{item.voteAverage.toFixed(1)}</Text>
|
||||||
size={12}
|
|
||||||
color={primary}
|
|
||||||
/>
|
|
||||||
<Text className="text-primary text-xs">
|
|
||||||
{item.voteAverage.toFixed(1)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<Text className="text-white/50 text-xs">
|
<Text className="text-xs text-white/50">{item.releaseDate?.slice(0, 4)}</Text>
|
||||||
{item.releaseDate?.slice(0, 4)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</GlassView>
|
</GlassView>
|
||||||
) : (
|
) : (
|
||||||
@@ -115,36 +102,24 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
|||||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||||
/>
|
/>
|
||||||
<View className="flex-1 justify-end p-4">
|
<View className="flex-1 justify-end p-4">
|
||||||
<Text
|
<Text className="font-display text-2xl text-white" numberOfLines={2}>
|
||||||
className="font-display text-2xl text-white"
|
|
||||||
numberOfLines={2}
|
|
||||||
>
|
|
||||||
{item.title}
|
{item.title}
|
||||||
</Text>
|
</Text>
|
||||||
{item.overview ? (
|
{item.overview ? (
|
||||||
<Text
|
<Text className="mt-1 text-xs text-white/70" numberOfLines={2}>
|
||||||
className="mt-1 text-white/70 text-xs"
|
|
||||||
numberOfLines={2}
|
|
||||||
>
|
|
||||||
{item.overview}
|
{item.overview}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<View className="mt-2 flex-row items-center gap-2">
|
<View className="mt-2 flex-row items-center gap-2">
|
||||||
{item.voteAverage != null && item.voteAverage > 0 && (
|
{item.voteAverage != null && item.voteAverage > 0 && (
|
||||||
<View className="flex-row items-center gap-1">
|
<View className="flex-row items-center gap-1">
|
||||||
<ScaledIcon
|
<ScaledIcon icon={IconStarFilled} size={12} color={primary} />
|
||||||
icon={IconStarFilled}
|
|
||||||
size={12}
|
|
||||||
color={primary}
|
|
||||||
/>
|
|
||||||
<Text className="text-primary text-xs">
|
<Text className="text-primary text-xs">
|
||||||
{item.voteAverage.toFixed(1)}
|
{item.voteAverage.toFixed(1)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<Text className="text-white/50 text-xs">
|
<Text className="text-xs text-white/50">{item.releaseDate?.slice(0, 4)}</Text>
|
||||||
{item.releaseDate?.slice(0, 4)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useLingui } from "@lingui/react/macro";
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { Alert, Pressable, View } from "react-native";
|
import { Alert, Pressable, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
|
||||||
import { Image } from "@/components/ui/image";
|
import { Image } from "@/components/ui/image";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
@@ -26,10 +27,7 @@ export function HeaderAvatar() {
|
|||||||
accessibilityLabel="User menu"
|
accessibilityLabel="User menu"
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
<View
|
<View className="size-8 overflow-hidden rounded-full" accessible={false}>
|
||||||
className="size-8 overflow-hidden rounded-full"
|
|
||||||
accessible={false}
|
|
||||||
>
|
|
||||||
{user.image ? (
|
{user.image ? (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: user.image }}
|
source={{ uri: user.image }}
|
||||||
@@ -38,10 +36,10 @@ export function HeaderAvatar() {
|
|||||||
accessible={false}
|
accessible={false}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View className="flex-1 items-center justify-center bg-primary/[0.08]">
|
<View className="bg-primary/[0.08] flex-1 items-center justify-center">
|
||||||
<Text
|
<Text
|
||||||
maxFontSizeMultiplier={1.0}
|
maxFontSizeMultiplier={1.0}
|
||||||
className="font-display font-medium text-primary text-sm"
|
className="font-display text-primary text-sm font-medium"
|
||||||
>
|
>
|
||||||
{user.name?.charAt(0)?.toUpperCase() ?? "?"}
|
{user.name?.charAt(0)?.toUpperCase() ?? "?"}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -52,14 +50,8 @@ export function HeaderAvatar() {
|
|||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
<DropdownMenu.Content>
|
<DropdownMenu.Content>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item key="settings" onSelect={() => navigate("/(tabs)/(settings)")}>
|
||||||
key="settings"
|
<DropdownMenu.ItemIcon ios={{ name: "gear" }} androidIconName="ic_menu_preferences" />
|
||||||
onSelect={() => navigate("/(tabs)/(settings)")}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIcon
|
|
||||||
ios={{ name: "gear" }}
|
|
||||||
androidIconName="ic_menu_preferences"
|
|
||||||
/>
|
|
||||||
<DropdownMenu.ItemTitle>{t`Settings`}</DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>{t`Settings`}</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import {
|
import { NativeTabs, type NativeTabsProps } from "expo-router/unstable-native-tabs";
|
||||||
NativeTabs,
|
|
||||||
type NativeTabsProps,
|
|
||||||
} from "expo-router/unstable-native-tabs";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useCSSVariable, useResolveClassNames } from "uniwind";
|
import { useCSSVariable, useResolveClassNames } from "uniwind";
|
||||||
|
|
||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
export function NativeTabBar({
|
export function NativeTabBar({ showSettingsBadge }: { showSettingsBadge: boolean }) {
|
||||||
showSettingsBadge,
|
|
||||||
}: {
|
|
||||||
showSettingsBadge: boolean;
|
|
||||||
}) {
|
|
||||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
const surfaceColor = useCSSVariable("--color-card") as string;
|
const surfaceColor = useCSSVariable("--color-card") as string;
|
||||||
const rippleColor = useCSSVariable("--color-secondary") as string;
|
const rippleColor = useCSSVariable("--color-secondary") as string;
|
||||||
const labelTextStyle = useResolveClassNames("font-medium font-sans text-xs");
|
const labelTextStyle = useResolveClassNames("font-sans text-xs font-medium");
|
||||||
|
|
||||||
const screenListeners = useMemo<NativeTabsProps["screenListeners"]>(
|
const screenListeners = useMemo<NativeTabsProps["screenListeners"]>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -58,9 +52,7 @@ export function NativeTabBar({
|
|||||||
<NativeTabs.Trigger name="(settings)" disableTransparentOnScrollEdge>
|
<NativeTabs.Trigger name="(settings)" disableTransparentOnScrollEdge>
|
||||||
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
||||||
<NativeTabs.Trigger.Icon md="settings" />
|
<NativeTabs.Trigger.Icon md="settings" />
|
||||||
{showSettingsBadge ? (
|
{showSettingsBadge ? <NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge> : null}
|
||||||
<NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge>
|
|
||||||
) : null}
|
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ import * as Haptics from "expo-haptics";
|
|||||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
export function NativeTabBar({
|
export function NativeTabBar({ showSettingsBadge }: { showSettingsBadge: boolean }) {
|
||||||
showSettingsBadge,
|
|
||||||
}: {
|
|
||||||
showSettingsBadge: boolean;
|
|
||||||
}) {
|
|
||||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||||
|
|
||||||
@@ -37,15 +33,9 @@ export function NativeTabBar({
|
|||||||
<NativeTabs.Trigger name="(settings)" disableTransparentOnScrollEdge>
|
<NativeTabs.Trigger name="(settings)" disableTransparentOnScrollEdge>
|
||||||
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
||||||
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
|
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
|
||||||
{showSettingsBadge ? (
|
{showSettingsBadge ? <NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge> : null}
|
||||||
<NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge>
|
|
||||||
) : null}
|
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
<NativeTabs.Trigger
|
<NativeTabs.Trigger name="(search)" role="search" disableTransparentOnScrollEdge>
|
||||||
name="(search)"
|
|
||||||
role="search"
|
|
||||||
disableTransparentOnScrollEdge
|
|
||||||
>
|
|
||||||
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
|
||||||
<NativeTabs.Trigger.Icon sf="magnifyingglass" md="search" />
|
<NativeTabs.Trigger.Icon sf="magnifyingglass" md="search" />
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
|
|||||||
@@ -1,26 +1,17 @@
|
|||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useCSSVariable, useResolveClassNames } from "uniwind";
|
import { useCSSVariable, useResolveClassNames } from "uniwind";
|
||||||
|
|
||||||
import { HeaderAvatar } from "@/components/header-avatar";
|
import { HeaderAvatar } from "@/components/header-avatar";
|
||||||
|
|
||||||
export function TabStack({
|
export function TabStack({ title, children }: { title?: string; children?: ReactNode }) {
|
||||||
title,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title?: string;
|
|
||||||
children?: ReactNode;
|
|
||||||
}) {
|
|
||||||
const contentStyle = useResolveClassNames("bg-background");
|
const contentStyle = useResolveClassNames("bg-background");
|
||||||
const tintColor = useCSSVariable("--color-primary") as string;
|
const tintColor = useCSSVariable("--color-primary") as string;
|
||||||
const backgroundColor = useCSSVariable("--color-background") as string;
|
const backgroundColor = useCSSVariable("--color-background") as string;
|
||||||
const iosHeaderLargeTitleStyle = useResolveClassNames(
|
const iosHeaderLargeTitleStyle = useResolveClassNames("font-display text-foreground");
|
||||||
"font-display text-foreground",
|
const iosHeaderTitleStyle = useResolveClassNames("font-display text-foreground text-lg");
|
||||||
);
|
|
||||||
const iosHeaderTitleStyle = useResolveClassNames(
|
|
||||||
"font-display text-foreground text-lg",
|
|
||||||
);
|
|
||||||
const androidHeaderTitleStyle = useResolveClassNames(
|
const androidHeaderTitleStyle = useResolveClassNames(
|
||||||
"font-sans font-semibold text-foreground text-lg",
|
"text-foreground font-sans text-lg font-semibold",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (process.env.EXPO_OS === "ios") {
|
if (process.env.EXPO_OS === "ios") {
|
||||||
@@ -80,9 +71,7 @@ export function TabStack({
|
|||||||
shadowColor: "transparent",
|
shadowColor: "transparent",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen.Title
|
<Stack.Screen.Title style={androidHeaderTitleStyle as Record<string, unknown>}>
|
||||||
style={androidHeaderTitleStyle as Record<string, unknown>}
|
|
||||||
>
|
|
||||||
{title}
|
{title}
|
||||||
</Stack.Screen.Title>
|
</Stack.Screen.Title>
|
||||||
</Stack.Screen>
|
</Stack.Screen>
|
||||||
|
|||||||
@@ -15,12 +15,10 @@ import { useCallback } from "react";
|
|||||||
import { Alert, Pressable, View } from "react-native";
|
import { Alert, Pressable, View } from "react-native";
|
||||||
import Animated, { FadeIn } from "react-native-reanimated";
|
import Animated, { FadeIn } from "react-native-reanimated";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import { RecentlyViewedRowContent } from "@/components/search/recently-viewed-row-content";
|
import { RecentlyViewedRowContent } from "@/components/search/recently-viewed-row-content";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import {
|
import { type RecentlyViewedItem, useRecentlyViewed } from "@/lib/recently-viewed";
|
||||||
type RecentlyViewedItem,
|
|
||||||
useRecentlyViewed,
|
|
||||||
} from "@/lib/recently-viewed";
|
|
||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
export function RecentlyViewedList() {
|
export function RecentlyViewedList() {
|
||||||
@@ -56,28 +54,21 @@ export function RecentlyViewedList() {
|
|||||||
|
|
||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
Alert.alert(
|
Alert.alert(t`Clear Recently Viewed?`, t`This will remove all items from your history.`, [
|
||||||
t`Clear Recently Viewed?`,
|
{ text: t`Cancel`, style: "cancel" },
|
||||||
t`This will remove all items from your history.`,
|
{
|
||||||
[
|
text: t`Clear`,
|
||||||
{ text: t`Cancel`, style: "cancel" },
|
style: "destructive",
|
||||||
{
|
onPress: () => clearAll(),
|
||||||
text: t`Clear`,
|
},
|
||||||
style: "destructive",
|
]);
|
||||||
onPress: () => clearAll(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}, [clearAll, t]);
|
}, [clearAll, t]);
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View entering={FadeIn.duration(400)} className="flex-1 items-center justify-center">
|
||||||
entering={FadeIn.duration(400)}
|
|
||||||
className="flex-1 items-center justify-center"
|
|
||||||
>
|
|
||||||
<IconSearch size={64} color={mutedForeground} />
|
<IconSearch size={64} color={mutedForeground} />
|
||||||
<Text className="mt-3 text-base text-muted-foreground">
|
<Text className="text-muted-foreground mt-3 text-base">
|
||||||
<Trans>Search for movies, shows, or people</Trans>
|
<Trans>Search for movies, shows, or people</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -87,9 +78,7 @@ export function RecentlyViewedList() {
|
|||||||
return (
|
return (
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Host colorScheme="dark" style={{ flex: 1 }}>
|
<Host colorScheme="dark" style={{ flex: 1 }}>
|
||||||
<List
|
<List modifiers={[listStyle("plain"), scrollContentBackground("hidden")]}>
|
||||||
modifiers={[listStyle("plain"), scrollContentBackground("hidden")]}
|
|
||||||
>
|
|
||||||
<VStack
|
<VStack
|
||||||
modifiers={[
|
modifiers={[
|
||||||
listRowBackground("clear"),
|
listRowBackground("clear"),
|
||||||
@@ -99,7 +88,7 @@ export function RecentlyViewedList() {
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<RNHostView matchContents>
|
<RNHostView matchContents>
|
||||||
<View className="flex-row items-center justify-between bg-background px-2">
|
<View className="bg-background flex-row items-center justify-between px-2">
|
||||||
<View className="flex-row items-center gap-2">
|
<View className="flex-row items-center gap-2">
|
||||||
<IconHistory size={20} color={primaryColor} />
|
<IconHistory size={20} color={primaryColor} />
|
||||||
<Text className="font-display text-foreground text-xl tracking-tight">
|
<Text className="font-display text-foreground text-xl tracking-tight">
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ import { useCallback } from "react";
|
|||||||
import { Alert, Pressable, View } from "react-native";
|
import { Alert, Pressable, View } from "react-native";
|
||||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import { RecentlyViewedRow } from "@/components/search/recently-viewed-row";
|
import { RecentlyViewedRow } from "@/components/search/recently-viewed-row";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import {
|
import { type RecentlyViewedItem, useRecentlyViewed } from "@/lib/recently-viewed";
|
||||||
type RecentlyViewedItem,
|
|
||||||
useRecentlyViewed,
|
|
||||||
} from "@/lib/recently-viewed";
|
|
||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
export function RecentlyViewedList() {
|
export function RecentlyViewedList() {
|
||||||
@@ -30,18 +28,14 @@ export function RecentlyViewedList() {
|
|||||||
|
|
||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
Alert.alert(
|
Alert.alert(t`Clear Recently Viewed?`, t`This will remove all items from your history.`, [
|
||||||
t`Clear Recently Viewed?`,
|
{ text: t`Cancel`, style: "cancel" },
|
||||||
t`This will remove all items from your history.`,
|
{
|
||||||
[
|
text: t`Clear`,
|
||||||
{ text: t`Cancel`, style: "cancel" },
|
style: "destructive",
|
||||||
{
|
onPress: () => clearAll(),
|
||||||
text: t`Clear`,
|
},
|
||||||
style: "destructive",
|
]);
|
||||||
onPress: () => clearAll(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}, [clearAll, t]);
|
}, [clearAll, t]);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
@@ -55,12 +49,9 @@ export function RecentlyViewedList() {
|
|||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View entering={FadeIn.duration(400)} className="flex-1 items-center justify-center">
|
||||||
entering={FadeIn.duration(400)}
|
|
||||||
className="flex-1 items-center justify-center"
|
|
||||||
>
|
|
||||||
<IconSearch size={64} color={mutedForeground} />
|
<IconSearch size={64} color={mutedForeground} />
|
||||||
<Text className="mt-3 text-base text-muted-foreground">
|
<Text className="text-muted-foreground mt-3 text-base">
|
||||||
<Trans>Search for movies, shows, or people</Trans>
|
<Trans>Search for movies, shows, or people</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useLingui } from "@lingui/react/macro";
|
|||||||
import { IconDeviceTv, IconMovie, IconUser } from "@tabler/icons-react-native";
|
import { IconDeviceTv, IconMovie, IconUser } from "@tabler/icons-react-native";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import { Image } from "@/components/ui/image";
|
import { Image } from "@/components/ui/image";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import type { RecentlyViewedItem } from "@/lib/recently-viewed";
|
import type { RecentlyViewedItem } from "@/lib/recently-viewed";
|
||||||
@@ -12,9 +13,7 @@ const TypeIcon = {
|
|||||||
person: IconUser,
|
person: IconUser,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function getTypeLabel(
|
function getTypeLabel(t: (strings: TemplateStringsArray, ...values: unknown[]) => string) {
|
||||||
t: (strings: TemplateStringsArray, ...values: unknown[]) => string,
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
movie: t`Movie`,
|
movie: t`Movie`,
|
||||||
tv: t`TV`,
|
tv: t`TV`,
|
||||||
@@ -22,11 +21,7 @@ function getTypeLabel(
|
|||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RecentlyViewedRowContent({
|
export function RecentlyViewedRowContent({ item }: { item: RecentlyViewedItem }) {
|
||||||
item,
|
|
||||||
}: {
|
|
||||||
item: RecentlyViewedItem;
|
|
||||||
}) {
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
||||||
const Icon = TypeIcon[item.type];
|
const Icon = TypeIcon[item.type];
|
||||||
@@ -34,7 +29,7 @@ export function RecentlyViewedRowContent({
|
|||||||
return (
|
return (
|
||||||
<View className="flex-row items-center">
|
<View className="flex-row items-center">
|
||||||
<View
|
<View
|
||||||
className="mr-3 overflow-hidden bg-secondary"
|
className="bg-secondary mr-3 overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
width: 48,
|
width: 48,
|
||||||
height: item.type === "person" ? 48 : 72,
|
height: item.type === "person" ? 48 : 72,
|
||||||
@@ -57,25 +52,17 @@ export function RecentlyViewedRowContent({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text
|
<Text numberOfLines={1} className="text-foreground font-sans text-base font-medium">
|
||||||
numberOfLines={1}
|
|
||||||
className="font-medium font-sans text-base text-foreground"
|
|
||||||
>
|
|
||||||
{item.title}
|
{item.title}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="mt-1 flex-row items-center gap-2">
|
<View className="mt-1 flex-row items-center gap-2">
|
||||||
<View className="rounded-full bg-secondary px-2 py-0.5">
|
<View className="bg-secondary rounded-full px-2 py-0.5">
|
||||||
<Text
|
<Text maxFontSizeMultiplier={1.0} className="text-muted-foreground text-xs">
|
||||||
maxFontSizeMultiplier={1.0}
|
|
||||||
className="text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
{getTypeLabel(t)[item.type]}
|
{getTypeLabel(t)[item.type]}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{item.subtitle ? (
|
{item.subtitle ? (
|
||||||
<Text className="text-muted-foreground text-xs">
|
<Text className="text-muted-foreground text-xs">{item.subtitle}</Text>
|
||||||
{item.subtitle}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link } from "expo-router";
|
import { Link } from "expo-router";
|
||||||
import { memo, useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
import { Pressable } from "react-native";
|
import { Pressable } from "react-native";
|
||||||
|
|
||||||
import { RecentlyViewedRowContent } from "@/components/search/recently-viewed-row-content";
|
import { RecentlyViewedRowContent } from "@/components/search/recently-viewed-row-content";
|
||||||
import { SwipeableRow } from "@/components/ui/swipeable-row";
|
import { SwipeableRow } from "@/components/ui/swipeable-row";
|
||||||
import type { RecentlyViewedItem } from "@/lib/recently-viewed";
|
import type { RecentlyViewedItem } from "@/lib/recently-viewed";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Link } from "expo-router";
|
|||||||
import { memo, useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import { Image } from "@/components/ui/image";
|
import { Image } from "@/components/ui/image";
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
@@ -30,17 +31,8 @@ export const SearchResultRow = memo(function SearchResultRow({
|
|||||||
const primary = useCSSVariable("--color-primary") as string;
|
const primary = useCSSVariable("--color-primary") as string;
|
||||||
const imageSrc = item.posterPath ?? item.profilePath;
|
const imageSrc = item.posterPath ?? item.profilePath;
|
||||||
|
|
||||||
const typeLabel =
|
const typeLabel = item.type === "movie" ? t`Movie` : item.type === "tv" ? t`TV show` : t`Person`;
|
||||||
item.type === "movie"
|
const accessibilityLabel = [item.title, typeLabel, item.releaseDate?.slice(0, 4)]
|
||||||
? t`Movie`
|
|
||||||
: item.type === "tv"
|
|
||||||
? t`TV show`
|
|
||||||
: t`Person`;
|
|
||||||
const accessibilityLabel = [
|
|
||||||
item.title,
|
|
||||||
typeLabel,
|
|
||||||
item.releaseDate?.slice(0, 4),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
@@ -61,7 +53,7 @@ export const SearchResultRow = memo(function SearchResultRow({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className="mr-3 overflow-hidden bg-secondary"
|
className="bg-secondary mr-3 overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
width: 44,
|
width: 44,
|
||||||
height: item.type === "person" ? 44 : 66,
|
height: item.type === "person" ? 44 : 66,
|
||||||
@@ -80,29 +72,17 @@ export const SearchResultRow = memo(function SearchResultRow({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text
|
<Text numberOfLines={1} className="text-foreground font-sans text-base font-medium">
|
||||||
numberOfLines={1}
|
|
||||||
className="font-medium font-sans text-base text-foreground"
|
|
||||||
>
|
|
||||||
{item.title}
|
{item.title}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="mt-1 flex-row items-center gap-2">
|
<View className="mt-1 flex-row items-center gap-2">
|
||||||
<View className="rounded-full bg-secondary px-2 py-0.5">
|
<View className="bg-secondary rounded-full px-2 py-0.5">
|
||||||
<Text
|
<Text maxFontSizeMultiplier={1.0} className="text-muted-foreground text-xs">
|
||||||
maxFontSizeMultiplier={1.0}
|
{item.type === "movie" ? t`Movie` : item.type === "tv" ? t`TV` : t`Person`}
|
||||||
className="text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
{item.type === "movie"
|
|
||||||
? t`Movie`
|
|
||||||
: item.type === "tv"
|
|
||||||
? t`TV`
|
|
||||||
: t`Person`}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{item.releaseDate ? (
|
{item.releaseDate ? (
|
||||||
<Text className="text-muted-foreground text-xs">
|
<Text className="text-muted-foreground text-xs">{item.releaseDate.slice(0, 4)}</Text>
|
||||||
{item.releaseDate.slice(0, 4)}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -111,7 +91,7 @@ export const SearchResultRow = memo(function SearchResultRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex-row items-center border-border border-b px-4 py-3"
|
className="border-border flex-row items-center border-b px-4 py-3"
|
||||||
style={{
|
style={{
|
||||||
borderBottomWidth: 0.5,
|
borderBottomWidth: 0.5,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import Animated, {
|
|||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import type { IntegrationConfig } from "@/components/settings/integration-configs";
|
import type { IntegrationConfig } from "@/components/settings/integration-configs";
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
@@ -40,12 +41,7 @@ interface IntegrationCardProps {
|
|||||||
|
|
||||||
export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
||||||
const { provider, label } = config;
|
const { provider, label } = config;
|
||||||
const providerInput = provider as
|
const providerInput = provider as "plex" | "jellyfin" | "emby" | "sonarr" | "radarr";
|
||||||
| "plex"
|
|
||||||
| "jellyfin"
|
|
||||||
| "emby"
|
|
||||||
| "sonarr"
|
|
||||||
| "radarr";
|
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
@@ -61,21 +57,13 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chevronRotation.set(
|
chevronRotation.set(
|
||||||
reduceMotion
|
reduceMotion ? (expanded ? 180 : 0) : withTiming(expanded ? 180 : 0, { duration: 200 }),
|
||||||
? expanded
|
|
||||||
? 180
|
|
||||||
: 0
|
|
||||||
: withTiming(expanded ? 180 : 0, { duration: 200 }),
|
|
||||||
);
|
);
|
||||||
}, [expanded, chevronRotation, reduceMotion]);
|
}, [expanded, chevronRotation, reduceMotion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setupChevronRotation.set(
|
setupChevronRotation.set(
|
||||||
reduceMotion
|
reduceMotion ? (setupOpen ? 180 : 0) : withTiming(setupOpen ? 180 : 0, { duration: 200 }),
|
||||||
? setupOpen
|
|
||||||
? 180
|
|
||||||
: 0
|
|
||||||
: withTiming(setupOpen ? 180 : 0, { duration: 200 }),
|
|
||||||
);
|
);
|
||||||
}, [setupOpen, setupChevronRotation, reduceMotion]);
|
}, [setupOpen, setupChevronRotation, reduceMotion]);
|
||||||
|
|
||||||
@@ -164,7 +152,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="mb-2 overflow-hidden rounded-xl border bg-card"
|
className="bg-card mb-2 overflow-hidden rounded-xl border"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "rgba(255,255,255,0.06)",
|
borderColor: "rgba(255,255,255,0.06)",
|
||||||
borderCurve: "continuous",
|
borderCurve: "continuous",
|
||||||
@@ -179,19 +167,11 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
|||||||
className="flex-row items-center justify-between p-3"
|
className="flex-row items-center justify-between p-3"
|
||||||
>
|
>
|
||||||
<View className="flex-row items-center gap-3">
|
<View className="flex-row items-center gap-3">
|
||||||
<ScaledIcon
|
<ScaledIcon icon={Icon} size={18} color={connection ? primaryColor : mutedFgColor} />
|
||||||
icon={Icon}
|
|
||||||
size={18}
|
|
||||||
color={connection ? primaryColor : mutedFgColor}
|
|
||||||
/>
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-medium font-sans text-base text-foreground">
|
<Text className="text-foreground font-sans text-base font-medium">{label}</Text>
|
||||||
{label}
|
<Text className="text-muted-foreground mt-0.5 text-xs">
|
||||||
</Text>
|
{connection ? config.connectedStatus(connection.lastEventAt) : t`Not configured`}
|
||||||
<Text className="mt-0.5 text-muted-foreground text-xs">
|
|
||||||
{connection
|
|
||||||
? config.connectedStatus(connection.lastEventAt)
|
|
||||||
: t`Not configured`}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -206,49 +186,36 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeIn.duration(200)}
|
entering={FadeIn.duration(200)}
|
||||||
exiting={FadeOut.duration(150)}
|
exiting={FadeOut.duration(150)}
|
||||||
className="border-white/[0.06] border-t px-4 pt-3 pb-4"
|
className="border-t border-white/[0.06] px-4 pt-3 pb-4"
|
||||||
>
|
>
|
||||||
{/* Requirement note */}
|
{/* Requirement note */}
|
||||||
{config.requirementNote && (
|
{config.requirementNote && (
|
||||||
<View className="mb-3 flex-row items-start gap-2 rounded-lg bg-primary/[0.06] px-3 py-2.5">
|
<View className="bg-primary/[0.06] mb-3 flex-row items-start gap-2 rounded-lg px-3 py-2.5">
|
||||||
<ScaledIcon
|
<ScaledIcon icon={IconInfoCircle} size={14} color={primaryColor} className="mt-px" />
|
||||||
icon={IconInfoCircle}
|
<Text className="text-foreground/80 flex-1 text-xs">{config.requirementNote}</Text>
|
||||||
size={14}
|
|
||||||
color={primaryColor}
|
|
||||||
className="mt-px"
|
|
||||||
/>
|
|
||||||
<Text className="flex-1 text-foreground/80 text-xs">
|
|
||||||
{config.requirementNote}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!connection ? (
|
{!connection ? (
|
||||||
/* Connect button */
|
/* Connect button */
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() =>
|
onPress={() => connectMutation.mutate({ provider: providerInput })}
|
||||||
connectMutation.mutate({ provider: providerInput })
|
|
||||||
}
|
|
||||||
disabled={connectMutation.isPending}
|
disabled={connectMutation.isPending}
|
||||||
className="items-center rounded-lg bg-primary py-2.5 active:opacity-80"
|
className="bg-primary items-center rounded-lg py-2.5 active:opacity-80"
|
||||||
>
|
>
|
||||||
<Text className="font-medium font-sans text-primary-foreground">
|
<Text className="text-primary-foreground font-sans font-medium">
|
||||||
{connectMutation.isPending
|
{connectMutation.isPending ? t`Connecting…` : t`Connect ${label}`}
|
||||||
? t`Connecting…`
|
|
||||||
: t`Connect ${label}`}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
<View className="gap-3">
|
<View className="gap-3">
|
||||||
{/* URL display */}
|
{/* URL display */}
|
||||||
<View>
|
<View>
|
||||||
<Text className="mb-1.5 text-muted-foreground text-xs">
|
<Text className="text-muted-foreground mb-1.5 text-xs">{config.urlLabel}</Text>
|
||||||
{config.urlLabel}
|
<View className="bg-secondary/50 flex-row items-center rounded-lg px-3 py-2.5">
|
||||||
</Text>
|
|
||||||
<View className="flex-row items-center rounded-lg bg-secondary/50 px-3 py-2.5">
|
|
||||||
<Text
|
<Text
|
||||||
maxFontSizeMultiplier={1.0}
|
maxFontSizeMultiplier={1.0}
|
||||||
className="flex-1 font-mono text-muted-foreground text-xs"
|
className="text-muted-foreground flex-1 font-mono text-xs"
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{url}
|
{url}
|
||||||
@@ -263,11 +230,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
|||||||
{copied ? (
|
{copied ? (
|
||||||
<ScaledIcon icon={IconCheck} size={16} color="#4ade80" />
|
<ScaledIcon icon={IconCheck} size={16} color="#4ade80" />
|
||||||
) : (
|
) : (
|
||||||
<ScaledIcon
|
<ScaledIcon icon={IconCopy} size={16} color={mutedFgColor} />
|
||||||
icon={IconCopy}
|
|
||||||
size={16}
|
|
||||||
color={mutedFgColor}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -278,23 +241,19 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleRegenerate}
|
onPress={handleRegenerate}
|
||||||
disabled={regenerateMutation.isPending}
|
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"
|
className="bg-secondary flex-1 flex-row items-center justify-center gap-1.5 rounded-lg py-2.5 active:opacity-80"
|
||||||
>
|
>
|
||||||
<ScaledIcon
|
<ScaledIcon icon={IconRefresh} size={14} color={mutedFgColor} />
|
||||||
icon={IconRefresh}
|
<Text className="text-foreground font-sans text-sm font-medium">
|
||||||
size={14}
|
|
||||||
color={mutedFgColor}
|
|
||||||
/>
|
|
||||||
<Text className="font-medium font-sans text-foreground text-sm">
|
|
||||||
<Trans>Regenerate</Trans>
|
<Trans>Regenerate</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleDisconnect}
|
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"
|
className="bg-destructive/10 flex-1 flex-row items-center justify-center gap-1.5 rounded-lg py-2.5 active:opacity-80"
|
||||||
>
|
>
|
||||||
<ScaledIcon icon={IconTrash} size={14} color="#ef4444" />
|
<ScaledIcon icon={IconTrash} size={14} color="#ef4444" />
|
||||||
<Text className="font-medium font-sans text-destructive text-sm">
|
<Text className="text-destructive font-sans text-sm font-medium">
|
||||||
<Trans>Disconnect</Trans>
|
<Trans>Disconnect</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -304,16 +263,9 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
|||||||
|
|
||||||
{/* Setup instructions (nested accordion) */}
|
{/* Setup instructions (nested accordion) */}
|
||||||
<View className="mt-3">
|
<View className="mt-3">
|
||||||
<Pressable
|
<Pressable onPress={toggleSetup} className="flex-row items-center gap-1.5">
|
||||||
onPress={toggleSetup}
|
|
||||||
className="flex-row items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Animated.View style={setupChevronStyle}>
|
<Animated.View style={setupChevronStyle}>
|
||||||
<ScaledIcon
|
<ScaledIcon icon={IconChevronDown} size={12} color={mutedFgColor} />
|
||||||
icon={IconChevronDown}
|
|
||||||
size={12}
|
|
||||||
color={mutedFgColor}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<Text className="text-muted-foreground text-xs">
|
<Text className="text-muted-foreground text-xs">
|
||||||
<Trans>Setup instructions</Trans>
|
<Trans>Setup instructions</Trans>
|
||||||
@@ -324,14 +276,12 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeIn.duration(200)}
|
entering={FadeIn.duration(200)}
|
||||||
exiting={FadeOut.duration(150)}
|
exiting={FadeOut.duration(150)}
|
||||||
className="mt-2 rounded-lg border border-white/[0.04] bg-secondary/30 px-3 py-2.5"
|
className="bg-secondary/30 mt-2 rounded-lg border border-white/[0.04] px-3 py-2.5"
|
||||||
>
|
>
|
||||||
{config.setupSteps.map((step, i) => (
|
{config.setupSteps.map((step, i) => (
|
||||||
<View key={step} className="flex-row py-1">
|
<View key={step} className="flex-row py-1">
|
||||||
<Text className="w-5 text-muted-foreground text-xs">
|
<Text className="text-muted-foreground w-5 text-xs">{i + 1}.</Text>
|
||||||
{i + 1}.
|
<Text className="text-muted-foreground flex-1 text-xs leading-[18px]">
|
||||||
</Text>
|
|
||||||
<Text className="flex-1 text-muted-foreground text-xs leading-[18px]">
|
|
||||||
{step}
|
{step}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { I18n } from "@lingui/core";
|
import type { I18n } from "@lingui/core";
|
||||||
import { msg } from "@lingui/core/macro";
|
import { msg } from "@lingui/core/macro";
|
||||||
import { formatRelativeTime } from "@sofa/i18n/format";
|
|
||||||
import type { SvgProps } from "react-native-svg";
|
import type { SvgProps } from "react-native-svg";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EmbyIcon,
|
EmbyIcon,
|
||||||
JellyfinIcon,
|
JellyfinIcon,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
SonarrIcon,
|
SonarrIcon,
|
||||||
} from "@/components/settings/icons";
|
} from "@/components/settings/icons";
|
||||||
import { getServerUrl } from "@/lib/server";
|
import { getServerUrl } from "@/lib/server";
|
||||||
|
import { formatRelativeTime } from "@sofa/i18n/format";
|
||||||
|
|
||||||
export interface IntegrationConfig {
|
export interface IntegrationConfig {
|
||||||
provider: "plex" | "jellyfin" | "emby" | "sonarr" | "radarr";
|
provider: "plex" | "jellyfin" | "emby" | "sonarr" | "radarr";
|
||||||
@@ -53,9 +54,7 @@ export function getIntegrationConfigs(i18n: I18n): IntegrationConfig[] {
|
|||||||
setupSteps: [
|
setupSteps: [
|
||||||
i18n._(msg`Open Plex, go to Settings > Webhooks`),
|
i18n._(msg`Open Plex, go to Settings > Webhooks`),
|
||||||
i18n._(msg`Click "Add Webhook" and paste the URL above`),
|
i18n._(msg`Click "Add Webhook" and paste the URL above`),
|
||||||
i18n._(
|
i18n._(msg`Sofa will automatically log movies and episodes when you finish watching them`),
|
||||||
msg`Sofa will automatically log movies and episodes when you finish watching them`,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -71,9 +70,7 @@ export function getIntegrationConfigs(i18n: I18n): IntegrationConfig[] {
|
|||||||
i18n._(msg`Go to Dashboard > Plugins > Webhook`),
|
i18n._(msg`Go to Dashboard > Plugins > Webhook`),
|
||||||
i18n._(msg`Add a "Generic Destination" and paste the URL above`),
|
i18n._(msg`Add a "Generic Destination" and paste the URL above`),
|
||||||
i18n._(msg`Enable the "Playback Stop" notification type`),
|
i18n._(msg`Enable the "Playback Stop" notification type`),
|
||||||
i18n._(
|
i18n._(msg`Sofa will automatically log movies and episodes when you finish watching them`),
|
||||||
msg`Sofa will automatically log movies and episodes when you finish watching them`,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -91,9 +88,7 @@ export function getIntegrationConfigs(i18n: I18n): IntegrationConfig[] {
|
|||||||
i18n._(msg`Open Emby, go to Settings > Webhooks`),
|
i18n._(msg`Open Emby, go to Settings > Webhooks`),
|
||||||
i18n._(msg`Add a new webhook and paste the URL above`),
|
i18n._(msg`Add a new webhook and paste the URL above`),
|
||||||
i18n._(msg`Enable the "Playback" event category`),
|
i18n._(msg`Enable the "Playback" event category`),
|
||||||
i18n._(
|
i18n._(msg`Sofa will automatically log movies and episodes when you finish watching them`),
|
||||||
msg`Sofa will automatically log movies and episodes when you finish watching them`,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Trans, useLingui } from "@lingui/react/macro";
|
|||||||
import { IconRefresh, IconWebhook } from "@tabler/icons-react-native";
|
import { IconRefresh, IconWebhook } from "@tabler/icons-react-native";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
|
|
||||||
import { IntegrationCard } from "@/components/settings/integration-card";
|
import { IntegrationCard } from "@/components/settings/integration-card";
|
||||||
import { getIntegrationConfigs } from "@/components/settings/integration-configs";
|
import { getIntegrationConfigs } from "@/components/settings/integration-configs";
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
@@ -30,15 +31,11 @@ export function IntegrationsSection() {
|
|||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => integrations.refetch()}
|
onPress={() => integrations.refetch()}
|
||||||
className="flex-row items-center gap-1.5 rounded-lg bg-secondary px-3 py-1.5"
|
className="bg-secondary flex-row items-center gap-1.5 rounded-lg px-3 py-1.5"
|
||||||
style={{ borderCurve: "continuous" }}
|
style={{ borderCurve: "continuous" }}
|
||||||
>
|
>
|
||||||
<ScaledIcon
|
<ScaledIcon icon={IconRefresh} size={14} className="accent-primary" />
|
||||||
icon={IconRefresh}
|
<Text className="text-primary font-sans text-sm font-medium">
|
||||||
size={14}
|
|
||||||
className="accent-primary"
|
|
||||||
/>
|
|
||||||
<Text className="font-medium font-sans text-primary text-sm">
|
|
||||||
<Trans>Retry</Trans>
|
<Trans>Retry</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -46,16 +43,8 @@ export function IntegrationsSection() {
|
|||||||
) : (
|
) : (
|
||||||
configs.map((config) => {
|
configs.map((config) => {
|
||||||
const connection =
|
const connection =
|
||||||
integrations.data?.integrations?.find(
|
integrations.data?.integrations?.find((i) => i.provider === config.provider) ?? null;
|
||||||
(i) => i.provider === config.provider,
|
return <IntegrationCard key={config.provider} config={config} connection={connection} />;
|
||||||
) ?? null;
|
|
||||||
return (
|
|
||||||
<IntegrationCard
|
|
||||||
key={config.provider}
|
|
||||||
config={config}
|
|
||||||
connection={connection}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { IconChevronRight } from "@tabler/icons-react-native";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
@@ -45,19 +46,14 @@ export function SettingsRow({
|
|||||||
{!right && value ? (
|
{!right && value ? (
|
||||||
<Text
|
<Text
|
||||||
selectable={!onPress}
|
selectable={!onPress}
|
||||||
className="mr-1 max-w-[180px] shrink text-right text-muted-foreground text-sm"
|
className="text-muted-foreground mr-1 max-w-[180px] shrink text-right text-sm"
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{!right && onPress && (
|
{!right && onPress && (
|
||||||
<ScaledIcon
|
<ScaledIcon icon={IconChevronRight} size={16} color={mutedFgColor} accessible={false} />
|
||||||
icon={IconChevronRight}
|
|
||||||
size={16}
|
|
||||||
color={mutedFgColor}
|
|
||||||
accessible={false}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Icon } from "@tabler/icons-react-native";
|
import type { Icon } from "@tabler/icons-react-native";
|
||||||
import { Children, Fragment, isValidElement, type ReactNode } from "react";
|
import { Children, Fragment, isValidElement, type ReactNode } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
|
||||||
import { SectionHeader } from "@/components/ui/section-header";
|
import { SectionHeader } from "@/components/ui/section-header";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
@@ -8,9 +9,7 @@ function flattenChildren(node: ReactNode): ReactNode[] {
|
|||||||
const result: ReactNode[] = [];
|
const result: ReactNode[] = [];
|
||||||
Children.forEach(node, (child) => {
|
Children.forEach(node, (child) => {
|
||||||
if (isValidElement(child) && child.type === Fragment) {
|
if (isValidElement(child) && child.type === Fragment) {
|
||||||
result.push(
|
result.push(...flattenChildren((child.props as { children?: ReactNode }).children));
|
||||||
...flattenChildren((child.props as { children?: ReactNode }).children),
|
|
||||||
);
|
|
||||||
} else if (child != null && child !== false) {
|
} else if (child != null && child !== false) {
|
||||||
result.push(child);
|
result.push(child);
|
||||||
}
|
}
|
||||||
@@ -36,10 +35,10 @@ export function SettingsSection({
|
|||||||
<View className="flex-row items-center gap-2">
|
<View className="flex-row items-center gap-2">
|
||||||
<SectionHeader title={title} icon={icon} />
|
<SectionHeader title={title} icon={icon} />
|
||||||
{badge ? (
|
{badge ? (
|
||||||
<View className="mb-2.5 rounded-full bg-primary/10 px-2 py-0.5">
|
<View className="bg-primary/10 mb-2.5 rounded-full px-2 py-0.5">
|
||||||
<Text
|
<Text
|
||||||
maxFontSizeMultiplier={1.0}
|
maxFontSizeMultiplier={1.0}
|
||||||
className="font-medium font-sans text-primary text-xs"
|
className="text-primary font-sans text-xs font-medium"
|
||||||
>
|
>
|
||||||
{badge}
|
{badge}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -47,23 +46,16 @@ export function SettingsSection({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
className="rounded-xl border border-white/[0.06] bg-card px-3"
|
className="bg-card rounded-xl border border-white/[0.06] px-3"
|
||||||
style={{ borderCurve: "continuous" }}
|
style={{ borderCurve: "continuous" }}
|
||||||
>
|
>
|
||||||
{items.map((child, i) => {
|
{items.map((child, i) => {
|
||||||
const key =
|
const key =
|
||||||
isValidElement(child) && child.key != null
|
isValidElement(child) && child.key != null ? String(child.key) : `settings-item-${i}`;
|
||||||
? String(child.key)
|
|
||||||
: `settings-item-${i}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={key}>
|
<Fragment key={key}>
|
||||||
{i > 0 && (
|
{i > 0 && <View className="border-border border-t" style={{ borderTopWidth: 0.5 }} />}
|
||||||
<View
|
|
||||||
className="border-border border-t"
|
|
||||||
style={{ borderTopWidth: 0.5 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{child}
|
{child}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Link } from "expo-router";
|
import { Link } from "expo-router";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
|
|
||||||
import { Image } from "@/components/ui/image";
|
import { Image } from "@/components/ui/image";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ export function CastCard({
|
|||||||
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
||||||
>
|
>
|
||||||
<View className="w-20 items-center">
|
<View className="w-20 items-center">
|
||||||
<View className="mb-2 h-16 w-16 overflow-hidden rounded-full bg-secondary">
|
<View className="bg-secondary mb-2 h-16 w-16 overflow-hidden rounded-full">
|
||||||
{person.profilePath && (
|
{person.profilePath && (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: person.profilePath }}
|
source={{ uri: person.profilePath }}
|
||||||
@@ -46,7 +47,7 @@ export function CastCard({
|
|||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
maxFontSizeMultiplier={1.2}
|
maxFontSizeMultiplier={1.2}
|
||||||
className="text-center font-medium font-sans text-foreground text-xs"
|
className="text-foreground text-center font-sans text-xs font-medium"
|
||||||
>
|
>
|
||||||
{person.name}
|
{person.name}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -54,7 +55,7 @@ export function CastCard({
|
|||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
maxFontSizeMultiplier={1.0}
|
maxFontSizeMultiplier={1.0}
|
||||||
className="text-center text-muted-foreground text-xs"
|
className="text-muted-foreground text-center text-xs"
|
||||||
>
|
>
|
||||||
{person.character}
|
{person.character}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { Season } from "@sofa/api/schemas";
|
|
||||||
import { getNextEpisode } from "@sofa/api/utils";
|
|
||||||
import { IconPlayerPlay } from "@tabler/icons-react-native";
|
import { IconPlayerPlay } from "@tabler/icons-react-native";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import { Image } from "@/components/ui/image";
|
import { Image } from "@/components/ui/image";
|
||||||
import { SectionHeader } from "@/components/ui/section-header";
|
import { SectionHeader } from "@/components/ui/section-header";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { Season } from "@sofa/api/schemas";
|
||||||
|
import { getNextEpisode } from "@sofa/api/utils";
|
||||||
|
|
||||||
export function ContinueWatchingBanner({
|
export function ContinueWatchingBanner({
|
||||||
seasons,
|
seasons,
|
||||||
@@ -34,21 +35,13 @@ export function ContinueWatchingBanner({
|
|||||||
if (userStatus !== "in_progress" || !nextEpisode) return null;
|
if (userStatus !== "in_progress" || !nextEpisode) return null;
|
||||||
|
|
||||||
const stillUrl = nextEpisode.stillPath ?? backdropPath ?? null;
|
const stillUrl = nextEpisode.stillPath ?? backdropPath ?? null;
|
||||||
const progress =
|
const progress = totalEpisodes > 0 ? (watchedEpisodes / totalEpisodes) * 100 : 0;
|
||||||
totalEpisodes > 0 ? (watchedEpisodes / totalEpisodes) * 100 : 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View entering={FadeInDown.duration(300).delay(350)} className="mt-6 px-4">
|
||||||
entering={FadeInDown.duration(300).delay(350)}
|
<SectionHeader title={t`Next Episode`} icon={IconPlayerPlay} iconColor={titleAccentColor} />
|
||||||
className="mt-6 px-4"
|
|
||||||
>
|
|
||||||
<SectionHeader
|
|
||||||
title={t`Next Episode`}
|
|
||||||
icon={IconPlayerPlay}
|
|
||||||
iconColor={titleAccentColor}
|
|
||||||
/>
|
|
||||||
<View
|
<View
|
||||||
className="overflow-hidden rounded-[12px] border bg-card"
|
className="bg-card overflow-hidden rounded-[12px] border"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "rgba(255,255,255,0.06)",
|
borderColor: "rgba(255,255,255,0.06)",
|
||||||
borderCurve: "continuous",
|
borderCurve: "continuous",
|
||||||
@@ -71,19 +64,16 @@ export function ContinueWatchingBanner({
|
|||||||
/>
|
/>
|
||||||
<View className="absolute right-3 bottom-2.5 left-3">
|
<View className="absolute right-3 bottom-2.5 left-3">
|
||||||
<View className="flex-row items-center gap-1.5">
|
<View className="flex-row items-center gap-1.5">
|
||||||
<View className="h-1.5 w-1.5 rounded-full bg-title-accent" />
|
<View className="bg-title-accent h-1.5 w-1.5 rounded-full" />
|
||||||
<Text
|
<Text
|
||||||
maxFontSizeMultiplier={1.0}
|
maxFontSizeMultiplier={1.0}
|
||||||
className="font-medium font-sans text-title-accent text-xs uppercase tracking-wider"
|
className="text-title-accent font-sans text-xs font-medium tracking-wider uppercase"
|
||||||
>
|
>
|
||||||
<Trans>Up next</Trans>
|
<Trans>Up next</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text numberOfLines={1} className="mt-0.5 font-sans text-sm font-medium text-white">
|
||||||
numberOfLines={1}
|
<Text className="font-sans text-xs font-medium text-white/60">
|
||||||
className="mt-0.5 font-medium font-sans text-sm text-white"
|
|
||||||
>
|
|
||||||
<Text className="font-medium font-sans text-white/60 text-xs">
|
|
||||||
S{nextEpisode.seasonNumber} E{nextEpisode.episodeNumber}
|
S{nextEpisode.seasonNumber} E{nextEpisode.episodeNumber}
|
||||||
</Text>
|
</Text>
|
||||||
{" "}
|
{" "}
|
||||||
@@ -94,10 +84,7 @@ export function ContinueWatchingBanner({
|
|||||||
className="absolute right-0 bottom-0 left-0 h-[3px]"
|
className="absolute right-0 bottom-0 left-0 h-[3px]"
|
||||||
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
|
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
|
||||||
>
|
>
|
||||||
<View
|
<View className="bg-title-accent h-full" style={{ width: `${progress}%` }} />
|
||||||
className="h-full bg-title-accent"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useLingui } from "@lingui/react/macro";
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import {
|
import { IconCircleCheckFilled, IconCircleDashed } from "@tabler/icons-react-native";
|
||||||
IconCircleCheckFilled,
|
|
||||||
IconCircleDashed,
|
|
||||||
} from "@tabler/icons-react-native";
|
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
|
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
@@ -34,30 +32,23 @@ export function EpisodeRow({
|
|||||||
accessibilityRole="checkbox"
|
accessibilityRole="checkbox"
|
||||||
accessibilityState={{ checked: isWatched }}
|
accessibilityState={{ checked: isWatched }}
|
||||||
accessibilityLabel={t`Episode ${episode.episodeNumber}, ${episodeLabel}`}
|
accessibilityLabel={t`Episode ${episode.episodeNumber}, ${episodeLabel}`}
|
||||||
className="flex-row items-center border-border border-b px-4 py-3"
|
className="border-border flex-row items-center border-b px-4 py-3"
|
||||||
style={{ borderBottomWidth: 0.5 }}
|
style={{ borderBottomWidth: 0.5 }}
|
||||||
>
|
>
|
||||||
{isWatched ? (
|
{isWatched ? (
|
||||||
<ScaledIcon
|
<ScaledIcon icon={IconCircleCheckFilled} size={22} color={accentColor} />
|
||||||
icon={IconCircleCheckFilled}
|
|
||||||
size={22}
|
|
||||||
color={accentColor}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<ScaledIcon icon={IconCircleDashed} size={22} color={mutedColor} />
|
<ScaledIcon icon={IconCircleDashed} size={22} color={mutedColor} />
|
||||||
)}
|
)}
|
||||||
<View className="ml-3 flex-1">
|
<View className="ml-3 flex-1">
|
||||||
<Text
|
<Text
|
||||||
className={`font-medium font-sans text-sm ${isWatched ? "text-muted-foreground" : "text-foreground"}`}
|
className={`font-sans text-sm font-medium ${isWatched ? "text-muted-foreground" : "text-foreground"}`}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{episode.episodeNumber}.{" "}
|
{episode.episodeNumber}. {episode.name ?? t`Episode ${episode.episodeNumber}`}
|
||||||
{episode.name ?? t`Episode ${episode.episodeNumber}`}
|
|
||||||
</Text>
|
</Text>
|
||||||
{episode.airDate ? (
|
{episode.airDate ? (
|
||||||
<Text className="mt-0.5 text-muted-foreground text-xs">
|
<Text className="text-muted-foreground mt-0.5 text-xs">{episode.airDate}</Text>
|
||||||
{episode.airDate}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Animated, {
|
|||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import { EpisodeRow } from "@/components/titles/episode-row";
|
import { EpisodeRow } from "@/components/titles/episode-row";
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
@@ -42,9 +43,7 @@ export function SeasonAccordion({
|
|||||||
const reduceMotion = useReducedMotion();
|
const reduceMotion = useReducedMotion();
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const chevronRotation = useSharedValue(0);
|
const chevronRotation = useSharedValue(0);
|
||||||
const watchedCount = episodes.filter((e) =>
|
const watchedCount = episodes.filter((e) => watchedEpisodeIds.has(e.id)).length;
|
||||||
watchedEpisodeIds.has(e.id),
|
|
||||||
).length;
|
|
||||||
const progress = episodes.length > 0 ? watchedCount / episodes.length : 0;
|
const progress = episodes.length > 0 ? watchedCount / episodes.length : 0;
|
||||||
|
|
||||||
// Progressive rendering: show first batch immediately, defer rest
|
// Progressive rendering: show first batch immediately, defer rest
|
||||||
@@ -67,11 +66,7 @@ export function SeasonAccordion({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chevronRotation.set(
|
chevronRotation.set(
|
||||||
reduceMotion
|
reduceMotion ? (expanded ? 180 : 0) : withTiming(expanded ? 180 : 0, { duration: 200 }),
|
||||||
? expanded
|
|
||||||
? 180
|
|
||||||
: 0
|
|
||||||
: withTiming(expanded ? 180 : 0, { duration: 200 }),
|
|
||||||
);
|
);
|
||||||
}, [expanded, chevronRotation, reduceMotion]);
|
}, [expanded, chevronRotation, reduceMotion]);
|
||||||
|
|
||||||
@@ -83,15 +78,11 @@ export function SeasonAccordion({
|
|||||||
toasts: {
|
toasts: {
|
||||||
watchEpisode: ({ id: epId }) => {
|
watchEpisode: ({ id: epId }) => {
|
||||||
const ep = episodes.find((e) => e.id === epId);
|
const ep = episodes.find((e) => e.id === epId);
|
||||||
return ep
|
return ep ? `Watched S${season.seasonNumber} E${ep.episodeNumber}` : "Episode watched";
|
||||||
? `Watched S${season.seasonNumber} E${ep.episodeNumber}`
|
|
||||||
: "Episode watched";
|
|
||||||
},
|
},
|
||||||
unwatchEpisode: ({ id: epId }) => {
|
unwatchEpisode: ({ id: epId }) => {
|
||||||
const ep = episodes.find((e) => e.id === epId);
|
const ep = episodes.find((e) => e.id === epId);
|
||||||
return ep
|
return ep ? `Unwatched S${season.seasonNumber} E${ep.episodeNumber}` : "Episode unwatched";
|
||||||
? `Unwatched S${season.seasonNumber} E${ep.episodeNumber}`
|
|
||||||
: "Episode unwatched";
|
|
||||||
},
|
},
|
||||||
watchSeason: `Watched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
|
watchSeason: `Watched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
|
||||||
},
|
},
|
||||||
@@ -110,7 +101,7 @@ export function SeasonAccordion({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="mb-2 overflow-hidden rounded-xl border bg-card"
|
className="bg-card mb-2 overflow-hidden rounded-xl border"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "rgba(255,255,255,0.06)",
|
borderColor: "rgba(255,255,255,0.06)",
|
||||||
borderCurve: "continuous",
|
borderCurve: "continuous",
|
||||||
@@ -124,10 +115,10 @@ export function SeasonAccordion({
|
|||||||
className="flex-row items-center justify-between p-4"
|
className="flex-row items-center justify-between p-4"
|
||||||
>
|
>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text className="font-medium font-sans text-base text-foreground">
|
<Text className="text-foreground font-sans text-base font-medium">
|
||||||
{season.name ?? t`Season ${season.seasonNumber}`}
|
{season.name ?? t`Season ${season.seasonNumber}`}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="mt-0.5 text-muted-foreground text-xs">
|
<Text className="text-muted-foreground mt-0.5 text-xs">
|
||||||
{t`${watchedCount}/${episodes.length} ${plural(episodes.length, { one: "episode", other: "episodes" })}`}
|
{t`${watchedCount}/${episodes.length} ${plural(episodes.length, { one: "episode", other: "episodes" })}`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -151,16 +142,13 @@ export function SeasonAccordion({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<Animated.View
|
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(150)}>
|
||||||
entering={FadeIn.duration(200)}
|
|
||||||
exiting={FadeOut.duration(150)}
|
|
||||||
>
|
|
||||||
{watchedCount < episodes.length && (
|
{watchedCount < episodes.length && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => watchSeason.mutate({ id: season.id })}
|
onPress={() => watchSeason.mutate({ id: season.id })}
|
||||||
className="mx-4 mb-2 flex-row items-center justify-center rounded-lg bg-secondary py-2"
|
className="bg-secondary mx-4 mb-2 flex-row items-center justify-center rounded-lg py-2"
|
||||||
>
|
>
|
||||||
<Text className="font-medium font-sans text-title-accent text-xs">
|
<Text className="text-title-accent font-sans text-xs font-medium">
|
||||||
<Trans>Mark All Watched</Trans>
|
<Trans>Mark All Watched</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from "@tabler/icons-react-native";
|
} from "@tabler/icons-react-native";
|
||||||
import { Pressable } from "react-native";
|
import { Pressable } from "react-native";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
@@ -70,15 +71,10 @@ export function StatusActionButton({
|
|||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={t`Add to watchlist`}
|
accessibilityLabel={t`Add to watchlist`}
|
||||||
className="flex-row items-center gap-1.5 rounded-lg border border-title-accent/20 bg-title-accent/10 px-4 py-2"
|
className="border-title-accent/20 bg-title-accent/10 flex-row items-center gap-1.5 rounded-lg border px-4 py-2"
|
||||||
>
|
>
|
||||||
<ScaledIcon
|
<ScaledIcon icon={IconPlus} size={14} color={titleAccent} strokeWidth={2.5} />
|
||||||
icon={IconPlus}
|
<Text className="text-title-accent font-sans text-sm font-medium">
|
||||||
size={14}
|
|
||||||
color={titleAccent}
|
|
||||||
strokeWidth={2.5}
|
|
||||||
/>
|
|
||||||
<Text className="font-medium font-sans text-sm text-title-accent">
|
|
||||||
<Trans>Watchlist</Trans>
|
<Trans>Watchlist</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -87,8 +83,7 @@ export function StatusActionButton({
|
|||||||
|
|
||||||
const StatusIcon = STATUS_ICONS[currentStatus];
|
const StatusIcon = STATUS_ICONS[currentStatus];
|
||||||
const styles = STATUS_STYLES[currentStatus];
|
const styles = STATUS_STYLES[currentStatus];
|
||||||
const iconColor =
|
const iconColor = currentStatus === "completed" ? completedColor : titleAccent;
|
||||||
currentStatus === "completed" ? completedColor : titleAccent;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -102,7 +97,7 @@ export function StatusActionButton({
|
|||||||
className={`flex-row items-center gap-1.5 rounded-lg border px-4 py-2 ${styles.bgClass}`}
|
className={`flex-row items-center gap-1.5 rounded-lg border px-4 py-2 ${styles.bgClass}`}
|
||||||
>
|
>
|
||||||
<ScaledIcon icon={StatusIcon} size={14} color={iconColor} />
|
<ScaledIcon icon={StatusIcon} size={14} color={iconColor} />
|
||||||
<Text className={`font-medium font-sans text-sm ${styles.textClass}`}>
|
<Text className={`font-sans text-sm font-medium ${styles.textClass}`}>
|
||||||
<StatusLabel status={currentStatus} />
|
<StatusLabel status={currentStatus} />
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -5,12 +5,7 @@ export function TmdbLogo({ height = 12 }: { height?: number }) {
|
|||||||
const width = height * aspectRatio;
|
const width = height * aspectRatio;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Svg
|
<Svg viewBox="0 0 190.24 81.52" width={width} height={height} accessibilityLabel="TMDB">
|
||||||
viewBox="0 0 190.24 81.52"
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
accessibilityLabel="TMDB"
|
|
||||||
>
|
|
||||||
<Defs>
|
<Defs>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
id="tmdb-grad"
|
id="tmdb-grad"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createContext, forwardRef, useContext } from "react";
|
import { createContext, forwardRef, useContext } from "react";
|
||||||
import { Pressable, type PressableProps, type TextProps } from "react-native";
|
import { Pressable, type PressableProps, type TextProps } from "react-native";
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
|
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
import { cn } from "@/utils/cn";
|
import { cn } from "@/utils/cn";
|
||||||
|
|
||||||
type ButtonVariant = "default" | "secondary";
|
type ButtonVariant = "default" | "secondary";
|
||||||
@@ -14,21 +14,8 @@ interface ButtonProps extends PressableProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Button = forwardRef<
|
export const Button = forwardRef<React.ComponentRef<typeof Pressable>, ButtonProps>(
|
||||||
React.ComponentRef<typeof Pressable>,
|
({ size = "default", variant = "default", className, style, disabled, ...props }, ref) => {
|
||||||
ButtonProps
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
size = "default",
|
|
||||||
variant = "default",
|
|
||||||
className,
|
|
||||||
style,
|
|
||||||
disabled,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
return (
|
return (
|
||||||
<ButtonVariantContext.Provider value={variant}>
|
<ButtonVariantContext.Provider value={variant}>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -67,10 +54,8 @@ export function ButtonLabel({ style, className, ...props }: ButtonLabelProps) {
|
|||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-center font-medium font-sans text-base",
|
"text-center font-sans text-base font-medium",
|
||||||
variant === "secondary"
|
variant === "secondary" ? "text-secondary-foreground" : "text-primary-foreground",
|
||||||
? "text-secondary-foreground"
|
|
||||||
: "text-primary-foreground",
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={style}
|
style={style}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { IconMovie } from "@tabler/icons-react-native";
|
|||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import Animated, { FadeIn } from "react-native-reanimated";
|
import Animated, { FadeIn } from "react-native-reanimated";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import { Button, ButtonLabel } from "@/components/ui/button";
|
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
@@ -32,19 +33,15 @@ export function EmptyState({
|
|||||||
<View accessible={false}>
|
<View accessible={false}>
|
||||||
<IconComponent size={48} color={mutedForeground} />
|
<IconComponent size={48} color={mutedForeground} />
|
||||||
</View>
|
</View>
|
||||||
<Text className="mt-3 text-center font-medium font-sans text-base text-foreground">
|
<Text className="text-foreground mt-3 text-center font-sans text-base font-medium">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{description && (
|
{description && (
|
||||||
<Text className="mt-1 text-center text-muted-foreground text-sm">
|
<Text className="text-muted-foreground mt-1 text-center text-sm">{description}</Text>
|
||||||
{description}
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
{actionLabel && onAction && (
|
{actionLabel && onAction && (
|
||||||
<Button onPress={onAction} size="sm" className="mt-4 bg-primary">
|
<Button onPress={onAction} size="sm" className="bg-primary mt-4">
|
||||||
<ButtonLabel className="text-primary-foreground">
|
<ButtonLabel className="text-primary-foreground">{actionLabel}</ButtonLabel>
|
||||||
{actionLabel}
|
|
||||||
</ButtonLabel>
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useLingui } from "@lingui/react/macro";
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import type { NativeSyntheticEvent, TextLayoutEventData } from "react-native";
|
import type { NativeSyntheticEvent, TextLayoutEventData } from "react-native";
|
||||||
import { Platform, Pressable, View } from "react-native";
|
import { Platform, Pressable, View } from "react-native";
|
||||||
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
export function ExpandableText({
|
export function ExpandableText({
|
||||||
@@ -63,7 +64,7 @@ export function ExpandableText({
|
|||||||
className="mt-1"
|
className="mt-1"
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
className="font-medium font-sans text-primary text-sm"
|
className="text-primary font-sans text-sm font-medium"
|
||||||
style={actionColor ? { color: actionColor } : undefined}
|
style={actionColor ? { color: actionColor } : undefined}
|
||||||
>
|
>
|
||||||
{expanded ? t`Show less` : t`Show more`}
|
{expanded ? t`Show less` : t`Show more`}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Image as ExpoImage, type ImageProps } from "expo-image";
|
import { Image as ExpoImage, type ImageProps } from "expo-image";
|
||||||
import { useResolveClassNames } from "uniwind";
|
import { useResolveClassNames } from "uniwind";
|
||||||
|
|
||||||
import { resolveUrl } from "@/lib/server";
|
import { resolveUrl } from "@/lib/server";
|
||||||
|
|
||||||
export function Image({
|
export function Image({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
|
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
@@ -70,14 +71,14 @@ export function OfflineBanner() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ScaledIcon icon={IconWifiOff} size={16} color="white" />
|
<ScaledIcon icon={IconWifiOff} size={16} color="white" />
|
||||||
<Text className="font-medium font-sans text-sm text-white">
|
<Text className="font-sans text-sm font-medium text-white">
|
||||||
<Trans>No internet connection</Trans>
|
<Trans>No internet connection</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</GlassView>
|
</GlassView>
|
||||||
) : (
|
) : (
|
||||||
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-destructive px-4 py-2.5">
|
<View className="bg-destructive mx-4 flex-row items-center justify-center gap-2 rounded-xl px-4 py-2.5">
|
||||||
<ScaledIcon icon={IconWifiOff} size={16} color="white" />
|
<ScaledIcon icon={IconWifiOff} size={16} color="white" />
|
||||||
<Text className="font-medium font-sans text-sm text-white">
|
<Text className="font-sans text-sm font-medium text-white">
|
||||||
<Trans>No internet connection</Trans>
|
<Trans>No internet connection</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
|
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
@@ -53,9 +54,9 @@ export function OfflineBanner() {
|
|||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-destructive px-4 py-2.5">
|
<View className="bg-destructive mx-4 flex-row items-center justify-center gap-2 rounded-xl px-4 py-2.5">
|
||||||
<ScaledIcon icon={IconWifiOff} size={16} color="white" />
|
<ScaledIcon icon={IconWifiOff} size={16} color="white" />
|
||||||
<Text className="font-medium font-sans text-sm text-white">
|
<Text className="font-sans text-sm font-medium text-white">
|
||||||
<Trans>No internet connection</Trans>
|
<Trans>No internet connection</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Pressable, View } from "react-native";
|
|||||||
import { GestureDetector } from "react-native-gesture-handler";
|
import { GestureDetector } from "react-native-gesture-handler";
|
||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import { Image } from "@/components/ui/image";
|
import { Image } from "@/components/ui/image";
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -83,18 +84,13 @@ export function PosterCard({
|
|||||||
: userStatus === "watchlist"
|
: userStatus === "watchlist"
|
||||||
? "on watchlist"
|
? "on watchlist"
|
||||||
: undefined;
|
: undefined;
|
||||||
const cardAccessibilityLabel = [
|
const cardAccessibilityLabel = [title, type === "movie" ? "movie" : "TV show", year, statusLabel]
|
||||||
title,
|
|
||||||
type === "movie" ? "movie" : "TV show",
|
|
||||||
year,
|
|
||||||
statusLabel,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
const cardContent = (
|
const cardContent = (
|
||||||
<View
|
<View
|
||||||
className={`overflow-hidden rounded-xl border bg-card ${
|
className={`bg-card overflow-hidden rounded-xl border ${
|
||||||
userStatus ? "border-primary/25" : "border-white/[0.06]"
|
userStatus ? "border-primary/25" : "border-white/[0.06]"
|
||||||
}`}
|
}`}
|
||||||
style={{ borderCurve: "continuous" }}
|
style={{ borderCurve: "continuous" }}
|
||||||
@@ -111,10 +107,8 @@ export function PosterCard({
|
|||||||
transition={200}
|
transition={200}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View className="flex-1 items-center justify-center bg-secondary p-3">
|
<View className="bg-secondary flex-1 items-center justify-center p-3">
|
||||||
<Text className="text-center font-display text-foreground/[0.44] text-sm">
|
<Text className="font-display text-foreground/[0.44] text-center text-sm">{title}</Text>
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -136,21 +130,19 @@ export function PosterCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Episode progress bar */}
|
{/* Episode progress bar */}
|
||||||
{episodeProgress &&
|
{episodeProgress && episodeProgress.total > 0 && episodeProgress.watched > 0 && (
|
||||||
episodeProgress.total > 0 &&
|
<View
|
||||||
episodeProgress.watched > 0 && (
|
className="absolute right-0 bottom-0 left-0 h-[3px]"
|
||||||
|
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
|
||||||
|
>
|
||||||
<View
|
<View
|
||||||
className="absolute right-0 bottom-0 left-0 h-[3px]"
|
className="bg-status-watching h-full"
|
||||||
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
|
style={{
|
||||||
>
|
width: `${episodeProgress.total > 0 ? (episodeProgress.watched / episodeProgress.total) * 100 : 0}%`,
|
||||||
<View
|
}}
|
||||||
className="h-full bg-status-watching"
|
/>
|
||||||
style={{
|
</View>
|
||||||
width: `${episodeProgress.total > 0 ? (episodeProgress.watched / episodeProgress.total) * 100 : 0}%`,
|
)}
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Metadata */}
|
{/* Metadata */}
|
||||||
@@ -162,10 +154,7 @@ export function PosterCard({
|
|||||||
style={{ backgroundColor: statusColors[userStatus] }}
|
style={{ backgroundColor: statusColors[userStatus] }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Text
|
<Text className="text-foreground flex-1 font-sans text-sm font-medium" numberOfLines={1}>
|
||||||
className="flex-1 font-medium font-sans text-foreground text-sm"
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -175,19 +164,11 @@ export function PosterCard({
|
|||||||
) : (
|
) : (
|
||||||
<ScaledIcon icon={IconDeviceTv} size={12} color={primaryColor} />
|
<ScaledIcon icon={IconDeviceTv} size={12} color={primaryColor} />
|
||||||
)}
|
)}
|
||||||
{year ? (
|
{year ? <Text className="text-muted-foreground text-xs">{year}</Text> : null}
|
||||||
<Text className="text-muted-foreground text-xs">{year}</Text>
|
|
||||||
) : null}
|
|
||||||
{voteAverage != null && voteAverage > 0 && (
|
{voteAverage != null && voteAverage > 0 && (
|
||||||
<View className="ml-auto flex-row items-center gap-1">
|
<View className="ml-auto flex-row items-center gap-1">
|
||||||
<ScaledIcon
|
<ScaledIcon icon={IconStarFilled} size={10} color={primaryColor} />
|
||||||
icon={IconStarFilled}
|
<Text className="text-primary text-xs">{voteAverage.toFixed(1)}</Text>
|
||||||
size={10}
|
|
||||||
color={primaryColor}
|
|
||||||
/>
|
|
||||||
<Text className="text-primary text-xs">
|
|
||||||
{voteAverage.toFixed(1)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -207,11 +188,7 @@ export function PosterCard({
|
|||||||
className="size-[30px] items-center justify-center rounded-full"
|
className="size-[30px] items-center justify-center rounded-full"
|
||||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||||
>
|
>
|
||||||
{isAdding ? (
|
{isAdding ? <IconLoader size={16} color="white" /> : <IconPlus size={16} color="white" />}
|
||||||
<IconLoader size={16} color="white" />
|
|
||||||
) : (
|
|
||||||
<IconPlus size={16} color="white" />
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null;
|
) : null;
|
||||||
@@ -224,10 +201,7 @@ export function PosterCard({
|
|||||||
<View>
|
<View>
|
||||||
<Link href={titleHref} asChild>
|
<Link href={titleHref} asChild>
|
||||||
<Link.Trigger withAppleZoom>
|
<Link.Trigger withAppleZoom>
|
||||||
<Pressable
|
<Pressable accessibilityRole="button" accessibilityLabel={cardAccessibilityLabel}>
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={cardAccessibilityLabel}
|
|
||||||
>
|
|
||||||
{cardContent}
|
{cardContent}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Link.Trigger>
|
</Link.Trigger>
|
||||||
@@ -275,7 +249,7 @@ export function PosterCardSkeleton({ width = 140 }: { width?: number }) {
|
|||||||
const imageHeight = width * 1.5;
|
const imageHeight = width * 1.5;
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="overflow-hidden rounded-xl border border-white/[0.06] bg-card"
|
className="bg-card overflow-hidden rounded-xl border border-white/[0.06]"
|
||||||
style={{
|
style={{
|
||||||
width,
|
width,
|
||||||
borderCurve: "continuous",
|
borderCurve: "continuous",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import type { SvgProps } from "react-native-svg";
|
import type { SvgProps } from "react-native-svg";
|
||||||
|
|
||||||
import { useScaledSize } from "@/hooks/use-scaled-size";
|
import { useScaledSize } from "@/hooks/use-scaled-size";
|
||||||
|
|
||||||
interface ScaledIconProps extends SvgProps {
|
interface ScaledIconProps extends SvgProps {
|
||||||
@@ -13,11 +14,7 @@ interface ScaledIconProps extends SvgProps {
|
|||||||
*
|
*
|
||||||
* Use this for icons that sit inline with text so they grow alongside it.
|
* Use this for icons that sit inline with text so they grow alongside it.
|
||||||
*/
|
*/
|
||||||
export function ScaledIcon({
|
export function ScaledIcon({ icon: IconComponent, size, ...props }: ScaledIconProps) {
|
||||||
icon: IconComponent,
|
|
||||||
size,
|
|
||||||
...props
|
|
||||||
}: ScaledIconProps) {
|
|
||||||
const scaled = useScaledSize();
|
const scaled = useScaledSize();
|
||||||
const s = scaled(size);
|
const s = scaled(size);
|
||||||
return <IconComponent size={s} width={s} height={s} {...props} />;
|
return <IconComponent size={s} width={s} height={s} {...props} />;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Icon } from "@tabler/icons-react-native";
|
import type { Icon } from "@tabler/icons-react-native";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
@@ -10,22 +11,14 @@ interface SectionHeaderProps {
|
|||||||
iconColor?: string;
|
iconColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SectionHeader({
|
export function SectionHeader({ title, icon: IconComponent, iconColor }: SectionHeaderProps) {
|
||||||
title,
|
|
||||||
icon: IconComponent,
|
|
||||||
iconColor,
|
|
||||||
}: SectionHeaderProps) {
|
|
||||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||||
const resolvedColor = iconColor ?? primaryColor;
|
const resolvedColor = iconColor ?? primaryColor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="mb-3 flex-row items-center gap-2">
|
<View className="mb-3 flex-row items-center gap-2">
|
||||||
{IconComponent && (
|
{IconComponent && <ScaledIcon icon={IconComponent} size={20} color={resolvedColor} />}
|
||||||
<ScaledIcon icon={IconComponent} size={20} color={resolvedColor} />
|
<Text className="font-display text-foreground text-xl tracking-tight">{title}</Text>
|
||||||
)}
|
|
||||||
<Text className="font-display text-foreground text-xl tracking-tight">
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Modal, Pressable, View } from "react-native";
|
|||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
import { ScaledIcon } from "./scaled-icon";
|
import { ScaledIcon } from "./scaled-icon";
|
||||||
|
|
||||||
export interface SelectModalOption {
|
export interface SelectModalOption {
|
||||||
@@ -51,34 +52,30 @@ export function SelectModal({
|
|||||||
onPress={() => onOpenChange(false)}
|
onPress={() => onOpenChange(false)}
|
||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
className="mx-8 w-full max-w-sm overflow-hidden rounded-2xl bg-card"
|
className="bg-card mx-8 w-full max-w-sm overflow-hidden rounded-2xl"
|
||||||
onPress={(e) => e.stopPropagation()}
|
onPress={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<View className="flex-row items-center border-border/50 border-b px-5 py-4">
|
<View className="border-border/50 flex-row items-center border-b px-5 py-4">
|
||||||
{Icon && <ScaledIcon icon={Icon} size={20} color={mutedFgColor} />}
|
{Icon && <ScaledIcon icon={Icon} size={20} color={mutedFgColor} />}
|
||||||
<Text className="ml-1.5 font-medium text-base text-foreground">
|
<Text className="text-foreground ml-1.5 text-base font-medium">{label}</Text>
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
const isSelected = option.value === selection;
|
const isSelected = option.value === selection;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className="flex-row items-center px-5 py-3.5 active:bg-primary/5"
|
className="active:bg-primary/5 flex-row items-center px-5 py-3.5"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
onSelect(option.value);
|
onSelect(option.value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
className={`flex-1 text-base ${isSelected ? "font-medium text-primary" : "text-foreground"}`}
|
className={`flex-1 text-base ${isSelected ? "text-primary font-medium" : "text-foreground"}`}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</Text>
|
</Text>
|
||||||
{isSelected && (
|
{isSelected && <IconCheck size={18} color={primaryColor} strokeWidth={2} />}
|
||||||
<IconCheck size={18} color={primaryColor} strokeWidth={2} />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
|
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
@@ -62,14 +63,11 @@ export function ServerUnreachableBanner() {
|
|||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<ScaledIcon icon={IconCloudOff} size={16} color="white" />
|
<ScaledIcon icon={IconCloudOff} size={16} color="white" />
|
||||||
<Text className="font-medium font-sans text-sm text-white">
|
<Text className="font-sans text-sm font-medium text-white">
|
||||||
<Trans>Can't reach server</Trans>
|
<Trans>Can't reach server</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable onPress={handleRetry} className="ml-1 rounded-md bg-white/20 px-2 py-0.5">
|
||||||
onPress={handleRetry}
|
<Text className="font-sans text-xs font-medium text-white">
|
||||||
className="ml-1 rounded-md bg-white/20 px-2 py-0.5"
|
|
||||||
>
|
|
||||||
<Text className="font-medium font-sans text-white text-xs">
|
|
||||||
<Trans>Retry</Trans>
|
<Trans>Retry</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -106,7 +104,7 @@ export function ServerUnreachableBanner() {
|
|||||||
{content}
|
{content}
|
||||||
</GlassView>
|
</GlassView>
|
||||||
) : (
|
) : (
|
||||||
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-status-watching px-4 py-2.5">
|
<View className="bg-status-watching mx-4 flex-row items-center justify-center gap-2 rounded-xl px-4 py-2.5">
|
||||||
{content}
|
{content}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
|
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
@@ -68,16 +69,13 @@ export function ServerUnreachableBanner() {
|
|||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-status-watching px-4 py-2.5">
|
<View className="bg-status-watching mx-4 flex-row items-center justify-center gap-2 rounded-xl px-4 py-2.5">
|
||||||
<ScaledIcon icon={IconCloudOff} size={16} color="white" />
|
<ScaledIcon icon={IconCloudOff} size={16} color="white" />
|
||||||
<Text className="font-medium font-sans text-sm text-white">
|
<Text className="font-sans text-sm font-medium text-white">
|
||||||
<Trans>Can't reach server</Trans>
|
<Trans>Can't reach server</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable onPress={handleRetry} className="ml-1 rounded-md bg-white/20 px-2 py-0.5">
|
||||||
onPress={handleRetry}
|
<Text className="font-sans text-xs font-medium text-white">
|
||||||
className="ml-1 rounded-md bg-white/20 px-2 py-0.5"
|
|
||||||
>
|
|
||||||
<Text className="font-medium font-sans text-white text-xs">
|
|
||||||
<Trans>Retry</Trans>
|
<Trans>Retry</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import Animated, {
|
|||||||
withRepeat,
|
withRepeat,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
interface SkeletonProps {
|
interface SkeletonProps {
|
||||||
@@ -17,12 +16,7 @@ interface SkeletonProps {
|
|||||||
style?: ViewStyle;
|
style?: ViewStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Skeleton({
|
export function Skeleton({ width, height = 14, borderRadius = 4, style }: SkeletonProps) {
|
||||||
width,
|
|
||||||
height = 14,
|
|
||||||
borderRadius = 4,
|
|
||||||
style,
|
|
||||||
}: SkeletonProps) {
|
|
||||||
const secondaryColor = useCSSVariable("--color-secondary") as string;
|
const secondaryColor = useCSSVariable("--color-secondary") as string;
|
||||||
const reduceMotion = useReducedMotion();
|
const reduceMotion = useReducedMotion();
|
||||||
const opacity = useSharedValue(reduceMotion ? 0.7 : 0.4);
|
const opacity = useSharedValue(reduceMotion ? 0.7 : 0.4);
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ interface SpinnerProps extends Omit<ActivityIndicatorProps, "size" | "color"> {
|
|||||||
|
|
||||||
export function Spinner({ size = "default", ...props }: SpinnerProps) {
|
export function Spinner({ size = "default", ...props }: SpinnerProps) {
|
||||||
return (
|
return (
|
||||||
<ActivityIndicator
|
<ActivityIndicator size={SIZES[size]} colorClassName="accent-primary-foreground" {...props} />
|
||||||
size={SIZES[size]}
|
|
||||||
colorClassName="accent-primary-foreground"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { IconStar, IconStarFilled } from "@tabler/icons-react-native";
|
import { IconStar, IconStarFilled } from "@tabler/icons-react-native";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
interface StarRatingProps {
|
interface StarRatingProps {
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import type { PropsWithChildren } from "react";
|
|||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import ReanimatedSwipeable from "react-native-gesture-handler/ReanimatedSwipeable";
|
import ReanimatedSwipeable from "react-native-gesture-handler/ReanimatedSwipeable";
|
||||||
import type { SharedValue } from "react-native-reanimated";
|
import type { SharedValue } from "react-native-reanimated";
|
||||||
import Animated, {
|
import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated";
|
||||||
interpolate,
|
|
||||||
useAnimatedStyle,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
@@ -22,10 +20,7 @@ function RightAction({ drag }: { drag: SharedValue<number> }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View className="bg-destructive items-center justify-center" style={{ width: ACTION_WIDTH }}>
|
||||||
className="items-center justify-center bg-destructive"
|
|
||||||
style={{ width: ACTION_WIDTH }}
|
|
||||||
>
|
|
||||||
<Animated.View style={animatedStyle}>
|
<Animated.View style={animatedStyle}>
|
||||||
<ScaledIcon icon={IconTrash} size={22} color="#fff" />
|
<ScaledIcon icon={IconTrash} size={22} color="#fff" />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -24,13 +24,7 @@ export function Switch({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
accessibilityLabel={accessibilityLabel}
|
accessibilityLabel={accessibilityLabel}
|
||||||
ios_backgroundColor={secondaryColor}
|
ios_backgroundColor={secondaryColor}
|
||||||
thumbColor={
|
thumbColor={process.env.EXPO_OS === "ios" ? undefined : value ? "#fff" : mutedFgColor}
|
||||||
process.env.EXPO_OS === "ios"
|
|
||||||
? undefined
|
|
||||||
: value
|
|
||||||
? "#fff"
|
|
||||||
: mutedFgColor
|
|
||||||
}
|
|
||||||
trackColor={{ false: secondaryColor, true: primaryColor }}
|
trackColor={{ false: secondaryColor, true: primaryColor }}
|
||||||
style={{
|
style={{
|
||||||
transform: [{ scaleX: switchScale }, { scaleY: switchScale }],
|
transform: [{ scaleX: switchScale }, { scaleY: switchScale }],
|
||||||
|
|||||||
@@ -1,18 +1,7 @@
|
|||||||
import {
|
import { createContext, forwardRef, type PropsWithChildren, useContext, useId } from "react";
|
||||||
createContext,
|
import { TextInput, type TextInputProps, type TextProps, View } from "react-native";
|
||||||
forwardRef,
|
|
||||||
type PropsWithChildren,
|
|
||||||
useContext,
|
|
||||||
useId,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
TextInput,
|
|
||||||
type TextInputProps,
|
|
||||||
type TextProps,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
|
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
import { cn } from "@/utils/cn";
|
import { cn } from "@/utils/cn";
|
||||||
|
|
||||||
const TextFieldContext = createContext<string | undefined>(undefined);
|
const TextFieldContext = createContext<string | undefined>(undefined);
|
||||||
@@ -26,40 +15,35 @@ export function TextField({ children }: PropsWithChildren) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Label({
|
export function Label({ className, nativeID, ...props }: TextProps & { className?: string }) {
|
||||||
className,
|
|
||||||
nativeID,
|
|
||||||
...props
|
|
||||||
}: TextProps & { className?: string }) {
|
|
||||||
const ctxId = useContext(TextFieldContext);
|
const ctxId = useContext(TextFieldContext);
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
nativeID={nativeID ?? ctxId}
|
nativeID={nativeID ?? ctxId}
|
||||||
className={cn("font-medium font-sans text-foreground text-sm", className)}
|
className={cn("text-foreground font-sans text-sm font-medium", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Input = forwardRef<
|
export const Input = forwardRef<TextInput, TextInputProps & { className?: string }>(
|
||||||
TextInput,
|
({ className, style, ...props }, ref) => {
|
||||||
TextInputProps & { className?: string }
|
const ctxId = useContext(TextFieldContext);
|
||||||
>(({ className, style, ...props }, ref) => {
|
return (
|
||||||
const ctxId = useContext(TextFieldContext);
|
<TextInput
|
||||||
return (
|
ref={ref}
|
||||||
<TextInput
|
accessibilityLabelledBy={ctxId}
|
||||||
ref={ref}
|
placeholderTextColorClassName="accent-muted-foreground/70"
|
||||||
accessibilityLabelledBy={ctxId}
|
className={cn(
|
||||||
placeholderTextColorClassName="accent-muted-foreground/70"
|
"border-border bg-input text-foreground min-h-12 rounded-[12px] border px-3.5 py-3 font-sans text-sm",
|
||||||
className={cn(
|
className,
|
||||||
"min-h-12 rounded-[12px] border border-border bg-input px-3.5 py-3 font-sans text-foreground text-sm",
|
)}
|
||||||
className,
|
style={[{ borderCurve: "continuous" }, style]}
|
||||||
)}
|
{...props}
|
||||||
style={[{ borderCurve: "continuous" }, style]}
|
/>
|
||||||
{...props}
|
);
|
||||||
/>
|
},
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
Input.displayName = "Input";
|
Input.displayName = "Input";
|
||||||
|
|
||||||
@@ -71,11 +55,7 @@ export function FieldError({
|
|||||||
}: PropsWithChildren<TextProps & { isInvalid?: boolean; className?: string }>) {
|
}: PropsWithChildren<TextProps & { isInvalid?: boolean; className?: string }>) {
|
||||||
if (!isInvalid) return null;
|
if (!isInvalid) return null;
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text selectable className={cn("text-destructive text-sm", className)} {...props}>
|
||||||
selectable
|
|
||||||
className={cn("text-destructive text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ export function usePressAnimation(scale = 0.97) {
|
|||||||
const animatedStyle = useAnimatedStyle(() => ({
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
transform: [
|
transform: [
|
||||||
{
|
{
|
||||||
scale: reduceMotion
|
scale: reduceMotion ? 1 : interpolate(pressed.get(), [0, 1], [1, scale]),
|
||||||
? 1
|
|
||||||
: interpolate(pressed.get(), [0, 1], [1, scale]),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
|
||||||
clearStorageScope,
|
import { clearStorageScope, hasScopedStorage, setStorageScope } from "@/lib/mmkv";
|
||||||
hasScopedStorage,
|
|
||||||
setStorageScope,
|
|
||||||
} from "@/lib/mmkv";
|
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
import {
|
import {
|
||||||
authClient,
|
authClient,
|
||||||
@@ -43,8 +40,7 @@ export function useServerConnection() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { data: session, isPending, isRefetching } = authClient.useSession();
|
const { data: session, isPending, isRefetching } = authClient.useSession();
|
||||||
const hasServerUrl =
|
const hasServerUrl = !!process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl();
|
||||||
!!process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl();
|
|
||||||
|
|
||||||
// --- Instance ID resolution ---
|
// --- Instance ID resolution ---
|
||||||
const [instanceId, setInstanceId] = useState(getCurrentInstanceId);
|
const [instanceId, setInstanceId] = useState(getCurrentInstanceId);
|
||||||
@@ -61,10 +57,7 @@ export function useServerConnection() {
|
|||||||
}, [instanceId, hasServerUrl]);
|
}, [instanceId, hasServerUrl]);
|
||||||
|
|
||||||
// Re-sync instanceId when server URL changes
|
// Re-sync instanceId when server URL changes
|
||||||
useEffect(
|
useEffect(() => onServerUrlChange(() => setInstanceId(getCurrentInstanceId())), []);
|
||||||
() => onServerUrlChange(() => setInstanceId(getCurrentInstanceId())),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Scoped storage (per instance + user) ---
|
// --- Scoped storage (per instance + user) ---
|
||||||
const userId = session?.user?.id;
|
const userId = session?.user?.id;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { plural } from "@lingui/core/macro";
|
import { plural } from "@lingui/core/macro";
|
||||||
import { useLingui } from "@lingui/react/macro";
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { orpc } from "@/lib/orpc";
|
import { orpc } from "@/lib/orpc";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
import { invalidateTitleQueries } from "@/lib/title-actions";
|
import { invalidateTitleQueries } from "@/lib/title-actions";
|
||||||
@@ -40,9 +41,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
|||||||
const quickAdd = useMutation(
|
const quickAdd = useMutation(
|
||||||
orpc.titles.quickAdd.mutationOptions({
|
orpc.titles.quickAdd.mutationOptions({
|
||||||
onSuccess: (_data, input) => {
|
onSuccess: (_data, input) => {
|
||||||
toast.success(
|
toast.success(resolveToast(toastOverrides?.quickAdd, t`Added to watchlist`, input));
|
||||||
resolveToast(toastOverrides?.quickAdd, t`Added to watchlist`, input),
|
|
||||||
);
|
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -64,9 +63,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
|||||||
const defaultMsg = input.status
|
const defaultMsg = input.status
|
||||||
? (statusMessages[input.status] ?? t`Status updated`)
|
? (statusMessages[input.status] ?? t`Status updated`)
|
||||||
: t`Removed from library`;
|
: t`Removed from library`;
|
||||||
toast.success(
|
toast.success(resolveToast(toastOverrides?.updateStatus, defaultMsg, input));
|
||||||
resolveToast(toastOverrides?.updateStatus, defaultMsg, input),
|
|
||||||
);
|
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
},
|
},
|
||||||
onError: () => toast.error(t`Failed to update status`),
|
onError: () => toast.error(t`Failed to update status`),
|
||||||
@@ -76,9 +73,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
|||||||
const watchMovie = useMutation(
|
const watchMovie = useMutation(
|
||||||
orpc.titles.watchMovie.mutationOptions({
|
orpc.titles.watchMovie.mutationOptions({
|
||||||
onSuccess: (_data, input) => {
|
onSuccess: (_data, input) => {
|
||||||
toast.success(
|
toast.success(resolveToast(toastOverrides?.watchMovie, t`Marked as watched`, input));
|
||||||
resolveToast(toastOverrides?.watchMovie, t`Marked as watched`, input),
|
|
||||||
);
|
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
},
|
},
|
||||||
onError: () => toast.error(t`Failed to mark as watched`),
|
onError: () => toast.error(t`Failed to mark as watched`),
|
||||||
@@ -92,9 +87,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
|||||||
input.stars > 0
|
input.stars > 0
|
||||||
? t`Rated ${input.stars} ${plural(input.stars, { one: "star", other: "stars" })}`
|
? t`Rated ${input.stars} ${plural(input.stars, { one: "star", other: "stars" })}`
|
||||||
: t`Rating removed`;
|
: t`Rating removed`;
|
||||||
toast.success(
|
toast.success(resolveToast(toastOverrides?.updateRating, defaultMsg, input));
|
||||||
resolveToast(toastOverrides?.updateRating, defaultMsg, input),
|
|
||||||
);
|
|
||||||
// Rating only invalidates title queries, not dashboard
|
// Rating only invalidates title queries, not dashboard
|
||||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||||
},
|
},
|
||||||
@@ -105,9 +98,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
|||||||
const watchEpisode = useMutation(
|
const watchEpisode = useMutation(
|
||||||
orpc.episodes.watch.mutationOptions({
|
orpc.episodes.watch.mutationOptions({
|
||||||
onSuccess: (_data, input) => {
|
onSuccess: (_data, input) => {
|
||||||
toast.success(
|
toast.success(resolveToast(toastOverrides?.watchEpisode, t`Episode watched`, input));
|
||||||
resolveToast(toastOverrides?.watchEpisode, t`Episode watched`, input),
|
|
||||||
);
|
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
},
|
},
|
||||||
onError: () => toast.error(t`Failed to mark episode`),
|
onError: () => toast.error(t`Failed to mark episode`),
|
||||||
@@ -117,13 +108,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
|||||||
const unwatchEpisode = useMutation(
|
const unwatchEpisode = useMutation(
|
||||||
orpc.episodes.unwatch.mutationOptions({
|
orpc.episodes.unwatch.mutationOptions({
|
||||||
onSuccess: (_data, input) => {
|
onSuccess: (_data, input) => {
|
||||||
toast.success(
|
toast.success(resolveToast(toastOverrides?.unwatchEpisode, t`Episode unwatched`, input));
|
||||||
resolveToast(
|
|
||||||
toastOverrides?.unwatchEpisode,
|
|
||||||
t`Episode unwatched`,
|
|
||||||
input,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
},
|
},
|
||||||
onError: () => toast.error(t`Failed to unmark episode`),
|
onError: () => toast.error(t`Failed to unmark episode`),
|
||||||
@@ -133,9 +118,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
|||||||
const watchSeason = useMutation(
|
const watchSeason = useMutation(
|
||||||
orpc.seasons.watch.mutationOptions({
|
orpc.seasons.watch.mutationOptions({
|
||||||
onSuccess: (_data, input) => {
|
onSuccess: (_data, input) => {
|
||||||
toast.success(
|
toast.success(resolveToast(toastOverrides?.watchSeason, t`Season watched`, input));
|
||||||
resolveToast(toastOverrides?.watchSeason, t`Season watched`, input),
|
|
||||||
);
|
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
},
|
},
|
||||||
onError: () => toast.error(t`Failed to mark some episodes`),
|
onError: () => toast.error(t`Failed to mark some episodes`),
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { ColorPalette } from "@sofa/api/schemas";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Uniwind } from "uniwind";
|
import { Uniwind } from "uniwind";
|
||||||
|
|
||||||
|
import type { ColorPalette } from "@sofa/api/schemas";
|
||||||
|
|
||||||
function hexToRelativeLuminance(hex: string): number {
|
function hexToRelativeLuminance(hex: string): number {
|
||||||
const r = Number.parseInt(hex.slice(1, 3), 16) / 255;
|
const r = Number.parseInt(hex.slice(1, 3), 16) / 255;
|
||||||
const g = Number.parseInt(hex.slice(3, 5), 16) / 255;
|
const g = Number.parseInt(hex.slice(3, 5), 16) / 255;
|
||||||
const b = Number.parseInt(hex.slice(5, 7), 16) / 255;
|
const b = Number.parseInt(hex.slice(5, 7), 16) / 255;
|
||||||
const toLinear = (c: number) =>
|
const toLinear = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
||||||
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
||||||
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,8 +18,7 @@ export function useTitleTheme(palette: ColorPalette | null | undefined): void {
|
|||||||
if (!vibrant) return;
|
if (!vibrant) return;
|
||||||
|
|
||||||
const luminance = hexToRelativeLuminance(vibrant);
|
const luminance = hexToRelativeLuminance(vibrant);
|
||||||
const foreground =
|
const foreground = luminance > 0.3 ? "oklch(0 0 0)" : "oklch(0.93 0.015 80)";
|
||||||
luminance > 0.3 ? "oklch(0 0 0)" : "oklch(0.93 0.015 80)";
|
|
||||||
|
|
||||||
Uniwind.updateCSSVariables("dark", {
|
Uniwind.updateCSSVariables("dark", {
|
||||||
"--color-title-accent": vibrant,
|
"--color-title-accent": vibrant,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ORPCError } from "@orpc/client";
|
import { ORPCError } from "@orpc/client";
|
||||||
|
|
||||||
import type { AppErrorCode } from "@sofa/api/errors";
|
import type { AppErrorCode } from "@sofa/api/errors";
|
||||||
|
|
||||||
export function getAppErrorCode(error: unknown): AppErrorCode | null {
|
export function getAppErrorCode(error: unknown): AppErrorCode | null {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import {
|
|
||||||
activateLocale,
|
|
||||||
SUPPORTED_LOCALES,
|
|
||||||
type SupportedLocale,
|
|
||||||
} from "@sofa/i18n";
|
|
||||||
import * as Localization from "expo-localization";
|
import * as Localization from "expo-localization";
|
||||||
|
|
||||||
|
import { activateLocale, SUPPORTED_LOCALES, type SupportedLocale } from "@sofa/i18n";
|
||||||
|
|
||||||
import { globalStorage } from "./mmkv";
|
import { globalStorage } from "./mmkv";
|
||||||
|
|
||||||
const LOCALE_STORAGE_KEY = "sofa:locale";
|
const LOCALE_STORAGE_KEY = "sofa:locale";
|
||||||
|
|||||||
@@ -10,10 +10,8 @@
|
|||||||
|
|
||||||
// 1. getCanonicalLocales (no dependencies)
|
// 1. getCanonicalLocales (no dependencies)
|
||||||
import "@formatjs/intl-getcanonicallocales/polyfill";
|
import "@formatjs/intl-getcanonicallocales/polyfill";
|
||||||
|
|
||||||
// 2. Locale (depends on getCanonicalLocales)
|
// 2. Locale (depends on getCanonicalLocales)
|
||||||
import "@formatjs/intl-locale/polyfill";
|
import "@formatjs/intl-locale/polyfill";
|
||||||
|
|
||||||
// 3. PluralRules (depends on Locale)
|
// 3. PluralRules (depends on Locale)
|
||||||
import "@formatjs/intl-pluralrules/polyfill-force";
|
import "@formatjs/intl-pluralrules/polyfill-force";
|
||||||
import "@formatjs/intl-pluralrules/locale-data/en";
|
import "@formatjs/intl-pluralrules/locale-data/en";
|
||||||
@@ -22,7 +20,6 @@ import "@formatjs/intl-pluralrules/locale-data/de";
|
|||||||
import "@formatjs/intl-pluralrules/locale-data/es";
|
import "@formatjs/intl-pluralrules/locale-data/es";
|
||||||
import "@formatjs/intl-pluralrules/locale-data/it";
|
import "@formatjs/intl-pluralrules/locale-data/it";
|
||||||
import "@formatjs/intl-pluralrules/locale-data/pt";
|
import "@formatjs/intl-pluralrules/locale-data/pt";
|
||||||
|
|
||||||
// 4. NumberFormat (depends on PluralRules, Locale)
|
// 4. NumberFormat (depends on PluralRules, Locale)
|
||||||
import "@formatjs/intl-numberformat/polyfill-force";
|
import "@formatjs/intl-numberformat/polyfill-force";
|
||||||
import "@formatjs/intl-numberformat/locale-data/en";
|
import "@formatjs/intl-numberformat/locale-data/en";
|
||||||
@@ -31,7 +28,6 @@ import "@formatjs/intl-numberformat/locale-data/de";
|
|||||||
import "@formatjs/intl-numberformat/locale-data/es";
|
import "@formatjs/intl-numberformat/locale-data/es";
|
||||||
import "@formatjs/intl-numberformat/locale-data/it";
|
import "@formatjs/intl-numberformat/locale-data/it";
|
||||||
import "@formatjs/intl-numberformat/locale-data/pt";
|
import "@formatjs/intl-numberformat/locale-data/pt";
|
||||||
|
|
||||||
// 5. DateTimeFormat (depends on Locale)
|
// 5. DateTimeFormat (depends on Locale)
|
||||||
import "@formatjs/intl-datetimeformat/polyfill-force";
|
import "@formatjs/intl-datetimeformat/polyfill-force";
|
||||||
import "@formatjs/intl-datetimeformat/locale-data/en";
|
import "@formatjs/intl-datetimeformat/locale-data/en";
|
||||||
@@ -41,7 +37,6 @@ import "@formatjs/intl-datetimeformat/locale-data/es";
|
|||||||
import "@formatjs/intl-datetimeformat/locale-data/it";
|
import "@formatjs/intl-datetimeformat/locale-data/it";
|
||||||
import "@formatjs/intl-datetimeformat/locale-data/pt";
|
import "@formatjs/intl-datetimeformat/locale-data/pt";
|
||||||
import "@formatjs/intl-datetimeformat/add-all-tz";
|
import "@formatjs/intl-datetimeformat/add-all-tz";
|
||||||
|
|
||||||
// 6. RelativeTimeFormat (depends on PluralRules, Locale)
|
// 6. RelativeTimeFormat (depends on PluralRules, Locale)
|
||||||
import "@formatjs/intl-relativetimeformat/polyfill-force";
|
import "@formatjs/intl-relativetimeformat/polyfill-force";
|
||||||
import "@formatjs/intl-relativetimeformat/locale-data/en";
|
import "@formatjs/intl-relativetimeformat/locale-data/en";
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { createORPCClient } from "@orpc/client";
|
|||||||
import { RPCLink } from "@orpc/client/fetch";
|
import { RPCLink } from "@orpc/client/fetch";
|
||||||
import type { ContractRouterClient } from "@orpc/contract";
|
import type { ContractRouterClient } from "@orpc/contract";
|
||||||
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
|
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
|
||||||
import type { contract } from "@sofa/api/contract";
|
|
||||||
|
|
||||||
import { authClient, getServerUrl, serverFetch } from "@/lib/server";
|
import { authClient, getServerUrl, serverFetch } from "@/lib/server";
|
||||||
|
import type { contract } from "@sofa/api/contract";
|
||||||
|
|
||||||
export const link = new RPCLink({
|
export const link = new RPCLink({
|
||||||
url: () => `${getServerUrl()}/rpc`,
|
url: () => `${getServerUrl()}/rpc`,
|
||||||
@@ -26,7 +26,6 @@ export const link = new RPCLink({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const client: ContractRouterClient<typeof contract> =
|
export const client: ContractRouterClient<typeof contract> = createORPCClient(link);
|
||||||
createORPCClient(link);
|
|
||||||
|
|
||||||
export const orpc = createTanstackQueryUtils(client);
|
export const orpc = createTanstackQueryUtils(client);
|
||||||
|
|||||||
@@ -81,10 +81,7 @@ export function applyTrackingTransparency(granted: boolean): boolean {
|
|||||||
// writes to ANALYTICS_ENABLED_KEY from being misidentified as legacy.
|
// writes to ANALYTICS_ENABLED_KEY from being misidentified as legacy.
|
||||||
if (!globalStorage.getBoolean(ATT_MIGRATED_KEY)) {
|
if (!globalStorage.getBoolean(ATT_MIGRATED_KEY)) {
|
||||||
globalStorage.set(ATT_MIGRATED_KEY, true);
|
globalStorage.set(ATT_MIGRATED_KEY, true);
|
||||||
if (
|
if (globalStorage.getBoolean(ANALYTICS_ENABLED_KEY) !== undefined && !hasExplicitPreference()) {
|
||||||
globalStorage.getBoolean(ANALYTICS_ENABLED_KEY) !== undefined &&
|
|
||||||
!hasExplicitPreference()
|
|
||||||
) {
|
|
||||||
globalStorage.set(ANALYTICS_EXPLICIT_KEY, true);
|
globalStorage.set(ANALYTICS_EXPLICIT_KEY, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { msg } from "@lingui/core/macro";
|
import { msg } from "@lingui/core/macro";
|
||||||
import { i18n } from "@sofa/i18n";
|
|
||||||
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { posthog } from "@/lib/posthog";
|
import { posthog } from "@/lib/posthog";
|
||||||
import { getIsReachable, isNetworkError } from "@/lib/server";
|
import { getIsReachable, isNetworkError } from "@/lib/server";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import { i18n } from "@sofa/i18n";
|
||||||
|
|
||||||
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
|
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import {
|
|
||||||
hasScopedStorage,
|
import { hasScopedStorage, onStorageScopeChange, scopedStorage } from "@/lib/mmkv";
|
||||||
onStorageScopeChange,
|
|
||||||
scopedStorage,
|
|
||||||
} from "@/lib/mmkv";
|
|
||||||
|
|
||||||
const STORAGE_KEY = "recently_viewed";
|
const STORAGE_KEY = "recently_viewed";
|
||||||
const MAX_ITEMS = 50;
|
const MAX_ITEMS = 50;
|
||||||
@@ -60,10 +57,7 @@ onStorageScopeChange(() => {
|
|||||||
|
|
||||||
export function addRecentlyViewed(item: Omit<RecentlyViewedItem, "viewedAt">) {
|
export function addRecentlyViewed(item: Omit<RecentlyViewedItem, "viewedAt">) {
|
||||||
const filtered = items.filter((i) => i.id !== item.id);
|
const filtered = items.filter((i) => i.id !== item.id);
|
||||||
const next = [{ ...item, viewedAt: Date.now() }, ...filtered].slice(
|
const next = [{ ...item, viewedAt: Date.now() }, ...filtered].slice(0, MAX_ITEMS);
|
||||||
0,
|
|
||||||
MAX_ITEMS,
|
|
||||||
);
|
|
||||||
persist(next);
|
persist(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,8 +46,7 @@ interface CachedSessionData {
|
|||||||
const SERVER_URL_KEY = "sofa_server_url";
|
const SERVER_URL_KEY = "sofa_server_url";
|
||||||
const SERVERS_MAP_KEY = "sofa_servers";
|
const SERVERS_MAP_KEY = "sofa_servers";
|
||||||
const CURRENT_INSTANCE_KEY = "sofa_current_instance_id";
|
const CURRENT_INSTANCE_KEY = "sofa_current_instance_id";
|
||||||
const DEFAULT_URL =
|
const DEFAULT_URL = process.env.EXPO_PUBLIC_SERVER_URL ?? "https://sofa.example.com";
|
||||||
process.env.EXPO_PUBLIC_SERVER_URL ?? "https://sofa.example.com";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// URL helpers
|
// URL helpers
|
||||||
@@ -131,9 +130,7 @@ export async function ensureInstanceId(): Promise<string | null> {
|
|||||||
const existing = getCurrentInstanceId();
|
const existing = getCurrentInstanceId();
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
const serverUrl = hasStoredServerUrl()
|
const serverUrl = hasStoredServerUrl() ? getServerUrl() : process.env.EXPO_PUBLIC_SERVER_URL;
|
||||||
? getServerUrl()
|
|
||||||
: process.env.EXPO_PUBLIC_SERVER_URL;
|
|
||||||
if (!serverUrl) return null;
|
if (!serverUrl) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -142,8 +139,7 @@ export async function ensureInstanceId(): Promise<string | null> {
|
|||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const instanceId =
|
const instanceId = typeof data.instanceId === "string" ? data.instanceId : null;
|
||||||
typeof data.instanceId === "string" ? data.instanceId : null;
|
|
||||||
if (instanceId) {
|
if (instanceId) {
|
||||||
registerServer(serverUrl, instanceId);
|
registerServer(serverUrl, instanceId);
|
||||||
return instanceId;
|
return instanceId;
|
||||||
@@ -158,9 +154,7 @@ export async function ensureInstanceId(): Promise<string | null> {
|
|||||||
// Validation
|
// Validation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function validateServerUrl(
|
export async function validateServerUrl(url: string): Promise<ValidationResult> {
|
||||||
url: string,
|
|
||||||
): Promise<ValidationResult> {
|
|
||||||
const normalized = normalizeUrl(url);
|
const normalized = normalizeUrl(url);
|
||||||
|
|
||||||
if (!normalized || !normalized.includes("://")) {
|
if (!normalized || !normalized.includes("://")) {
|
||||||
@@ -194,8 +188,7 @@ export async function validateServerUrl(
|
|||||||
return { status: "error", error: "not_sofa_server" };
|
return { status: "error", error: "not_sofa_server" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const instanceId =
|
const instanceId = typeof data.instanceId === "string" ? data.instanceId : null;
|
||||||
typeof data.instanceId === "string" ? data.instanceId : null;
|
|
||||||
if (!instanceId) {
|
if (!instanceId) {
|
||||||
return { status: "error", error: "not_sofa_server" };
|
return { status: "error", error: "not_sofa_server" };
|
||||||
}
|
}
|
||||||
@@ -205,10 +198,7 @@ export async function validateServerUrl(
|
|||||||
return { status: "error", error: "not_sofa_server" };
|
return { status: "error", error: "not_sofa_server" };
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
|
||||||
err instanceof Error &&
|
|
||||||
(err.name === "TimeoutError" || err.name === "AbortError")
|
|
||||||
) {
|
|
||||||
return { status: "error", error: "timeout" };
|
return { status: "error", error: "timeout" };
|
||||||
}
|
}
|
||||||
return { status: "error", error: "network_unreachable" };
|
return { status: "error", error: "network_unreachable" };
|
||||||
@@ -262,10 +252,7 @@ export function isNetworkError(error: unknown): error is Error {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function serverFetch(
|
export async function serverFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||||
input: RequestInfo | URL,
|
|
||||||
init?: RequestInit,
|
|
||||||
): Promise<Response> {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(input, init);
|
const response = await fetch(input, init);
|
||||||
setReachable(true);
|
setReachable(true);
|
||||||
@@ -285,9 +272,7 @@ export function getIsReachable(): boolean {
|
|||||||
return isReachable;
|
return isReachable;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onServerReachabilityChange(
|
export function onServerReachabilityChange(callback: (reachable: boolean) => void): () => void {
|
||||||
callback: (reachable: boolean) => void,
|
|
||||||
): () => void {
|
|
||||||
reachabilityListeners.push(callback);
|
reachabilityListeners.push(callback);
|
||||||
return () => {
|
return () => {
|
||||||
const index = reachabilityListeners.indexOf(callback);
|
const index = reachabilityListeners.indexOf(callback);
|
||||||
@@ -301,15 +286,12 @@ export function startReachabilityMonitor(): () => void {
|
|||||||
|
|
||||||
const networkSubscription = Network.addNetworkStateListener(syncOnlineState);
|
const networkSubscription = Network.addNetworkStateListener(syncOnlineState);
|
||||||
|
|
||||||
const appStateSubscription = AppState.addEventListener(
|
const appStateSubscription = AppState.addEventListener("change", (nextState) => {
|
||||||
"change",
|
syncAppFocus(nextState);
|
||||||
(nextState) => {
|
if (nextState === "active") {
|
||||||
syncAppFocus(nextState);
|
void Network.getNetworkStateAsync().then(syncOnlineState);
|
||||||
if (nextState === "active") {
|
}
|
||||||
void Network.getNetworkStateAsync().then(syncOnlineState);
|
});
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeServerUrlListener = onServerUrlChange(() => {
|
const removeServerUrlListener = onServerUrlChange(() => {
|
||||||
setReachable(true);
|
setReachable(true);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { msg, plural } from "@lingui/core/macro";
|
import { msg, plural } from "@lingui/core/macro";
|
||||||
import { i18n } from "@sofa/i18n";
|
|
||||||
import { client, orpc } from "@/lib/orpc";
|
import { client, orpc } from "@/lib/orpc";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import { i18n } from "@sofa/i18n";
|
||||||
|
|
||||||
/** Invalidate title + dashboard queries. Used by most title mutations. */
|
/** Invalidate title + dashboard queries. Used by most title mutations. */
|
||||||
export function invalidateTitleQueries() {
|
export function invalidateTitleQueries() {
|
||||||
@@ -59,9 +60,7 @@ export const titleActions = {
|
|||||||
try {
|
try {
|
||||||
await client.titles.watchMovie({ id });
|
await client.titles.watchMovie({ id });
|
||||||
toast.success(
|
toast.success(
|
||||||
titleName
|
titleName ? i18n._(msg`Marked "${titleName}" as watched`) : i18n._(msg`Marked as watched`),
|
||||||
? i18n._(msg`Marked "${titleName}" as watched`)
|
|
||||||
: i18n._(msg`Marked as watched`),
|
|
||||||
);
|
);
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -84,9 +83,7 @@ export const titleActions = {
|
|||||||
await client.titles.updateRating({ id, stars });
|
await client.titles.updateRating({ id, stars });
|
||||||
toast.success(
|
toast.success(
|
||||||
stars > 0
|
stars > 0
|
||||||
? i18n._(
|
? i18n._(msg`Rated ${plural(stars, { one: "# star", other: "# stars" })}`)
|
||||||
msg`Rated ${plural(stars, { one: "# star", other: "# stars" })}`,
|
|
||||||
)
|
|
||||||
: i18n._(msg`Rating removed`),
|
: i18n._(msg`Rating removed`),
|
||||||
);
|
);
|
||||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||||
@@ -119,9 +116,7 @@ export const titleActions = {
|
|||||||
try {
|
try {
|
||||||
await client.seasons.watch({ id });
|
await client.seasons.watch({ id });
|
||||||
toast.success(
|
toast.success(
|
||||||
seasonName
|
seasonName ? i18n._(msg`Watched all of ${seasonName}`) : i18n._(msg`Season watched`),
|
||||||
? i18n._(msg`Watched all of ${seasonName}`)
|
|
||||||
: i18n._(msg`Season watched`),
|
|
||||||
);
|
);
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -22,12 +22,8 @@ function show(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const toast = {
|
export const toast = {
|
||||||
success: (message: string, options?: ToastOptions) =>
|
success: (message: string, options?: ToastOptions) => show("done", "success", message, options),
|
||||||
show("done", "success", message, options),
|
error: (message: string, options?: ToastOptions) => show("error", "error", message, options),
|
||||||
error: (message: string, options?: ToastOptions) =>
|
info: (message: string, options?: ToastOptions) => show("none", "none", message, options),
|
||||||
show("error", "error", message, options),
|
warning: (message: string, options?: ToastOptions) => show("none", "warning", message, options),
|
||||||
info: (message: string, options?: ToastOptions) =>
|
|
||||||
show("none", "none", message, options),
|
|
||||||
warning: (message: string, options?: ToastOptions) =>
|
|
||||||
show("none", "warning", message, options),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ export function getFormErrors(error: ZodError): {
|
|||||||
message: string;
|
message: string;
|
||||||
fields: Set<string>;
|
fields: Set<string>;
|
||||||
} {
|
} {
|
||||||
const fieldErrors = error.flatten().fieldErrors as Record<
|
const fieldErrors = error.flatten().fieldErrors as Record<string, string[] | undefined>;
|
||||||
string,
|
|
||||||
string[] | undefined
|
|
||||||
>;
|
|
||||||
const fields = new Set<string>();
|
const fields = new Set<string>();
|
||||||
let message = "";
|
let message = "";
|
||||||
|
|
||||||
|
|||||||
@@ -16,18 +16,13 @@ const impactStyleToAndroid: Record<ImpactFeedbackStyle, AndroidHaptics> = {
|
|||||||
[ImpactFeedbackStyle.Rigid]: AndroidHaptics.Virtual_Key,
|
[ImpactFeedbackStyle.Rigid]: AndroidHaptics.Virtual_Key,
|
||||||
};
|
};
|
||||||
|
|
||||||
const notificationTypeToAndroid: Record<
|
const notificationTypeToAndroid: Record<NotificationFeedbackType, AndroidHaptics> = {
|
||||||
NotificationFeedbackType,
|
|
||||||
AndroidHaptics
|
|
||||||
> = {
|
|
||||||
[NotificationFeedbackType.Success]: AndroidHaptics.Confirm,
|
[NotificationFeedbackType.Success]: AndroidHaptics.Confirm,
|
||||||
[NotificationFeedbackType.Warning]: AndroidHaptics.Segment_Tick,
|
[NotificationFeedbackType.Warning]: AndroidHaptics.Segment_Tick,
|
||||||
[NotificationFeedbackType.Error]: AndroidHaptics.Reject,
|
[NotificationFeedbackType.Error]: AndroidHaptics.Reject,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function impactAsync(
|
export async function impactAsync(style: ImpactFeedbackStyle = ImpactFeedbackStyle.Medium) {
|
||||||
style: ImpactFeedbackStyle = ImpactFeedbackStyle.Medium,
|
|
||||||
) {
|
|
||||||
return performAndroidHapticsAsync(impactStyleToAndroid[style]);
|
return performAndroidHapticsAsync(impactStyleToAndroid[style]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,5 @@
|
|||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "uniwind-types.d.ts"]
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".expo/types/**/*.ts",
|
|
||||||
"expo-env.d.ts",
|
|
||||||
"uniwind-types.d.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
||||||
|
"extends": ["../../.oxlintrc.json"]
|
||||||
|
}
|
||||||
@@ -6,8 +6,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "PORT=3002 bun --watch src/app.ts",
|
"dev": "PORT=3002 bun --watch src/app.ts",
|
||||||
"start": "PORT=3002 bun src/app.ts",
|
"start": "PORT=3002 bun src/app.ts",
|
||||||
"lint": "biome check",
|
"lint": "oxlint",
|
||||||
"format": "biome format --write",
|
"format": "oxfmt --config ../../.oxfmtrc.json",
|
||||||
|
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
|
||||||
"check-types": "tsc --noEmit"
|
"check-types": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { checkRateLimit } from "@vercel/firewall";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getProvider, getProviderConfig } from "./providers";
|
import { getProvider, getProviderConfig } from "./providers";
|
||||||
|
|
||||||
const GITHUB_RELEASES_URL =
|
const GITHUB_RELEASES_URL = "https://api.github.com/repos/jakejarvis/sofa/releases/latest";
|
||||||
"https://api.github.com/repos/jakejarvis/sofa/releases/latest";
|
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -33,20 +33,14 @@ app.get("/v1/version", async (c) => {
|
|||||||
html_url: string;
|
html_url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
c.header(
|
c.header("Cache-Control", "public, s-maxage=900, stale-while-revalidate=3600");
|
||||||
"Cache-Control",
|
|
||||||
"public, s-maxage=900, stale-while-revalidate=3600",
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
version: data.tag_name.replace(/^v/, ""),
|
version: data.tag_name.replace(/^v/, ""),
|
||||||
release_url: data.html_url,
|
release_url: data.html_url,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return c.json(
|
return c.json({ error: e instanceof Error ? e.message : "Failed to fetch version" }, 502);
|
||||||
{ error: e instanceof Error ? e.message : "Failed to fetch version" },
|
|
||||||
502,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,7 +80,7 @@ app.post(
|
|||||||
arch: body.arch,
|
arch: body.arch,
|
||||||
users: body.users,
|
users: body.users,
|
||||||
titles: body.titles,
|
titles: body.titles,
|
||||||
...(body.features ?? {}),
|
...body.features,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
@@ -138,10 +132,7 @@ app.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await provider.getDeviceCode(
|
const result = await provider.getDeviceCode(config.clientId, config.clientSecret);
|
||||||
config.clientId,
|
|
||||||
config.clientSecret,
|
|
||||||
);
|
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return c.json(
|
return c.json(
|
||||||
@@ -191,11 +182,7 @@ app.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await provider.pollForToken(
|
const result = await provider.pollForToken(config.clientId, config.clientSecret, device_code);
|
||||||
config.clientId,
|
|
||||||
config.clientSecret,
|
|
||||||
device_code,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.status !== "authorized") {
|
if (result.status !== "authorized") {
|
||||||
return c.json({ status: result.status });
|
return c.json({ status: result.status });
|
||||||
@@ -203,10 +190,7 @@ app.post(
|
|||||||
|
|
||||||
// Fetch user data and return it inline
|
// Fetch user data and return it inline
|
||||||
try {
|
try {
|
||||||
const data = await provider.fetchUserData(
|
const data = await provider.fetchUserData(result.accessToken, config.clientId);
|
||||||
result.accessToken,
|
|
||||||
config.clientId,
|
|
||||||
);
|
|
||||||
return c.json({ status: "authorized", data });
|
return c.json({ status: "authorized", data });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Auth succeeded but data fetch failed. Return a distinct status so
|
// Auth succeeded but data fetch failed. Return a distinct status so
|
||||||
@@ -217,10 +201,7 @@ app.post(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return c.json(
|
return c.json({ error: e instanceof Error ? e.message : "Poll failed" }, 502);
|
||||||
{ error: e instanceof Error ? e.message : "Poll failed" },
|
|
||||||
502,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
import { parseSimklPayload } from "@sofa/core/imports/parsers";
|
import { parseSimklPayload } from "@sofa/core/imports/parsers";
|
||||||
import type {
|
|
||||||
DeviceCodeResponse,
|
import type { DeviceCodeResponse, ImportProvider, NormalizedImport, PollResult } from "./types";
|
||||||
ImportProvider,
|
|
||||||
NormalizedImport,
|
|
||||||
PollResult,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
const API_BASE = "https://api.simkl.com";
|
const API_BASE = "https://api.simkl.com";
|
||||||
const AUTH_BASE = "https://simkl.com";
|
const AUTH_BASE = "https://simkl.com";
|
||||||
|
|
||||||
function simklHeaders(
|
function simklHeaders(clientId: string, token?: string): Record<string, string> {
|
||||||
clientId: string,
|
|
||||||
token?: string,
|
|
||||||
): Record<string, string> {
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"simkl-api-key": clientId,
|
"simkl-api-key": clientId,
|
||||||
@@ -121,14 +114,8 @@ export const simkl: ImportProvider = {
|
|||||||
// Fetch movies, shows, and anime in parallel
|
// Fetch movies, shows, and anime in parallel
|
||||||
const [moviesRes, showsRes, animeRes] = await Promise.all([
|
const [moviesRes, showsRes, animeRes] = await Promise.all([
|
||||||
fetch(`${API_BASE}/sync/all-items/movies`, { headers }),
|
fetch(`${API_BASE}/sync/all-items/movies`, { headers }),
|
||||||
fetch(
|
fetch(`${API_BASE}/sync/all-items/shows?extended=full&episode_watched_at=yes`, { headers }),
|
||||||
`${API_BASE}/sync/all-items/shows?extended=full&episode_watched_at=yes`,
|
fetch(`${API_BASE}/sync/all-items/anime?extended=full&episode_watched_at=yes`, { headers }),
|
||||||
{ headers },
|
|
||||||
),
|
|
||||||
fetch(
|
|
||||||
`${API_BASE}/sync/all-items/anime?extended=full&episode_watched_at=yes`,
|
|
||||||
{ headers },
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// If all endpoints failed, throw so the caller gets a clear error
|
// If all endpoints failed, throw so the caller gets a clear error
|
||||||
@@ -139,15 +126,9 @@ export const simkl: ImportProvider = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [moviesData, showsData, animeData] = await Promise.all([
|
const [moviesData, showsData, animeData] = await Promise.all([
|
||||||
moviesRes.ok
|
moviesRes.ok ? (moviesRes.json() as Promise<SimklApiItem[]>) : ([] as SimklApiItem[]),
|
||||||
? (moviesRes.json() as Promise<SimklApiItem[]>)
|
showsRes.ok ? (showsRes.json() as Promise<SimklApiItem[]>) : ([] as SimklApiItem[]),
|
||||||
: ([] as SimklApiItem[]),
|
animeRes.ok ? (animeRes.json() as Promise<SimklApiItem[]>) : ([] as SimklApiItem[]),
|
||||||
showsRes.ok
|
|
||||||
? (showsRes.json() as Promise<SimklApiItem[]>)
|
|
||||||
: ([] as SimklApiItem[]),
|
|
||||||
animeRes.ok
|
|
||||||
? (animeRes.json() as Promise<SimklApiItem[]>)
|
|
||||||
: ([] as SimklApiItem[]),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Flatten API's nested movie/show objects into the flat format
|
// Flatten API's nested movie/show objects into the flat format
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { parseTraktPayload } from "@sofa/core/imports/parsers";
|
import { parseTraktPayload } from "@sofa/core/imports/parsers";
|
||||||
import type {
|
|
||||||
DeviceCodeResponse,
|
import type { DeviceCodeResponse, ImportProvider, NormalizedImport, PollResult } from "./types";
|
||||||
ImportProvider,
|
|
||||||
NormalizedImport,
|
|
||||||
PollResult,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
const API_BASE = "https://api.trakt.tv";
|
const API_BASE = "https://api.trakt.tv";
|
||||||
|
|
||||||
function traktHeaders(
|
function traktHeaders(clientId: string, token?: string): Record<string, string> {
|
||||||
clientId: string,
|
|
||||||
token?: string,
|
|
||||||
): Record<string, string> {
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"trakt-api-version": "2",
|
"trakt-api-version": "2",
|
||||||
@@ -82,13 +75,12 @@ export const trakt: ImportProvider = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [moviesData, showsData, watchlistData, ratingsData] =
|
const [moviesData, showsData, watchlistData, ratingsData] = await Promise.all([
|
||||||
await Promise.all([
|
moviesRes.ok ? moviesRes.json() : [],
|
||||||
moviesRes.ok ? moviesRes.json() : [],
|
showsRes.ok ? showsRes.json() : [],
|
||||||
showsRes.ok ? showsRes.json() : [],
|
watchlistRes.ok ? watchlistRes.json() : [],
|
||||||
watchlistRes.ok ? watchlistRes.json() : [],
|
ratingsRes.ok ? ratingsRes.json() : [],
|
||||||
ratingsRes.ok ? ratingsRes.json() : [],
|
]);
|
||||||
]);
|
|
||||||
|
|
||||||
// Restructure API response into the format parseTraktPayload expects.
|
// Restructure API response into the format parseTraktPayload expects.
|
||||||
// The Trakt API returns the same item shapes as the JSON export format.
|
// The Trakt API returns the same item shapes as the JSON export format.
|
||||||
|
|||||||
@@ -17,17 +17,7 @@ export type PollResult =
|
|||||||
| { status: "denied" };
|
| { status: "denied" };
|
||||||
|
|
||||||
export interface ImportProvider {
|
export interface ImportProvider {
|
||||||
getDeviceCode(
|
getDeviceCode(clientId: string, clientSecret: string): Promise<DeviceCodeResponse>;
|
||||||
clientId: string,
|
pollForToken(clientId: string, clientSecret: string, deviceCode: string): Promise<PollResult>;
|
||||||
clientSecret: string,
|
fetchUserData(accessToken: string, clientId: string): Promise<NormalizedImport>;
|
||||||
): Promise<DeviceCodeResponse>;
|
|
||||||
pollForToken(
|
|
||||||
clientId: string,
|
|
||||||
clientSecret: string,
|
|
||||||
deviceCode: string,
|
|
||||||
): Promise<PollResult>;
|
|
||||||
fetchUserData(
|
|
||||||
accessToken: string,
|
|
||||||
clientId: string,
|
|
||||||
): Promise<NormalizedImport>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
||||||
|
"extends": ["../../.oxlintrc.json"]
|
||||||
|
}
|
||||||
@@ -6,16 +6,17 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun --env-file=../../.env --watch src/index.ts",
|
"dev": "bun --env-file=../../.env --watch src/index.ts",
|
||||||
"start": "bun --env-file=../../.env src/index.ts",
|
"start": "bun --env-file=../../.env src/index.ts",
|
||||||
"lint": "biome check",
|
"lint": "oxlint",
|
||||||
"format": "biome format --write",
|
"format": "oxfmt --config ../../.oxfmtrc.json",
|
||||||
|
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
|
||||||
"check-types": "tsc --noEmit"
|
"check-types": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@orpc/contract": "catalog:",
|
"@orpc/contract": "catalog:",
|
||||||
"@orpc/json-schema": "1.13.7",
|
"@orpc/json-schema": "1.13.8",
|
||||||
"@orpc/openapi": "1.13.7",
|
"@orpc/openapi": "1.13.8",
|
||||||
"@orpc/server": "1.13.7",
|
"@orpc/server": "1.13.8",
|
||||||
"@orpc/zod": "1.13.7",
|
"@orpc/zod": "1.13.8",
|
||||||
"@sofa/api": "workspace:*",
|
"@sofa/api": "workspace:*",
|
||||||
"@sofa/auth": "workspace:*",
|
"@sofa/auth": "workspace:*",
|
||||||
"@sofa/config": "workspace:*",
|
"@sofa/config": "workspace:*",
|
||||||
|
|||||||
+18
-71
@@ -1,3 +1,5 @@
|
|||||||
|
import { Cron } from "croner";
|
||||||
|
|
||||||
import { refreshAvailability } from "@sofa/core/availability";
|
import { refreshAvailability } from "@sofa/core/availability";
|
||||||
import { createBackup, ensureBackupDir, pruneBackups } from "@sofa/core/backup";
|
import { createBackup, ensureBackupDir, pruneBackups } from "@sofa/core/backup";
|
||||||
import { refreshCredits, syncCastProfileThumbHashes } from "@sofa/core/credits";
|
import { refreshCredits, syncCastProfileThumbHashes } from "@sofa/core/credits";
|
||||||
@@ -16,10 +18,7 @@ import {
|
|||||||
} from "@sofa/core/metadata";
|
} from "@sofa/core/metadata";
|
||||||
import { getSetting } from "@sofa/core/settings";
|
import { getSetting } from "@sofa/core/settings";
|
||||||
import { performTelemetryReport } from "@sofa/core/telemetry";
|
import { performTelemetryReport } from "@sofa/core/telemetry";
|
||||||
import {
|
import { generateTitleBackdropThumbHash, generateTitlePosterThumbHash } from "@sofa/core/thumbhash";
|
||||||
generateTitleBackdropThumbHash,
|
|
||||||
generateTitlePosterThumbHash,
|
|
||||||
} from "@sofa/core/thumbhash";
|
|
||||||
import { performUpdateCheck } from "@sofa/core/update-check";
|
import { performUpdateCheck } from "@sofa/core/update-check";
|
||||||
import { db } from "@sofa/db/client";
|
import { db } from "@sofa/db/client";
|
||||||
import { and, eq, inArray, isNotNull, lt, or, sql } from "@sofa/db/helpers";
|
import { and, eq, inArray, isNotNull, lt, or, sql } from "@sofa/db/helpers";
|
||||||
@@ -35,7 +34,6 @@ import {
|
|||||||
} from "@sofa/db/schema";
|
} from "@sofa/db/schema";
|
||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
import { getTvDetails } from "@sofa/tmdb/client";
|
import { getTvDetails } from "@sofa/tmdb/client";
|
||||||
import { Cron } from "croner";
|
|
||||||
|
|
||||||
export type BackupFrequency = "6h" | "12h" | "1d" | "7d";
|
export type BackupFrequency = "6h" | "12h" | "1d" | "7d";
|
||||||
|
|
||||||
@@ -132,14 +130,8 @@ function getThumbhashBackfillTitleIds(): string[] {
|
|||||||
.from(titles)
|
.from(titles)
|
||||||
.where(
|
.where(
|
||||||
or(
|
or(
|
||||||
and(
|
and(isNotNull(titles.posterPath), sql`${titles.posterThumbHash} IS NULL`),
|
||||||
isNotNull(titles.posterPath),
|
and(isNotNull(titles.backdropPath), sql`${titles.backdropThumbHash} IS NULL`),
|
||||||
sql`${titles.posterThumbHash} IS NULL`,
|
|
||||||
),
|
|
||||||
and(
|
|
||||||
isNotNull(titles.backdropPath),
|
|
||||||
sql`${titles.backdropThumbHash} IS NULL`,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.all()
|
.all()
|
||||||
@@ -150,12 +142,7 @@ function getThumbhashBackfillTitleIds(): string[] {
|
|||||||
db
|
db
|
||||||
.select({ titleId: seasons.titleId })
|
.select({ titleId: seasons.titleId })
|
||||||
.from(seasons)
|
.from(seasons)
|
||||||
.where(
|
.where(and(isNotNull(seasons.posterPath), sql`${seasons.posterThumbHash} IS NULL`))
|
||||||
and(
|
|
||||||
isNotNull(seasons.posterPath),
|
|
||||||
sql`${seasons.posterThumbHash} IS NULL`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.groupBy(seasons.titleId)
|
.groupBy(seasons.titleId)
|
||||||
.all()
|
.all()
|
||||||
.map((row) => row.titleId),
|
.map((row) => row.titleId),
|
||||||
@@ -166,12 +153,7 @@ function getThumbhashBackfillTitleIds(): string[] {
|
|||||||
.select({ titleId: seasons.titleId })
|
.select({ titleId: seasons.titleId })
|
||||||
.from(episodes)
|
.from(episodes)
|
||||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
||||||
.where(
|
.where(and(isNotNull(episodes.stillPath), sql`${episodes.stillThumbHash} IS NULL`))
|
||||||
and(
|
|
||||||
isNotNull(episodes.stillPath),
|
|
||||||
sql`${episodes.stillThumbHash} IS NULL`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.groupBy(seasons.titleId)
|
.groupBy(seasons.titleId)
|
||||||
.all()
|
.all()
|
||||||
.map((row) => row.titleId),
|
.map((row) => row.titleId),
|
||||||
@@ -182,12 +164,7 @@ function getThumbhashBackfillTitleIds(): string[] {
|
|||||||
.select({ titleId: titleCast.titleId })
|
.select({ titleId: titleCast.titleId })
|
||||||
.from(titleCast)
|
.from(titleCast)
|
||||||
.innerJoin(persons, eq(titleCast.personId, persons.id))
|
.innerJoin(persons, eq(titleCast.personId, persons.id))
|
||||||
.where(
|
.where(and(isNotNull(persons.profilePath), sql`${persons.profileThumbHash} IS NULL`))
|
||||||
and(
|
|
||||||
isNotNull(persons.profilePath),
|
|
||||||
sql`${persons.profileThumbHash} IS NULL`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.groupBy(titleCast.titleId)
|
.groupBy(titleCast.titleId)
|
||||||
.all()
|
.all()
|
||||||
.map((row) => row.titleId),
|
.map((row) => row.titleId),
|
||||||
@@ -207,12 +184,7 @@ async function nightlyRefreshLibrary() {
|
|||||||
const staleLibrary = db
|
const staleLibrary = db
|
||||||
.select({ id: titles.id })
|
.select({ id: titles.id })
|
||||||
.from(titles)
|
.from(titles)
|
||||||
.where(
|
.where(and(inArray(titles.id, libraryIds), lt(titles.lastFetchedAt, libraryStale)))
|
||||||
and(
|
|
||||||
inArray(titles.id, libraryIds),
|
|
||||||
lt(titles.lastFetchedAt, libraryStale),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
for (const { id } of staleLibrary) {
|
for (const { id } of staleLibrary) {
|
||||||
@@ -224,12 +196,7 @@ async function nightlyRefreshLibrary() {
|
|||||||
const nonLibrary = db
|
const nonLibrary = db
|
||||||
.select()
|
.select()
|
||||||
.from(titles)
|
.from(titles)
|
||||||
.where(
|
.where(and(isNotNull(titles.lastFetchedAt), lt(titles.lastFetchedAt, nonLibraryStale)))
|
||||||
and(
|
|
||||||
isNotNull(titles.lastFetchedAt),
|
|
||||||
lt(titles.lastFetchedAt, nonLibraryStale),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(50)
|
.limit(50)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
@@ -282,9 +249,7 @@ async function refreshAvailabilityJob() {
|
|||||||
|
|
||||||
async function refreshRecommendationsJob() {
|
async function refreshRecommendationsJob() {
|
||||||
const libraryIds = getLibraryTitleIds();
|
const libraryIds = getLibraryTitleIds();
|
||||||
log.debug(
|
log.debug(`Refreshing recommendations for ${libraryIds.length} library titles`);
|
||||||
`Refreshing recommendations for ${libraryIds.length} library titles`,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const titleId of libraryIds) {
|
for (const titleId of libraryIds) {
|
||||||
await refreshRecommendations(titleId);
|
await refreshRecommendations(titleId);
|
||||||
@@ -316,12 +281,7 @@ async function refreshTvChildrenJob() {
|
|||||||
? db
|
? db
|
||||||
.select({ titleId: seasons.titleId })
|
.select({ titleId: seasons.titleId })
|
||||||
.from(seasons)
|
.from(seasons)
|
||||||
.where(
|
.where(and(inArray(seasons.titleId, tvIds), lt(seasons.lastFetchedAt, stale)))
|
||||||
and(
|
|
||||||
inArray(seasons.titleId, tvIds),
|
|
||||||
lt(seasons.lastFetchedAt, stale),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.groupBy(seasons.titleId)
|
.groupBy(seasons.titleId)
|
||||||
.all()
|
.all()
|
||||||
.map((r) => r.titleId)
|
.map((r) => r.titleId)
|
||||||
@@ -340,17 +300,11 @@ async function refreshTvChildrenJob() {
|
|||||||
|
|
||||||
async function cacheImagesJob() {
|
async function cacheImagesJob() {
|
||||||
const titleIds = getThumbhashBackfillTitleIds();
|
const titleIds = getThumbhashBackfillTitleIds();
|
||||||
log.debug(
|
log.debug(`Caching images for ${titleIds.length} titles needing art backfill`);
|
||||||
`Caching images for ${titleIds.length} titles needing art backfill`,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const titleId of titleIds) {
|
for (const titleId of titleIds) {
|
||||||
try {
|
try {
|
||||||
const title = db
|
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
||||||
.select()
|
|
||||||
.from(titles)
|
|
||||||
.where(eq(titles.id, titleId))
|
|
||||||
.get();
|
|
||||||
if (!title) continue;
|
if (!title) continue;
|
||||||
|
|
||||||
// Phase 1: warm the image cache so thumbhash generation can read from disk
|
// Phase 1: warm the image cache so thumbhash generation can read from disk
|
||||||
@@ -370,18 +324,14 @@ async function cacheImagesJob() {
|
|||||||
hashTasks.push(generateTitlePosterThumbHash(titleId, title.posterPath));
|
hashTasks.push(generateTitlePosterThumbHash(titleId, title.posterPath));
|
||||||
}
|
}
|
||||||
if (!title.backdropThumbHash && title.backdropPath) {
|
if (!title.backdropThumbHash && title.backdropPath) {
|
||||||
hashTasks.push(
|
hashTasks.push(generateTitleBackdropThumbHash(titleId, title.backdropPath));
|
||||||
generateTitleBackdropThumbHash(titleId, title.backdropPath),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (title.type === "tv") {
|
if (title.type === "tv") {
|
||||||
hashTasks.push(syncTvChildArt(titleId, { warmCache: false }));
|
hashTasks.push(syncTvChildArt(titleId, { warmCache: false }));
|
||||||
}
|
}
|
||||||
|
|
||||||
hashTasks.push(
|
hashTasks.push(syncCastProfileThumbHashes(titleId, undefined, { warmCache: false }));
|
||||||
syncCastProfileThumbHashes(titleId, undefined, { warmCache: false }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all(hashTasks);
|
await Promise.all(hashTasks);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -404,9 +354,7 @@ async function refreshCreditsJob() {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
const needsRefresh =
|
const needsRefresh = !castEntry || (castEntry.lastFetchedAt && castEntry.lastFetchedAt < stale);
|
||||||
!castEntry ||
|
|
||||||
(castEntry.lastFetchedAt && castEntry.lastFetchedAt < stale);
|
|
||||||
|
|
||||||
if (needsRefresh) {
|
if (needsRefresh) {
|
||||||
await refreshCredits(titleId);
|
await refreshCredits(titleId);
|
||||||
@@ -453,8 +401,7 @@ export function buildBackupCron(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getBackupCronFromSettings(): string {
|
function getBackupCronFromSettings(): string {
|
||||||
const frequency = (getSetting("backupScheduleFrequency") ??
|
const frequency = (getSetting("backupScheduleFrequency") ?? "1d") as BackupFrequency;
|
||||||
"1d") as BackupFrequency;
|
|
||||||
const time = getSetting("backupScheduleTime") ?? "02:00";
|
const time = getSetting("backupScheduleTime") ?? "02:00";
|
||||||
const dayOfWeek = Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10);
|
const dayOfWeek = Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10);
|
||||||
return buildBackupCron(frequency, time, dayOfWeek);
|
return buildBackupCron(frequency, time, dayOfWeek);
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { type Context, Hono } from "hono";
|
||||||
|
import { serveStatic } from "hono/bun";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
|
|
||||||
import { CACHE_DIR } from "@sofa/config";
|
import { CACHE_DIR } from "@sofa/config";
|
||||||
import { ensureBackupDir } from "@sofa/core/backup";
|
import { ensureBackupDir } from "@sofa/core/backup";
|
||||||
import { ensureImageDirs, imageCacheEnabled } from "@sofa/core/image-cache";
|
import { ensureImageDirs, imageCacheEnabled } from "@sofa/core/image-cache";
|
||||||
@@ -5,9 +9,7 @@ import { registerJobScheduleProvider } from "@sofa/core/system-health";
|
|||||||
import { closeDatabase } from "@sofa/db/client";
|
import { closeDatabase } from "@sofa/db/client";
|
||||||
import { runMigrations } from "@sofa/db/migrate";
|
import { runMigrations } from "@sofa/db/migrate";
|
||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
import { type Context, Hono } from "hono";
|
|
||||||
import { serveStatic } from "hono/bun";
|
|
||||||
import { cors } from "hono/cors";
|
|
||||||
import { getJobSchedules, startJobs, stopJobs } from "./cron";
|
import { getJobSchedules, startJobs, stopJobs } from "./cron";
|
||||||
import { handler as rpcHandler } from "./orpc/handler";
|
import { handler as rpcHandler } from "./orpc/handler";
|
||||||
import { openApiHandler } from "./orpc/openapi-handler";
|
import { openApiHandler } from "./orpc/openapi-handler";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user