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
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
plugin_marketplaces: "https://github.com/anthropics/claude-code.git"
plugins: "code-review@claude-code-plugins"
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
# 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
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
+2 -1
View File
@@ -11,7 +11,8 @@ on:
- "turbo.json"
- "package.json"
- "bun.lock"
- "biome.json"
- ".oxlintrc.json"
- ".oxfmtrc.json"
- "tsconfig.json"
- ".github/workflows/docker.yml"
tags: ["v*"]
+8
View File
@@ -12,6 +12,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -25,6 +30,9 @@ jobs:
- name: Lint
run: bunx turbo run lint
- name: Format check
run: bunx turbo run format:check
- name: Type check
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": [
"biomejs.biome",
"bradlc.vscode-tailwindcss",
"expo.vscode-expo-tools",
"oxc.oxc-vscode",
"vercel.turbo-vsc",
"vitest.explorer"
]
+9 -10
View File
@@ -1,11 +1,10 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.defaultFormatter": "biomejs.biome",
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
"source.fixAll.oxlint": "explicit"
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
@@ -18,25 +17,25 @@
},
"emmet.showExpandedAbbreviation": "never",
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"tailwindCSS.classAttributes": [
"class",
+7 -4
View File
@@ -6,8 +6,8 @@
# Root commands (via Turborepo)
bun run dev # Start API server + Vite dev server
bun run build # Production build (both apps)
bun run lint # Biome lint check
bun run format # Biome format (auto-fix)
bun run lint # Oxlint lint check
bun run format # Oxfmt format (auto-fix)
bun run check-types # TypeScript type check
bun run test # Run tests
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)
│ ├── logger/ # @sofa/logger — Pino-based structured logging (JIT)
│ └── tmdb/ # @sofa/tmdb — TMDB API client + image URL helper (JIT)
├── .oxlintrc.json
├── .oxfmtrc.json
├── turbo.json
├── biome.json
├── Dockerfile
└── 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
- **Monorepo**: Turborepo with Bun workspaces
- **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)
### 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/`.
Cross-package imports:
- `@sofa/api/contract`, `@sofa/api/schemas` — Contract and Zod types
- `@sofa/db/client`, `@sofa/db/schema`, `@sofa/db/helpers`, `@sofa/db/migrate`, `@sofa/db/test-utils`
- `@sofa/tmdb/client`, `@sofa/tmdb/image`
@@ -107,6 +109,7 @@ Cross-package imports:
Required: `TMDB_API_READ_ACCESS_TOKEN`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`.
Optional:
- `DATA_DIR` — Root for DB + cache (default `./data`). `DATABASE_URL` and `CACHE_DIR` derived from it but overridable.
- `TMDB_API_BASE_URL`, `TMDB_IMAGE_BASE_URL` — Override TMDB endpoints.
- `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
| Variable | Required | Notes |
| --- | --- | --- |
| `TMDB_API_READ_ACCESS_TOKEN` | Yes | TMDB metadata and discovery |
| `BETTER_AUTH_SECRET` | Yes | Session and auth secret |
| `BETTER_AUTH_URL` | Yes | Public base URL of the app |
| `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 |
| `LOG_LEVEL` | No | `error`, `warn`, `info`, or `debug` |
| `OIDC_CLIENT_ID` | No | Enable OIDC when set with the matching secret and issuer |
| `OIDC_CLIENT_SECRET` | No | OIDC client secret |
| `OIDC_ISSUER_URL` | No | OIDC issuer URL |
| `OIDC_PROVIDER_NAME` | No | Login button label. Defaults to `SSO` |
| `OIDC_AUTO_REGISTER` | No | Defaults to `true` |
| `DISABLE_PASSWORD_LOGIN` | No | Set to `true` to hide email/password login when OIDC is configured |
| Variable | Required | Notes |
| ---------------------------- | -------- | ----------------------------------------------------------------------------------------------- |
| `TMDB_API_READ_ACCESS_TOKEN` | Yes | TMDB metadata and discovery |
| `BETTER_AUTH_SECRET` | Yes | Session and auth secret |
| `BETTER_AUTH_URL` | Yes | Public base URL of the app |
| `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 |
| `LOG_LEVEL` | No | `error`, `warn`, `info`, or `debug` |
| `OIDC_CLIENT_ID` | No | Enable OIDC when set with the matching secret and issuer |
| `OIDC_CLIENT_SECRET` | No | OIDC client secret |
| `OIDC_ISSUER_URL` | No | OIDC issuer URL |
| `OIDC_PROVIDER_NAME` | No | Login button label. Defaults to `SSO` |
| `OIDC_AUTO_REGISTER` | No | Defaults to `true` |
| `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.
+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 { withUniwindConfig } = require("uniwind/metro");
const {
wrapWithReanimatedMetroConfig,
} = require("react-native-reanimated/metro-config");
const { wrapWithReanimatedMetroConfig } = require("react-native-reanimated/metro-config");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
+6 -5
View File
@@ -9,8 +9,9 @@
"android": "expo run:android",
"ios": "expo run:ios",
"prebuild": "expo prebuild",
"lint": "biome check",
"format": "biome format --write",
"lint": "oxlint",
"format": "oxfmt --config ../../.oxfmtrc.json",
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
"check-types": "tsc --noEmit"
},
"dependencies": {
@@ -35,10 +36,10 @@
"@sofa/api": "workspace:*",
"@sofa/i18n": "workspace:*",
"@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-query": "catalog:",
"@tanstack/react-query-persist-client": "5.90.24",
"@tanstack/react-query-persist-client": "5.90.25",
"better-auth": "catalog:",
"burnt": "0.13.0",
"expo": "55.0.7",
@@ -63,7 +64,7 @@
"expo-system-ui": "55.0.10",
"expo-tracking-transparency": "55.0.9",
"expo-web-browser": "55.0.10",
"posthog-react-native": "4.37.3",
"posthog-react-native": "4.37.4",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.2",
+2 -3
View File
@@ -1,12 +1,11 @@
import { Stack } from "expo-router";
import { useResolveClassNames } from "uniwind";
import { hasStoredServerUrl } from "@/lib/server";
export const unstable_settings = {
initialRouteName:
process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl()
? "login"
: "server-url",
process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl() ? "login" : "server-url",
};
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 { useCSSVariable } from "uniwind";
import { z } from "zod";
import { AuthScreen } from "@/components/auth-screen";
import { Button, ButtonLabel } from "@/components/ui/button";
import { ScaledIcon } from "@/components/ui/scaled-icon";
@@ -22,11 +23,7 @@ import { getFormErrors } from "@/utils/form-errors";
import * as Haptics from "@/utils/haptics";
const signInSchema = z.object({
email: z
.string()
.trim()
.min(1, "Email is required")
.email("Enter a valid email address"),
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
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 busy = isSubmitting || isSignedIn;
const statusCompletedColor = useCSSVariable(
"--color-status-completed",
) as string;
const statusCompletedColor = useCSSVariable("--color-status-completed") as string;
const serverHost = splitUrl(getServerUrl()).host;
const showPasswordLogin = !authConfig.data?.passwordLoginDisabled;
@@ -90,10 +85,7 @@ export default function LoginScreen() {
return (
<AuthScreen title="Sofa" subtitle={t`Sign in to continue`}>
{showOidc && (
<Animated.View
entering={FadeInDown.duration(300).delay(100)}
className="mb-4"
>
<Animated.View entering={FadeInDown.duration(300).delay(100)} className="mb-4">
<Button
onPress={() => {
authClient.signIn.oauth2({
@@ -105,19 +97,17 @@ export default function LoginScreen() {
className="w-full"
>
<ButtonLabel>
<Trans>
Sign in with {authConfig.data?.oidcProviderName ?? "SSO"}
</Trans>
<Trans>Sign in with {authConfig.data?.oidcProviderName ?? "SSO"}</Trans>
</ButtonLabel>
</Button>
{showPasswordLogin && (
<View className="my-4 flex-row items-center">
<View className="h-px flex-1 bg-border" />
<Text className="px-3 text-muted-foreground text-xs">
<View className="bg-border h-px flex-1" />
<Text className="text-muted-foreground px-3 text-xs">
<Trans>OR</Trans>
</Text>
<View className="h-px flex-1 bg-border" />
<View className="bg-border h-px flex-1" />
</View>
)}
</Animated.View>
@@ -148,11 +138,7 @@ export default function LoginScreen() {
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => passwordRef.current?.focus()}
className={
errorFields.has("email")
? "border-destructive"
: undefined
}
className={errorFields.has("email") ? "border-destructive" : undefined}
/>
</TextField>
)}
@@ -181,11 +167,7 @@ export default function LoginScreen() {
textContentType="password"
returnKeyType="go"
onSubmitEditing={form.handleSubmit}
className={
errorFields.has("password")
? "border-destructive"
: undefined
}
className={errorFields.has("password") ? "border-destructive" : undefined}
/>
</TextField>
)}
@@ -193,11 +175,7 @@ export default function LoginScreen() {
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
<Button
onPress={form.handleSubmit}
disabled={busy}
className="mt-2"
>
<Button onPress={form.handleSubmit} disabled={busy} className="mt-2">
{busy ? (
<Spinner size="sm" />
) : (
@@ -220,10 +198,7 @@ export default function LoginScreen() {
</Animated.View>
)}
<Animated.View
entering={FadeIn.duration(300).delay(500)}
className="mt-8 items-center"
>
<Animated.View entering={FadeIn.duration(300).delay(500)} className="mt-8 items-center">
<Link href="/(auth)/server-url" replace asChild>
<Pressable
disabled={busy}
@@ -231,16 +206,10 @@ export default function LoginScreen() {
accessibilityState={{ disabled: busy }}
className="flex-row items-center gap-1.5"
>
<ScaledIcon
icon={IconServer2}
size={14}
color={statusCompletedColor}
/>
<Text className="font-sans text-muted-foreground text-xs">
<ScaledIcon icon={IconServer2} size={14} color={statusCompletedColor} />
<Text className="text-muted-foreground font-sans text-xs">
<Trans>
Connected to{" "}
<Text className="font-medium">{serverHost}</Text>. Tap to
change.
Connected to <Text className="font-medium">{serverHost}</Text>. Tap to change.
</Trans>
</Text>
</Pressable>
+11 -39
View File
@@ -6,6 +6,7 @@ import { useRef, useState } from "react";
import { Pressable, type TextInput, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { z } from "zod";
import { AuthScreen } from "@/components/auth-screen";
import { Button, ButtonLabel } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
@@ -19,20 +20,9 @@ import { getFormErrors } from "@/utils/form-errors";
import * as Haptics from "@/utils/haptics";
const signUpSchema = z.object({
name: z
.string()
.trim()
.min(1, "Name is required")
.min(2, "Name must be at least 2 characters"),
email: z
.string()
.trim()
.min(1, "Email is required")
.email("Enter a valid email address"),
password: z
.string()
.min(1, "Password is required")
.min(8, "Use at least 8 characters"),
name: z.string().trim().min(1, "Name is required").min(2, "Name must be at least 2 characters"),
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"),
});
export default function RegisterScreen() {
@@ -99,7 +89,7 @@ export default function RegisterScreen() {
>
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<Link href="/(auth)/login" asChild>
<Button className="mt-6 bg-primary">
<Button className="bg-primary mt-6">
<ButtonLabel className="text-primary-foreground">
<Trans>Back to Login</Trans>
</ButtonLabel>
@@ -111,10 +101,7 @@ export default function RegisterScreen() {
}
return (
<AuthScreen
title={t`Create Account`}
subtitle={t`Registering on ${serverHost}`}
>
<AuthScreen title={t`Create Account`} subtitle={t`Registering on ${serverHost}`}>
<View className="gap-3">
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<form.Field name="name">
@@ -137,9 +124,7 @@ export default function RegisterScreen() {
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => emailRef.current?.focus()}
className={
errorFields.has("name") ? "border-destructive" : undefined
}
className={errorFields.has("name") ? "border-destructive" : undefined}
/>
</TextField>
)}
@@ -170,9 +155,7 @@ export default function RegisterScreen() {
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => passwordRef.current?.focus()}
className={
errorFields.has("email") ? "border-destructive" : undefined
}
className={errorFields.has("email") ? "border-destructive" : undefined}
/>
</TextField>
)}
@@ -201,11 +184,7 @@ export default function RegisterScreen() {
textContentType="newPassword"
returnKeyType="go"
onSubmitEditing={form.handleSubmit}
className={
errorFields.has("password")
? "border-destructive"
: undefined
}
className={errorFields.has("password") ? "border-destructive" : undefined}
/>
</TextField>
)}
@@ -213,11 +192,7 @@ export default function RegisterScreen() {
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
<Button
onPress={form.handleSubmit}
disabled={busy}
className="mt-1 bg-primary"
>
<Button onPress={form.handleSubmit} disabled={busy} className="bg-primary mt-1">
{busy ? (
<Spinner size="sm" />
) : (
@@ -229,10 +204,7 @@ export default function RegisterScreen() {
</Animated.View>
</View>
<Animated.View
entering={FadeIn.duration(300).delay(500)}
className="mt-6 items-center"
>
<Animated.View entering={FadeIn.duration(300).delay(500)} className="mt-6 items-center">
<Link href="/(auth)/login" asChild>
<Pressable disabled={busy}>
<Text className="text-primary text-sm">
+12 -37
View File
@@ -1,9 +1,5 @@
import { Trans, useLingui } from "@lingui/react/macro";
import {
IconAlertCircle,
IconCircleCheck,
IconInfoCircle,
} from "@tabler/icons-react-native";
import { IconAlertCircle, IconCircleCheck, IconInfoCircle } from "@tabler/icons-react-native";
import { useRouter } from "expo-router";
import { useEffect, useRef, useState } from "react";
import { Linking, Pressable, type TextInput, View } from "react-native";
@@ -16,16 +12,13 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { AuthScreen } from "@/components/auth-screen";
import { Button, ButtonLabel } from "@/components/ui/button";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
import { Input } from "@/components/ui/text-field";
import {
getServerUrl,
serverManager,
type ValidationError,
} from "@/lib/server";
import { getServerUrl, serverManager, type ValidationError } from "@/lib/server";
import * as Haptics from "@/utils/haptics";
type ConnectionState =
@@ -52,16 +45,12 @@ export default function ServerUrlScreen() {
const inputRef = useRef<TextInput>(null);
const successTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const [url, setUrl] = useState(() =>
serverManager.hasStoredServerUrl() ? getServerUrl() : "",
);
const [url, setUrl] = useState(() => (serverManager.hasStoredServerUrl() ? getServerUrl() : ""));
const [connection, setConnection] = useState<ConnectionState>({
phase: "idle",
});
const statusCompletedColor = useCSSVariable(
"--color-status-completed",
) as string;
const statusCompletedColor = useCSSVariable("--color-status-completed") as string;
const destructiveColor = useCSSVariable("--color-destructive") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
@@ -172,37 +161,23 @@ export default function ServerUrlScreen() {
</Animated.View>
{/* Connect Button / Status */}
<Animated.View
entering={FadeInDown.duration(300).delay(300)}
className="mt-4"
>
<Animated.View entering={FadeInDown.duration(300).delay(300)} className="mt-4">
{isConnecting ? (
<View className="min-h-12 flex-row items-center justify-center gap-2 py-2">
<Animated.View
className="size-1.5 rounded-full bg-primary"
style={dotAnimatedStyle}
/>
<Animated.View className="bg-primary size-1.5 rounded-full" style={dotAnimatedStyle} />
<Text className="text-muted-foreground text-sm">
<Trans>Connecting to server...</Trans>
</Text>
</View>
) : isSuccess ? (
<View className="min-h-12 flex-row items-center justify-center gap-1.5 py-2">
<ScaledIcon
icon={IconCircleCheck}
size={16}
color={statusCompletedColor}
/>
<Text className="font-medium font-sans text-sm text-status-completed">
<ScaledIcon icon={IconCircleCheck} size={16} color={statusCompletedColor} />
<Text className="text-status-completed font-sans text-sm font-medium">
<Trans>Connected</Trans>
</Text>
</View>
) : (
<Button
onPress={handleConnect}
disabled={!isValidUrl}
className="bg-primary"
>
<Button onPress={handleConnect} disabled={!isValidUrl} className="bg-primary">
<ButtonLabel>
<Trans>Connect</Trans>
</ButtonLabel>
@@ -222,7 +197,7 @@ export default function ServerUrlScreen() {
color={destructiveColor}
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]}
</Text>
</Animated.View>
@@ -238,7 +213,7 @@ export default function ServerUrlScreen() {
className="flex-row items-center gap-1.5"
>
<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>
</Text>
</Pressable>
+15 -29
View File
@@ -25,23 +25,13 @@ export default function ExploreScreen() {
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
);
const popularMovies = useQuery(
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
);
const popularTv = useQuery(
orpc.explore.popular.queryOptions({ input: { type: "tv" } }),
);
const movieGenres = useQuery(
orpc.explore.genres.queryOptions({ input: { type: "movie" } }),
);
const tvGenres = useQuery(
orpc.explore.genres.queryOptions({ input: { type: "tv" } }),
);
const popularMovies = useQuery(orpc.explore.popular.queryOptions({ input: { type: "movie" } }));
const popularTv = useQuery(orpc.explore.popular.queryOptions({ input: { type: "tv" } }));
const movieGenres = useQuery(orpc.explore.genres.queryOptions({ input: { type: "movie" } }));
const tvGenres = useQuery(orpc.explore.genres.queryOptions({ input: { type: "tv" } }));
const isRefreshing =
trending.isRefetching ||
popularMovies.isRefetching ||
popularTv.isRefetching;
trending.isRefetching || popularMovies.isRefetching || popularTv.isRefetching;
const onRefresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: orpc.explore.key() });
@@ -56,18 +46,18 @@ export default function ExploreScreen() {
);
const trendingStatuses = useMemo(
() =>
Object.assign(
{},
...(trending.data?.pages.map((p) => p.userStatuses) ?? []),
) as Record<string, "watchlist" | "in_progress" | "completed">,
Object.assign({}, ...(trending.data?.pages.map((p) => p.userStatuses) ?? [])) as Record<
string,
"watchlist" | "in_progress" | "completed"
>,
[trending.data?.pages],
);
const trendingProgress = useMemo(
() =>
Object.assign(
{},
...(trending.data?.pages.map((p) => p.episodeProgress) ?? []),
) as Record<string, { watched: number; total: number }>,
Object.assign({}, ...(trending.data?.pages.map((p) => p.episodeProgress) ?? [])) as Record<
string,
{ watched: number; total: number }
>,
[trending.data?.pages],
);
@@ -77,15 +67,11 @@ export default function ExploreScreen() {
contentContainerStyle={exploreContentContainerStyle}
contentInsetAdjustmentBehavior="automatic"
scrollToOverflowEnabled
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />
}
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />}
>
<View className="gap-8">
{heroItem && (
<Animated.View
entering={FadeIn.duration(400).withInitialValues({ opacity: 0.01 })}
>
<Animated.View entering={FadeIn.duration(400).withInitialValues({ opacity: 0.01 })}>
<HeroBanner item={heroItem} />
</Animated.View>
)}
+10 -29
View File
@@ -1,10 +1,6 @@
import { useLingui } from "@lingui/react/macro";
import { FlashList } from "@shopify/flash-list";
import {
IconBooks,
IconPlayerPlay,
IconThumbUp,
} from "@tabler/icons-react-native";
import { IconBooks, IconPlayerPlay, IconThumbUp } from "@tabler/icons-react-native";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useCallback, useMemo } from "react";
@@ -36,16 +32,11 @@ export default function DashboardScreen() {
authClient.useSession();
const stats = useQuery(orpc.dashboard.stats.queryOptions());
const continueWatching = useQuery(
orpc.dashboard.continueWatching.queryOptions(),
);
const continueWatching = useQuery(orpc.dashboard.continueWatching.queryOptions());
const library = useQuery(orpc.dashboard.library.queryOptions({ input: {} }));
const recommendations = useQuery(
orpc.dashboard.recommendations.queryOptions(),
);
const recommendations = useQuery(orpc.dashboard.recommendations.queryOptions());
const isRefreshing =
stats.isRefetching || continueWatching.isRefetching || library.isRefetching;
const isRefreshing = stats.isRefetching || continueWatching.isRefetching || library.isRefetching;
const onRefresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
@@ -72,11 +63,9 @@ export default function DashboardScreen() {
[],
);
const renderContinueWatchingItem = useCallback(
({
item,
}: {
item: NonNullable<typeof continueWatching.data>["items"][number];
}) => <ContinueWatchingCard item={item} />,
({ item }: { item: NonNullable<typeof continueWatching.data>["items"][number] }) => (
<ContinueWatchingCard item={item} />
),
[],
);
@@ -86,9 +75,7 @@ export default function DashboardScreen() {
contentContainerStyle={dashboardContentContainerStyle}
contentInsetAdjustmentBehavior="automatic"
scrollToOverflowEnabled
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />
}
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />}
>
<View className="gap-8">
{/* Stats */}
@@ -109,10 +96,7 @@ export default function DashboardScreen() {
{hasContinueWatching && (
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<View className="px-4">
<SectionHeader
title={t`Continue Watching`}
icon={IconPlayerPlay}
/>
<SectionHeader title={t`Continue Watching`} icon={IconPlayerPlay} />
</View>
<FlashList
horizontal
@@ -150,10 +134,7 @@ export default function DashboardScreen() {
{hasRecommendations && (
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
<View className="px-4">
<SectionHeader
title={t`Recommended for You`}
icon={IconThumbUp}
/>
<SectionHeader title={t`Recommended for You`} icon={IconThumbUp} />
</View>
<HorizontalPosterRow
items={recommendations.data?.items ?? []}
+9 -25
View File
@@ -5,11 +5,9 @@ import { Stack } from "expo-router";
import { useCallback, useMemo, useState } from "react";
import { ActivityIndicator, View } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated";
import { RecentlyViewedList } from "@/components/search/recently-viewed-list";
import {
type SearchResultItem,
SearchResultRow,
} from "@/components/search/search-result-row";
import { type SearchResultItem, SearchResultRow } from "@/components/search/search-result-row";
import { Spinner } from "@/components/ui/spinner";
import { Text } from "@/components/ui/text";
import { useDebounce } from "@/hooks/use-debounce";
@@ -58,28 +56,19 @@ export default function SearchScreen() {
[searchResults.data?.pages],
);
const addingId = quickAddMutation.isPending
? (quickAddMutation.variables?.id ?? null)
: null;
const addingId = quickAddMutation.isPending ? (quickAddMutation.variables?.id ?? null) : null;
const renderItem = useCallback(
({ item }: { item: SearchResultItem }) => (
<SearchResultRow
item={item}
onQuickAdd={handleQuickAdd}
isAdding={addingId === item.id}
/>
<SearchResultRow item={item} onQuickAdd={handleQuickAdd} isAdding={addingId === item.id} />
),
[handleQuickAdd, addingId],
);
const keyExtractor = useCallback(
(item: SearchResultItem) => `${item.type}-${item.id}`,
[],
);
const keyExtractor = useCallback((item: SearchResultItem) => `${item.type}-${item.id}`, []);
return (
<View className="flex-1 bg-background">
<View className="bg-background flex-1">
<Stack.Header
transparent={false}
style={{ backgroundColor: "#000" }}
@@ -93,9 +82,7 @@ export default function SearchScreen() {
onClose={() => setQuery("")}
hideWhenScrolling={false}
placement={process.env.EXPO_OS === "ios" ? "integrated" : undefined}
allowToolbarIntegration={
process.env.EXPO_OS === "ios" ? true : undefined
}
allowToolbarIntegration={process.env.EXPO_OS === "ios" ? true : undefined}
/>
{debouncedQuery.length === 0 ? (
@@ -109,7 +96,7 @@ export default function SearchScreen() {
entering={FadeIn.duration(300)}
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>
</Text>
</Animated.View>
@@ -121,10 +108,7 @@ export default function SearchScreen() {
keyboardShouldPersistTaps="handled"
contentInsetAdjustmentBehavior="automatic"
onEndReached={() => {
if (
searchResults.hasNextPage &&
!searchResults.isFetchingNextPage
) {
if (searchResults.hasNextPage && !searchResults.isFetchingNextPage) {
searchResults.fetchNextPage();
}
}}
+47 -99
View File
@@ -1,7 +1,5 @@
import { plural } from "@lingui/core/macro";
import { Trans, useLingui } from "@lingui/react/macro";
import { activateLocale, type SupportedLocale } from "@sofa/i18n";
import { LOCALE_INFO } from "@sofa/i18n/locales";
import {
IconArrowUpRight,
IconBrandGithub,
@@ -39,6 +37,7 @@ import {
import Animated, { FadeInDown } from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import * as DropdownMenu from "zeego/dropdown-menu";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { SettingsRow } from "@/components/settings/settings-row";
import { SettingsSection } from "@/components/settings/settings-section";
@@ -55,6 +54,8 @@ import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client";
import { authClient, getServerUrl } from "@/lib/server";
import { toast } from "@/lib/toast";
import { activateLocale, type SupportedLocale } from "@sofa/i18n";
import { LOCALE_INFO } from "@sofa/i18n/locales";
const settingsContentContainerStyle = {
paddingTop: 8,
@@ -74,8 +75,7 @@ export default function SettingsScreen() {
}, [session?.user?.name, isEditingName]);
const [languageModalOpen, setLanguageModalOpen] = useState(false);
const languageLabel =
LOCALE_INFO.find((o) => o.code === i18n.locale)?.nativeName ?? i18n.locale;
const languageLabel = LOCALE_INFO.find((o) => o.code === i18n.locale)?.nativeName ?? i18n.locale;
const [analyticsEnabled, setAnalyticsToggle] = useState(isAnalyticsEnabled);
const isAdmin = session?.user?.role === "admin";
@@ -90,11 +90,8 @@ export default function SettingsScreen() {
},
});
const hasPassword =
accounts?.some(
(a: { providerId: string }) => a.providerId === "credential",
) ?? false;
const showPasswordOption =
hasPassword && !(authConfig.data?.passwordLoginDisabled ?? true);
accounts?.some((a: { providerId: string }) => a.providerId === "credential") ?? false;
const showPasswordOption = hasPassword && !(authConfig.data?.passwordLoginDisabled ?? true);
const systemHealth = useQuery({
...orpc.admin.systemHealth.queryOptions(),
@@ -181,9 +178,7 @@ export default function SettingsScreen() {
const toggleUpdateCheck = useMutation(
orpc.admin.toggleUpdateCheck.mutationOptions({
onSuccess: (_data, { enabled }) => {
toast.success(
enabled ? t`Update checks enabled` : t`Update checks disabled`,
);
toast.success(enabled ? t`Update checks enabled` : t`Update checks disabled`);
queryClient.invalidateQueries({
queryKey: orpc.admin.updateCheck.key(),
});
@@ -222,9 +217,7 @@ export default function SettingsScreen() {
contentContainerStyle={settingsContentContainerStyle}
contentInsetAdjustmentBehavior="automatic"
scrollToOverflowEnabled
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
>
{/* Account */}
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
@@ -240,7 +233,7 @@ export default function SettingsScreen() {
className="mr-3"
hitSlop={8}
>
<View className="size-11 overflow-hidden rounded-full bg-secondary">
<View className="bg-secondary size-11 overflow-hidden rounded-full">
{uploadAvatar.isPending ? (
<View className="flex-1 items-center justify-center">
<Spinner size="sm" colorClassName="accent-primary" />
@@ -253,16 +246,14 @@ export default function SettingsScreen() {
/>
)}
</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} />
</View>
</Pressable>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item key="change" onSelect={() => pickAvatar()}>
<DropdownMenu.ItemIcon
ios={{ name: "photo.on.rectangle.angled" }}
/>
<DropdownMenu.ItemIcon ios={{ name: "photo.on.rectangle.angled" }} />
<DropdownMenu.ItemTitle>
<Trans>Change Photo</Trans>
</DropdownMenu.ItemTitle>
@@ -287,20 +278,20 @@ export default function SettingsScreen() {
className="mr-3"
hitSlop={8}
>
<View className="size-11 overflow-hidden rounded-full bg-secondary">
<View className="bg-secondary size-11 overflow-hidden rounded-full">
{uploadAvatar.isPending ? (
<View className="flex-1 items-center justify-center">
<Spinner size="sm" colorClassName="accent-primary" />
</View>
) : (
<View className="flex-1 items-center justify-center bg-primary/[0.08]">
<Text className="font-display font-medium text-lg text-primary">
<View className="bg-primary/[0.08] flex-1 items-center justify-center">
<Text className="font-display text-primary text-lg font-medium">
{session?.user?.name?.charAt(0)?.toUpperCase() ?? "?"}
</Text>
</View>
)}
</View>
<View className="absolute right-0 bottom-0 size-[18px] items-center justify-center rounded-full bg-primary">
<View className="bg-primary absolute right-0 bottom-0 size-[18px] items-center justify-center rounded-full">
<IconCamera size={10} color={primaryFgColor} />
</View>
</Pressable>
@@ -312,12 +303,10 @@ export default function SettingsScreen() {
value={nameInput}
accessibilityLabel="Display name"
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
/>
<Pressable
onPress={() => updateName.mutate({ name: nameInput })}
>
<Pressable onPress={() => updateName.mutate({ name: nameInput })}>
<Text className="text-primary text-sm">
<Trans>Save</Trans>
</Text>
@@ -335,20 +324,20 @@ export default function SettingsScreen() {
</View>
) : (
<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}
</Text>
</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}
</Text>
</View>
{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
maxFontSizeMultiplier={1.0}
className="font-medium font-sans text-primary text-xs"
className="text-primary font-sans text-xs font-medium"
>
<Trans>Admin</Trans>
</Text>
@@ -364,12 +353,7 @@ export default function SettingsScreen() {
/>
)}
<SettingsRow
label={t`Sign out`}
icon={IconLogout}
onPress={handleSignOut}
destructive
/>
<SettingsRow label={t`Sign out`} icon={IconLogout} onPress={handleSignOut} destructive />
</SettingsSection>
</Animated.View>
@@ -381,22 +365,18 @@ export default function SettingsScreen() {
value={serverUrl}
icon={IconLink}
onPress={() => {
Alert.alert(
t`Change Server`,
t`You'll be signed out to change the server URL.`,
[
{ text: t`Cancel`, style: "cancel" },
{
text: t`Continue`,
style: "destructive",
onPress: async () => {
await authClient.signOut();
queryClient.clear();
push("/(auth)/server-url");
},
Alert.alert(t`Change Server`, t`You'll be signed out to change the server URL.`, [
{ text: t`Cancel`, style: "cancel" },
{
text: t`Continue`,
style: "destructive",
onPress: async () => {
await authClient.signOut();
queryClient.clear();
push("/(auth)/server-url");
},
],
);
},
]);
}}
/>
<SettingsRow
@@ -448,11 +428,7 @@ export default function SettingsScreen() {
{/* Admin: Server Health */}
{isAdmin && (
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
<SettingsSection
title={t`Server Health`}
icon={IconServer}
badge={t`Admin`}
>
<SettingsSection title={t`Server Health`} icon={IconServer} badge={t`Admin`}>
{systemHealth.isPending ? (
<View className="items-center py-4">
<Spinner colorClassName="accent-primary" />
@@ -470,9 +446,7 @@ export default function SettingsScreen() {
/>
<SettingsRow
label="TMDB"
value={
systemHealth.data?.tmdb?.connected ? t`Connected` : "—"
}
value={systemHealth.data?.tmdb?.connected ? t`Connected` : "—"}
icon={IconCloud}
/>
<SettingsRow
@@ -493,11 +467,7 @@ export default function SettingsScreen() {
{/* Admin: Security */}
{isAdmin && (
<Animated.View entering={FadeInDown.duration(300).delay(500)}>
<SettingsSection
title={t`Security`}
icon={IconShield}
badge={t`Admin`}
>
<SettingsSection title={t`Security`} icon={IconShield} badge={t`Admin`}>
<SettingsRow
label={t`Open registration`}
icon={IconUserPlus}
@@ -516,19 +486,14 @@ export default function SettingsScreen() {
<Switch
value={updateCheck.data?.enabled ?? false}
accessibilityLabel="Check for updates"
onValueChange={(enabled) =>
toggleUpdateCheck.mutate({ enabled })
}
onValueChange={(enabled) => toggleUpdateCheck.mutate({ enabled })}
/>
}
/>
{updateCheck.data?.updateCheck?.updateAvailable && (
<View className="py-3.5">
<Text className="font-medium font-sans text-sm text-status-completed">
<Trans>
Update available:{" "}
{updateCheck.data.updateCheck.latestVersion}
</Trans>
<Text className="text-status-completed font-sans text-sm font-medium">
<Trans>Update available: {updateCheck.data.updateCheck.latestVersion}</Trans>
</Text>
</View>
)}
@@ -544,31 +509,20 @@ export default function SettingsScreen() {
className="flex-row items-center justify-center py-3.5 active:opacity-70"
>
<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>
</Text>
<ScaledIcon
icon={IconArrowUpRight}
size={16}
color={mutedFgColor}
/>
<ScaledIcon icon={IconArrowUpRight} size={16} color={mutedFgColor} />
</Pressable>
</SettingsSection>
</Animated.View>
{/* Version */}
<Animated.View
entering={FadeInDown.duration(300).delay(400)}
className="mt-6 items-center"
>
<Animated.View entering={FadeInDown.duration(300).delay(400)} className="mt-6 items-center">
<Text className="text-muted-foreground text-xs">
Native
{Application.nativeApplicationVersion
? ` v${Application.nativeApplicationVersion}`
: ""}
{Application.nativeBuildVersion
? ` (${Application.nativeBuildVersion})`
: ""}
{Application.nativeApplicationVersion ? ` v${Application.nativeApplicationVersion}` : ""}
{Application.nativeBuildVersion ? ` (${Application.nativeBuildVersion})` : ""}
{updateCheck.data?.updateCheck?.currentVersion
? ` · Server v${updateCheck.data.updateCheck.currentVersion}`
: ""}
@@ -576,10 +530,7 @@ export default function SettingsScreen() {
</Animated.View>
{/* GitHub */}
<Animated.View
entering={FadeInDown.duration(300).delay(450)}
className="mt-3 items-center"
>
<Animated.View entering={FadeInDown.duration(300).delay(450)} className="mt-3 items-center">
<Pressable
onPress={() => Linking.openURL("https://github.com/jakejarvis/sofa")}
className="flex-row items-center gap-1.5 active:opacity-70"
@@ -601,12 +552,9 @@ export default function SettingsScreen() {
<TmdbLogo height={12} />
<Text
maxFontSizeMultiplier={1.0}
className="text-center text-muted-foreground text-xs leading-relaxed"
className="text-muted-foreground text-center text-xs leading-relaxed"
>
<Trans>
This product uses the TMDB API but is not endorsed or certified by
TMDB.
</Trans>
<Trans>This product uses the TMDB API but is not endorsed or certified by TMDB.</Trans>
</Text>
</Pressable>
</Animated.View>
+1
View File
@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { NativeTabBar } from "@/components/navigation/native-tab-bar";
import { orpc } from "@/lib/orpc";
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 Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { Container } from "@/components/container";
import { Button, ButtonLabel } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
@@ -18,15 +19,12 @@ export default function NotFoundScreen() {
</Stack.Screen.Title>
<Container>
<View className="flex-1 items-center justify-center p-4">
<Animated.View
entering={FadeIn.duration(400)}
className="items-center"
>
<Animated.View entering={FadeIn.duration(400)} className="items-center">
<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>
</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>
</Text>
</Animated.View>
+6 -17
View File
@@ -2,7 +2,6 @@ import "@/lib/intl-polyfills";
import "@/global.css";
import { I18nProvider } from "@lingui/react";
import { ThemeProvider } from "@react-navigation/native";
import { i18n } from "@sofa/i18n";
import { QueryClientProvider } from "@tanstack/react-query";
import {
persistQueryClientRestore,
@@ -23,19 +22,16 @@ import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { enableFreeze } from "react-native-screens";
import { Uniwind, useResolveClassNames } from "uniwind";
import { OfflineBanner } from "@/components/ui/offline-banner";
import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner";
import { useServerConnection } from "@/hooks/use-server-connection";
import { initLocale } from "@/lib/i18n";
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client";
import {
getScopeKey,
initialize,
onStorageScopeChange,
queryPersister,
} from "@/lib/server";
import { getScopeKey, initialize, onStorageScopeChange, queryPersister } from "@/lib/server";
import { sofaTheme } from "@/lib/theme";
import { i18n } from "@sofa/i18n";
SplashScreen.preventAutoHideAsync();
enableFreeze(true);
@@ -73,9 +69,7 @@ function AppContent() {
const [isLocaleReady, setLocaleReady] = useState(false);
useEffect(() => {
localeReady
.then(() => setLocaleReady(true))
.catch(() => setLocaleReady(true));
localeReady.then(() => setLocaleReady(true)).catch(() => setLocaleReady(true));
}, []);
// --- App Tracking Transparency (must resolve before screen tracking) ---
@@ -149,10 +143,7 @@ function AppContent() {
<Stack.Protected guard={!!session}>
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="change-password"
options={changePasswordOptions}
/>
<Stack.Screen name="change-password" options={changePasswordOptions} />
<Stack.Screen
name="title/[id]"
dangerouslySingular
@@ -223,9 +214,7 @@ function QueryProvider({ children }: { children: React.ReactNode }) {
};
}, [scopeKey]);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
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 { useResolveClassNames } from "uniwind";
import { z } from "zod";
import { Button, ButtonLabel } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Text } from "@/components/ui/text";
import {
FieldError,
Input,
Label,
TextField,
} from "@/components/ui/text-field";
import { FieldError, Input, Label, TextField } from "@/components/ui/text-field";
import { authClient } from "@/lib/server";
import { toast } from "@/lib/toast";
import * as Haptics from "@/utils/haptics";
@@ -85,7 +81,7 @@ export default function ChangePasswordScreen() {
return (
<ScrollView
className="flex-1 bg-background"
className="bg-background flex-1"
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: 16,
@@ -156,9 +152,7 @@ export default function ChangePasswordScreen() {
textContentType="newPassword"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() =>
confirmPasswordRef.current?.focus()
}
onSubmitEditing={() => confirmPasswordRef.current?.focus()}
/>
</TextField>
)}
@@ -192,10 +186,10 @@ export default function ChangePasswordScreen() {
<Animated.View
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" }}
>
<Text className="text-base text-foreground">
<Text className="text-foreground text-base">
<Trans>Sign out of other sessions</Trans>
</Text>
<Switch
@@ -209,7 +203,7 @@ export default function ChangePasswordScreen() {
<Button
onPress={form.handleSubmit}
disabled={isSubmitting}
className="mt-2 bg-primary"
className="bg-primary mt-2"
>
{isSubmitting ? (
<Spinner size="sm" />
+41 -87
View File
@@ -1,7 +1,6 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { useHeaderHeight } from "@react-navigation/elements";
import { FlashList } from "@shopify/flash-list";
import { formatDate } from "@sofa/i18n/format";
import {
IconAlertTriangle,
IconCalendar,
@@ -12,15 +11,11 @@ import {
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useMemo } from "react";
import {
ActivityIndicator,
Pressable,
useWindowDimensions,
View,
} from "react-native";
import { ActivityIndicator, Pressable, useWindowDimensions, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCSSVariable } from "uniwind";
import { DetailStackHeader } from "@/components/navigation/modal-stack-header";
import { ExpandableText } from "@/components/ui/expandable-text";
import { Image } from "@/components/ui/image";
@@ -32,6 +27,7 @@ import { Text } from "@/components/ui/text";
import { useTitleActions } from "@/hooks/use-title-actions";
import { orpc } from "@/lib/orpc";
import { addRecentlyViewed } from "@/lib/recently-viewed";
import { formatDate } from "@sofa/i18n/format";
const FILMOGRAPHY_GAP = 12;
const FILMOGRAPHY_PADDING = 16;
@@ -68,12 +64,9 @@ export default function PersonDetailScreen() {
const { back } = useRouter();
const { width: screenWidth } = useWindowDimensions();
const useAutomaticInsets = process.env.EXPO_OS === "ios";
const filmographyColumns =
screenWidth >= 900 ? 4 : screenWidth >= 600 ? 3 : 2;
const filmographyColumns = screenWidth >= 900 ? 4 : screenWidth >= 600 ? 3 : 2;
const columnWidth = Math.floor(
(screenWidth -
FILMOGRAPHY_PADDING * 2 -
FILMOGRAPHY_GAP * (filmographyColumns - 1)) /
(screenWidth - FILMOGRAPHY_PADDING * 2 - FILMOGRAPHY_GAP * (filmographyColumns - 1)) /
filmographyColumns,
);
@@ -82,40 +75,29 @@ export default function PersonDetailScreen() {
const { quickAdd } = useTitleActions();
const handleQuickAdd = useCallback(
(id: string) => quickAdd.mutate({ id }),
(titleId: string) => quickAdd.mutate({ id: titleId }),
[quickAdd],
);
const addingKey = quickAdd.isPending
? (quickAdd.variables?.id ?? null)
: null;
const addingKey = quickAdd.isPending ? (quickAdd.variables?.id ?? null) : null;
const {
data,
isPending,
isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = 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 { data, isPending, isError, fetchNextPage, hasNextPage, isFetchingNextPage } =
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 filmography = useMemo(
() => data?.pages.flatMap((p) => p.filmography) ?? [],
[data?.pages],
);
const filmography = useMemo(() => data?.pages.flatMap((p) => p.filmography) ?? [], [data?.pages]);
const userStatuses = useMemo(
() =>
Object.assign(
{},
...(data?.pages.map((p) => p.userStatuses) ?? []),
) as Record<string, "watchlist" | "in_progress" | "completed">,
Object.assign({}, ...(data?.pages.map((p) => p.userStatuses) ?? [])) as Record<
string,
"watchlist" | "in_progress" | "completed"
>,
[data?.pages],
);
@@ -166,25 +148,15 @@ export default function PersonDetailScreen() {
<>
<DetailStackHeader />
<View
className="flex-1 items-center bg-background"
className="bg-background flex-1 items-center"
style={{ paddingTop: headerHeight + 24 }}
>
{/* Profile photo skeleton */}
<Skeleton width={120} height={120} borderRadius={60} />
{/* Name skeleton */}
<Skeleton
width={180}
height={28}
borderRadius={6}
style={{ marginTop: 16 }}
/>
<Skeleton width={180} height={28} borderRadius={6} style={{ marginTop: 16 }} />
{/* Department badge skeleton */}
<Skeleton
width={80}
height={24}
borderRadius={12}
style={{ marginTop: 8 }}
/>
<Skeleton width={80} height={24} borderRadius={12} style={{ marginTop: 8 }} />
{/* Bio skeleton */}
<View className="mt-6 gap-2 self-stretch px-4">
<Skeleton width="100%" height={14} />
@@ -201,18 +173,15 @@ export default function PersonDetailScreen() {
<>
<DetailStackHeader />
<View
className="flex-1 items-center justify-center bg-background"
className="bg-background flex-1 items-center justify-center"
style={{ paddingTop: insets.top }}
>
<Animated.View
entering={FadeIn.duration(400)}
className="items-center"
>
<Animated.View entering={FadeIn.duration(400)} className="items-center">
<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>
</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>
</Text>
<Pressable onPress={() => back()} className="mt-4">
@@ -231,15 +200,12 @@ export default function PersonDetailScreen() {
<>
<DetailStackHeader />
<View
className="flex-1 items-center justify-center bg-background"
className="bg-background flex-1 items-center justify-center"
style={{ paddingTop: insets.top }}
>
<Animated.View
entering={FadeIn.duration(400)}
className="items-center"
>
<Animated.View entering={FadeIn.duration(400)} className="items-center">
<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>
</Text>
<Pressable onPress={() => back()} className="mt-4">
@@ -266,7 +232,7 @@ export default function PersonDetailScreen() {
paddingBottom: 24,
}}
>
<View className="size-[120px] overflow-hidden rounded-full bg-secondary">
<View className="bg-secondary size-[120px] overflow-hidden rounded-full">
{person.profilePath && (
<Image
source={{ uri: person.profilePath }}
@@ -277,15 +243,14 @@ export default function PersonDetailScreen() {
)}
</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}
</Text>
{person.knownForDepartment ? (
<View className="mt-2 rounded-full bg-secondary px-3 py-1">
<Text className="text-muted-foreground text-xs uppercase tracking-wider">
{getDepartmentLabels(t)[person.knownForDepartment] ??
person.knownForDepartment}
<View className="bg-secondary mt-2 rounded-full px-3 py-1">
<Text className="text-muted-foreground text-xs tracking-wider uppercase">
{getDepartmentLabels(t)[person.knownForDepartment] ?? person.knownForDepartment}
</Text>
</View>
) : null}
@@ -294,18 +259,12 @@ export default function PersonDetailScreen() {
<View className="mt-3 items-center gap-1.5">
{person.birthday ? (
<View className="flex-row items-center gap-1.5">
<ScaledIcon
icon={IconCalendar}
size={14}
color={primaryColor}
/>
<ScaledIcon icon={IconCalendar} size={14} color={primaryColor} />
<Text selectable className="text-muted-foreground text-sm">
{formatDate(person.birthday)}
{(() => {
const age = calculateAge(person.birthday, person.deathday);
return person.deathday
? ` (${t`died at ${age}`})`
: ` (${t`age ${age}`})`;
return person.deathday ? ` (${t`died at ${age}`})` : ` (${t`age ${age}`})`;
})()}
</Text>
</View>
@@ -333,10 +292,7 @@ export default function PersonDetailScreen() {
{/* Filmography section header */}
{filmography.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(200)}
className="px-4"
>
<Animated.View entering={FadeInDown.duration(300).delay(200)} className="px-4">
<SectionHeader title={t`Filmography`} icon={IconMovie} />
</Animated.View>
)}
@@ -344,16 +300,14 @@ export default function PersonDetailScreen() {
);
return (
<View className="flex-1 bg-background" collapsable={false}>
<View className="bg-background flex-1" collapsable={false}>
<FlashList
data={filmography}
keyExtractor={(item) => item.titleId}
renderItem={renderFilmographyItem}
numColumns={filmographyColumns}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior={
useAutomaticInsets ? "automatic" : "never"
}
contentInsetAdjustmentBehavior={useAutomaticInsets ? "automatic" : "never"}
contentContainerStyle={{
paddingBottom: useAutomaticInsets ? 32 : insets.bottom + 32,
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 { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCSSVariable } from "uniwind";
import {
HorizontalPosterRow,
type PosterRowItem,
@@ -102,12 +103,8 @@ export default function TitleDetailScreen() {
]) as [string, string, string];
const detail = useQuery(orpc.titles.detail.queryOptions({ input: { id } }));
const userInfo = useQuery(
orpc.titles.userInfo.queryOptions({ input: { id } }),
);
const recommendations = useQuery(
orpc.titles.recommendations.queryOptions({ input: { id } }),
);
const userInfo = useQuery(orpc.titles.userInfo.queryOptions({ input: { id } }));
const recommendations = useQuery(orpc.titles.recommendations.queryOptions({ input: { id } }));
const {
updateStatus,
@@ -116,24 +113,19 @@ export default function TitleDetailScreen() {
quickAdd: quickAddMutation,
} = useTitleActions({
toasts: {
watchMovie: () =>
title?.title
? `Marked "${title.title}" as watched`
: "Marked as watched",
watchMovie: () => (title?.title ? `Marked "${title.title}" as watched` : "Marked as watched"),
},
});
const title = detail.data?.title;
const palette = title?.colorPalette ?? null;
useTitleTheme(palette);
const providerIcon =
process.env.EXPO_OS === "ios" ? IconBrandAppstore : IconBrandGooglePlay;
const providerIcon = process.env.EXPO_OS === "ios" ? IconBrandAppstore : IconBrandGooglePlay;
const titleName = title?.title;
const titleType = title?.type;
const titlePosterPath = title?.posterPath ?? null;
const titleYear =
(title?.releaseDate ?? title?.firstAirDate)?.slice(0, 4) ?? null;
const titleYear = (title?.releaseDate ?? title?.firstAirDate)?.slice(0, 4) ?? null;
useEffect(() => {
if (titleName && titleType) {
@@ -166,9 +158,7 @@ export default function TitleDetailScreen() {
releaseDate: item.releaseDate,
firstAirDate: item.firstAirDate,
voteAverage: item.voteAverage,
userStatus: item.id
? (recommendations.data?.userStatuses?.[item.id] ?? null)
: null,
userStatus: item.id ? (recommendations.data?.userStatuses?.[item.id] ?? null) : null,
})),
[recommendations.data],
);
@@ -188,17 +178,11 @@ export default function TitleDetailScreen() {
[useAutomaticInsets, headerHeight],
);
const darkMutedOverlayStyle = useMemo(
() =>
palette?.darkMuted
? { backgroundColor: palette.darkMuted, opacity: 0.2 }
: undefined,
() => (palette?.darkMuted ? { backgroundColor: palette.darkMuted, opacity: 0.2 } : undefined),
[palette?.darkMuted],
);
const vibrantOverlayStyle = useMemo(
() =>
palette?.vibrant
? { backgroundColor: palette.vibrant, opacity: 0.06 }
: undefined,
() => (palette?.vibrant ? { backgroundColor: palette.vibrant, opacity: 0.06 } : undefined),
[palette?.vibrant],
);
const posterShadowStyle = useMemo(
@@ -215,7 +199,7 @@ export default function TitleDetailScreen() {
return (
<>
<DetailStackHeader />
<View className="flex-1 bg-background">
<View className="bg-background flex-1">
{/* Hero skeleton */}
<Skeleton width="100%" height={300} borderRadius={0} />
{/* Genre chips skeleton */}
@@ -245,9 +229,9 @@ export default function TitleDetailScreen() {
return (
<>
<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} />
<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>
</Text>
<Pressable onPress={() => back()} className="mt-4">
@@ -265,9 +249,7 @@ export default function TitleDetailScreen() {
return (
<ScrollView
className="bg-background"
contentInsetAdjustmentBehavior={
useAutomaticInsets ? "automatic" : "never"
}
contentInsetAdjustmentBehavior={useAutomaticInsets ? "automatic" : "never"}
contentContainerStyle={titleScrollContentStyle}
>
<DetailStackHeader />
@@ -285,12 +267,8 @@ export default function TitleDetailScreen() {
{/* Base darkening overlay */}
<View style={titleDetailStyles.heroBaseOverlay} />
{/* Colored tint from palette */}
{palette?.darkMuted && (
<View style={[titleDetailStyles.heroFill, darkMutedOverlayStyle]} />
)}
{palette?.vibrant && (
<View style={[titleDetailStyles.heroFill, vibrantOverlayStyle]} />
)}
{palette?.darkMuted && <View style={[titleDetailStyles.heroFill, darkMutedOverlayStyle]} />}
{palette?.vibrant && <View style={[titleDetailStyles.heroFill, vibrantOverlayStyle]} />}
{/* Bottom fade to background */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.6)", "rgba(0,0,0,0.95)"]}
@@ -347,39 +325,26 @@ export default function TitleDetailScreen() {
</Link.AppleZoomTarget>
)}
<View className="flex-1 pb-1">
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
>
<Text className="font-display text-2xl text-white" numberOfLines={2}>
{title.title}
</Text>
<View className="mt-1.5 flex-row flex-wrap items-center gap-2">
<View className="rounded-full bg-title-accent px-2 py-0.5">
<View className="bg-title-accent rounded-full px-2 py-0.5">
<Text
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`}
</Text>
</View>
{year ? (
<Text className="text-sm text-white/70">{year}</Text>
) : null}
{year ? <Text className="text-sm text-white/70">{year}</Text> : null}
{title.contentRating ? (
<Text className="text-white/50 text-xs">
{title.contentRating}
</Text>
<Text className="text-xs text-white/50">{title.contentRating}</Text>
) : null}
{title.voteAverage != null && title.voteAverage > 0 && (
<View className="flex-row items-center gap-0.5">
<ScaledIcon
icon={IconStarFilled}
size={12}
color={titleAccent}
/>
<Text className="text-title-accent text-xs">
{title.voteAverage.toFixed(1)}
</Text>
<ScaledIcon icon={IconStarFilled} size={12} color={titleAccent} />
<Text className="text-title-accent text-xs">{title.voteAverage.toFixed(1)}</Text>
</View>
)}
</View>
@@ -397,10 +362,7 @@ export default function TitleDetailScreen() {
contentContainerStyle={titleGenresContentStyle}
>
{title.genres.map((genre: string) => (
<View
key={genre}
className="mr-2 rounded-full bg-secondary px-2.5 py-1"
>
<View key={genre} className="bg-secondary mr-2 rounded-full px-2.5 py-1">
<Text className="text-muted-foreground text-xs">{genre}</Text>
</View>
))}
@@ -409,10 +371,7 @@ export default function TitleDetailScreen() {
)}
{/* Actions */}
<Animated.View
entering={FadeInDown.duration(300).delay(200)}
className="mt-4 px-4"
>
<Animated.View entering={FadeInDown.duration(300).delay(200)} className="mt-4 px-4">
<View className="flex-row flex-wrap items-center gap-3">
<StatusActionButton
currentStatus={userInfo.data?.status ?? null}
@@ -423,29 +382,21 @@ export default function TitleDetailScreen() {
updateStatus.mutate({ id, status: null });
}
}}
isPending={
updateStatus.isPending ||
quickAddMutation.isPending ||
watchMovie.isPending
}
isPending={updateStatus.isPending || quickAddMutation.isPending || watchMovie.isPending}
/>
{title.type === "movie" && (
<Pressable
onPress={() => watchMovie.mutate({ id })}
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 ? (
<Spinner size="sm" />
) : (
<>
<ScaledIcon
icon={IconCheck}
size={16}
color={titleAccentForeground}
/>
<Text className="font-medium font-sans text-sm text-title-accent-foreground">
<ScaledIcon icon={IconCheck} size={16} color={titleAccentForeground} />
<Text className="text-title-accent-foreground font-sans text-sm font-medium">
<Trans>Mark Watched</Trans>
</Text>
</>
@@ -453,7 +404,7 @@ export default function TitleDetailScreen() {
</Pressable>
)}
<View className="h-6 w-px bg-border/50" />
<View className="bg-border/50 h-6 w-px" />
<StarRating
rating={userInfo.data?.rating ?? 0}
@@ -474,16 +425,9 @@ export default function TitleDetailScreen() {
{/* Availability */}
{availability.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(400)}
className="mt-6"
>
<Animated.View entering={FadeInDown.duration(300).delay(400)} className="mt-6">
<View className="px-4">
<SectionHeader
title={t`Where to Watch`}
icon={providerIcon}
iconColor={titleAccent}
/>
<SectionHeader title={t`Where to Watch`} icon={providerIcon} iconColor={titleAccent} />
</View>
<ScrollView
horizontal
@@ -491,10 +435,7 @@ export default function TitleDetailScreen() {
contentContainerStyle={titleAvailabilityContentStyle}
>
{availability.map((offer) => (
<View
key={`${offer.providerId}-${offer.offerType}`}
className="items-center"
>
<View key={`${offer.providerId}-${offer.offerType}`} className="items-center">
{offer.logoPath && (
<Image
source={{ uri: offer.logoPath }}
@@ -504,7 +445,7 @@ export default function TitleDetailScreen() {
)}
<Text
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}
>
{offer.providerName}
@@ -528,15 +469,8 @@ export default function TitleDetailScreen() {
{/* Seasons & Episodes */}
{title.type === "tv" && seasons.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(400)}
className="mt-6 px-4"
>
<SectionHeader
title={t`Seasons`}
icon={IconList}
iconColor={titleAccent}
/>
<Animated.View entering={FadeInDown.duration(300).delay(400)} className="mt-6 px-4">
<SectionHeader title={t`Seasons`} icon={IconList} iconColor={titleAccent} />
{seasons.map((season) => (
<SeasonAccordion
key={season.id}
@@ -550,16 +484,9 @@ export default function TitleDetailScreen() {
{/* Cast */}
{cast.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(500)}
className="mt-6"
>
<Animated.View entering={FadeInDown.duration(300).delay(500)} className="mt-6">
<View className="px-4">
<SectionHeader
title={t`Cast`}
icon={IconUsers}
iconColor={titleAccent}
/>
<SectionHeader title={t`Cast`} icon={IconUsers} iconColor={titleAccent} />
</View>
<FlashList
horizontal
@@ -576,16 +503,9 @@ export default function TitleDetailScreen() {
{/* Recommendations */}
{recItems.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(600)}
className="mt-6"
>
<Animated.View entering={FadeInDown.duration(300).delay(600)} className="mt-6">
<View className="px-4">
<SectionHeader
title={t`More Like This`}
icon={IconThumbUp}
iconColor={titleAccent}
/>
<SectionHeader title={t`More Like This`} icon={IconThumbUp} iconColor={titleAccent} />
</View>
<HorizontalPosterRow items={recItems} />
</Animated.View>
+5 -19
View File
@@ -3,6 +3,7 @@ import type { StyleProp, ViewStyle } from "react-native";
import { ScrollView } from "react-native";
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
import Animated, { FadeIn } from "react-native-reanimated";
import { SofaLogo } from "@/components/ui/sofa-logo";
import { Text } from "@/components/ui/text";
@@ -13,12 +14,7 @@ interface AuthScreenProps {
children: ReactNode;
}
export function AuthScreen({
title,
subtitle,
logoStyle,
children,
}: AuthScreenProps) {
export function AuthScreen({ title, subtitle, logoStyle, children }: AuthScreenProps) {
return (
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
<ScrollView
@@ -34,10 +30,7 @@ export function AuthScreen({
bounces={false}
className="bg-background"
>
<Animated.View
entering={FadeIn.duration(400)}
className="mb-4 items-center"
>
<Animated.View entering={FadeIn.duration(400)} className="mb-4 items-center">
{logoStyle ? (
<Animated.View style={logoStyle}>
<SofaLogo size={48} />
@@ -45,17 +38,10 @@ export function AuthScreen({
) : (
<SofaLogo size={48} />
)}
<Text
maxFontSizeMultiplier={1.3}
className="mt-1 font-display text-3xl text-foreground"
>
<Text maxFontSizeMultiplier={1.3} className="font-display text-foreground mt-1 text-3xl">
{title}
</Text>
{subtitle && (
<Text className="mt-2 text-muted-foreground text-sm">
{subtitle}
</Text>
)}
{subtitle && <Text className="text-muted-foreground mt-2 text-sm">{subtitle}</Text>}
</Animated.View>
{children}
</ScrollView>
+3 -7
View File
@@ -1,12 +1,8 @@
import type { PropsWithChildren } from "react";
import {
ScrollView,
type ScrollViewProps,
View,
type ViewProps,
} from "react-native";
import { ScrollView, type ScrollViewProps, View, type ViewProps } from "react-native";
import Animated, { type AnimatedProps } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { cn } from "@/utils/cn";
const AnimatedView = Animated.createAnimatedComponent(View);
@@ -28,7 +24,7 @@ export function Container({
return (
<AnimatedView
className={cn("flex-1 bg-background", className)}
className={cn("bg-background flex-1", className)}
{...props}
style={[{ paddingBottom: insets.bottom }, props.style]}
>
@@ -3,6 +3,7 @@ import { Link } from "expo-router";
import { Pressable, View } from "react-native";
import { GestureDetector } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
import { usePressAnimation } from "@/hooks/use-press-animation";
@@ -34,21 +35,16 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
const nextEpLabel = item.nextEpisode
? `Next: Season ${item.nextEpisode.seasonNumber} Episode ${item.nextEpisode.episodeNumber}`
: undefined;
const cardLabel = [item.title.title, progressLabel, nextEpLabel]
.filter(Boolean)
.join(", ");
const cardLabel = [item.title.title, progressLabel, nextEpLabel].filter(Boolean).join(", ");
return (
<GestureDetector gesture={tapGesture}>
<Animated.View className="w-[200px]" style={animatedStyle}>
<Link href={`/title/${item.title.id}` as `/title/${string}`} asChild>
<Link.Trigger withAppleZoom>
<Pressable
accessibilityRole="button"
accessibilityLabel={cardLabel}
>
<Pressable accessibilityRole="button" accessibilityLabel={cardLabel}>
<View
className="overflow-hidden rounded-[12px] border bg-card"
className="bg-card overflow-hidden rounded-[12px] border"
style={{
borderColor: "rgba(255,255,255,0.06)",
borderCurve: "continuous",
@@ -58,13 +54,9 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
{(item.nextEpisode?.stillPath || item.title.backdropPath) && (
<Image
source={{
uri: (item.nextEpisode?.stillPath ??
item.title.backdropPath) as string,
uri: (item.nextEpisode?.stillPath ?? item.title.backdropPath) as string,
}}
thumbHash={
item.nextEpisode?.stillThumbHash ??
item.title.backdropThumbHash
}
thumbHash={item.nextEpisode?.stillThumbHash ?? item.title.backdropThumbHash}
recyclingKey={item.title.id}
className="h-full w-full"
contentFit="cover"
@@ -78,10 +70,9 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
<View className="absolute right-2.5 bottom-3 left-2.5">
<Text
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
{item.nextEpisode.episodeNumber}
S{item.nextEpisode.seasonNumber} E{item.nextEpisode.episodeNumber}
</Text>
</View>
)}
@@ -90,7 +81,7 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
>
<View
className="h-full bg-status-watching"
className="bg-status-watching h-full"
style={{
width: `${item.totalEpisodes > 0 ? (item.watchedEpisodes / item.totalEpisodes) * 100 : 0}%`,
}}
@@ -98,17 +89,11 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
</View>
</View>
<View className="p-2.5">
<Text
numberOfLines={1}
className="font-medium font-sans text-foreground text-sm"
>
<Text numberOfLines={1} className="text-foreground font-sans text-sm font-medium">
{item.title.title}
</Text>
{item.nextEpisode && (
<Text
numberOfLines={1}
className="mt-0.5 text-muted-foreground text-xs"
>
<Text numberOfLines={1} className="text-muted-foreground mt-0.5 text-xs">
{item.nextEpisode.name}
</Text>
)}
@@ -121,9 +106,7 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
<Link.MenuAction
title={t`Mark as Completed`}
icon="checkmark.circle"
onPress={() =>
titleActions.markCompleted(item.title.id, item.title.title)
}
onPress={() => titleActions.markCompleted(item.title.id, item.title.title)}
/>
<Link.MenuAction
title={t`Remove from Library`}
@@ -30,13 +30,8 @@ export function HorizontalPosterRow({
isLoading?: boolean;
}) {
const { quickAdd } = useTitleActions();
const handleQuickAdd = useCallback(
(id: string) => quickAdd.mutate({ id }),
[quickAdd],
);
const addingKey = quickAdd.isPending
? (quickAdd.variables?.id ?? null)
: null;
const handleQuickAdd = useCallback((id: string) => quickAdd.mutate({ id }), [quickAdd]);
const addingKey = quickAdd.isPending ? (quickAdd.variables?.id ?? null) : null;
const keyExtractor = useCallback((item: PosterRowItem) => item.id, []);
const renderItem = useCallback(
({ item }: { item: PosterRowItem }) => (
@@ -1,25 +1,20 @@
import { View } from "react-native";
import { Text } from "@/components/ui/text";
export function StatsCard({
label,
value,
}: {
label: string;
value: number | undefined;
}) {
export function StatsCard({ label, value }: { label: string; value: number | undefined }) {
return (
<View
className="min-w-[120px] rounded-xl border border-white/[0.06] bg-card px-4 py-3"
className="bg-card min-w-[120px] rounded-xl border border-white/[0.06] px-4 py-3"
style={{ borderCurve: "continuous" }}
>
<Text
className="font-bold font-sans text-2xl text-primary"
className="text-primary font-sans text-2xl font-bold"
style={{ fontVariant: ["tabular-nums"] }}
>
{value ?? "—"}
</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>
);
}
@@ -3,6 +3,7 @@ import type { Icon } from "@tabler/icons-react-native";
import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { ScrollView, View } from "react-native";
import {
HorizontalPosterRow,
type PosterRowItem,
@@ -68,28 +69,25 @@ export function FilterableTitleRow({
);
const discoverStatuses = useMemo(
() =>
Object.assign(
{},
...(discover.data?.pages.map((p) => p.userStatuses) ?? []),
) as Record<string, TitleStatus>,
Object.assign({}, ...(discover.data?.pages.map((p) => p.userStatuses) ?? [])) as Record<
string,
TitleStatus
>,
[discover.data?.pages],
);
const discoverProgress = useMemo(
() =>
Object.assign(
{},
...(discover.data?.pages.map((p) => p.episodeProgress) ?? []),
) as Record<string, { watched: number; total: number }>,
Object.assign({}, ...(discover.data?.pages.map((p) => p.episodeProgress) ?? [])) as Record<
string,
{ watched: number; total: number }
>,
[discover.data?.pages],
);
const rawItems = selectedGenre === null ? defaultItems : discoverItems;
const userStatuses =
selectedGenre === null ? defaultUserStatuses : discoverStatuses;
const episodeProgress =
selectedGenre === null ? defaultEpisodeProgress : discoverProgress;
const showLoading =
isLoading || (selectedGenre !== null && discover.isPending);
const userStatuses = selectedGenre === null ? defaultUserStatuses : discoverStatuses;
const episodeProgress = selectedGenre === null ? defaultEpisodeProgress : discoverProgress;
const showLoading = isLoading || (selectedGenre !== null && discover.isPending);
// Map items into PosterRowItem shape with status/progress resolved
const items = useMemo<PosterRowItem[]>(
@@ -125,9 +123,7 @@ export function FilterableTitleRow({
key={genre.id}
label={genre.name}
isSelected={selectedGenre === genre.id}
onPress={() =>
setSelectedGenre(selectedGenre === genre.id ? null : genre.id)
}
onPress={() => setSelectedGenre(selectedGenre === genre.id ? null : genre.id)}
/>
))}
</ScrollView>
@@ -1,5 +1,6 @@
import { useLingui } from "@lingui/react/macro";
import { Pressable } from "react-native";
import { Text } from "@/components/ui/text";
import * as Haptics from "@/utils/haptics";
@@ -23,14 +24,12 @@ export function GenreChip({
accessibilityLabel={label}
accessibilityState={{ selected: isSelected }}
accessibilityHint={
isSelected
? t`Double tap to clear this filter`
: t`Double tap to filter by ${label}`
isSelected ? 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"}`}
>
<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}
</Text>
@@ -6,6 +6,7 @@ import { Pressable, View } from "react-native";
import { GestureDetector } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
@@ -76,36 +77,22 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
padding: 16,
}}
>
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
>
<Text className="font-display text-2xl text-white" numberOfLines={2}>
{item.title}
</Text>
{item.overview ? (
<Text
className="mt-1 text-white/70 text-xs"
numberOfLines={2}
>
<Text className="mt-1 text-xs text-white/70" numberOfLines={2}>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<ScaledIcon
icon={IconStarFilled}
size={12}
color={primary}
/>
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
<ScaledIcon icon={IconStarFilled} size={12} color={primary} />
<Text className="text-primary text-xs">{item.voteAverage.toFixed(1)}</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
<Text className="text-xs text-white/50">{item.releaseDate?.slice(0, 4)}</Text>
</View>
</GlassView>
) : (
@@ -115,36 +102,24 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
/>
<View className="flex-1 justify-end p-4">
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
>
<Text className="font-display text-2xl text-white" numberOfLines={2}>
{item.title}
</Text>
{item.overview ? (
<Text
className="mt-1 text-white/70 text-xs"
numberOfLines={2}
>
<Text className="mt-1 text-xs text-white/70" numberOfLines={2}>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<ScaledIcon
icon={IconStarFilled}
size={12}
color={primary}
/>
<ScaledIcon icon={IconStarFilled} size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
<Text className="text-xs text-white/50">{item.releaseDate?.slice(0, 4)}</Text>
</View>
</View>
</>
+6 -14
View File
@@ -2,6 +2,7 @@ import { useLingui } from "@lingui/react/macro";
import { useRouter } from "expo-router";
import { Alert, Pressable, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
import { queryClient } from "@/lib/query-client";
@@ -26,10 +27,7 @@ export function HeaderAvatar() {
accessibilityLabel="User menu"
hitSlop={8}
>
<View
className="size-8 overflow-hidden rounded-full"
accessible={false}
>
<View className="size-8 overflow-hidden rounded-full" accessible={false}>
{user.image ? (
<Image
source={{ uri: user.image }}
@@ -38,10 +36,10 @@ export function HeaderAvatar() {
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
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() ?? "?"}
</Text>
@@ -52,14 +50,8 @@ export function HeaderAvatar() {
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item
key="settings"
onSelect={() => navigate("/(tabs)/(settings)")}
>
<DropdownMenu.ItemIcon
ios={{ name: "gear" }}
androidIconName="ic_menu_preferences"
/>
<DropdownMenu.Item key="settings" onSelect={() => navigate("/(tabs)/(settings)")}>
<DropdownMenu.ItemIcon ios={{ name: "gear" }} androidIconName="ic_menu_preferences" />
<DropdownMenu.ItemTitle>{t`Settings`}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
@@ -1,21 +1,15 @@
import {
NativeTabs,
type NativeTabsProps,
} from "expo-router/unstable-native-tabs";
import { NativeTabs, type NativeTabsProps } from "expo-router/unstable-native-tabs";
import { useMemo } from "react";
import { useCSSVariable, useResolveClassNames } from "uniwind";
import * as Haptics from "@/utils/haptics";
export function NativeTabBar({
showSettingsBadge,
}: {
showSettingsBadge: boolean;
}) {
export function NativeTabBar({ showSettingsBadge }: { showSettingsBadge: boolean }) {
const primaryColor = useCSSVariable("--color-primary") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const surfaceColor = useCSSVariable("--color-card") 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"]>(
() => ({
@@ -58,9 +52,7 @@ export function NativeTabBar({
<NativeTabs.Trigger name="(settings)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon md="settings" />
{showSettingsBadge ? (
<NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge>
) : null}
{showSettingsBadge ? <NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge> : null}
</NativeTabs.Trigger>
</NativeTabs>
);
@@ -2,11 +2,7 @@ import * as Haptics from "expo-haptics";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { useCSSVariable } from "uniwind";
export function NativeTabBar({
showSettingsBadge,
}: {
showSettingsBadge: boolean;
}) {
export function NativeTabBar({ showSettingsBadge }: { showSettingsBadge: boolean }) {
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string;
@@ -37,15 +33,9 @@ export function NativeTabBar({
<NativeTabs.Trigger name="(settings)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
{showSettingsBadge ? (
<NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge>
) : null}
{showSettingsBadge ? <NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge> : null}
</NativeTabs.Trigger>
<NativeTabs.Trigger
name="(search)"
role="search"
disableTransparentOnScrollEdge
>
<NativeTabs.Trigger name="(search)" role="search" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="magnifyingglass" md="search" />
</NativeTabs.Trigger>
@@ -1,26 +1,17 @@
import { Stack } from "expo-router";
import type { ReactNode } from "react";
import { useCSSVariable, useResolveClassNames } from "uniwind";
import { HeaderAvatar } from "@/components/header-avatar";
export function TabStack({
title,
children,
}: {
title?: string;
children?: ReactNode;
}) {
export function TabStack({ title, children }: { title?: string; children?: ReactNode }) {
const contentStyle = useResolveClassNames("bg-background");
const tintColor = useCSSVariable("--color-primary") as string;
const backgroundColor = useCSSVariable("--color-background") as string;
const iosHeaderLargeTitleStyle = useResolveClassNames(
"font-display text-foreground",
);
const iosHeaderTitleStyle = useResolveClassNames(
"font-display text-foreground text-lg",
);
const iosHeaderLargeTitleStyle = useResolveClassNames("font-display text-foreground");
const iosHeaderTitleStyle = useResolveClassNames("font-display text-foreground text-lg");
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") {
@@ -80,9 +71,7 @@ export function TabStack({
shadowColor: "transparent",
}}
/>
<Stack.Screen.Title
style={androidHeaderTitleStyle as Record<string, unknown>}
>
<Stack.Screen.Title style={androidHeaderTitleStyle as Record<string, unknown>}>
{title}
</Stack.Screen.Title>
</Stack.Screen>
@@ -15,12 +15,10 @@ import { useCallback } from "react";
import { Alert, Pressable, View } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { RecentlyViewedRowContent } from "@/components/search/recently-viewed-row-content";
import { Text } from "@/components/ui/text";
import {
type RecentlyViewedItem,
useRecentlyViewed,
} from "@/lib/recently-viewed";
import { type RecentlyViewedItem, useRecentlyViewed } from "@/lib/recently-viewed";
import * as Haptics from "@/utils/haptics";
export function RecentlyViewedList() {
@@ -56,28 +54,21 @@ export function RecentlyViewedList() {
const handleClear = useCallback(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
Alert.alert(
t`Clear Recently Viewed?`,
t`This will remove all items from your history.`,
[
{ text: t`Cancel`, style: "cancel" },
{
text: t`Clear`,
style: "destructive",
onPress: () => clearAll(),
},
],
);
Alert.alert(t`Clear Recently Viewed?`, t`This will remove all items from your history.`, [
{ text: t`Cancel`, style: "cancel" },
{
text: t`Clear`,
style: "destructive",
onPress: () => clearAll(),
},
]);
}, [clearAll, t]);
if (items.length === 0) {
return (
<Animated.View
entering={FadeIn.duration(400)}
className="flex-1 items-center justify-center"
>
<Animated.View entering={FadeIn.duration(400)} className="flex-1 items-center justify-center">
<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>
</Text>
</Animated.View>
@@ -87,9 +78,7 @@ export function RecentlyViewedList() {
return (
<View className="flex-1">
<Host colorScheme="dark" style={{ flex: 1 }}>
<List
modifiers={[listStyle("plain"), scrollContentBackground("hidden")]}
>
<List modifiers={[listStyle("plain"), scrollContentBackground("hidden")]}>
<VStack
modifiers={[
listRowBackground("clear"),
@@ -99,7 +88,7 @@ export function RecentlyViewedList() {
]}
>
<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">
<IconHistory size={20} color={primaryColor} />
<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 Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { RecentlyViewedRow } from "@/components/search/recently-viewed-row";
import { Text } from "@/components/ui/text";
import {
type RecentlyViewedItem,
useRecentlyViewed,
} from "@/lib/recently-viewed";
import { type RecentlyViewedItem, useRecentlyViewed } from "@/lib/recently-viewed";
import * as Haptics from "@/utils/haptics";
export function RecentlyViewedList() {
@@ -30,18 +28,14 @@ export function RecentlyViewedList() {
const handleClear = useCallback(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
Alert.alert(
t`Clear Recently Viewed?`,
t`This will remove all items from your history.`,
[
{ text: t`Cancel`, style: "cancel" },
{
text: t`Clear`,
style: "destructive",
onPress: () => clearAll(),
},
],
);
Alert.alert(t`Clear Recently Viewed?`, t`This will remove all items from your history.`, [
{ text: t`Cancel`, style: "cancel" },
{
text: t`Clear`,
style: "destructive",
onPress: () => clearAll(),
},
]);
}, [clearAll, t]);
const renderItem = useCallback(
@@ -55,12 +49,9 @@ export function RecentlyViewedList() {
if (items.length === 0) {
return (
<Animated.View
entering={FadeIn.duration(400)}
className="flex-1 items-center justify-center"
>
<Animated.View entering={FadeIn.duration(400)} className="flex-1 items-center justify-center">
<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>
</Text>
</Animated.View>
@@ -2,6 +2,7 @@ import { useLingui } from "@lingui/react/macro";
import { IconDeviceTv, IconMovie, IconUser } from "@tabler/icons-react-native";
import { View } from "react-native";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
import type { RecentlyViewedItem } from "@/lib/recently-viewed";
@@ -12,9 +13,7 @@ const TypeIcon = {
person: IconUser,
} as const;
function getTypeLabel(
t: (strings: TemplateStringsArray, ...values: unknown[]) => string,
) {
function getTypeLabel(t: (strings: TemplateStringsArray, ...values: unknown[]) => string) {
return {
movie: t`Movie`,
tv: t`TV`,
@@ -22,11 +21,7 @@ function getTypeLabel(
} as const;
}
export function RecentlyViewedRowContent({
item,
}: {
item: RecentlyViewedItem;
}) {
export function RecentlyViewedRowContent({ item }: { item: RecentlyViewedItem }) {
const { t } = useLingui();
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const Icon = TypeIcon[item.type];
@@ -34,7 +29,7 @@ export function RecentlyViewedRowContent({
return (
<View className="flex-row items-center">
<View
className="mr-3 overflow-hidden bg-secondary"
className="bg-secondary mr-3 overflow-hidden"
style={{
width: 48,
height: item.type === "person" ? 48 : 72,
@@ -57,25 +52,17 @@ export function RecentlyViewedRowContent({
)}
</View>
<View className="flex-1">
<Text
numberOfLines={1}
className="font-medium font-sans text-base text-foreground"
>
<Text numberOfLines={1} className="text-foreground font-sans text-base font-medium">
{item.title}
</Text>
<View className="mt-1 flex-row items-center gap-2">
<View className="rounded-full bg-secondary px-2 py-0.5">
<Text
maxFontSizeMultiplier={1.0}
className="text-muted-foreground text-xs"
>
<View className="bg-secondary rounded-full px-2 py-0.5">
<Text maxFontSizeMultiplier={1.0} className="text-muted-foreground text-xs">
{getTypeLabel(t)[item.type]}
</Text>
</View>
{item.subtitle ? (
<Text className="text-muted-foreground text-xs">
{item.subtitle}
</Text>
<Text className="text-muted-foreground text-xs">{item.subtitle}</Text>
) : null}
</View>
</View>
@@ -1,6 +1,7 @@
import { Link } from "expo-router";
import { memo, useMemo } from "react";
import { Pressable } from "react-native";
import { RecentlyViewedRowContent } from "@/components/search/recently-viewed-row-content";
import { SwipeableRow } from "@/components/ui/swipeable-row";
import type { RecentlyViewedItem } from "@/lib/recently-viewed";
@@ -4,6 +4,7 @@ import { Link } from "expo-router";
import { memo, useMemo } from "react";
import { Pressable, View } from "react-native";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
@@ -30,17 +31,8 @@ export const SearchResultRow = memo(function SearchResultRow({
const primary = useCSSVariable("--color-primary") as string;
const imageSrc = item.posterPath ?? item.profilePath;
const typeLabel =
item.type === "movie"
? t`Movie`
: item.type === "tv"
? t`TV show`
: t`Person`;
const accessibilityLabel = [
item.title,
typeLabel,
item.releaseDate?.slice(0, 4),
]
const typeLabel = item.type === "movie" ? t`Movie` : item.type === "tv" ? t`TV show` : t`Person`;
const accessibilityLabel = [item.title, typeLabel, item.releaseDate?.slice(0, 4)]
.filter(Boolean)
.join(", ");
@@ -61,7 +53,7 @@ export const SearchResultRow = memo(function SearchResultRow({
})}
>
<View
className="mr-3 overflow-hidden bg-secondary"
className="bg-secondary mr-3 overflow-hidden"
style={{
width: 44,
height: item.type === "person" ? 44 : 66,
@@ -80,29 +72,17 @@ export const SearchResultRow = memo(function SearchResultRow({
</View>
<View className="flex-1">
<Text
numberOfLines={1}
className="font-medium font-sans text-base text-foreground"
>
<Text numberOfLines={1} className="text-foreground font-sans text-base font-medium">
{item.title}
</Text>
<View className="mt-1 flex-row items-center gap-2">
<View className="rounded-full bg-secondary px-2 py-0.5">
<Text
maxFontSizeMultiplier={1.0}
className="text-muted-foreground text-xs"
>
{item.type === "movie"
? t`Movie`
: item.type === "tv"
? t`TV`
: t`Person`}
<View className="bg-secondary rounded-full px-2 py-0.5">
<Text maxFontSizeMultiplier={1.0} className="text-muted-foreground text-xs">
{item.type === "movie" ? t`Movie` : item.type === "tv" ? t`TV` : t`Person`}
</Text>
</View>
{item.releaseDate ? (
<Text className="text-muted-foreground text-xs">
{item.releaseDate.slice(0, 4)}
</Text>
<Text className="text-muted-foreground text-xs">{item.releaseDate.slice(0, 4)}</Text>
) : null}
</View>
</View>
@@ -111,7 +91,7 @@ export const SearchResultRow = memo(function SearchResultRow({
return (
<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={{
borderBottomWidth: 0.5,
}}
@@ -20,6 +20,7 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import type { IntegrationConfig } from "@/components/settings/integration-configs";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
@@ -40,12 +41,7 @@ interface IntegrationCardProps {
export function IntegrationCard({ config, connection }: IntegrationCardProps) {
const { provider, label } = config;
const providerInput = provider as
| "plex"
| "jellyfin"
| "emby"
| "sonarr"
| "radarr";
const providerInput = provider as "plex" | "jellyfin" | "emby" | "sonarr" | "radarr";
const { t } = useLingui();
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
@@ -61,21 +57,13 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
useEffect(() => {
chevronRotation.set(
reduceMotion
? expanded
? 180
: 0
: withTiming(expanded ? 180 : 0, { duration: 200 }),
reduceMotion ? (expanded ? 180 : 0) : withTiming(expanded ? 180 : 0, { duration: 200 }),
);
}, [expanded, chevronRotation, reduceMotion]);
useEffect(() => {
setupChevronRotation.set(
reduceMotion
? setupOpen
? 180
: 0
: withTiming(setupOpen ? 180 : 0, { duration: 200 }),
reduceMotion ? (setupOpen ? 180 : 0) : withTiming(setupOpen ? 180 : 0, { duration: 200 }),
);
}, [setupOpen, setupChevronRotation, reduceMotion]);
@@ -164,7 +152,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
return (
<View
className="mb-2 overflow-hidden rounded-xl border bg-card"
className="bg-card mb-2 overflow-hidden rounded-xl border"
style={{
borderColor: "rgba(255,255,255,0.06)",
borderCurve: "continuous",
@@ -179,19 +167,11 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
className="flex-row items-center justify-between p-3"
>
<View className="flex-row items-center gap-3">
<ScaledIcon
icon={Icon}
size={18}
color={connection ? primaryColor : mutedFgColor}
/>
<ScaledIcon icon={Icon} size={18} color={connection ? primaryColor : mutedFgColor} />
<View>
<Text className="font-medium font-sans text-base text-foreground">
{label}
</Text>
<Text className="mt-0.5 text-muted-foreground text-xs">
{connection
? config.connectedStatus(connection.lastEventAt)
: t`Not configured`}
<Text className="text-foreground font-sans text-base font-medium">{label}</Text>
<Text className="text-muted-foreground mt-0.5 text-xs">
{connection ? config.connectedStatus(connection.lastEventAt) : t`Not configured`}
</Text>
</View>
</View>
@@ -206,49 +186,36 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
<Animated.View
entering={FadeIn.duration(200)}
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 */}
{config.requirementNote && (
<View className="mb-3 flex-row items-start gap-2 rounded-lg bg-primary/[0.06] px-3 py-2.5">
<ScaledIcon
icon={IconInfoCircle}
size={14}
color={primaryColor}
className="mt-px"
/>
<Text className="flex-1 text-foreground/80 text-xs">
{config.requirementNote}
</Text>
<View className="bg-primary/[0.06] mb-3 flex-row items-start gap-2 rounded-lg px-3 py-2.5">
<ScaledIcon icon={IconInfoCircle} size={14} color={primaryColor} className="mt-px" />
<Text className="text-foreground/80 flex-1 text-xs">{config.requirementNote}</Text>
</View>
)}
{!connection ? (
/* Connect button */
<Pressable
onPress={() =>
connectMutation.mutate({ provider: providerInput })
}
onPress={() => connectMutation.mutate({ provider: providerInput })}
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">
{connectMutation.isPending
? t`Connecting…`
: t`Connect ${label}`}
<Text className="text-primary-foreground font-sans font-medium">
{connectMutation.isPending ? t`Connecting…` : t`Connect ${label}`}
</Text>
</Pressable>
) : (
<View className="gap-3">
{/* URL display */}
<View>
<Text className="mb-1.5 text-muted-foreground text-xs">
{config.urlLabel}
</Text>
<View className="flex-row items-center rounded-lg bg-secondary/50 px-3 py-2.5">
<Text className="text-muted-foreground mb-1.5 text-xs">{config.urlLabel}</Text>
<View className="bg-secondary/50 flex-row items-center rounded-lg px-3 py-2.5">
<Text
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}
>
{url}
@@ -263,11 +230,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
{copied ? (
<ScaledIcon icon={IconCheck} size={16} color="#4ade80" />
) : (
<ScaledIcon
icon={IconCopy}
size={16}
color={mutedFgColor}
/>
<ScaledIcon icon={IconCopy} size={16} color={mutedFgColor} />
)}
</Pressable>
</View>
@@ -278,23 +241,19 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
<Pressable
onPress={handleRegenerate}
disabled={regenerateMutation.isPending}
className="flex-1 flex-row items-center justify-center gap-1.5 rounded-lg bg-secondary py-2.5 active:opacity-80"
className="bg-secondary flex-1 flex-row items-center justify-center gap-1.5 rounded-lg py-2.5 active:opacity-80"
>
<ScaledIcon
icon={IconRefresh}
size={14}
color={mutedFgColor}
/>
<Text className="font-medium font-sans text-foreground text-sm">
<ScaledIcon icon={IconRefresh} size={14} color={mutedFgColor} />
<Text className="text-foreground font-sans text-sm font-medium">
<Trans>Regenerate</Trans>
</Text>
</Pressable>
<Pressable
onPress={handleDisconnect}
className="flex-1 flex-row items-center justify-center gap-1.5 rounded-lg bg-destructive/10 py-2.5 active:opacity-80"
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" />
<Text className="font-medium font-sans text-destructive text-sm">
<Text className="text-destructive font-sans text-sm font-medium">
<Trans>Disconnect</Trans>
</Text>
</Pressable>
@@ -304,16 +263,9 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
{/* Setup instructions (nested accordion) */}
<View className="mt-3">
<Pressable
onPress={toggleSetup}
className="flex-row items-center gap-1.5"
>
<Pressable onPress={toggleSetup} className="flex-row items-center gap-1.5">
<Animated.View style={setupChevronStyle}>
<ScaledIcon
icon={IconChevronDown}
size={12}
color={mutedFgColor}
/>
<ScaledIcon icon={IconChevronDown} size={12} color={mutedFgColor} />
</Animated.View>
<Text className="text-muted-foreground text-xs">
<Trans>Setup instructions</Trans>
@@ -324,14 +276,12 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
className="mt-2 rounded-lg border border-white/[0.04] bg-secondary/30 px-3 py-2.5"
className="bg-secondary/30 mt-2 rounded-lg border border-white/[0.04] px-3 py-2.5"
>
{config.setupSteps.map((step, i) => (
<View key={step} className="flex-row py-1">
<Text className="w-5 text-muted-foreground text-xs">
{i + 1}.
</Text>
<Text className="flex-1 text-muted-foreground text-xs leading-[18px]">
<Text className="text-muted-foreground w-5 text-xs">{i + 1}.</Text>
<Text className="text-muted-foreground flex-1 text-xs leading-[18px]">
{step}
</Text>
</View>
@@ -1,7 +1,7 @@
import type { I18n } from "@lingui/core";
import { msg } from "@lingui/core/macro";
import { formatRelativeTime } from "@sofa/i18n/format";
import type { SvgProps } from "react-native-svg";
import {
EmbyIcon,
JellyfinIcon,
@@ -10,6 +10,7 @@ import {
SonarrIcon,
} from "@/components/settings/icons";
import { getServerUrl } from "@/lib/server";
import { formatRelativeTime } from "@sofa/i18n/format";
export interface IntegrationConfig {
provider: "plex" | "jellyfin" | "emby" | "sonarr" | "radarr";
@@ -53,9 +54,7 @@ export function getIntegrationConfigs(i18n: I18n): IntegrationConfig[] {
setupSteps: [
i18n._(msg`Open Plex, go to Settings > Webhooks`),
i18n._(msg`Click "Add Webhook" and paste the URL above`),
i18n._(
msg`Sofa will automatically log movies and episodes when you finish watching them`,
),
i18n._(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`Add a "Generic Destination" and paste the URL above`),
i18n._(msg`Enable the "Playback Stop" notification type`),
i18n._(
msg`Sofa will automatically log movies and episodes when you finish watching them`,
),
i18n._(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`Add a new webhook and paste the URL above`),
i18n._(msg`Enable the "Playback" event category`),
i18n._(
msg`Sofa will automatically log movies and episodes when you finish watching them`,
),
i18n._(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 { useQuery } from "@tanstack/react-query";
import { Pressable, View } from "react-native";
import { IntegrationCard } from "@/components/settings/integration-card";
import { getIntegrationConfigs } from "@/components/settings/integration-configs";
import { ScaledIcon } from "@/components/ui/scaled-icon";
@@ -30,15 +31,11 @@ export function IntegrationsSection() {
</Text>
<Pressable
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" }}
>
<ScaledIcon
icon={IconRefresh}
size={14}
className="accent-primary"
/>
<Text className="font-medium font-sans text-primary text-sm">
<ScaledIcon icon={IconRefresh} size={14} className="accent-primary" />
<Text className="text-primary font-sans text-sm font-medium">
<Trans>Retry</Trans>
</Text>
</Pressable>
@@ -46,16 +43,8 @@ export function IntegrationsSection() {
) : (
configs.map((config) => {
const connection =
integrations.data?.integrations?.find(
(i) => i.provider === config.provider,
) ?? null;
return (
<IntegrationCard
key={config.provider}
config={config}
connection={connection}
/>
);
integrations.data?.integrations?.find((i) => i.provider === config.provider) ?? null;
return <IntegrationCard key={config.provider} config={config} connection={connection} />;
})
)}
</View>
@@ -3,6 +3,7 @@ import { IconChevronRight } from "@tabler/icons-react-native";
import type { ReactNode } from "react";
import { Pressable, View } from "react-native";
import { useCSSVariable } from "uniwind";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
@@ -45,19 +46,14 @@ export function SettingsRow({
{!right && value ? (
<Text
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}
>
{value}
</Text>
) : null}
{!right && onPress && (
<ScaledIcon
icon={IconChevronRight}
size={16}
color={mutedFgColor}
accessible={false}
/>
<ScaledIcon icon={IconChevronRight} size={16} color={mutedFgColor} accessible={false} />
)}
</>
);
@@ -1,6 +1,7 @@
import type { Icon } from "@tabler/icons-react-native";
import { Children, Fragment, isValidElement, type ReactNode } from "react";
import { View } from "react-native";
import { SectionHeader } from "@/components/ui/section-header";
import { Text } from "@/components/ui/text";
@@ -8,9 +9,7 @@ function flattenChildren(node: ReactNode): ReactNode[] {
const result: ReactNode[] = [];
Children.forEach(node, (child) => {
if (isValidElement(child) && child.type === Fragment) {
result.push(
...flattenChildren((child.props as { children?: ReactNode }).children),
);
result.push(...flattenChildren((child.props as { children?: ReactNode }).children));
} else if (child != null && child !== false) {
result.push(child);
}
@@ -36,10 +35,10 @@ export function SettingsSection({
<View className="flex-row items-center gap-2">
<SectionHeader title={title} icon={icon} />
{badge ? (
<View className="mb-2.5 rounded-full bg-primary/10 px-2 py-0.5">
<View className="bg-primary/10 mb-2.5 rounded-full px-2 py-0.5">
<Text
maxFontSizeMultiplier={1.0}
className="font-medium font-sans text-primary text-xs"
className="text-primary font-sans text-xs font-medium"
>
{badge}
</Text>
@@ -47,23 +46,16 @@ export function SettingsSection({
) : null}
</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" }}
>
{items.map((child, i) => {
const key =
isValidElement(child) && child.key != null
? String(child.key)
: `settings-item-${i}`;
isValidElement(child) && child.key != null ? String(child.key) : `settings-item-${i}`;
return (
<Fragment key={key}>
{i > 0 && (
<View
className="border-border border-t"
style={{ borderTopWidth: 0.5 }}
/>
)}
{i > 0 && <View className="border-border border-t" style={{ borderTopWidth: 0.5 }} />}
{child}
</Fragment>
);
@@ -1,5 +1,6 @@
import { Link } from "expo-router";
import { Pressable, View } from "react-native";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
@@ -31,7 +32,7 @@ export function CastCard({
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
<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 && (
<Image
source={{ uri: person.profilePath }}
@@ -46,7 +47,7 @@ export function CastCard({
<Text
numberOfLines={1}
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}
</Text>
@@ -54,7 +55,7 @@ export function CastCard({
<Text
numberOfLines={1}
maxFontSizeMultiplier={1.0}
className="text-center text-muted-foreground text-xs"
className="text-muted-foreground text-center text-xs"
>
{person.character}
</Text>
@@ -1,14 +1,15 @@
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 { useMemo } from "react";
import { View } from "react-native";
import Animated, { FadeInDown } from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { SectionHeader } from "@/components/ui/section-header";
import { Text } from "@/components/ui/text";
import type { Season } from "@sofa/api/schemas";
import { getNextEpisode } from "@sofa/api/utils";
export function ContinueWatchingBanner({
seasons,
@@ -34,21 +35,13 @@ export function ContinueWatchingBanner({
if (userStatus !== "in_progress" || !nextEpisode) return null;
const stillUrl = nextEpisode.stillPath ?? backdropPath ?? null;
const progress =
totalEpisodes > 0 ? (watchedEpisodes / totalEpisodes) * 100 : 0;
const progress = totalEpisodes > 0 ? (watchedEpisodes / totalEpisodes) * 100 : 0;
return (
<Animated.View
entering={FadeInDown.duration(300).delay(350)}
className="mt-6 px-4"
>
<SectionHeader
title={t`Next Episode`}
icon={IconPlayerPlay}
iconColor={titleAccentColor}
/>
<Animated.View entering={FadeInDown.duration(300).delay(350)} className="mt-6 px-4">
<SectionHeader title={t`Next Episode`} icon={IconPlayerPlay} iconColor={titleAccentColor} />
<View
className="overflow-hidden rounded-[12px] border bg-card"
className="bg-card overflow-hidden rounded-[12px] border"
style={{
borderColor: "rgba(255,255,255,0.06)",
borderCurve: "continuous",
@@ -71,19 +64,16 @@ export function ContinueWatchingBanner({
/>
<View className="absolute right-3 bottom-2.5 left-3">
<View className="flex-row items-center gap-1.5">
<View className="h-1.5 w-1.5 rounded-full bg-title-accent" />
<View className="bg-title-accent h-1.5 w-1.5 rounded-full" />
<Text
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>
</Text>
</View>
<Text
numberOfLines={1}
className="mt-0.5 font-medium font-sans text-sm text-white"
>
<Text className="font-medium font-sans text-white/60 text-xs">
<Text numberOfLines={1} className="mt-0.5 font-sans text-sm font-medium text-white">
<Text className="font-sans text-xs font-medium text-white/60">
S{nextEpisode.seasonNumber} E{nextEpisode.episodeNumber}
</Text>
{" "}
@@ -94,10 +84,7 @@ export function ContinueWatchingBanner({
className="absolute right-0 bottom-0 left-0 h-[3px]"
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
>
<View
className="h-full bg-title-accent"
style={{ width: `${progress}%` }}
/>
<View className="bg-title-accent h-full" style={{ width: `${progress}%` }} />
</View>
</View>
</View>
@@ -1,9 +1,7 @@
import { useLingui } from "@lingui/react/macro";
import {
IconCircleCheckFilled,
IconCircleDashed,
} from "@tabler/icons-react-native";
import { IconCircleCheckFilled, IconCircleDashed } from "@tabler/icons-react-native";
import { Pressable, View } from "react-native";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
@@ -34,30 +32,23 @@ export function EpisodeRow({
accessibilityRole="checkbox"
accessibilityState={{ checked: isWatched }}
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 }}
>
{isWatched ? (
<ScaledIcon
icon={IconCircleCheckFilled}
size={22}
color={accentColor}
/>
<ScaledIcon icon={IconCircleCheckFilled} size={22} color={accentColor} />
) : (
<ScaledIcon icon={IconCircleDashed} size={22} color={mutedColor} />
)}
<View className="ml-3 flex-1">
<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}
>
{episode.episodeNumber}.{" "}
{episode.name ?? t`Episode ${episode.episodeNumber}`}
{episode.episodeNumber}. {episode.name ?? t`Episode ${episode.episodeNumber}`}
</Text>
{episode.airDate ? (
<Text className="mt-0.5 text-muted-foreground text-xs">
{episode.airDate}
</Text>
<Text className="text-muted-foreground mt-0.5 text-xs">{episode.airDate}</Text>
) : null}
</View>
</Pressable>
@@ -12,6 +12,7 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { EpisodeRow } from "@/components/titles/episode-row";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
@@ -42,9 +43,7 @@ export function SeasonAccordion({
const reduceMotion = useReducedMotion();
const [expanded, setExpanded] = useState(false);
const chevronRotation = useSharedValue(0);
const watchedCount = episodes.filter((e) =>
watchedEpisodeIds.has(e.id),
).length;
const watchedCount = episodes.filter((e) => watchedEpisodeIds.has(e.id)).length;
const progress = episodes.length > 0 ? watchedCount / episodes.length : 0;
// Progressive rendering: show first batch immediately, defer rest
@@ -67,11 +66,7 @@ export function SeasonAccordion({
useEffect(() => {
chevronRotation.set(
reduceMotion
? expanded
? 180
: 0
: withTiming(expanded ? 180 : 0, { duration: 200 }),
reduceMotion ? (expanded ? 180 : 0) : withTiming(expanded ? 180 : 0, { duration: 200 }),
);
}, [expanded, chevronRotation, reduceMotion]);
@@ -83,15 +78,11 @@ export function SeasonAccordion({
toasts: {
watchEpisode: ({ id: epId }) => {
const ep = episodes.find((e) => e.id === epId);
return ep
? `Watched S${season.seasonNumber} E${ep.episodeNumber}`
: "Episode watched";
return ep ? `Watched S${season.seasonNumber} E${ep.episodeNumber}` : "Episode watched";
},
unwatchEpisode: ({ id: epId }) => {
const ep = episodes.find((e) => e.id === epId);
return ep
? `Unwatched S${season.seasonNumber} E${ep.episodeNumber}`
: "Episode unwatched";
return ep ? `Unwatched S${season.seasonNumber} E${ep.episodeNumber}` : "Episode unwatched";
},
watchSeason: `Watched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
},
@@ -110,7 +101,7 @@ export function SeasonAccordion({
return (
<View
className="mb-2 overflow-hidden rounded-xl border bg-card"
className="bg-card mb-2 overflow-hidden rounded-xl border"
style={{
borderColor: "rgba(255,255,255,0.06)",
borderCurve: "continuous",
@@ -124,10 +115,10 @@ export function SeasonAccordion({
className="flex-row items-center justify-between p-4"
>
<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}`}
</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" })}`}
</Text>
</View>
@@ -151,16 +142,13 @@ export function SeasonAccordion({
</Pressable>
{expanded && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
>
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(150)}>
{watchedCount < episodes.length && (
<Pressable
onPress={() => watchSeason.mutate({ id: season.id })}
className="mx-4 mb-2 flex-row items-center justify-center rounded-lg bg-secondary py-2"
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>
</Text>
</Pressable>
@@ -7,6 +7,7 @@ import {
} from "@tabler/icons-react-native";
import { Pressable } from "react-native";
import { useCSSVariable } from "uniwind";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
import * as Haptics from "@/utils/haptics";
@@ -70,15 +71,10 @@ export function StatusActionButton({
disabled={isPending}
accessibilityRole="button"
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
icon={IconPlus}
size={14}
color={titleAccent}
strokeWidth={2.5}
/>
<Text className="font-medium font-sans text-sm text-title-accent">
<ScaledIcon icon={IconPlus} size={14} color={titleAccent} strokeWidth={2.5} />
<Text className="text-title-accent font-sans text-sm font-medium">
<Trans>Watchlist</Trans>
</Text>
</Pressable>
@@ -87,8 +83,7 @@ export function StatusActionButton({
const StatusIcon = STATUS_ICONS[currentStatus];
const styles = STATUS_STYLES[currentStatus];
const iconColor =
currentStatus === "completed" ? completedColor : titleAccent;
const iconColor = currentStatus === "completed" ? completedColor : titleAccent;
return (
<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}`}
>
<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} />
</Text>
</Pressable>
+1 -6
View File
@@ -5,12 +5,7 @@ export function TmdbLogo({ height = 12 }: { height?: number }) {
const width = height * aspectRatio;
return (
<Svg
viewBox="0 0 190.24 81.52"
width={width}
height={height}
accessibilityLabel="TMDB"
>
<Svg viewBox="0 0 190.24 81.52" width={width} height={height} accessibilityLabel="TMDB">
<Defs>
<LinearGradient
id="tmdb-grad"
+5 -20
View File
@@ -1,7 +1,7 @@
import { createContext, forwardRef, useContext } from "react";
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";
type ButtonVariant = "default" | "secondary";
@@ -14,21 +14,8 @@ interface ButtonProps extends PressableProps {
className?: string;
}
export const Button = forwardRef<
React.ComponentRef<typeof Pressable>,
ButtonProps
>(
(
{
size = "default",
variant = "default",
className,
style,
disabled,
...props
},
ref,
) => {
export const Button = forwardRef<React.ComponentRef<typeof Pressable>, ButtonProps>(
({ size = "default", variant = "default", className, style, disabled, ...props }, ref) => {
return (
<ButtonVariantContext.Provider value={variant}>
<Pressable
@@ -67,10 +54,8 @@ export function ButtonLabel({ style, className, ...props }: ButtonLabelProps) {
return (
<Text
className={cn(
"text-center font-medium font-sans text-base",
variant === "secondary"
? "text-secondary-foreground"
: "text-primary-foreground",
"text-center font-sans text-base font-medium",
variant === "secondary" ? "text-secondary-foreground" : "text-primary-foreground",
className,
)}
style={style}
@@ -3,6 +3,7 @@ import { IconMovie } from "@tabler/icons-react-native";
import { View } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { Button, ButtonLabel } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
@@ -32,19 +33,15 @@ export function EmptyState({
<View accessible={false}>
<IconComponent size={48} color={mutedForeground} />
</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}
</Text>
{description && (
<Text className="mt-1 text-center text-muted-foreground text-sm">
{description}
</Text>
<Text className="text-muted-foreground mt-1 text-center text-sm">{description}</Text>
)}
{actionLabel && onAction && (
<Button onPress={onAction} size="sm" className="mt-4 bg-primary">
<ButtonLabel className="text-primary-foreground">
{actionLabel}
</ButtonLabel>
<Button onPress={onAction} size="sm" className="bg-primary mt-4">
<ButtonLabel className="text-primary-foreground">{actionLabel}</ButtonLabel>
</Button>
)}
</Animated.View>
@@ -2,6 +2,7 @@ import { useLingui } from "@lingui/react/macro";
import { useCallback, useRef, useState } from "react";
import type { NativeSyntheticEvent, TextLayoutEventData } from "react-native";
import { Platform, Pressable, View } from "react-native";
import { Text } from "@/components/ui/text";
export function ExpandableText({
@@ -63,7 +64,7 @@ export function ExpandableText({
className="mt-1"
>
<Text
className="font-medium font-sans text-primary text-sm"
className="text-primary font-sans text-sm font-medium"
style={actionColor ? { color: actionColor } : undefined}
>
{expanded ? t`Show less` : t`Show more`}
+1
View File
@@ -1,5 +1,6 @@
import { Image as ExpoImage, type ImageProps } from "expo-image";
import { useResolveClassNames } from "uniwind";
import { resolveUrl } from "@/lib/server";
export function Image({
@@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react";
import { View } from "react-native";
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
import * as Haptics from "@/utils/haptics";
@@ -70,14 +71,14 @@ export function OfflineBanner() {
}}
>
<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>
</Text>
</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" />
<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>
</Text>
</View>
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { View } from "react-native";
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
import * as Haptics from "@/utils/haptics";
@@ -53,9 +54,9 @@ export function OfflineBanner() {
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" />
<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>
</Text>
</View>
+24 -50
View File
@@ -15,6 +15,7 @@ import { Pressable, View } from "react-native";
import { GestureDetector } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Skeleton } from "@/components/ui/skeleton";
@@ -83,18 +84,13 @@ export function PosterCard({
: userStatus === "watchlist"
? "on watchlist"
: undefined;
const cardAccessibilityLabel = [
title,
type === "movie" ? "movie" : "TV show",
year,
statusLabel,
]
const cardAccessibilityLabel = [title, type === "movie" ? "movie" : "TV show", year, statusLabel]
.filter(Boolean)
.join(", ");
const cardContent = (
<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]"
}`}
style={{ borderCurve: "continuous" }}
@@ -111,10 +107,8 @@ export function PosterCard({
transition={200}
/>
) : (
<View className="flex-1 items-center justify-center bg-secondary p-3">
<Text className="text-center font-display text-foreground/[0.44] text-sm">
{title}
</Text>
<View className="bg-secondary flex-1 items-center justify-center p-3">
<Text className="font-display text-foreground/[0.44] text-center text-sm">{title}</Text>
</View>
)}
@@ -136,21 +130,19 @@ export function PosterCard({
)}
{/* Episode progress bar */}
{episodeProgress &&
episodeProgress.total > 0 &&
episodeProgress.watched > 0 && (
{episodeProgress && episodeProgress.total > 0 && episodeProgress.watched > 0 && (
<View
className="absolute right-0 bottom-0 left-0 h-[3px]"
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
>
<View
className="absolute right-0 bottom-0 left-0 h-[3px]"
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
>
<View
className="h-full bg-status-watching"
style={{
width: `${episodeProgress.total > 0 ? (episodeProgress.watched / episodeProgress.total) * 100 : 0}%`,
}}
/>
</View>
)}
className="bg-status-watching h-full"
style={{
width: `${episodeProgress.total > 0 ? (episodeProgress.watched / episodeProgress.total) * 100 : 0}%`,
}}
/>
</View>
)}
</View>
{/* Metadata */}
@@ -162,10 +154,7 @@ export function PosterCard({
style={{ backgroundColor: statusColors[userStatus] }}
/>
)}
<Text
className="flex-1 font-medium font-sans text-foreground text-sm"
numberOfLines={1}
>
<Text className="text-foreground flex-1 font-sans text-sm font-medium" numberOfLines={1}>
{title}
</Text>
</View>
@@ -175,19 +164,11 @@ export function PosterCard({
) : (
<ScaledIcon icon={IconDeviceTv} size={12} color={primaryColor} />
)}
{year ? (
<Text className="text-muted-foreground text-xs">{year}</Text>
) : null}
{year ? <Text className="text-muted-foreground text-xs">{year}</Text> : null}
{voteAverage != null && voteAverage > 0 && (
<View className="ml-auto flex-row items-center gap-1">
<ScaledIcon
icon={IconStarFilled}
size={10}
color={primaryColor}
/>
<Text className="text-primary text-xs">
{voteAverage.toFixed(1)}
</Text>
<ScaledIcon icon={IconStarFilled} size={10} color={primaryColor} />
<Text className="text-primary text-xs">{voteAverage.toFixed(1)}</Text>
</View>
)}
</View>
@@ -207,11 +188,7 @@ export function PosterCard({
className="size-[30px] items-center justify-center rounded-full"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
>
{isAdding ? (
<IconLoader size={16} color="white" />
) : (
<IconPlus size={16} color="white" />
)}
{isAdding ? <IconLoader size={16} color="white" /> : <IconPlus size={16} color="white" />}
</View>
</Pressable>
) : null;
@@ -224,10 +201,7 @@ export function PosterCard({
<View>
<Link href={titleHref} asChild>
<Link.Trigger withAppleZoom>
<Pressable
accessibilityRole="button"
accessibilityLabel={cardAccessibilityLabel}
>
<Pressable accessibilityRole="button" accessibilityLabel={cardAccessibilityLabel}>
{cardContent}
</Pressable>
</Link.Trigger>
@@ -275,7 +249,7 @@ export function PosterCardSkeleton({ width = 140 }: { width?: number }) {
const imageHeight = width * 1.5;
return (
<View
className="overflow-hidden rounded-xl border border-white/[0.06] bg-card"
className="bg-card overflow-hidden rounded-xl border border-white/[0.06]"
style={{
width,
borderCurve: "continuous",
@@ -1,5 +1,6 @@
import type { ComponentType } from "react";
import type { SvgProps } from "react-native-svg";
import { useScaledSize } from "@/hooks/use-scaled-size";
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.
*/
export function ScaledIcon({
icon: IconComponent,
size,
...props
}: ScaledIconProps) {
export function ScaledIcon({ icon: IconComponent, size, ...props }: ScaledIconProps) {
const scaled = useScaledSize();
const s = scaled(size);
return <IconComponent size={s} width={s} height={s} {...props} />;
@@ -1,6 +1,7 @@
import type { Icon } from "@tabler/icons-react-native";
import { View } from "react-native";
import { useCSSVariable } from "uniwind";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
@@ -10,22 +11,14 @@ interface SectionHeaderProps {
iconColor?: string;
}
export function SectionHeader({
title,
icon: IconComponent,
iconColor,
}: SectionHeaderProps) {
export function SectionHeader({ title, icon: IconComponent, iconColor }: SectionHeaderProps) {
const primaryColor = useCSSVariable("--color-primary") as string;
const resolvedColor = iconColor ?? primaryColor;
return (
<View className="mb-3 flex-row items-center gap-2">
{IconComponent && (
<ScaledIcon icon={IconComponent} size={20} color={resolvedColor} />
)}
<Text className="font-display text-foreground text-xl tracking-tight">
{title}
</Text>
{IconComponent && <ScaledIcon icon={IconComponent} size={20} color={resolvedColor} />}
<Text className="font-display text-foreground text-xl tracking-tight">{title}</Text>
</View>
);
}
+7 -10
View File
@@ -3,6 +3,7 @@ import { Modal, Pressable, View } from "react-native";
import { useCSSVariable } from "uniwind";
import { Text } from "@/components/ui/text";
import { ScaledIcon } from "./scaled-icon";
export interface SelectModalOption {
@@ -51,34 +52,30 @@ export function SelectModal({
onPress={() => onOpenChange(false)}
>
<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()}
>
<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} />}
<Text className="ml-1.5 font-medium text-base text-foreground">
{label}
</Text>
<Text className="text-foreground ml-1.5 text-base font-medium">{label}</Text>
</View>
{options.map((option) => {
const isSelected = option.value === selection;
return (
<Pressable
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={() => {
onOpenChange(false);
onSelect(option.value);
}}
>
<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}
</Text>
{isSelected && (
<IconCheck size={18} color={primaryColor} strokeWidth={2} />
)}
{isSelected && <IconCheck size={18} color={primaryColor} strokeWidth={2} />}
</Pressable>
);
})}
@@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react";
import { Pressable, View } from "react-native";
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
import { queryClient } from "@/lib/query-client";
@@ -62,14 +63,11 @@ export function ServerUnreachableBanner() {
const content = (
<>
<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>
</Text>
<Pressable
onPress={handleRetry}
className="ml-1 rounded-md bg-white/20 px-2 py-0.5"
>
<Text className="font-medium font-sans text-white text-xs">
<Pressable onPress={handleRetry} className="ml-1 rounded-md bg-white/20 px-2 py-0.5">
<Text className="font-sans text-xs font-medium text-white">
<Trans>Retry</Trans>
</Text>
</Pressable>
@@ -106,7 +104,7 @@ export function ServerUnreachableBanner() {
{content}
</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}
</View>
)}
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { Pressable, View } from "react-native";
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
import { queryClient } from "@/lib/query-client";
@@ -68,16 +69,13 @@ export function ServerUnreachableBanner() {
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" />
<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>
</Text>
<Pressable
onPress={handleRetry}
className="ml-1 rounded-md bg-white/20 px-2 py-0.5"
>
<Text className="font-medium font-sans text-white text-xs">
<Pressable onPress={handleRetry} className="ml-1 rounded-md bg-white/20 px-2 py-0.5">
<Text className="font-sans text-xs font-medium text-white">
<Trans>Retry</Trans>
</Text>
</Pressable>
+1 -7
View File
@@ -7,7 +7,6 @@ import Animated, {
withRepeat,
withTiming,
} from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
interface SkeletonProps {
@@ -17,12 +16,7 @@ interface SkeletonProps {
style?: ViewStyle;
}
export function Skeleton({
width,
height = 14,
borderRadius = 4,
style,
}: SkeletonProps) {
export function Skeleton({ width, height = 14, borderRadius = 4, style }: SkeletonProps) {
const secondaryColor = useCSSVariable("--color-secondary") as string;
const reduceMotion = useReducedMotion();
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) {
return (
<ActivityIndicator
size={SIZES[size]}
colorClassName="accent-primary-foreground"
{...props}
/>
<ActivityIndicator size={SIZES[size]} colorClassName="accent-primary-foreground" {...props} />
);
}
@@ -1,6 +1,7 @@
import { IconStar, IconStarFilled } from "@tabler/icons-react-native";
import { Pressable, View } from "react-native";
import { useCSSVariable } from "uniwind";
import * as Haptics from "@/utils/haptics";
interface StarRatingProps {
@@ -3,10 +3,8 @@ import type { PropsWithChildren } from "react";
import { View } from "react-native";
import ReanimatedSwipeable from "react-native-gesture-handler/ReanimatedSwipeable";
import type { SharedValue } from "react-native-reanimated";
import Animated, {
interpolate,
useAnimatedStyle,
} from "react-native-reanimated";
import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import * as Haptics from "@/utils/haptics";
@@ -22,10 +20,7 @@ function RightAction({ drag }: { drag: SharedValue<number> }) {
});
return (
<View
className="items-center justify-center bg-destructive"
style={{ width: ACTION_WIDTH }}
>
<View className="bg-destructive items-center justify-center" style={{ width: ACTION_WIDTH }}>
<Animated.View style={animatedStyle}>
<ScaledIcon icon={IconTrash} size={22} color="#fff" />
</Animated.View>
+1 -7
View File
@@ -24,13 +24,7 @@ export function Switch({
disabled={disabled}
accessibilityLabel={accessibilityLabel}
ios_backgroundColor={secondaryColor}
thumbColor={
process.env.EXPO_OS === "ios"
? undefined
: value
? "#fff"
: mutedFgColor
}
thumbColor={process.env.EXPO_OS === "ios" ? undefined : value ? "#fff" : mutedFgColor}
trackColor={{ false: secondaryColor, true: primaryColor }}
style={{
transform: [{ scaleX: switchScale }, { scaleY: switchScale }],
+24 -44
View File
@@ -1,18 +1,7 @@
import {
createContext,
forwardRef,
type PropsWithChildren,
useContext,
useId,
} from "react";
import {
TextInput,
type TextInputProps,
type TextProps,
View,
} from "react-native";
import { Text } from "@/components/ui/text";
import { createContext, forwardRef, type PropsWithChildren, useContext, useId } from "react";
import { TextInput, type TextInputProps, type TextProps, View } from "react-native";
import { Text } from "@/components/ui/text";
import { cn } from "@/utils/cn";
const TextFieldContext = createContext<string | undefined>(undefined);
@@ -26,40 +15,35 @@ export function TextField({ children }: PropsWithChildren) {
);
}
export function Label({
className,
nativeID,
...props
}: TextProps & { className?: string }) {
export function Label({ className, nativeID, ...props }: TextProps & { className?: string }) {
const ctxId = useContext(TextFieldContext);
return (
<Text
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}
/>
);
}
export const Input = forwardRef<
TextInput,
TextInputProps & { className?: string }
>(({ className, style, ...props }, ref) => {
const ctxId = useContext(TextFieldContext);
return (
<TextInput
ref={ref}
accessibilityLabelledBy={ctxId}
placeholderTextColorClassName="accent-muted-foreground/70"
className={cn(
"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}
/>
);
});
export const Input = forwardRef<TextInput, TextInputProps & { className?: string }>(
({ className, style, ...props }, ref) => {
const ctxId = useContext(TextFieldContext);
return (
<TextInput
ref={ref}
accessibilityLabelledBy={ctxId}
placeholderTextColorClassName="accent-muted-foreground/70"
className={cn(
"border-border bg-input text-foreground min-h-12 rounded-[12px] border px-3.5 py-3 font-sans text-sm",
className,
)}
style={[{ borderCurve: "continuous" }, style]}
{...props}
/>
);
},
);
Input.displayName = "Input";
@@ -71,11 +55,7 @@ export function FieldError({
}: PropsWithChildren<TextProps & { isInvalid?: boolean; className?: string }>) {
if (!isInvalid) return null;
return (
<Text
selectable
className={cn("text-destructive text-sm", className)}
{...props}
>
<Text selectable className={cn("text-destructive text-sm", className)} {...props}>
{children}
</Text>
);
+1 -3
View File
@@ -20,9 +20,7 @@ export function usePressAnimation(scale = 0.97) {
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{
scale: reduceMotion
? 1
: interpolate(pressed.get(), [0, 1], [1, scale]),
scale: reduceMotion ? 1 : interpolate(pressed.get(), [0, 1], [1, scale]),
},
],
}));
+4 -11
View File
@@ -1,10 +1,7 @@
import { useRouter } from "expo-router";
import { useEffect, useRef, useState } from "react";
import {
clearStorageScope,
hasScopedStorage,
setStorageScope,
} from "@/lib/mmkv";
import { clearStorageScope, hasScopedStorage, setStorageScope } from "@/lib/mmkv";
import { queryClient } from "@/lib/query-client";
import {
authClient,
@@ -43,8 +40,7 @@ export function useServerConnection() {
);
const { data: session, isPending, isRefetching } = authClient.useSession();
const hasServerUrl =
!!process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl();
const hasServerUrl = !!process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl();
// --- Instance ID resolution ---
const [instanceId, setInstanceId] = useState(getCurrentInstanceId);
@@ -61,10 +57,7 @@ export function useServerConnection() {
}, [instanceId, hasServerUrl]);
// Re-sync instanceId when server URL changes
useEffect(
() => onServerUrlChange(() => setInstanceId(getCurrentInstanceId())),
[],
);
useEffect(() => onServerUrlChange(() => setInstanceId(getCurrentInstanceId())), []);
// --- Scoped storage (per instance + user) ---
const userId = session?.user?.id;
+8 -25
View File
@@ -1,6 +1,7 @@
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { useMutation } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { invalidateTitleQueries } from "@/lib/title-actions";
@@ -40,9 +41,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
const quickAdd = useMutation(
orpc.titles.quickAdd.mutationOptions({
onSuccess: (_data, input) => {
toast.success(
resolveToast(toastOverrides?.quickAdd, t`Added to watchlist`, input),
);
toast.success(resolveToast(toastOverrides?.quickAdd, t`Added to watchlist`, input));
invalidateTitleQueries();
},
onError: () => {
@@ -64,9 +63,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
const defaultMsg = input.status
? (statusMessages[input.status] ?? t`Status updated`)
: t`Removed from library`;
toast.success(
resolveToast(toastOverrides?.updateStatus, defaultMsg, input),
);
toast.success(resolveToast(toastOverrides?.updateStatus, defaultMsg, input));
invalidateTitleQueries();
},
onError: () => toast.error(t`Failed to update status`),
@@ -76,9 +73,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
const watchMovie = useMutation(
orpc.titles.watchMovie.mutationOptions({
onSuccess: (_data, input) => {
toast.success(
resolveToast(toastOverrides?.watchMovie, t`Marked as watched`, input),
);
toast.success(resolveToast(toastOverrides?.watchMovie, t`Marked as watched`, input));
invalidateTitleQueries();
},
onError: () => toast.error(t`Failed to mark as watched`),
@@ -92,9 +87,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
input.stars > 0
? t`Rated ${input.stars} ${plural(input.stars, { one: "star", other: "stars" })}`
: t`Rating removed`;
toast.success(
resolveToast(toastOverrides?.updateRating, defaultMsg, input),
);
toast.success(resolveToast(toastOverrides?.updateRating, defaultMsg, input));
// Rating only invalidates title queries, not dashboard
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
},
@@ -105,9 +98,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
const watchEpisode = useMutation(
orpc.episodes.watch.mutationOptions({
onSuccess: (_data, input) => {
toast.success(
resolveToast(toastOverrides?.watchEpisode, t`Episode watched`, input),
);
toast.success(resolveToast(toastOverrides?.watchEpisode, t`Episode watched`, input));
invalidateTitleQueries();
},
onError: () => toast.error(t`Failed to mark episode`),
@@ -117,13 +108,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
const unwatchEpisode = useMutation(
orpc.episodes.unwatch.mutationOptions({
onSuccess: (_data, input) => {
toast.success(
resolveToast(
toastOverrides?.unwatchEpisode,
t`Episode unwatched`,
input,
),
);
toast.success(resolveToast(toastOverrides?.unwatchEpisode, t`Episode unwatched`, input));
invalidateTitleQueries();
},
onError: () => toast.error(t`Failed to unmark episode`),
@@ -133,9 +118,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
const watchSeason = useMutation(
orpc.seasons.watch.mutationOptions({
onSuccess: (_data, input) => {
toast.success(
resolveToast(toastOverrides?.watchSeason, t`Season watched`, input),
);
toast.success(resolveToast(toastOverrides?.watchSeason, t`Season watched`, input));
invalidateTitleQueries();
},
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 { Uniwind } from "uniwind";
import type { ColorPalette } from "@sofa/api/schemas";
function hexToRelativeLuminance(hex: string): number {
const r = Number.parseInt(hex.slice(1, 3), 16) / 255;
const g = Number.parseInt(hex.slice(3, 5), 16) / 255;
const b = Number.parseInt(hex.slice(5, 7), 16) / 255;
const toLinear = (c: number) =>
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
const toLinear = (c: number) => (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);
}
@@ -18,8 +18,7 @@ export function useTitleTheme(palette: ColorPalette | null | undefined): void {
if (!vibrant) return;
const luminance = hexToRelativeLuminance(vibrant);
const foreground =
luminance > 0.3 ? "oklch(0 0 0)" : "oklch(0.93 0.015 80)";
const foreground = luminance > 0.3 ? "oklch(0 0 0)" : "oklch(0.93 0.015 80)";
Uniwind.updateCSSVariables("dark", {
"--color-title-accent": vibrant,
+1
View File
@@ -1,4 +1,5 @@
import { ORPCError } from "@orpc/client";
import type { AppErrorCode } from "@sofa/api/errors";
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 { activateLocale, SUPPORTED_LOCALES, type SupportedLocale } from "@sofa/i18n";
import { globalStorage } from "./mmkv";
const LOCALE_STORAGE_KEY = "sofa:locale";
-5
View File
@@ -10,10 +10,8 @@
// 1. getCanonicalLocales (no dependencies)
import "@formatjs/intl-getcanonicallocales/polyfill";
// 2. Locale (depends on getCanonicalLocales)
import "@formatjs/intl-locale/polyfill";
// 3. PluralRules (depends on Locale)
import "@formatjs/intl-pluralrules/polyfill-force";
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/it";
import "@formatjs/intl-pluralrules/locale-data/pt";
// 4. NumberFormat (depends on PluralRules, Locale)
import "@formatjs/intl-numberformat/polyfill-force";
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/it";
import "@formatjs/intl-numberformat/locale-data/pt";
// 5. DateTimeFormat (depends on Locale)
import "@formatjs/intl-datetimeformat/polyfill-force";
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/pt";
import "@formatjs/intl-datetimeformat/add-all-tz";
// 6. RelativeTimeFormat (depends on PluralRules, Locale)
import "@formatjs/intl-relativetimeformat/polyfill-force";
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 type { ContractRouterClient } from "@orpc/contract";
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
import type { contract } from "@sofa/api/contract";
import { authClient, getServerUrl, serverFetch } from "@/lib/server";
import type { contract } from "@sofa/api/contract";
export const link = new RPCLink({
url: () => `${getServerUrl()}/rpc`,
@@ -26,7 +26,6 @@ export const link = new RPCLink({
},
});
export const client: ContractRouterClient<typeof contract> =
createORPCClient(link);
export const client: ContractRouterClient<typeof contract> = createORPCClient(link);
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.
if (!globalStorage.getBoolean(ATT_MIGRATED_KEY)) {
globalStorage.set(ATT_MIGRATED_KEY, true);
if (
globalStorage.getBoolean(ANALYTICS_ENABLED_KEY) !== undefined &&
!hasExplicitPreference()
) {
if (globalStorage.getBoolean(ANALYTICS_ENABLED_KEY) !== undefined && !hasExplicitPreference()) {
globalStorage.set(ANALYTICS_EXPLICIT_KEY, true);
}
}
+1 -1
View File
@@ -1,10 +1,10 @@
import { msg } from "@lingui/core/macro";
import { i18n } from "@sofa/i18n";
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { posthog } from "@/lib/posthog";
import { getIsReachable, isNetworkError } from "@/lib/server";
import { toast } from "@/lib/toast";
import { i18n } from "@sofa/i18n";
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
+3 -9
View File
@@ -1,9 +1,6 @@
import { useSyncExternalStore } from "react";
import {
hasScopedStorage,
onStorageScopeChange,
scopedStorage,
} from "@/lib/mmkv";
import { hasScopedStorage, onStorageScopeChange, scopedStorage } from "@/lib/mmkv";
const STORAGE_KEY = "recently_viewed";
const MAX_ITEMS = 50;
@@ -60,10 +57,7 @@ onStorageScopeChange(() => {
export function addRecentlyViewed(item: Omit<RecentlyViewedItem, "viewedAt">) {
const filtered = items.filter((i) => i.id !== item.id);
const next = [{ ...item, viewedAt: Date.now() }, ...filtered].slice(
0,
MAX_ITEMS,
);
const next = [{ ...item, viewedAt: Date.now() }, ...filtered].slice(0, MAX_ITEMS);
persist(next);
}
+14 -32
View File
@@ -46,8 +46,7 @@ interface CachedSessionData {
const SERVER_URL_KEY = "sofa_server_url";
const SERVERS_MAP_KEY = "sofa_servers";
const CURRENT_INSTANCE_KEY = "sofa_current_instance_id";
const DEFAULT_URL =
process.env.EXPO_PUBLIC_SERVER_URL ?? "https://sofa.example.com";
const DEFAULT_URL = process.env.EXPO_PUBLIC_SERVER_URL ?? "https://sofa.example.com";
// ---------------------------------------------------------------------------
// URL helpers
@@ -131,9 +130,7 @@ export async function ensureInstanceId(): Promise<string | null> {
const existing = getCurrentInstanceId();
if (existing) return existing;
const serverUrl = hasStoredServerUrl()
? getServerUrl()
: process.env.EXPO_PUBLIC_SERVER_URL;
const serverUrl = hasStoredServerUrl() ? getServerUrl() : process.env.EXPO_PUBLIC_SERVER_URL;
if (!serverUrl) return null;
try {
@@ -142,8 +139,7 @@ export async function ensureInstanceId(): Promise<string | null> {
});
if (!res.ok) return null;
const data = await res.json();
const instanceId =
typeof data.instanceId === "string" ? data.instanceId : null;
const instanceId = typeof data.instanceId === "string" ? data.instanceId : null;
if (instanceId) {
registerServer(serverUrl, instanceId);
return instanceId;
@@ -158,9 +154,7 @@ export async function ensureInstanceId(): Promise<string | null> {
// Validation
// ---------------------------------------------------------------------------
export async function validateServerUrl(
url: string,
): Promise<ValidationResult> {
export async function validateServerUrl(url: string): Promise<ValidationResult> {
const normalized = normalizeUrl(url);
if (!normalized || !normalized.includes("://")) {
@@ -194,8 +188,7 @@ export async function validateServerUrl(
return { status: "error", error: "not_sofa_server" };
}
const instanceId =
typeof data.instanceId === "string" ? data.instanceId : null;
const instanceId = typeof data.instanceId === "string" ? data.instanceId : null;
if (!instanceId) {
return { status: "error", error: "not_sofa_server" };
}
@@ -205,10 +198,7 @@ export async function validateServerUrl(
return { status: "error", error: "not_sofa_server" };
}
} catch (err) {
if (
err instanceof Error &&
(err.name === "TimeoutError" || err.name === "AbortError")
) {
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
return { status: "error", error: "timeout" };
}
return { status: "error", error: "network_unreachable" };
@@ -262,10 +252,7 @@ export function isNetworkError(error: unknown): error is Error {
);
}
export async function serverFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
export async function serverFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
try {
const response = await fetch(input, init);
setReachable(true);
@@ -285,9 +272,7 @@ export function getIsReachable(): boolean {
return isReachable;
}
export function onServerReachabilityChange(
callback: (reachable: boolean) => void,
): () => void {
export function onServerReachabilityChange(callback: (reachable: boolean) => void): () => void {
reachabilityListeners.push(callback);
return () => {
const index = reachabilityListeners.indexOf(callback);
@@ -301,15 +286,12 @@ export function startReachabilityMonitor(): () => void {
const networkSubscription = Network.addNetworkStateListener(syncOnlineState);
const appStateSubscription = AppState.addEventListener(
"change",
(nextState) => {
syncAppFocus(nextState);
if (nextState === "active") {
void Network.getNetworkStateAsync().then(syncOnlineState);
}
},
);
const appStateSubscription = AppState.addEventListener("change", (nextState) => {
syncAppFocus(nextState);
if (nextState === "active") {
void Network.getNetworkStateAsync().then(syncOnlineState);
}
});
const removeServerUrlListener = onServerUrlChange(() => {
setReachable(true);
+5 -10
View File
@@ -1,8 +1,9 @@
import { msg, plural } from "@lingui/core/macro";
import { i18n } from "@sofa/i18n";
import { client, orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
import { i18n } from "@sofa/i18n";
/** Invalidate title + dashboard queries. Used by most title mutations. */
export function invalidateTitleQueries() {
@@ -59,9 +60,7 @@ export const titleActions = {
try {
await client.titles.watchMovie({ id });
toast.success(
titleName
? i18n._(msg`Marked "${titleName}" as watched`)
: i18n._(msg`Marked as watched`),
titleName ? i18n._(msg`Marked "${titleName}" as watched`) : i18n._(msg`Marked as watched`),
);
invalidateTitleQueries();
} catch {
@@ -84,9 +83,7 @@ export const titleActions = {
await client.titles.updateRating({ id, stars });
toast.success(
stars > 0
? i18n._(
msg`Rated ${plural(stars, { one: "# star", other: "# stars" })}`,
)
? i18n._(msg`Rated ${plural(stars, { one: "# star", other: "# stars" })}`)
: i18n._(msg`Rating removed`),
);
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
@@ -119,9 +116,7 @@ export const titleActions = {
try {
await client.seasons.watch({ id });
toast.success(
seasonName
? i18n._(msg`Watched all of ${seasonName}`)
: i18n._(msg`Season watched`),
seasonName ? i18n._(msg`Watched all of ${seasonName}`) : i18n._(msg`Season watched`),
);
invalidateTitleQueries();
} catch {
+4 -8
View File
@@ -22,12 +22,8 @@ function show(
}
export const toast = {
success: (message: string, options?: ToastOptions) =>
show("done", "success", message, options),
error: (message: string, options?: ToastOptions) =>
show("error", "error", message, options),
info: (message: string, options?: ToastOptions) =>
show("none", "none", message, options),
warning: (message: string, options?: ToastOptions) =>
show("none", "warning", message, options),
success: (message: string, options?: ToastOptions) => show("done", "success", message, options),
error: (message: string, options?: ToastOptions) => show("error", "error", 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;
fields: Set<string>;
} {
const fieldErrors = error.flatten().fieldErrors as Record<
string,
string[] | undefined
>;
const fieldErrors = error.flatten().fieldErrors as Record<string, string[] | undefined>;
const fields = new Set<string>();
let message = "";
+2 -7
View File
@@ -16,18 +16,13 @@ const impactStyleToAndroid: Record<ImpactFeedbackStyle, AndroidHaptics> = {
[ImpactFeedbackStyle.Rigid]: AndroidHaptics.Virtual_Key,
};
const notificationTypeToAndroid: Record<
NotificationFeedbackType,
AndroidHaptics
> = {
const notificationTypeToAndroid: Record<NotificationFeedbackType, AndroidHaptics> = {
[NotificationFeedbackType.Success]: AndroidHaptics.Confirm,
[NotificationFeedbackType.Warning]: AndroidHaptics.Segment_Tick,
[NotificationFeedbackType.Error]: AndroidHaptics.Reject,
};
export async function impactAsync(
style: ImpactFeedbackStyle = ImpactFeedbackStyle.Medium,
) {
export async function impactAsync(style: ImpactFeedbackStyle = ImpactFeedbackStyle.Medium) {
return performAndroidHapticsAsync(impactStyleToAndroid[style]);
}
+1 -7
View File
@@ -7,11 +7,5 @@
"@/*": ["./src/*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"uniwind-types.d.ts"
]
"include": ["**/*.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": {
"dev": "PORT=3002 bun --watch src/app.ts",
"start": "PORT=3002 bun src/app.ts",
"lint": "biome check",
"format": "biome format --write",
"lint": "oxlint",
"format": "oxfmt --config ../../.oxfmtrc.json",
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
"check-types": "tsc --noEmit"
},
"dependencies": {
+9 -28
View File
@@ -3,10 +3,10 @@ import { checkRateLimit } from "@vercel/firewall";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { z } from "zod";
import { getProvider, getProviderConfig } from "./providers";
const GITHUB_RELEASES_URL =
"https://api.github.com/repos/jakejarvis/sofa/releases/latest";
const GITHUB_RELEASES_URL = "https://api.github.com/repos/jakejarvis/sofa/releases/latest";
const app = new Hono();
@@ -33,20 +33,14 @@ app.get("/v1/version", async (c) => {
html_url: string;
};
c.header(
"Cache-Control",
"public, s-maxage=900, stale-while-revalidate=3600",
);
c.header("Cache-Control", "public, s-maxage=900, stale-while-revalidate=3600");
return c.json({
version: data.tag_name.replace(/^v/, ""),
release_url: data.html_url,
});
} catch (e) {
return c.json(
{ error: e instanceof Error ? e.message : "Failed to fetch version" },
502,
);
return c.json({ error: e instanceof Error ? e.message : "Failed to fetch version" }, 502);
}
});
@@ -86,7 +80,7 @@ app.post(
arch: body.arch,
users: body.users,
titles: body.titles,
...(body.features ?? {}),
...body.features,
},
}),
signal: AbortSignal.timeout(10_000),
@@ -138,10 +132,7 @@ app.post(
}
try {
const result = await provider.getDeviceCode(
config.clientId,
config.clientSecret,
);
const result = await provider.getDeviceCode(config.clientId, config.clientSecret);
return c.json(result);
} catch (e) {
return c.json(
@@ -191,11 +182,7 @@ app.post(
}
try {
const result = await provider.pollForToken(
config.clientId,
config.clientSecret,
device_code,
);
const result = await provider.pollForToken(config.clientId, config.clientSecret, device_code);
if (result.status !== "authorized") {
return c.json({ status: result.status });
@@ -203,10 +190,7 @@ app.post(
// Fetch user data and return it inline
try {
const data = await provider.fetchUserData(
result.accessToken,
config.clientId,
);
const data = await provider.fetchUserData(result.accessToken, config.clientId);
return c.json({ status: "authorized", data });
} catch (e) {
// Auth succeeded but data fetch failed. Return a distinct status so
@@ -217,10 +201,7 @@ app.post(
});
}
} catch (e) {
return c.json(
{ error: e instanceof Error ? e.message : "Poll failed" },
502,
);
return c.json({ 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 type {
DeviceCodeResponse,
ImportProvider,
NormalizedImport,
PollResult,
} from "./types";
import type { DeviceCodeResponse, ImportProvider, NormalizedImport, PollResult } from "./types";
const API_BASE = "https://api.simkl.com";
const AUTH_BASE = "https://simkl.com";
function simklHeaders(
clientId: string,
token?: string,
): Record<string, string> {
function simklHeaders(clientId: string, token?: string): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"simkl-api-key": clientId,
@@ -121,14 +114,8 @@ export const simkl: ImportProvider = {
// Fetch movies, shows, and anime in parallel
const [moviesRes, showsRes, animeRes] = await Promise.all([
fetch(`${API_BASE}/sync/all-items/movies`, { headers }),
fetch(
`${API_BASE}/sync/all-items/shows?extended=full&episode_watched_at=yes`,
{ headers },
),
fetch(
`${API_BASE}/sync/all-items/anime?extended=full&episode_watched_at=yes`,
{ headers },
),
fetch(`${API_BASE}/sync/all-items/shows?extended=full&episode_watched_at=yes`, { 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
@@ -139,15 +126,9 @@ export const simkl: ImportProvider = {
}
const [moviesData, showsData, animeData] = await Promise.all([
moviesRes.ok
? (moviesRes.json() as Promise<SimklApiItem[]>)
: ([] as SimklApiItem[]),
showsRes.ok
? (showsRes.json() as Promise<SimklApiItem[]>)
: ([] as SimklApiItem[]),
animeRes.ok
? (animeRes.json() as Promise<SimklApiItem[]>)
: ([] as SimklApiItem[]),
moviesRes.ok ? (moviesRes.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
+9 -17
View File
@@ -1,17 +1,10 @@
import { parseTraktPayload } from "@sofa/core/imports/parsers";
import type {
DeviceCodeResponse,
ImportProvider,
NormalizedImport,
PollResult,
} from "./types";
import type { DeviceCodeResponse, ImportProvider, NormalizedImport, PollResult } from "./types";
const API_BASE = "https://api.trakt.tv";
function traktHeaders(
clientId: string,
token?: string,
): Record<string, string> {
function traktHeaders(clientId: string, token?: string): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"trakt-api-version": "2",
@@ -82,13 +75,12 @@ export const trakt: ImportProvider = {
);
}
const [moviesData, showsData, watchlistData, ratingsData] =
await Promise.all([
moviesRes.ok ? moviesRes.json() : [],
showsRes.ok ? showsRes.json() : [],
watchlistRes.ok ? watchlistRes.json() : [],
ratingsRes.ok ? ratingsRes.json() : [],
]);
const [moviesData, showsData, watchlistData, ratingsData] = await Promise.all([
moviesRes.ok ? moviesRes.json() : [],
showsRes.ok ? showsRes.json() : [],
watchlistRes.ok ? watchlistRes.json() : [],
ratingsRes.ok ? ratingsRes.json() : [],
]);
// Restructure API response into the format parseTraktPayload expects.
// 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" };
export interface ImportProvider {
getDeviceCode(
clientId: string,
clientSecret: string,
): Promise<DeviceCodeResponse>;
pollForToken(
clientId: string,
clientSecret: string,
deviceCode: string,
): Promise<PollResult>;
fetchUserData(
accessToken: string,
clientId: string,
): Promise<NormalizedImport>;
getDeviceCode(clientId: string, clientSecret: string): 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": {
"dev": "bun --env-file=../../.env --watch src/index.ts",
"start": "bun --env-file=../../.env src/index.ts",
"lint": "biome check",
"format": "biome format --write",
"lint": "oxlint",
"format": "oxfmt --config ../../.oxfmtrc.json",
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@orpc/contract": "catalog:",
"@orpc/json-schema": "1.13.7",
"@orpc/openapi": "1.13.7",
"@orpc/server": "1.13.7",
"@orpc/zod": "1.13.7",
"@orpc/json-schema": "1.13.8",
"@orpc/openapi": "1.13.8",
"@orpc/server": "1.13.8",
"@orpc/zod": "1.13.8",
"@sofa/api": "workspace:*",
"@sofa/auth": "workspace:*",
"@sofa/config": "workspace:*",
+18 -71
View File
@@ -1,3 +1,5 @@
import { Cron } from "croner";
import { refreshAvailability } from "@sofa/core/availability";
import { createBackup, ensureBackupDir, pruneBackups } from "@sofa/core/backup";
import { refreshCredits, syncCastProfileThumbHashes } from "@sofa/core/credits";
@@ -16,10 +18,7 @@ import {
} from "@sofa/core/metadata";
import { getSetting } from "@sofa/core/settings";
import { performTelemetryReport } from "@sofa/core/telemetry";
import {
generateTitleBackdropThumbHash,
generateTitlePosterThumbHash,
} from "@sofa/core/thumbhash";
import { generateTitleBackdropThumbHash, generateTitlePosterThumbHash } from "@sofa/core/thumbhash";
import { performUpdateCheck } from "@sofa/core/update-check";
import { db } from "@sofa/db/client";
import { and, eq, inArray, isNotNull, lt, or, sql } from "@sofa/db/helpers";
@@ -35,7 +34,6 @@ import {
} from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { getTvDetails } from "@sofa/tmdb/client";
import { Cron } from "croner";
export type BackupFrequency = "6h" | "12h" | "1d" | "7d";
@@ -132,14 +130,8 @@ function getThumbhashBackfillTitleIds(): string[] {
.from(titles)
.where(
or(
and(
isNotNull(titles.posterPath),
sql`${titles.posterThumbHash} IS NULL`,
),
and(
isNotNull(titles.backdropPath),
sql`${titles.backdropThumbHash} IS NULL`,
),
and(isNotNull(titles.posterPath), sql`${titles.posterThumbHash} IS NULL`),
and(isNotNull(titles.backdropPath), sql`${titles.backdropThumbHash} IS NULL`),
),
)
.all()
@@ -150,12 +142,7 @@ function getThumbhashBackfillTitleIds(): string[] {
db
.select({ titleId: seasons.titleId })
.from(seasons)
.where(
and(
isNotNull(seasons.posterPath),
sql`${seasons.posterThumbHash} IS NULL`,
),
)
.where(and(isNotNull(seasons.posterPath), sql`${seasons.posterThumbHash} IS NULL`))
.groupBy(seasons.titleId)
.all()
.map((row) => row.titleId),
@@ -166,12 +153,7 @@ function getThumbhashBackfillTitleIds(): string[] {
.select({ titleId: seasons.titleId })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(
and(
isNotNull(episodes.stillPath),
sql`${episodes.stillThumbHash} IS NULL`,
),
)
.where(and(isNotNull(episodes.stillPath), sql`${episodes.stillThumbHash} IS NULL`))
.groupBy(seasons.titleId)
.all()
.map((row) => row.titleId),
@@ -182,12 +164,7 @@ function getThumbhashBackfillTitleIds(): string[] {
.select({ titleId: titleCast.titleId })
.from(titleCast)
.innerJoin(persons, eq(titleCast.personId, persons.id))
.where(
and(
isNotNull(persons.profilePath),
sql`${persons.profileThumbHash} IS NULL`,
),
)
.where(and(isNotNull(persons.profilePath), sql`${persons.profileThumbHash} IS NULL`))
.groupBy(titleCast.titleId)
.all()
.map((row) => row.titleId),
@@ -207,12 +184,7 @@ async function nightlyRefreshLibrary() {
const staleLibrary = db
.select({ id: titles.id })
.from(titles)
.where(
and(
inArray(titles.id, libraryIds),
lt(titles.lastFetchedAt, libraryStale),
),
)
.where(and(inArray(titles.id, libraryIds), lt(titles.lastFetchedAt, libraryStale)))
.all();
for (const { id } of staleLibrary) {
@@ -224,12 +196,7 @@ async function nightlyRefreshLibrary() {
const nonLibrary = db
.select()
.from(titles)
.where(
and(
isNotNull(titles.lastFetchedAt),
lt(titles.lastFetchedAt, nonLibraryStale),
),
)
.where(and(isNotNull(titles.lastFetchedAt), lt(titles.lastFetchedAt, nonLibraryStale)))
.limit(50)
.all();
@@ -282,9 +249,7 @@ async function refreshAvailabilityJob() {
async function refreshRecommendationsJob() {
const libraryIds = getLibraryTitleIds();
log.debug(
`Refreshing recommendations for ${libraryIds.length} library titles`,
);
log.debug(`Refreshing recommendations for ${libraryIds.length} library titles`);
for (const titleId of libraryIds) {
await refreshRecommendations(titleId);
@@ -316,12 +281,7 @@ async function refreshTvChildrenJob() {
? db
.select({ titleId: seasons.titleId })
.from(seasons)
.where(
and(
inArray(seasons.titleId, tvIds),
lt(seasons.lastFetchedAt, stale),
),
)
.where(and(inArray(seasons.titleId, tvIds), lt(seasons.lastFetchedAt, stale)))
.groupBy(seasons.titleId)
.all()
.map((r) => r.titleId)
@@ -340,17 +300,11 @@ async function refreshTvChildrenJob() {
async function cacheImagesJob() {
const titleIds = getThumbhashBackfillTitleIds();
log.debug(
`Caching images for ${titleIds.length} titles needing art backfill`,
);
log.debug(`Caching images for ${titleIds.length} titles needing art backfill`);
for (const titleId of titleIds) {
try {
const title = db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) continue;
// 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));
}
if (!title.backdropThumbHash && title.backdropPath) {
hashTasks.push(
generateTitleBackdropThumbHash(titleId, title.backdropPath),
);
hashTasks.push(generateTitleBackdropThumbHash(titleId, title.backdropPath));
}
if (title.type === "tv") {
hashTasks.push(syncTvChildArt(titleId, { warmCache: false }));
}
hashTasks.push(
syncCastProfileThumbHashes(titleId, undefined, { warmCache: false }),
);
hashTasks.push(syncCastProfileThumbHashes(titleId, undefined, { warmCache: false }));
await Promise.all(hashTasks);
} catch (err) {
@@ -404,9 +354,7 @@ async function refreshCreditsJob() {
.limit(1)
.get();
const needsRefresh =
!castEntry ||
(castEntry.lastFetchedAt && castEntry.lastFetchedAt < stale);
const needsRefresh = !castEntry || (castEntry.lastFetchedAt && castEntry.lastFetchedAt < stale);
if (needsRefresh) {
await refreshCredits(titleId);
@@ -453,8 +401,7 @@ export function buildBackupCron(
}
function getBackupCronFromSettings(): string {
const frequency = (getSetting("backupScheduleFrequency") ??
"1d") as BackupFrequency;
const frequency = (getSetting("backupScheduleFrequency") ?? "1d") as BackupFrequency;
const time = getSetting("backupScheduleTime") ?? "02:00";
const dayOfWeek = Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10);
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 { ensureBackupDir } from "@sofa/core/backup";
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 { runMigrations } from "@sofa/db/migrate";
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 { handler as rpcHandler } from "./orpc/handler";
import { openApiHandler } from "./orpc/openapi-handler";

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