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:
2026-03-18 13:26:50 -04:00
co-authored by Claude Opus 4.6
parent a80c8c9a0c
commit 2b0c683b7b
359 changed files with 3894 additions and 7208 deletions
+3 -4
View File
@@ -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
-1
View File
@@ -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:*)'
+2 -1
View File
@@ -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*"]
+8
View File
@@ -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
+54
View File
@@ -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"
]
}
+18
View File
@@ -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"]
}
+1 -1
View File
@@ -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"
] ]
+9 -10
View File
@@ -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",
+7 -4
View File
@@ -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.
+14 -14
View File
@@ -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.
+5
View File
@@ -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 -3
View File
@@ -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);
+6 -5
View File
@@ -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",
+2 -3
View File
@@ -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() {
+15 -46
View File
@@ -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>
+11 -39
View File
@@ -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">
+12 -37
View File
@@ -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>
+15 -29
View File
@@ -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>
)} )}
+10 -29
View File
@@ -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 ?? []}
+9 -25
View File
@@ -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();
} }
}} }}
+47 -99
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+6 -17
View File
@@ -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() {
+7 -13
View File
@@ -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" />
+41 -87
View File
@@ -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,
+39 -119
View File
@@ -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>
+5 -19
View File
@@ -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>
+3 -7
View File
@@ -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>
</> </>
+6 -14
View File
@@ -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>
+1 -6
View File
@@ -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"
+5 -20
View File
@@ -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
View File
@@ -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>
+24 -50
View File
@@ -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>
); );
} }
+7 -10
View File
@@ -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>
+1 -7
View File
@@ -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);
+1 -5
View File
@@ -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>
+1 -7
View File
@@ -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 }],
+24 -44
View File
@@ -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>
); );
+1 -3
View File
@@ -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]),
}, },
], ],
})); }));
+4 -11
View File
@@ -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;
+8 -25
View File
@@ -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`),
+4 -5
View File
@@ -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
View File
@@ -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 {
+3 -5
View File
@@ -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";
-5
View File
@@ -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 -3
View File
@@ -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);
+1 -4
View File
@@ -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 -1
View File
@@ -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;
+3 -9
View File
@@ -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);
} }
+14 -32
View File
@@ -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);
+5 -10
View File
@@ -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 {
+4 -8
View File
@@ -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),
}; };
+1 -4
View File
@@ -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 = "";
+2 -7
View File
@@ -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]);
} }
+1 -7
View File
@@ -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"
]
} }
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "../../node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.json"]
}
+3 -2
View File
@@ -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": {
+9 -28
View File
@@ -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,
);
} }
}, },
); );
+8 -27
View File
@@ -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
+9 -17
View File
@@ -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.
+3 -13
View File
@@ -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>;
} }
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "../../node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.json"]
}
+7 -6
View File
@@ -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
View File
@@ -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);
+5 -3
View File
@@ -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