feat: add i18n with LingUI, Crowdin integration, and typed error codes (#16)

* feat: add i18n with LingUI, Crowdin integration, and typed error codes

- Add `@sofa/i18n` shared package with LingUI v5 (v6-ready config using
  `defineConfig` + `@lingui/format-po`), eager English + lazy-loaded
  fr/de/es/it/pt catalogs, `Intl`-based date/number format utilities,
  and test helpers
- Wire `@lingui/vite-plugin` + babel macro plugin for web, and
  `@lingui/metro-transformer` + babel config for native
- Add `I18nProvider` to both app roots with locale auto-detection
  (navigator.language / expo-localization) and persistence
  (localStorage / MMKV)
- Wrap all ~512 user-facing strings across web and native with LingUI
  macros (`<Trans>`, `useLingui`/`t`, `i18n._(msg`...`)`, `plural`)
- Add language switcher to Settings in both apps
- Add `@sofa/api/errors` with 13 typed `AppErrorCode` values and
  `appErrorData()` helper for contract `.errors()` schemas
- Update all oRPC contract error definitions with typed `data` fields;
  convert import procedure `throw new Error()` to `ORPCError` with codes
- Add per-app `error-messages.ts` utilities that map error codes to
  localized strings; update global `QueryCache.onError` handlers to stop
  leaking raw `error.message` to users
- Add `crowdin.yml` config and `.github/workflows/crowdin.yml` for
  automated source upload on merge and translation pull via dispatch
- Add `lingui.config.ts` at repo root with `bun run i18n:extract` and
  `bun run i18n:compile` convenience scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add Crowdin language mapping and ignore context file

* fix(i18n): address 20 localization quality issues

- Stop leaking raw error.message to users in change-password and
  account-section; use localized fallbacks instead
- Add typed error data to discover contract's .errors() schema
- Replace all manual English plural suffixes with LingUI plural() macro
  in episode counts, backup counts, star ratings, title/image counts
- Replace date-fns formatDistanceToNow and hardcoded date patterns with
  locale-aware formatDate/formatRelativeTime from @sofa/i18n/format
- Convert integration-configs from module-scope translations (frozen at
  import time) to lazy getIntegrationConfigs(i18n) function
- Combine split Trans fragments into single translatable units in
  backup-schedule retention sentence and stats-display period labels
- Localize TV media type badge and full episode accessibilityLabel
- Replace Android-incompatible Alert.alert language picker (max 3
  buttons) with zeego DropdownMenu
- Await async locale initialization before hiding splash screen so
  non-English users don't see English flash on cold start
- Add @lingui/core as direct native app dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(i18n): add Intl polyfills for Hermes, replace DropdownMenu language picker, and remove date-fns
- Add `@formatjs/intl-*` polyfills loaded at app startup in `intl-polyfills.ts` so Hermes on Android has full `Intl` support for locale-aware formatting
- Alias `@formatjs/icu-messageformat-parser` to its `no-parser` variant in metro config to reduce bundle size
- Replace the zeego `DropdownMenu` language picker in Settings with a new `SelectModal` component that works reliably on all platforms without the Android 3-button limit
- Replace `date-fns` `format`/`parseISO` in `person/[id].tsx` with `formatDate` from `@sofa/i18n/format`; remove `date-fns` from native and web `package.json`
- Refactor `StatusActionButton` to use a `StatusLabel` component with `<Trans>` rather than a `getStatusConfig(t)` factory so labels are always reactive to locale changes
- Fix crowdin workflow to use root `bun run i18n:compile` script instead of `cd packages/i18n && bun run compile`
- Wrap root layout `GestureHandlerRootView` in `SafeAreaProvider` and add `.catch` to locale-ready promise to prevent unhandled rejections blocking the splash screen

* fix(i18n): localize integration status helpers, fix stats-display Trans wrapping, and clean up SelectModal
- Localize `webhookStatus` and `listStatus` in `integration-card.tsx` using `i18n._(msg`...`)` so status strings are translated instead of always rendering in English
- Wrap "Movies {select}" and "Episodes {select}" in a single `<Trans>` in `stats-display.tsx` so the period selector element is embedded inside the translatable unit rather than concatenated outside it
- Fix locale activation order in Settings: close the modal first, then `activateLocale`, and only call `setPersistedLocale` on success to avoid persisting a locale that failed to load
- Remove the redundant `SafeAreaProvider`/`SafeAreaView` wrapper from `SelectModal` — the root layout already provides `SafeAreaProvider`
- Recompile all six `.po`/`.ts` catalogs to pick up new and updated message strings

* chore(i18n): crowdin sync

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 12:16:10 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e50ab44783
commit ad0497ccd6
142 changed files with 18002 additions and 1243 deletions
+68
View File
@@ -0,0 +1,68 @@
name: Crowdin Sync
on:
push:
branches: [main]
paths:
- "packages/i18n/src/po/en.po"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
upload:
name: Upload sources
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Upload sources to Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: false
download_translations: false
config: crowdin.yml
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
download:
name: Download translations
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- name: Download translations from Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
download_translations: true
create_pull_request: true
pull_request_title: "chore(i18n): update translations from Crowdin"
pull_request_body: "Automated PR with latest translations from Crowdin."
config: crowdin.yml
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Compile catalogs
run: bun run i18n:compile
- name: Commit compiled catalogs
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add packages/i18n/src/po/
git diff --cached --quiet || git commit -m "chore(i18n): compile updated translation catalogs"
git push
+1
View File
@@ -23,6 +23,7 @@ on:
paths: paths:
- apps/native/** - apps/native/**
- packages/api/** - packages/api/**
- packages/i18n/**
jobs: jobs:
build: build:
+1
View File
@@ -49,6 +49,7 @@ coverage
.cache .cache
tmp tmp
temp temp
crowdin-context.jsonl
# local data # local data
*.db *.db
+7
View File
@@ -0,0 +1,7 @@
module.exports = (api) => {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: ["@lingui/babel-plugin-lingui-macro"],
};
};
+22 -3
View File
@@ -7,9 +7,28 @@ const {
/** @type {import('expo/metro-config').MetroConfig} */ /** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname); const config = getDefaultConfig(__dirname);
const uniwindConfig = withUniwindConfig(wrapWithReanimatedMetroConfig(config), { config.transformer = {
...config.transformer,
babelTransformerPath: require.resolve("@lingui/metro-transformer/expo"),
};
config.resolver = {
...config.resolver,
sourceExts: [...config.resolver.sourceExts, "po", "pot"],
// Use pre-compiled ICU message parser for smaller bundles
// https://formatjs.github.io/docs/guides/react-native-hermes
resolveRequest(context, moduleName, platform) {
if (moduleName === "@formatjs/icu-messageformat-parser") {
return context.resolveRequest(
context,
"@formatjs/icu-messageformat-parser/no-parser",
platform,
);
}
return context.resolveRequest(context, moduleName, platform);
},
};
module.exports = withUniwindConfig(wrapWithReanimatedMetroConfig(config), {
cssEntryFile: "./src/global.css", cssEntryFile: "./src/global.css",
dtsFile: "./uniwind-types.d.ts", dtsFile: "./uniwind-types.d.ts",
}); });
module.exports = uniwindConfig;
+11 -1
View File
@@ -17,6 +17,14 @@
"@better-auth/expo": "catalog:", "@better-auth/expo": "catalog:",
"@expo/metro-runtime": "55.0.6", "@expo/metro-runtime": "55.0.6",
"@expo/ui": "55.0.3", "@expo/ui": "55.0.3",
"@formatjs/intl-datetimeformat": "7.2.6",
"@formatjs/intl-getcanonicallocales": "3.2.2",
"@formatjs/intl-locale": "5.2.2",
"@formatjs/intl-numberformat": "9.2.4",
"@formatjs/intl-pluralrules": "6.2.4",
"@formatjs/intl-relativetimeformat": "12.2.4",
"@lingui/core": "catalog:",
"@lingui/react": "catalog:",
"@orpc/client": "catalog:", "@orpc/client": "catalog:",
"@orpc/contract": "catalog:", "@orpc/contract": "catalog:",
"@orpc/tanstack-query": "catalog:", "@orpc/tanstack-query": "catalog:",
@@ -25,6 +33,7 @@
"@react-navigation/native": "7.1.33", "@react-navigation/native": "7.1.33",
"@shopify/flash-list": "2.0.2", "@shopify/flash-list": "2.0.2",
"@sofa/api": "workspace:*", "@sofa/api": "workspace:*",
"@sofa/i18n": "workspace:*",
"@tabler/icons-react-native": "3.40.0", "@tabler/icons-react-native": "3.40.0",
"@tanstack/query-async-storage-persister": "5.90.24", "@tanstack/query-async-storage-persister": "5.90.24",
"@tanstack/react-form": "1.28.5", "@tanstack/react-form": "1.28.5",
@@ -32,7 +41,6 @@
"@tanstack/react-query-persist-client": "5.90.24", "@tanstack/react-query-persist-client": "5.90.24",
"better-auth": "catalog:", "better-auth": "catalog:",
"burnt": "0.13.0", "burnt": "0.13.0",
"date-fns": "catalog:",
"expo": "55.0.7", "expo": "55.0.7",
"expo-application": "55.0.10", "expo-application": "55.0.10",
"expo-clipboard": "55.0.9", "expo-clipboard": "55.0.9",
@@ -77,6 +85,8 @@
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@lingui/babel-plugin-lingui-macro": "catalog:",
"@lingui/metro-transformer": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/react": "catalog:", "@types/react": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
+27 -10
View File
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { IconServer2 } from "@tabler/icons-react-native"; import { IconServer2 } from "@tabler/icons-react-native";
import { useForm, useStore } from "@tanstack/react-form"; import { useForm, useStore } from "@tanstack/react-form";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -30,6 +31,7 @@ const signInSchema = z.object({
}); });
export default function LoginScreen() { export default function LoginScreen() {
const { t } = useLingui();
const passwordRef = useRef<TextInput>(null); const passwordRef = useRef<TextInput>(null);
const [errorFields, setErrorFields] = useState<Set<string>>(new Set()); const [errorFields, setErrorFields] = useState<Set<string>>(new Set());
const [isSignedIn, setIsSignedIn] = useState(false); const [isSignedIn, setIsSignedIn] = useState(false);
@@ -51,7 +53,7 @@ export default function LoginScreen() {
{ email: result.data.email, password: result.data.password }, { email: result.data.email, password: result.data.password },
{ {
onError(error) { onError(error) {
toast.error(error.error?.message || "Failed to sign in"); toast.error(error.error?.message || t`Failed to sign in`);
}, },
onSuccess() { onSuccess() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
@@ -86,7 +88,7 @@ export default function LoginScreen() {
}; };
return ( return (
<AuthScreen title="Sofa" subtitle="Sign in to continue"> <AuthScreen title="Sofa" subtitle={t`Sign in to continue`}>
{showOidc && ( {showOidc && (
<Animated.View <Animated.View
entering={FadeInDown.duration(300).delay(100)} entering={FadeInDown.duration(300).delay(100)}
@@ -103,14 +105,18 @@ export default function LoginScreen() {
className="w-full" className="w-full"
> >
<ButtonLabel> <ButtonLabel>
Sign in with {authConfig.data?.oidcProviderName ?? "SSO"} <Trans>
Sign in with {authConfig.data?.oidcProviderName ?? "SSO"}
</Trans>
</ButtonLabel> </ButtonLabel>
</Button> </Button>
{showPasswordLogin && ( {showPasswordLogin && (
<View className="my-4 flex-row items-center"> <View className="my-4 flex-row items-center">
<View className="h-px flex-1 bg-border" /> <View className="h-px flex-1 bg-border" />
<Text className="px-3 text-muted-foreground text-xs">OR</Text> <Text className="px-3 text-muted-foreground text-xs">
<Trans>OR</Trans>
</Text>
<View className="h-px flex-1 bg-border" /> <View className="h-px flex-1 bg-border" />
</View> </View>
)} )}
@@ -123,7 +129,9 @@ export default function LoginScreen() {
<form.Field name="email"> <form.Field name="email">
{(field) => ( {(field) => (
<TextField> <TextField>
<Label>Email</Label> <Label>
<Trans>Email</Trans>
</Label>
<Input <Input
value={field.state.value} value={field.state.value}
accessibilityLabel="Email" accessibilityLabel="Email"
@@ -155,7 +163,9 @@ export default function LoginScreen() {
<form.Field name="password"> <form.Field name="password">
{(field) => ( {(field) => (
<TextField> <TextField>
<Label>Password</Label> <Label>
<Trans>Password</Trans>
</Label>
<Input <Input
ref={passwordRef} ref={passwordRef}
value={field.state.value} value={field.state.value}
@@ -191,7 +201,9 @@ export default function LoginScreen() {
{busy ? ( {busy ? (
<Spinner size="sm" /> <Spinner size="sm" />
) : ( ) : (
<ButtonLabel>Sign In</ButtonLabel> <ButtonLabel>
<Trans>Sign In</Trans>
</ButtonLabel>
)} )}
</Button> </Button>
</Animated.View> </Animated.View>
@@ -200,7 +212,9 @@ export default function LoginScreen() {
<Animated.View entering={FadeIn.duration(300).delay(500)}> <Animated.View entering={FadeIn.duration(300).delay(500)}>
<Link href="/(auth)/register" asChild> <Link href="/(auth)/register" asChild>
<Button disabled={busy} variant="secondary"> <Button disabled={busy} variant="secondary">
<ButtonLabel>Create an account</ButtonLabel> <ButtonLabel>
<Trans>Create an account</Trans>
</ButtonLabel>
</Button> </Button>
</Link> </Link>
</Animated.View> </Animated.View>
@@ -223,8 +237,11 @@ export default function LoginScreen() {
color={statusCompletedColor} color={statusCompletedColor}
/> />
<Text className="font-sans text-muted-foreground text-xs"> <Text className="font-sans text-muted-foreground text-xs">
Connected to <Text className="font-medium">{serverHost}</Text> <Trans>
. Tap to change. Connected to{" "}
<Text className="font-medium">{serverHost}</Text>. Tap to
change.
</Trans>
</Text> </Text>
</Pressable> </Pressable>
</Link> </Link>
+22 -12
View File
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { useForm, useStore } from "@tanstack/react-form"; import { useForm, useStore } from "@tanstack/react-form";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link } from "expo-router"; import { Link } from "expo-router";
@@ -35,6 +36,7 @@ const signUpSchema = z.object({
}); });
export default function RegisterScreen() { export default function RegisterScreen() {
const { t } = useLingui();
const emailRef = useRef<TextInput>(null); const emailRef = useRef<TextInput>(null);
const passwordRef = useRef<TextInput>(null); const passwordRef = useRef<TextInput>(null);
const [errorFields, setErrorFields] = useState<Set<string>>(new Set()); const [errorFields, setErrorFields] = useState<Set<string>>(new Set());
@@ -62,7 +64,7 @@ export default function RegisterScreen() {
}, },
{ {
onError(error) { onError(error) {
toast.error(error.error?.message || "Failed to create account"); toast.error(error.error?.message || t`Failed to create account`);
}, },
onSuccess() { onSuccess() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
@@ -92,14 +94,14 @@ export default function RegisterScreen() {
if (!registrationOpen && !publicInfo.isPending) { if (!registrationOpen && !publicInfo.isPending) {
return ( return (
<AuthScreen <AuthScreen
title="Registration Closed" title={t`Registration Closed`}
subtitle="New account creation is currently disabled." subtitle={t`New account creation is currently disabled.`}
> >
<Animated.View entering={FadeInDown.duration(300).delay(200)}> <Animated.View entering={FadeInDown.duration(300).delay(200)}>
<Link href="/(auth)/login" asChild> <Link href="/(auth)/login" asChild>
<Button className="mt-6 bg-primary"> <Button className="mt-6 bg-primary">
<ButtonLabel className="text-primary-foreground"> <ButtonLabel className="text-primary-foreground">
Back to Login <Trans>Back to Login</Trans>
</ButtonLabel> </ButtonLabel>
</Button> </Button>
</Link> </Link>
@@ -110,15 +112,17 @@ export default function RegisterScreen() {
return ( return (
<AuthScreen <AuthScreen
title="Create Account" title={t`Create Account`}
subtitle={`Registering on ${serverHost}`} subtitle={t`Registering on ${serverHost}`}
> >
<View className="gap-3"> <View className="gap-3">
<Animated.View entering={FadeInDown.duration(300).delay(100)}> <Animated.View entering={FadeInDown.duration(300).delay(100)}>
<form.Field name="name"> <form.Field name="name">
{(field) => ( {(field) => (
<TextField> <TextField>
<Label>Name</Label> <Label>
<Trans>Name</Trans>
</Label>
<Input <Input
value={field.state.value} value={field.state.value}
accessibilityLabel="Name" accessibilityLabel="Name"
@@ -127,7 +131,7 @@ export default function RegisterScreen() {
field.handleChange(text); field.handleChange(text);
clearFieldError("name"); clearFieldError("name");
}} }}
placeholder="Your name" placeholder={t`Your name`}
autoComplete="name" autoComplete="name"
textContentType="name" textContentType="name"
returnKeyType="next" returnKeyType="next"
@@ -146,7 +150,9 @@ export default function RegisterScreen() {
<form.Field name="email"> <form.Field name="email">
{(field) => ( {(field) => (
<TextField> <TextField>
<Label>Email</Label> <Label>
<Trans>Email</Trans>
</Label>
<Input <Input
ref={emailRef} ref={emailRef}
value={field.state.value} value={field.state.value}
@@ -177,7 +183,9 @@ export default function RegisterScreen() {
<form.Field name="password"> <form.Field name="password">
{(field) => ( {(field) => (
<TextField> <TextField>
<Label>Password</Label> <Label>
<Trans>Password</Trans>
</Label>
<Input <Input
ref={passwordRef} ref={passwordRef}
value={field.state.value} value={field.state.value}
@@ -213,7 +221,9 @@ export default function RegisterScreen() {
{busy ? ( {busy ? (
<Spinner size="sm" /> <Spinner size="sm" />
) : ( ) : (
<ButtonLabel>Create Account</ButtonLabel> <ButtonLabel>
<Trans>Create Account</Trans>
</ButtonLabel>
)} )}
</Button> </Button>
</Animated.View> </Animated.View>
@@ -226,7 +236,7 @@ export default function RegisterScreen() {
<Link href="/(auth)/login" asChild> <Link href="/(auth)/login" asChild>
<Pressable disabled={busy}> <Pressable disabled={busy}>
<Text className="text-primary text-sm"> <Text className="text-primary text-sm">
Already have an account? Sign in <Trans>Already have an account? Sign in</Trans>
</Text> </Text>
</Pressable> </Pressable>
</Link> </Link>
+21 -16
View File
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { import {
IconAlertCircle, IconAlertCircle,
IconCircleCheck, IconCircleCheck,
@@ -33,18 +34,20 @@ type ConnectionState =
| { phase: "success" } | { phase: "success" }
| { phase: "error"; error: ValidationError }; | { phase: "error"; error: ValidationError };
const ERROR_MESSAGES: Record<ValidationError, string> = { function getErrorMessages(
network_unreachable: t: (strings: TemplateStringsArray, ...values: unknown[]) => string,
"Could not reach the server. Check your connection and the URL.", ): Record<ValidationError, string> {
timeout: "Connection timed out. The server might be down or unreachable.", return {
not_sofa_server: network_unreachable: t`Could not reach the server. Check your connection and the URL.`,
"This doesn't appear to be a Sofa server. Double-check the URL.", timeout: t`Connection timed out. The server might be down or unreachable.`,
server_unhealthy: not_sofa_server: t`This doesn't appear to be a Sofa server. Double-check the URL.`,
"Server found but reporting an issue. Try again in a moment.", server_unhealthy: t`Server found but reporting an issue. Try again in a moment.`,
invalid_url: "That URL doesn't look right. Include the full server address.", invalid_url: t`That URL doesn't look right. Include the full server address.`,
}; };
}
export default function ServerUrlScreen() { export default function ServerUrlScreen() {
const { t } = useLingui();
const { replace } = useRouter(); const { replace } = useRouter();
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const successTimeout = useRef<ReturnType<typeof setTimeout> | null>(null); const successTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -145,7 +148,7 @@ export default function ServerUrlScreen() {
return ( return (
<AuthScreen <AuthScreen
title="Sofa" title="Sofa"
subtitle="Enter your Sofa server URL to get started" subtitle={t`Enter your Sofa server URL to get started`}
logoStyle={iconAnimatedStyle} logoStyle={iconAnimatedStyle}
> >
<Animated.View entering={FadeInDown.duration(300).delay(200)}> <Animated.View entering={FadeInDown.duration(300).delay(200)}>
@@ -180,7 +183,7 @@ export default function ServerUrlScreen() {
style={dotAnimatedStyle} style={dotAnimatedStyle}
/> />
<Text className="text-muted-foreground text-sm"> <Text className="text-muted-foreground text-sm">
Connecting to server... <Trans>Connecting to server...</Trans>
</Text> </Text>
</View> </View>
) : isSuccess ? ( ) : isSuccess ? (
@@ -191,7 +194,7 @@ export default function ServerUrlScreen() {
color={statusCompletedColor} color={statusCompletedColor}
/> />
<Text className="font-medium font-sans text-sm text-status-completed"> <Text className="font-medium font-sans text-sm text-status-completed">
Connected <Trans>Connected</Trans>
</Text> </Text>
</View> </View>
) : ( ) : (
@@ -200,7 +203,9 @@ export default function ServerUrlScreen() {
disabled={!isValidUrl} disabled={!isValidUrl}
className="bg-primary" className="bg-primary"
> >
<ButtonLabel>Connect</ButtonLabel> <ButtonLabel>
<Trans>Connect</Trans>
</ButtonLabel>
</Button> </Button>
)} )}
</Animated.View> </Animated.View>
@@ -218,7 +223,7 @@ export default function ServerUrlScreen() {
style={{ marginTop: 1 }} style={{ marginTop: 1 }}
/> />
<Text selectable className="flex-1 text-destructive text-sm"> <Text selectable className="flex-1 text-destructive text-sm">
{ERROR_MESSAGES[connection.error]} {getErrorMessages(t)[connection.error]}
</Text> </Text>
</Animated.View> </Animated.View>
)} )}
@@ -234,7 +239,7 @@ export default function ServerUrlScreen() {
> >
<ScaledIcon icon={IconInfoCircle} size={16} color={mutedFgColor} /> <ScaledIcon icon={IconInfoCircle} size={16} color={mutedFgColor} />
<Text className="font-medium font-sans text-primary text-sm"> <Text className="font-medium font-sans text-primary text-sm">
Don't have a server? <Trans>Don't have a server?</Trans>
</Text> </Text>
</Pressable> </Pressable>
</Animated.View> </Animated.View>
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react-native"; import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react-native";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
@@ -15,6 +16,7 @@ const exploreContentContainerStyle = {
}; };
export default function ExploreScreen() { export default function ExploreScreen() {
const { t } = useLingui();
const trending = useInfiniteQuery( const trending = useInfiniteQuery(
orpc.explore.trending.infiniteOptions({ orpc.explore.trending.infiniteOptions({
input: (pageParam: number) => ({ type: "all" as const, page: pageParam }), input: (pageParam: number) => ({ type: "all" as const, page: pageParam }),
@@ -81,14 +83,16 @@ export default function ExploreScreen() {
> >
<View className="gap-8"> <View className="gap-8">
{heroItem && ( {heroItem && (
<Animated.View entering={FadeIn.duration(400)}> <Animated.View
entering={FadeIn.duration(400).withInitialValues({ opacity: 0.01 })}
>
<HeroBanner item={heroItem} /> <HeroBanner item={heroItem} />
</Animated.View> </Animated.View>
)} )}
<Animated.View entering={FadeInDown.duration(300).delay(100)}> <Animated.View entering={FadeInDown.duration(300).delay(100)}>
<FilterableTitleRow <FilterableTitleRow
title="Trending Today" title={t`Trending Today`}
icon={IconFlame} icon={IconFlame}
mediaType="movie" mediaType="movie"
defaultItems={trendingItems} defaultItems={trendingItems}
@@ -100,7 +104,7 @@ export default function ExploreScreen() {
<Animated.View entering={FadeInDown.duration(300).delay(200)}> <Animated.View entering={FadeInDown.duration(300).delay(200)}>
<FilterableTitleRow <FilterableTitleRow
title="Popular Movies" title={t`Popular Movies`}
icon={IconMovie} icon={IconMovie}
mediaType="movie" mediaType="movie"
defaultItems={popularMovies.data?.items ?? []} defaultItems={popularMovies.data?.items ?? []}
@@ -113,7 +117,7 @@ export default function ExploreScreen() {
<Animated.View entering={FadeInDown.duration(300).delay(300)}> <Animated.View entering={FadeInDown.duration(300).delay(300)}>
<FilterableTitleRow <FilterableTitleRow
title="Popular TV Shows" title={t`Popular TV Shows`}
icon={IconDeviceTv} icon={IconDeviceTv}
mediaType="tv" mediaType="tv"
defaultItems={popularTv.data?.items ?? []} defaultItems={popularTv.data?.items ?? []}
+19 -11
View File
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { import {
IconBooks, IconBooks,
@@ -30,6 +31,7 @@ const dashboardContentContainerStyle = {
}; };
export default function DashboardScreen() { export default function DashboardScreen() {
const { t } = useLingui();
const { push } = useRouter(); const { push } = useRouter();
authClient.useSession(); authClient.useSession();
@@ -55,12 +57,12 @@ export default function DashboardScreen() {
const statsData = useMemo( const statsData = useMemo(
() => [ () => [
{ label: "Movies this month", value: stats.data?.moviesThisMonth }, { label: t`Movies this month`, value: stats.data?.moviesThisMonth },
{ label: "Episodes this week", value: stats.data?.episodesThisWeek }, { label: t`Episodes this week`, value: stats.data?.episodesThisWeek },
{ label: "In library", value: stats.data?.librarySize }, { label: t`In library`, value: stats.data?.librarySize },
{ label: "Completed", value: stats.data?.completed }, { label: t`Completed`, value: stats.data?.completed },
], ],
[stats.data], [stats.data, t],
); );
const renderStatItem = useCallback( const renderStatItem = useCallback(
@@ -107,7 +109,10 @@ export default function DashboardScreen() {
{hasContinueWatching && ( {hasContinueWatching && (
<Animated.View entering={FadeInDown.duration(300).delay(200)}> <Animated.View entering={FadeInDown.duration(300).delay(200)}>
<View className="px-4"> <View className="px-4">
<SectionHeader title="Continue Watching" icon={IconPlayerPlay} /> <SectionHeader
title={t`Continue Watching`}
icon={IconPlayerPlay}
/>
</View> </View>
<FlashList <FlashList
horizontal horizontal
@@ -125,7 +130,7 @@ export default function DashboardScreen() {
{/* Library */} {/* Library */}
<Animated.View entering={FadeInDown.duration(300).delay(300)}> <Animated.View entering={FadeInDown.duration(300).delay(300)}>
<View className="px-4"> <View className="px-4">
<SectionHeader title="In Your Library" icon={IconBooks} /> <SectionHeader title={t`In Your Library`} icon={IconBooks} />
</View> </View>
{library.isPending ? ( {library.isPending ? (
<HorizontalPosterRow items={[]} isLoading /> <HorizontalPosterRow items={[]} isLoading />
@@ -133,9 +138,9 @@ export default function DashboardScreen() {
<HorizontalPosterRow items={library.data?.items ?? []} /> <HorizontalPosterRow items={library.data?.items ?? []} />
) : ( ) : (
<EmptyState <EmptyState
title="Your library is empty" title={t`Your library is empty`}
description="Start tracking movies and shows" description={t`Start tracking movies and shows`}
actionLabel="Explore" actionLabel={t`Explore`}
onAction={() => push("/(tabs)/(explore)")} onAction={() => push("/(tabs)/(explore)")}
/> />
)} )}
@@ -145,7 +150,10 @@ export default function DashboardScreen() {
{hasRecommendations && ( {hasRecommendations && (
<Animated.View entering={FadeInDown.duration(300).delay(400)}> <Animated.View entering={FadeInDown.duration(300).delay(400)}>
<View className="px-4"> <View className="px-4">
<SectionHeader title="Recommended for You" icon={IconThumbUp} /> <SectionHeader
title={t`Recommended for You`}
icon={IconThumbUp}
/>
</View> </View>
<HorizontalPosterRow <HorizontalPosterRow
items={recommendations.data?.items ?? []} items={recommendations.data?.items ?? []}
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { skipToken, useInfiniteQuery } from "@tanstack/react-query"; import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
@@ -16,6 +17,7 @@ import { useTitleActions } from "@/hooks/use-title-actions";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
export default function SearchScreen() { export default function SearchScreen() {
const { t } = useLingui();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query.trim(), 300); const debouncedQuery = useDebounce(query.trim(), 300);
@@ -85,7 +87,7 @@ export default function SearchScreen() {
/> />
<Stack.Screen.Title>Search</Stack.Screen.Title> <Stack.Screen.Title>Search</Stack.Screen.Title>
<Stack.SearchBar <Stack.SearchBar
placeholder="Search movies, shows, people..." placeholder={t`Search movies, shows, people...`}
onChangeText={(e) => setQuery(e.nativeEvent.text)} onChangeText={(e) => setQuery(e.nativeEvent.text)}
onCancelButtonPress={() => setQuery("")} onCancelButtonPress={() => setQuery("")}
onClose={() => setQuery("")} onClose={() => setQuery("")}
@@ -108,7 +110,7 @@ export default function SearchScreen() {
className="flex-1 items-center justify-center" className="flex-1 items-center justify-center"
> >
<Text className="text-base text-muted-foreground"> <Text className="text-base text-muted-foreground">
No results for "{debouncedQuery}" <Trans>No results for "{debouncedQuery}"</Trans>
</Text> </Text>
</Animated.View> </Animated.View>
) : ( ) : (
+92 -43
View File
@@ -1,3 +1,7 @@
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 { import {
IconArrowUpRight, IconArrowUpRight,
IconBrandGithub, IconBrandGithub,
@@ -7,6 +11,7 @@ import {
IconDatabase, IconDatabase,
IconDeviceMobileCog, IconDeviceMobileCog,
IconDots, IconDots,
IconLanguage,
IconLink, IconLink,
IconLock, IconLock,
IconLogout, IconLogout,
@@ -40,9 +45,11 @@ import { SettingsSection } from "@/components/settings/settings-section";
import { TmdbLogo } from "@/components/tmdb-logo"; import { TmdbLogo } from "@/components/tmdb-logo";
import { Image } from "@/components/ui/image"; import { Image } from "@/components/ui/image";
import { ScaledIcon } from "@/components/ui/scaled-icon"; import { ScaledIcon } from "@/components/ui/scaled-icon";
import { SelectModal } from "@/components/ui/select-modal";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { setPersistedLocale } from "@/lib/i18n";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog"; import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
@@ -56,6 +63,7 @@ const settingsContentContainerStyle = {
}; };
export default function SettingsScreen() { export default function SettingsScreen() {
const { t, i18n } = useLingui();
const { push } = useRouter(); const { push } = useRouter();
const { data: session, refetch: refetchSession } = authClient.useSession(); const { data: session, refetch: refetchSession } = authClient.useSession();
const [isEditingName, setIsEditingName] = useState(false); const [isEditingName, setIsEditingName] = useState(false);
@@ -65,7 +73,11 @@ export default function SettingsScreen() {
if (!isEditingName && session?.user?.name) setNameInput(session.user.name); if (!isEditingName && session?.user?.name) setNameInput(session.user.name);
}, [session?.user?.name, isEditingName]); }, [session?.user?.name, isEditingName]);
const [languageModalOpen, setLanguageModalOpen] = useState(false);
const languageLabel =
LOCALE_INFO.find((o) => o.code === i18n.locale)?.nativeName ?? i18n.locale;
const [analyticsEnabled, setAnalyticsToggle] = useState(isAnalyticsEnabled); const [analyticsEnabled, setAnalyticsToggle] = useState(isAnalyticsEnabled);
const isAdmin = session?.user?.role === "admin"; const isAdmin = session?.user?.role === "admin";
const serverUrl = getServerUrl(); const serverUrl = getServerUrl();
@@ -92,34 +104,34 @@ export default function SettingsScreen() {
const updateName = useMutation( const updateName = useMutation(
orpc.account.updateName.mutationOptions({ orpc.account.updateName.mutationOptions({
onSuccess: () => { onSuccess: () => {
toast.success("Name updated"); toast.success(t`Name updated`);
setIsEditingName(false); setIsEditingName(false);
queryClient.invalidateQueries({ queryKey: orpc.account.key() }); queryClient.invalidateQueries({ queryKey: orpc.account.key() });
refetchSession(); refetchSession();
}, },
onError: () => toast.error("Failed to update name"), onError: () => toast.error(t`Failed to update name`),
}), }),
); );
const uploadAvatar = useMutation( const uploadAvatar = useMutation(
orpc.account.uploadAvatar.mutationOptions({ orpc.account.uploadAvatar.mutationOptions({
onSuccess: () => { onSuccess: () => {
toast.success("Profile picture updated"); toast.success(t`Profile picture updated`);
queryClient.invalidateQueries({ queryKey: orpc.account.key() }); queryClient.invalidateQueries({ queryKey: orpc.account.key() });
refetchSession(); refetchSession();
}, },
onError: () => toast.error("Failed to upload avatar"), onError: () => toast.error(t`Failed to upload avatar`),
}), }),
); );
const removeAvatar = useMutation( const removeAvatar = useMutation(
orpc.account.removeAvatar.mutationOptions({ orpc.account.removeAvatar.mutationOptions({
onSuccess: () => { onSuccess: () => {
toast.success("Profile picture removed"); toast.success(t`Profile picture removed`);
queryClient.invalidateQueries({ queryKey: orpc.account.key() }); queryClient.invalidateQueries({ queryKey: orpc.account.key() });
refetchSession(); refetchSession();
}, },
onError: () => toast.error("Failed to remove profile picture"), onError: () => toast.error(t`Failed to remove profile picture`),
}), }),
); );
@@ -152,12 +164,12 @@ export default function SettingsScreen() {
const toggleRegistration = useMutation( const toggleRegistration = useMutation(
orpc.admin.toggleRegistration.mutationOptions({ orpc.admin.toggleRegistration.mutationOptions({
onSuccess: (_data, { open }) => { onSuccess: (_data, { open }) => {
toast.success(open ? "Registration opened" : "Registration closed"); toast.success(open ? t`Registration opened` : t`Registration closed`);
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: orpc.admin.registration.key(), queryKey: orpc.admin.registration.key(),
}); });
}, },
onError: () => toast.error("Failed to update registration setting"), onError: () => toast.error(t`Failed to update registration setting`),
}), }),
); );
@@ -170,13 +182,13 @@ export default function SettingsScreen() {
orpc.admin.toggleUpdateCheck.mutationOptions({ orpc.admin.toggleUpdateCheck.mutationOptions({
onSuccess: (_data, { enabled }) => { onSuccess: (_data, { enabled }) => {
toast.success( toast.success(
enabled ? "Update checks enabled" : "Update checks disabled", enabled ? t`Update checks enabled` : t`Update checks disabled`,
); );
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: orpc.admin.updateCheck.key(), queryKey: orpc.admin.updateCheck.key(),
}); });
}, },
onError: () => toast.error("Failed to update setting"), onError: () => toast.error(t`Failed to update setting`),
}), }),
); );
@@ -191,10 +203,10 @@ export default function SettingsScreen() {
}, []); }, []);
const handleSignOut = () => { const handleSignOut = () => {
Alert.alert("Sign Out", "Are you sure you want to sign out?", [ Alert.alert(t`Sign Out`, t`Are you sure you want to sign out?`, [
{ text: "Cancel", style: "cancel" }, { text: t`Cancel`, style: "cancel" },
{ {
text: "Sign Out", text: t`Sign Out`,
style: "destructive", style: "destructive",
onPress: () => { onPress: () => {
authClient.signOut(); authClient.signOut();
@@ -216,7 +228,7 @@ export default function SettingsScreen() {
> >
{/* Account */} {/* Account */}
<Animated.View entering={FadeInDown.duration(300).delay(100)}> <Animated.View entering={FadeInDown.duration(300).delay(100)}>
<SettingsSection title="Account" icon={IconUser}> <SettingsSection title={t`Account`} icon={IconUser}>
<View className="flex-row items-center py-3.5"> <View className="flex-row items-center py-3.5">
{hasAvatarImage ? ( {hasAvatarImage ? (
<DropdownMenu.Root> <DropdownMenu.Root>
@@ -252,7 +264,7 @@ export default function SettingsScreen() {
ios={{ name: "photo.on.rectangle.angled" }} ios={{ name: "photo.on.rectangle.angled" }}
/> />
<DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>
Change Photo <Trans>Change Photo</Trans>
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
@@ -262,7 +274,7 @@ export default function SettingsScreen() {
> >
<DropdownMenu.ItemIcon ios={{ name: "trash" }} /> <DropdownMenu.ItemIcon ios={{ name: "trash" }} />
<DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>
Remove Photo <Trans>Remove Photo</Trans>
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
@@ -306,7 +318,9 @@ export default function SettingsScreen() {
<Pressable <Pressable
onPress={() => updateName.mutate({ name: nameInput })} onPress={() => updateName.mutate({ name: nameInput })}
> >
<Text className="text-primary text-sm">Save</Text> <Text className="text-primary text-sm">
<Trans>Save</Trans>
</Text>
</Pressable> </Pressable>
<Pressable <Pressable
onPress={() => { onPress={() => {
@@ -315,7 +329,7 @@ export default function SettingsScreen() {
}} }}
> >
<Text className="text-muted-foreground text-sm"> <Text className="text-muted-foreground text-sm">
Cancel <Trans>Cancel</Trans>
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
@@ -336,7 +350,7 @@ export default function SettingsScreen() {
maxFontSizeMultiplier={1.0} maxFontSizeMultiplier={1.0}
className="font-medium font-sans text-primary text-xs" className="font-medium font-sans text-primary text-xs"
> >
Admin <Trans>Admin</Trans>
</Text> </Text>
</View> </View>
)} )}
@@ -344,14 +358,14 @@ export default function SettingsScreen() {
{showPasswordOption && ( {showPasswordOption && (
<SettingsRow <SettingsRow
label={hasPassword ? "Change password" : "Set password"} label={hasPassword ? t`Change password` : t`Set password`}
icon={IconLock} icon={IconLock}
onPress={() => push("/change-password")} onPress={() => push("/change-password")}
/> />
)} )}
<SettingsRow <SettingsRow
label="Sign out" label={t`Sign out`}
icon={IconLogout} icon={IconLogout}
onPress={handleSignOut} onPress={handleSignOut}
destructive destructive
@@ -361,19 +375,19 @@ export default function SettingsScreen() {
{/* Server */} {/* Server */}
<Animated.View entering={FadeInDown.duration(300).delay(200)}> <Animated.View entering={FadeInDown.duration(300).delay(200)}>
<SettingsSection title="Application" icon={IconDeviceMobileCog}> <SettingsSection title={t`Application`} icon={IconDeviceMobileCog}>
<SettingsRow <SettingsRow
label="Server URL" label={t`Server URL`}
value={serverUrl} value={serverUrl}
icon={IconLink} icon={IconLink}
onPress={() => { onPress={() => {
Alert.alert( Alert.alert(
"Change Server", t`Change Server`,
"You'll be signed out to change the server URL.", t`You'll be signed out to change the server URL.`,
[ [
{ text: "Cancel", style: "cancel" }, { text: t`Cancel`, style: "cancel" },
{ {
text: "Continue", text: t`Continue`,
style: "destructive", style: "destructive",
onPress: async () => { onPress: async () => {
await authClient.signOut(); await authClient.signOut();
@@ -386,7 +400,31 @@ export default function SettingsScreen() {
}} }}
/> />
<SettingsRow <SettingsRow
label="Anonymous usage reporting" label={t`Language`}
value={languageLabel}
icon={IconLanguage}
onPress={() => setLanguageModalOpen(true)}
/>
<SelectModal
open={languageModalOpen}
onOpenChange={setLanguageModalOpen}
label={t`Language`}
icon={IconLanguage}
selection={i18n.locale}
options={LOCALE_INFO.map((info) => ({
value: info.code,
label: info.nativeName,
}))}
onSelect={(locale) => {
setLanguageModalOpen(false);
activateLocale(locale as SupportedLocale).then(
() => setPersistedLocale(locale as SupportedLocale),
() => {},
);
}}
/>
<SettingsRow
label={t`Anonymous usage reporting`}
icon={IconChartBar} icon={IconChartBar}
right={ right={
<Switch <Switch
@@ -411,9 +449,9 @@ export default function SettingsScreen() {
{isAdmin && ( {isAdmin && (
<Animated.View entering={FadeInDown.duration(300).delay(400)}> <Animated.View entering={FadeInDown.duration(300).delay(400)}>
<SettingsSection <SettingsSection
title="Server Health" title={t`Server Health`}
icon={IconServer} icon={IconServer}
badge="Admin" badge={t`Admin`}
> >
{systemHealth.isPending ? ( {systemHealth.isPending ? (
<View className="items-center py-4"> <View className="items-center py-4">
@@ -422,24 +460,26 @@ export default function SettingsScreen() {
) : systemHealth.data ? ( ) : systemHealth.data ? (
<> <>
<SettingsRow <SettingsRow
label="Database" label={t`Database`}
value={ value={
systemHealth.data?.database systemHealth.data?.database
? `${systemHealth.data.database.titleCount} titles` ? t`${plural(systemHealth.data.database.titleCount, { one: "# title", other: "# titles" })}`
: "—" : "—"
} }
icon={IconDatabase} icon={IconDatabase}
/> />
<SettingsRow <SettingsRow
label="TMDB" label="TMDB"
value={systemHealth.data?.tmdb?.connected ? "Connected" : "—"} value={
systemHealth.data?.tmdb?.connected ? t`Connected` : "—"
}
icon={IconCloud} icon={IconCloud}
/> />
<SettingsRow <SettingsRow
label="Image Cache" label={t`Image Cache`}
value={ value={
systemHealth.data?.imageCache systemHealth.data?.imageCache
? `${systemHealth.data.imageCache.imageCount} images` ? t`${plural(systemHealth.data.imageCache.imageCount, { one: "# image", other: "# images" })}`
: "—" : "—"
} }
icon={IconPhoto} icon={IconPhoto}
@@ -453,9 +493,13 @@ export default function SettingsScreen() {
{/* Admin: Security */} {/* Admin: Security */}
{isAdmin && ( {isAdmin && (
<Animated.View entering={FadeInDown.duration(300).delay(500)}> <Animated.View entering={FadeInDown.duration(300).delay(500)}>
<SettingsSection title="Security" icon={IconShield} badge="Admin"> <SettingsSection
title={t`Security`}
icon={IconShield}
badge={t`Admin`}
>
<SettingsRow <SettingsRow
label="Open registration" label={t`Open registration`}
icon={IconUserPlus} icon={IconUserPlus}
right={ right={
<Switch <Switch
@@ -466,7 +510,7 @@ export default function SettingsScreen() {
} }
/> />
<SettingsRow <SettingsRow
label="Check for updates" label={t`Check for updates`}
icon={IconCloud} icon={IconCloud}
right={ right={
<Switch <Switch
@@ -481,7 +525,10 @@ export default function SettingsScreen() {
{updateCheck.data?.updateCheck?.updateAvailable && ( {updateCheck.data?.updateCheck?.updateAvailable && (
<View className="py-3.5"> <View className="py-3.5">
<Text className="font-medium font-sans text-sm text-status-completed"> <Text className="font-medium font-sans text-sm text-status-completed">
Update available: {updateCheck.data.updateCheck.latestVersion} <Trans>
Update available:{" "}
{updateCheck.data.updateCheck.latestVersion}
</Trans>
</Text> </Text>
</View> </View>
)} )}
@@ -491,14 +538,14 @@ export default function SettingsScreen() {
{/* More Settings */} {/* More Settings */}
<Animated.View entering={FadeInDown.duration(300).delay(400)}> <Animated.View entering={FadeInDown.duration(300).delay(400)}>
<SettingsSection title="More Settings" icon={IconDots}> <SettingsSection title={t`More Settings`} icon={IconDots}>
<Pressable <Pressable
onPress={() => Linking.openURL(`${serverUrl}/settings`)} onPress={() => Linking.openURL(`${serverUrl}/settings`)}
className="flex-row items-center justify-center py-3.5 active:opacity-70" className="flex-row items-center justify-center py-3.5 active:opacity-70"
> >
<ScaledIcon icon={IconWorld} size={18} color={mutedFgColor} /> <ScaledIcon icon={IconWorld} size={18} color={mutedFgColor} />
<Text className="ml-2 flex-1 text-base text-foreground"> <Text className="ml-2 flex-1 text-base text-foreground">
Open in browser <Trans>Open in browser</Trans>
</Text> </Text>
<ScaledIcon <ScaledIcon
icon={IconArrowUpRight} icon={IconArrowUpRight}
@@ -556,8 +603,10 @@ export default function SettingsScreen() {
maxFontSizeMultiplier={1.0} maxFontSizeMultiplier={1.0}
className="text-center text-muted-foreground text-xs leading-relaxed" className="text-center text-muted-foreground text-xs leading-relaxed"
> >
This product uses the TMDB API but is not endorsed or certified by <Trans>
TMDB. This product uses the TMDB API but is not endorsed or certified by
TMDB.
</Trans>
</Text> </Text>
</Pressable> </Pressable>
</Animated.View> </Animated.View>
+7 -4
View File
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { IconAlertTriangle } from "@tabler/icons-react-native"; import { IconAlertTriangle } from "@tabler/icons-react-native";
import { Link, Stack } from "expo-router"; import { Link, Stack } from "expo-router";
import { View } from "react-native"; import { View } from "react-native";
@@ -12,7 +13,9 @@ export default function NotFoundScreen() {
return ( return (
<> <>
<Stack.Screen.Title>Not Found</Stack.Screen.Title> <Stack.Screen.Title>
<Trans>Not Found</Trans>
</Stack.Screen.Title>
<Container> <Container>
<View className="flex-1 items-center justify-center p-4"> <View className="flex-1 items-center justify-center p-4">
<Animated.View <Animated.View
@@ -21,17 +24,17 @@ export default function NotFoundScreen() {
> >
<IconAlertTriangle size={48} color={mutedForeground} /> <IconAlertTriangle size={48} color={mutedForeground} />
<Text className="mt-3 font-display text-foreground text-xl"> <Text className="mt-3 font-display text-foreground text-xl">
Page Not Found <Trans>Page Not Found</Trans>
</Text> </Text>
<Text className="mt-1 mb-4 text-center text-muted-foreground text-sm"> <Text className="mt-1 mb-4 text-center text-muted-foreground text-sm">
The page you're looking for doesn't exist. <Trans>The page you're looking for doesn't exist.</Trans>
</Text> </Text>
</Animated.View> </Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(200)}> <Animated.View entering={FadeInDown.duration(300).delay(200)}>
<Link href="/" asChild> <Link href="/" asChild>
<Button size="sm" className="bg-primary"> <Button size="sm" className="bg-primary">
<ButtonLabel className="text-primary-foreground"> <ButtonLabel className="text-primary-foreground">
Go Home <Trans>Go Home</Trans>
</ButtonLabel> </ButtonLabel>
</Button> </Button>
</Link> </Link>
+28 -9
View File
@@ -1,5 +1,8 @@
import "@/lib/intl-polyfills";
import "@/global.css"; import "@/global.css";
import { I18nProvider } from "@lingui/react";
import { ThemeProvider } from "@react-navigation/native"; import { ThemeProvider } from "@react-navigation/native";
import { i18n } from "@sofa/i18n";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { import {
persistQueryClientRestore, persistQueryClientRestore,
@@ -17,11 +20,13 @@ import { PostHogErrorBoundary, PostHogProvider } from "posthog-react-native";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller"; import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { enableFreeze } from "react-native-screens"; import { enableFreeze } from "react-native-screens";
import { Uniwind, useResolveClassNames } from "uniwind"; import { Uniwind, useResolveClassNames } from "uniwind";
import { OfflineBanner } from "@/components/ui/offline-banner"; import { OfflineBanner } from "@/components/ui/offline-banner";
import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner"; import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner";
import { useServerConnection } from "@/hooks/use-server-connection"; import { useServerConnection } from "@/hooks/use-server-connection";
import { initLocale } from "@/lib/i18n";
import { applyTrackingTransparency, posthog } from "@/lib/posthog"; import { applyTrackingTransparency, posthog } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
import { import {
@@ -35,6 +40,7 @@ import { sofaTheme } from "@/lib/theme";
SplashScreen.preventAutoHideAsync(); SplashScreen.preventAutoHideAsync();
enableFreeze(true); enableFreeze(true);
initialize(); initialize();
const localeReady = initLocale();
export const unstable_settings = { export const unstable_settings = {
initialRouteName: "(tabs)", initialRouteName: "(tabs)",
@@ -63,6 +69,15 @@ function AppContent() {
const contentStyle = useResolveClassNames("bg-background"); const contentStyle = useResolveClassNames("bg-background");
const { session, isPending, hasServerUrl } = useServerConnection(); const { session, isPending, hasServerUrl } = useServerConnection();
// --- Locale readiness (wait for async catalog load before showing UI) ---
const [isLocaleReady, setLocaleReady] = useState(false);
useEffect(() => {
localeReady
.then(() => setLocaleReady(true))
.catch(() => setLocaleReady(true));
}, []);
// --- App Tracking Transparency (must resolve before screen tracking) --- // --- App Tracking Transparency (must resolve before screen tracking) ---
const [trackingReady, setTrackingReady] = useState(false); const [trackingReady, setTrackingReady] = useState(false);
@@ -111,10 +126,10 @@ function AppContent() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!isPending || !hasServerUrl) { if (isLocaleReady && (!isPending || !hasServerUrl)) {
SplashScreen.hideAsync(); SplashScreen.hideAsync();
} }
}, [isPending, hasServerUrl]); }, [isPending, hasServerUrl, isLocaleReady]);
return ( return (
<ThemeProvider value={sofaTheme}> <ThemeProvider value={sofaTheme}>
@@ -215,13 +230,17 @@ function QueryProvider({ children }: { children: React.ReactNode }) {
export default function RootLayout() { export default function RootLayout() {
const inner = ( const inner = (
<QueryProvider> <I18nProvider i18n={i18n}>
<GestureHandlerRootView style={{ flex: 1 }}> <QueryProvider>
<KeyboardProvider> <SafeAreaProvider>
<AppContent /> <GestureHandlerRootView style={{ flex: 1 }}>
</KeyboardProvider> <KeyboardProvider>
</GestureHandlerRootView> <AppContent />
</QueryProvider> </KeyboardProvider>
</GestureHandlerRootView>
</SafeAreaProvider>
</QueryProvider>
</I18nProvider>
); );
if (!posthog) return inner; if (!posthog) return inner;
+19 -12
View File
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
@@ -43,6 +44,7 @@ function formatFormErrors(errors: unknown): string | null {
} }
export default function ChangePasswordScreen() { export default function ChangePasswordScreen() {
const { t } = useLingui();
const { back } = useRouter(); const { back } = useRouter();
const newPasswordRef = useRef<TextInput>(null); const newPasswordRef = useRef<TextInput>(null);
const confirmPasswordRef = useRef<TextInput>(null); const confirmPasswordRef = useRef<TextInput>(null);
@@ -66,20 +68,17 @@ export default function ChangePasswordScreen() {
}); });
if (result.error) { if (result.error) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert( Alert.alert(t`Error`, t`Failed to change password`);
"Error",
result.error.message ?? "Failed to change password",
);
return; return;
} }
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
toast.success("Password updated"); toast.success(t`Password updated`);
formApi.reset(); formApi.reset();
back(); back();
} catch { } catch {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Error", "Something went wrong"); Alert.alert(t`Error`, t`Something went wrong`);
} }
}, },
}); });
@@ -96,7 +95,7 @@ export default function ChangePasswordScreen() {
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
<Stack.Screen.Title style={headerTitleStyle as Record<string, unknown>}> <Stack.Screen.Title style={headerTitleStyle as Record<string, unknown>}>
Change Password <Trans>Change Password</Trans>
</Stack.Screen.Title> </Stack.Screen.Title>
<form.Subscribe <form.Subscribe
@@ -117,7 +116,9 @@ export default function ChangePasswordScreen() {
<form.Field name="currentPassword"> <form.Field name="currentPassword">
{(field) => ( {(field) => (
<TextField> <TextField>
<Label>Current password</Label> <Label>
<Trans>Current password</Trans>
</Label>
<Input <Input
value={field.state.value} value={field.state.value}
accessibilityLabel="Current password" accessibilityLabel="Current password"
@@ -140,7 +141,9 @@ export default function ChangePasswordScreen() {
<form.Field name="newPassword"> <form.Field name="newPassword">
{(field) => ( {(field) => (
<TextField> <TextField>
<Label>New password</Label> <Label>
<Trans>New password</Trans>
</Label>
<Input <Input
ref={newPasswordRef} ref={newPasswordRef}
value={field.state.value} value={field.state.value}
@@ -166,7 +169,9 @@ export default function ChangePasswordScreen() {
<form.Field name="confirmPassword"> <form.Field name="confirmPassword">
{(field) => ( {(field) => (
<TextField> <TextField>
<Label>Confirm new password</Label> <Label>
<Trans>Confirm new password</Trans>
</Label>
<Input <Input
ref={confirmPasswordRef} ref={confirmPasswordRef}
value={field.state.value} value={field.state.value}
@@ -191,7 +196,7 @@ export default function ChangePasswordScreen() {
style={{ borderCurve: "continuous" }} style={{ borderCurve: "continuous" }}
> >
<Text className="text-base text-foreground"> <Text className="text-base text-foreground">
Sign out of other sessions <Trans>Sign out of other sessions</Trans>
</Text> </Text>
<Switch <Switch
value={revokeOtherSessions} value={revokeOtherSessions}
@@ -209,7 +214,9 @@ export default function ChangePasswordScreen() {
{isSubmitting ? ( {isSubmitting ? (
<Spinner size="sm" /> <Spinner size="sm" />
) : ( ) : (
<ButtonLabel>Update Password</ButtonLabel> <ButtonLabel>
<Trans>Update Password</Trans>
</ButtonLabel>
)} )}
</Button> </Button>
</Animated.View> </Animated.View>
+28 -19
View File
@@ -1,5 +1,7 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { useHeaderHeight } from "@react-navigation/elements"; import { useHeaderHeight } from "@react-navigation/elements";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { formatDate } from "@sofa/i18n/format";
import { import {
IconAlertTriangle, IconAlertTriangle,
IconCalendar, IconCalendar,
@@ -8,8 +10,6 @@ import {
IconUser, IconUser,
} from "@tabler/icons-react-native"; } from "@tabler/icons-react-native";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { format } from "date-fns/format";
import { parseISO } from "date-fns/parseISO";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { import {
@@ -48,15 +48,20 @@ function calculateAge(birthday: string, deathday?: string | null): number {
return age; return age;
} }
const departmentLabels: Record<string, string> = { function getDepartmentLabels(
Acting: "Actor", t: (strings: TemplateStringsArray, ...values: unknown[]) => string,
Directing: "Director", ): Record<string, string> {
Writing: "Writer", return {
Production: "Producer", Acting: t`Actor`,
Editing: "Editor", Directing: t`Director`,
}; Writing: t`Writer`,
Production: t`Producer`,
Editing: t`Editor`,
};
}
export default function PersonDetailScreen() { export default function PersonDetailScreen() {
const { t } = useLingui();
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const headerHeight = useHeaderHeight(); const headerHeight = useHeaderHeight();
@@ -205,13 +210,15 @@ export default function PersonDetailScreen() {
> >
<IconAlertTriangle size={48} color={mutedForeground} /> <IconAlertTriangle size={48} color={mutedForeground} />
<Text className="mt-3 font-display text-foreground text-xl"> <Text className="mt-3 font-display text-foreground text-xl">
Something went wrong <Trans>Something went wrong</Trans>
</Text> </Text>
<Text className="mt-1 text-center text-muted-foreground text-sm"> <Text className="mt-1 text-center text-muted-foreground text-sm">
Could not load person details <Trans>Could not load person details</Trans>
</Text> </Text>
<Pressable onPress={() => back()} className="mt-4"> <Pressable onPress={() => back()} className="mt-4">
<Text className="text-primary">Go back</Text> <Text className="text-primary">
<Trans>Go back</Trans>
</Text>
</Pressable> </Pressable>
</Animated.View> </Animated.View>
</View> </View>
@@ -233,10 +240,12 @@ export default function PersonDetailScreen() {
> >
<IconUser size={48} color={mutedForeground} /> <IconUser size={48} color={mutedForeground} />
<Text className="mt-3 font-display text-foreground text-xl"> <Text className="mt-3 font-display text-foreground text-xl">
Person not found <Trans>Person not found</Trans>
</Text> </Text>
<Pressable onPress={() => back()} className="mt-4"> <Pressable onPress={() => back()} className="mt-4">
<Text className="text-primary">Go back</Text> <Text className="text-primary">
<Trans>Go back</Trans>
</Text>
</Pressable> </Pressable>
</Animated.View> </Animated.View>
</View> </View>
@@ -275,7 +284,7 @@ export default function PersonDetailScreen() {
{person.knownForDepartment ? ( {person.knownForDepartment ? (
<View className="mt-2 rounded-full bg-secondary px-3 py-1"> <View className="mt-2 rounded-full bg-secondary px-3 py-1">
<Text className="text-muted-foreground text-xs uppercase tracking-wider"> <Text className="text-muted-foreground text-xs uppercase tracking-wider">
{departmentLabels[person.knownForDepartment] ?? {getDepartmentLabels(t)[person.knownForDepartment] ??
person.knownForDepartment} person.knownForDepartment}
</Text> </Text>
</View> </View>
@@ -291,12 +300,12 @@ export default function PersonDetailScreen() {
color={primaryColor} color={primaryColor}
/> />
<Text selectable className="text-muted-foreground text-sm"> <Text selectable className="text-muted-foreground text-sm">
{format(parseISO(person.birthday), "MMMM d, yyyy")} {formatDate(person.birthday)}
{(() => { {(() => {
const age = calculateAge(person.birthday, person.deathday); const age = calculateAge(person.birthday, person.deathday);
return person.deathday return person.deathday
? ` (died at ${age})` ? ` (${t`died at ${age}`})`
: ` (age ${age})`; : ` (${t`age ${age}`})`;
})()} })()}
</Text> </Text>
</View> </View>
@@ -328,7 +337,7 @@ export default function PersonDetailScreen() {
entering={FadeInDown.duration(300).delay(200)} entering={FadeInDown.duration(300).delay(200)}
className="px-4" className="px-4"
> >
<SectionHeader title="Filmography" icon={IconMovie} /> <SectionHeader title={t`Filmography`} icon={IconMovie} />
</Animated.View> </Animated.View>
)} )}
</> </>
+12 -8
View File
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { useHeaderHeight } from "@react-navigation/elements"; import { useHeaderHeight } from "@react-navigation/elements";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { import {
@@ -87,6 +88,7 @@ const titleDetailStyles = StyleSheet.create({
}); });
export default function TitleDetailScreen() { export default function TitleDetailScreen() {
const { t } = useLingui();
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const headerHeight = useHeaderHeight(); const headerHeight = useHeaderHeight();
@@ -246,10 +248,12 @@ export default function TitleDetailScreen() {
<View className="flex-1 items-center justify-center bg-background px-6"> <View className="flex-1 items-center justify-center bg-background px-6">
<IconMovie size={48} color={mutedForeground} /> <IconMovie size={48} color={mutedForeground} />
<Text className="mt-3 font-display text-foreground text-xl"> <Text className="mt-3 font-display text-foreground text-xl">
Title not found <Trans>Title not found</Trans>
</Text> </Text>
<Pressable onPress={() => back()} className="mt-4"> <Pressable onPress={() => back()} className="mt-4">
<Text className="text-primary">Go back</Text> <Text className="text-primary">
<Trans>Go back</Trans>
</Text>
</Pressable> </Pressable>
</View> </View>
</> </>
@@ -355,7 +359,7 @@ export default function TitleDetailScreen() {
maxFontSizeMultiplier={1.0} maxFontSizeMultiplier={1.0}
className="font-medium font-sans text-title-accent-foreground text-xs" className="font-medium font-sans text-title-accent-foreground text-xs"
> >
{title.type === "movie" ? "Movie" : "TV"} {title.type === "movie" ? t`Movie` : t`TV`}
</Text> </Text>
</View> </View>
{year ? ( {year ? (
@@ -442,7 +446,7 @@ export default function TitleDetailScreen() {
color={titleAccentForeground} color={titleAccentForeground}
/> />
<Text className="font-medium font-sans text-sm text-title-accent-foreground"> <Text className="font-medium font-sans text-sm text-title-accent-foreground">
Mark Watched <Trans>Mark Watched</Trans>
</Text> </Text>
</> </>
)} )}
@@ -476,7 +480,7 @@ export default function TitleDetailScreen() {
> >
<View className="px-4"> <View className="px-4">
<SectionHeader <SectionHeader
title="Where to Watch" title={t`Where to Watch`}
icon={providerIcon} icon={providerIcon}
iconColor={titleAccent} iconColor={titleAccent}
/> />
@@ -529,7 +533,7 @@ export default function TitleDetailScreen() {
className="mt-6 px-4" className="mt-6 px-4"
> >
<SectionHeader <SectionHeader
title="Seasons" title={t`Seasons`}
icon={IconList} icon={IconList}
iconColor={titleAccent} iconColor={titleAccent}
/> />
@@ -552,7 +556,7 @@ export default function TitleDetailScreen() {
> >
<View className="px-4"> <View className="px-4">
<SectionHeader <SectionHeader
title="Cast" title={t`Cast`}
icon={IconUsers} icon={IconUsers}
iconColor={titleAccent} iconColor={titleAccent}
/> />
@@ -578,7 +582,7 @@ export default function TitleDetailScreen() {
> >
<View className="px-4"> <View className="px-4">
<SectionHeader <SectionHeader
title="More Like This" title={t`More Like This`}
icon={IconThumbUp} icon={IconThumbUp}
iconColor={titleAccent} iconColor={titleAccent}
/> />
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { Link } from "expo-router"; import { Link } from "expo-router";
import { Pressable, View } from "react-native"; import { Pressable, View } from "react-native";
import { GestureDetector } from "react-native-gesture-handler"; import { GestureDetector } from "react-native-gesture-handler";
@@ -26,6 +27,7 @@ export interface ContinueWatchingItem {
} }
export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) { export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
const { t } = useLingui();
const { animatedStyle, gesture: tapGesture } = usePressAnimation(); const { animatedStyle, gesture: tapGesture } = usePressAnimation();
const progressLabel = `${item.watchedEpisodes} of ${item.totalEpisodes} episodes`; const progressLabel = `${item.watchedEpisodes} of ${item.totalEpisodes} episodes`;
@@ -117,14 +119,14 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
<Link.Preview /> <Link.Preview />
<Link.Menu> <Link.Menu>
<Link.MenuAction <Link.MenuAction
title="Mark as Completed" title={t`Mark as Completed`}
icon="checkmark.circle" icon="checkmark.circle"
onPress={() => onPress={() =>
titleActions.markCompleted(item.title.id, item.title.title) titleActions.markCompleted(item.title.id, item.title.title)
} }
/> />
<Link.MenuAction <Link.MenuAction
title="Remove from Library" title={t`Remove from Library`}
icon="trash" icon="trash"
destructive destructive
onPress={() => titleActions.removeFromLibrary(item.title.id)} onPress={() => titleActions.removeFromLibrary(item.title.id)}
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { Icon } from "@tabler/icons-react-native"; import type { Icon } from "@tabler/icons-react-native";
import { skipToken, useInfiniteQuery } from "@tanstack/react-query"; import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
@@ -42,6 +43,7 @@ export function FilterableTitleRow({
genres?: Array<{ id: number; name: string }>; genres?: Array<{ id: number; name: string }>;
isLoading?: boolean; isLoading?: boolean;
}) { }) {
const { t } = useLingui();
const [selectedGenre, setSelectedGenre] = useState<number | null>(null); const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
const discover = useInfiniteQuery({ const discover = useInfiniteQuery({
@@ -114,7 +116,7 @@ export function FilterableTitleRow({
contentContainerStyle={genreChipsContentStyle} contentContainerStyle={genreChipsContentStyle}
> >
<GenreChip <GenreChip
label="All" label={t`All`}
isSelected={selectedGenre === null} isSelected={selectedGenre === null}
onPress={() => setSelectedGenre(null)} onPress={() => setSelectedGenre(null)}
/> />
@@ -134,7 +136,7 @@ export function FilterableTitleRow({
{!showLoading && items.length === 0 && selectedGenre !== null ? ( {!showLoading && items.length === 0 && selectedGenre !== null ? (
<View className="items-center py-6"> <View className="items-center py-6">
<Text className="text-muted-foreground text-sm"> <Text className="text-muted-foreground text-sm">
No titles found for this genre. <Trans>No titles found for this genre.</Trans>
</Text> </Text>
</View> </View>
) : ( ) : (
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { Pressable } from "react-native"; import { Pressable } from "react-native";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import * as Haptics from "@/utils/haptics"; import * as Haptics from "@/utils/haptics";
@@ -11,6 +12,7 @@ export function GenreChip({
isSelected: boolean; isSelected: boolean;
onPress: () => void; onPress: () => void;
}) { }) {
const { t } = useLingui();
return ( return (
<Pressable <Pressable
onPress={() => { onPress={() => {
@@ -22,8 +24,8 @@ export function GenreChip({
accessibilityState={{ selected: isSelected }} accessibilityState={{ selected: isSelected }}
accessibilityHint={ accessibilityHint={
isSelected isSelected
? "Double tap to clear this filter" ? t`Double tap to clear this filter`
: `Double tap to filter by ${label}` : t`Double tap to filter by ${label}`
} }
className={`mr-2 rounded-full px-3 py-1.5 ${isSelected ? "bg-primary" : "bg-secondary"}`} className={`mr-2 rounded-full px-3 py-1.5 ${isSelected ? "bg-primary" : "bg-secondary"}`}
> >
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { IconStarFilled } from "@tabler/icons-react-native"; import { IconStarFilled } from "@tabler/icons-react-native";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { Link } from "expo-router"; import { Link } from "expo-router";
@@ -22,6 +23,7 @@ export interface HeroBannerItem {
} }
export function HeroBanner({ item }: { item: HeroBannerItem }) { export function HeroBanner({ item }: { item: HeroBannerItem }) {
const { t } = useLingui();
const primary = useCSSVariable("--color-primary") as string; const primary = useCSSVariable("--color-primary") as string;
const { animatedStyle, gesture: tapGesture } = usePressAnimation(0.98); const { animatedStyle, gesture: tapGesture } = usePressAnimation(0.98);
@@ -152,7 +154,7 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
<Link.Preview /> <Link.Preview />
<Link.Menu> <Link.Menu>
<Link.MenuAction <Link.MenuAction
title="Add to Watchlist" title={t`Add to Watchlist`}
icon="bookmark" icon="bookmark"
onPress={() => titleActions.quickAdd(item.id, item.title)} onPress={() => titleActions.quickAdd(item.id, item.title)}
/> />
+7 -5
View File
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { Alert, Pressable, View } from "react-native"; import { Alert, Pressable, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
@@ -8,6 +9,7 @@ import { authClient } from "@/lib/server";
import * as Haptics from "@/utils/haptics"; import * as Haptics from "@/utils/haptics";
export function HeaderAvatar() { export function HeaderAvatar() {
const { t } = useLingui();
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const { navigate } = useRouter(); const { navigate } = useRouter();
@@ -58,17 +60,17 @@ export function HeaderAvatar() {
ios={{ name: "gear" }} ios={{ name: "gear" }}
androidIconName="ic_menu_preferences" androidIconName="ic_menu_preferences"
/> />
<DropdownMenu.ItemTitle>Settings</DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>{t`Settings`}</DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
key="sign-out" key="sign-out"
destructive destructive
onSelect={() => { onSelect={() => {
Alert.alert("Sign Out", "Are you sure you want to sign out?", [ Alert.alert(t`Sign Out`, t`Are you sure you want to sign out?`, [
{ text: "Cancel", style: "cancel" }, { text: t`Cancel`, style: "cancel" },
{ {
text: "Sign Out", text: t`Sign Out`,
style: "destructive", style: "destructive",
onPress: () => { onPress: () => {
authClient.signOut(); authClient.signOut();
@@ -82,7 +84,7 @@ export function HeaderAvatar() {
ios={{ name: "rectangle.portrait.and.arrow.right" }} ios={{ name: "rectangle.portrait.and.arrow.right" }}
androidIconName="ic_menu_close_clear_cancel" androidIconName="ic_menu_close_clear_cancel"
/> />
<DropdownMenu.ItemTitle>Sign out</DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>{t`Sign out`}</DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
@@ -1,15 +1,17 @@
import { useLingui } from "@lingui/react/macro";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { View } from "react-native"; import { View } from "react-native";
export function DetailStackHeader() { export function DetailStackHeader() {
const { dismissAll } = useRouter(); const { dismissAll } = useRouter();
const { t } = useLingui();
return ( return (
<> <>
<Stack.Header transparent blurEffect="none" /> <Stack.Header transparent blurEffect="none" />
<Stack.Toolbar placement="right"> <Stack.Toolbar placement="right">
<Stack.Toolbar.Button onPress={() => dismissAll()}> <Stack.Toolbar.Button onPress={() => dismissAll()}>
<Stack.Toolbar.Icon sf="xmark" /> <Stack.Toolbar.Icon sf="xmark" />
<Stack.Toolbar.Label>Close</Stack.Toolbar.Label> <Stack.Toolbar.Label>{t`Close`}</Stack.Toolbar.Label>
</Stack.Toolbar.Button> </Stack.Toolbar.Button>
</Stack.Toolbar> </Stack.Toolbar>
<Stack.Screen.Title asChild> <Stack.Screen.Title asChild>
@@ -8,6 +8,7 @@ import {
onTapGesture, onTapGesture,
scrollContentBackground, scrollContentBackground,
} from "@expo/ui/swift-ui/modifiers"; } from "@expo/ui/swift-ui/modifiers";
import { Trans, useLingui } from "@lingui/react/macro";
import { IconHistory, IconSearch } from "@tabler/icons-react-native"; import { IconHistory, IconSearch } from "@tabler/icons-react-native";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useCallback } from "react"; import { useCallback } from "react";
@@ -23,6 +24,7 @@ import {
import * as Haptics from "@/utils/haptics"; import * as Haptics from "@/utils/haptics";
export function RecentlyViewedList() { export function RecentlyViewedList() {
const { t } = useLingui();
const { navigate } = useRouter(); const { navigate } = useRouter();
const { items, removeItem, clearAll } = useRecentlyViewed(); const { items, removeItem, clearAll } = useRecentlyViewed();
const [mutedForeground, primaryColor] = useCSSVariable([ const [mutedForeground, primaryColor] = useCSSVariable([
@@ -55,18 +57,18 @@ export function RecentlyViewedList() {
const handleClear = useCallback(() => { const handleClear = useCallback(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
Alert.alert( Alert.alert(
"Clear Recently Viewed?", t`Clear Recently Viewed?`,
"This will remove all items from your history.", t`This will remove all items from your history.`,
[ [
{ text: "Cancel", style: "cancel" }, { text: t`Cancel`, style: "cancel" },
{ {
text: "Clear", text: t`Clear`,
style: "destructive", style: "destructive",
onPress: () => clearAll(), onPress: () => clearAll(),
}, },
], ],
); );
}, [clearAll]); }, [clearAll, t]);
if (items.length === 0) { if (items.length === 0) {
return ( return (
@@ -76,7 +78,7 @@ export function RecentlyViewedList() {
> >
<IconSearch size={64} color={mutedForeground} /> <IconSearch size={64} color={mutedForeground} />
<Text className="mt-3 text-base text-muted-foreground"> <Text className="mt-3 text-base text-muted-foreground">
Search for movies, shows, or people <Trans>Search for movies, shows, or people</Trans>
</Text> </Text>
</Animated.View> </Animated.View>
); );
@@ -101,7 +103,7 @@ export function RecentlyViewedList() {
<View className="flex-row items-center gap-2"> <View className="flex-row items-center gap-2">
<IconHistory size={20} color={primaryColor} /> <IconHistory size={20} color={primaryColor} />
<Text className="font-display text-foreground text-xl tracking-tight"> <Text className="font-display text-foreground text-xl tracking-tight">
Recently Viewed <Trans>Recently Viewed</Trans>
</Text> </Text>
</View> </View>
<Pressable <Pressable
@@ -109,7 +111,9 @@ export function RecentlyViewedList() {
hitSlop={8} hitSlop={8}
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
> >
<Text className="text-primary text-sm">Clear</Text> <Text className="text-primary text-sm">
<Trans>Clear</Trans>
</Text>
</Pressable> </Pressable>
</View> </View>
</RNHostView> </RNHostView>
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { IconHistory, IconSearch } from "@tabler/icons-react-native"; import { IconHistory, IconSearch } from "@tabler/icons-react-native";
import { useCallback } from "react"; import { useCallback } from "react";
@@ -13,6 +14,7 @@ import {
import * as Haptics from "@/utils/haptics"; import * as Haptics from "@/utils/haptics";
export function RecentlyViewedList() { export function RecentlyViewedList() {
const { t } = useLingui();
const { items, removeItem, clearAll } = useRecentlyViewed(); const { items, removeItem, clearAll } = useRecentlyViewed();
const [mutedForeground, primaryColor] = useCSSVariable([ const [mutedForeground, primaryColor] = useCSSVariable([
"--color-muted-foreground", "--color-muted-foreground",
@@ -29,18 +31,18 @@ export function RecentlyViewedList() {
const handleClear = useCallback(() => { const handleClear = useCallback(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
Alert.alert( Alert.alert(
"Clear Recently Viewed?", t`Clear Recently Viewed?`,
"This will remove all items from your history.", t`This will remove all items from your history.`,
[ [
{ text: "Cancel", style: "cancel" }, { text: t`Cancel`, style: "cancel" },
{ {
text: "Clear", text: t`Clear`,
style: "destructive", style: "destructive",
onPress: () => clearAll(), onPress: () => clearAll(),
}, },
], ],
); );
}, [clearAll]); }, [clearAll, t]);
const renderItem = useCallback( const renderItem = useCallback(
({ item }: { item: RecentlyViewedItem }) => ( ({ item }: { item: RecentlyViewedItem }) => (
@@ -59,7 +61,7 @@ export function RecentlyViewedList() {
> >
<IconSearch size={64} color={mutedForeground} /> <IconSearch size={64} color={mutedForeground} />
<Text className="mt-3 text-base text-muted-foreground"> <Text className="mt-3 text-base text-muted-foreground">
Search for movies, shows, or people <Trans>Search for movies, shows, or people</Trans>
</Text> </Text>
</Animated.View> </Animated.View>
); );
@@ -81,7 +83,7 @@ export function RecentlyViewedList() {
<View className="flex-row items-center gap-2"> <View className="flex-row items-center gap-2">
<IconHistory size={20} color={primaryColor} /> <IconHistory size={20} color={primaryColor} />
<Text className="font-display text-foreground text-xl tracking-tight"> <Text className="font-display text-foreground text-xl tracking-tight">
Recently Viewed <Trans>Recently Viewed</Trans>
</Text> </Text>
</View> </View>
<Pressable <Pressable
@@ -89,7 +91,9 @@ export function RecentlyViewedList() {
hitSlop={8} hitSlop={8}
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
> >
<Text className="text-primary text-sm">Clear</Text> <Text className="text-primary text-sm">
<Trans>Clear</Trans>
</Text>
</Pressable> </Pressable>
</Animated.View> </Animated.View>
} }
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { IconDeviceTv, IconMovie, IconUser } from "@tabler/icons-react-native"; import { IconDeviceTv, IconMovie, IconUser } from "@tabler/icons-react-native";
import { View } from "react-native"; import { View } from "react-native";
import { useCSSVariable } from "uniwind"; import { useCSSVariable } from "uniwind";
@@ -11,17 +12,22 @@ const TypeIcon = {
person: IconUser, person: IconUser,
} as const; } as const;
const TypeLabel = { function getTypeLabel(
movie: "Movie", t: (strings: TemplateStringsArray, ...values: unknown[]) => string,
tv: "TV", ) {
person: "Person", return {
} as const; movie: t`Movie`,
tv: t`TV`,
person: t`Person`,
} as const;
}
export function RecentlyViewedRowContent({ export function RecentlyViewedRowContent({
item, item,
}: { }: {
item: RecentlyViewedItem; item: RecentlyViewedItem;
}) { }) {
const { t } = useLingui();
const mutedForeground = useCSSVariable("--color-muted-foreground") as string; const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const Icon = TypeIcon[item.type]; const Icon = TypeIcon[item.type];
@@ -63,7 +69,7 @@ export function RecentlyViewedRowContent({
maxFontSizeMultiplier={1.0} maxFontSizeMultiplier={1.0}
className="text-muted-foreground text-xs" className="text-muted-foreground text-xs"
> >
{TypeLabel[item.type]} {getTypeLabel(t)[item.type]}
</Text> </Text>
</View> </View>
{item.subtitle ? ( {item.subtitle ? (
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { IconLoader, IconPlus } from "@tabler/icons-react-native"; import { IconLoader, IconPlus } from "@tabler/icons-react-native";
import { Link } from "expo-router"; import { Link } from "expo-router";
import { memo, useMemo } from "react"; import { memo, useMemo } from "react";
@@ -25,11 +26,16 @@ export const SearchResultRow = memo(function SearchResultRow({
onQuickAdd: (id: string) => void; onQuickAdd: (id: string) => void;
isAdding: boolean; isAdding: boolean;
}) { }) {
const { t } = useLingui();
const primary = useCSSVariable("--color-primary") as string; const primary = useCSSVariable("--color-primary") as string;
const imageSrc = item.posterPath ?? item.profilePath; const imageSrc = item.posterPath ?? item.profilePath;
const typeLabel = const typeLabel =
item.type === "movie" ? "Movie" : item.type === "tv" ? "TV show" : "Person"; item.type === "movie"
? t`Movie`
: item.type === "tv"
? t`TV show`
: t`Person`;
const accessibilityLabel = [ const accessibilityLabel = [
item.title, item.title,
typeLabel, typeLabel,
@@ -87,10 +93,10 @@ export const SearchResultRow = memo(function SearchResultRow({
className="text-muted-foreground text-xs" className="text-muted-foreground text-xs"
> >
{item.type === "movie" {item.type === "movie"
? "Movie" ? t`Movie`
: item.type === "tv" : item.type === "tv"
? "TV" ? t`TV`
: "Person"} : t`Person`}
</Text> </Text>
</View> </View>
{item.releaseDate ? ( {item.releaseDate ? (
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { import {
IconCheck, IconCheck,
IconChevronDown, IconChevronDown,
@@ -46,6 +47,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
| "sonarr" | "sonarr"
| "radarr"; | "radarr";
const { t } = useLingui();
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string; const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string; const primaryColor = useCSSVariable("--color-primary") as string;
@@ -91,30 +93,30 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
const connectMutation = useMutation( const connectMutation = useMutation(
orpc.integrations.create.mutationOptions({ orpc.integrations.create.mutationOptions({
onSuccess: () => { onSuccess: () => {
toast.success(`${label} connected`); toast.success(t`${label} connected`);
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() }); queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
}, },
onError: () => toast.error(`Failed to connect ${label}`), onError: () => toast.error(t`Failed to connect ${label}`),
}), }),
); );
const deleteMutation = useMutation( const deleteMutation = useMutation(
orpc.integrations.delete.mutationOptions({ orpc.integrations.delete.mutationOptions({
onSuccess: () => { onSuccess: () => {
toast.success(`${label} disconnected`); toast.success(t`${label} disconnected`);
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() }); queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
}, },
onError: () => toast.error(`Failed to disconnect ${label}`), onError: () => toast.error(t`Failed to disconnect ${label}`),
}), }),
); );
const regenerateMutation = useMutation( const regenerateMutation = useMutation(
orpc.integrations.regenerateToken.mutationOptions({ orpc.integrations.regenerateToken.mutationOptions({
onSuccess: () => { onSuccess: () => {
toast.success(`${label} URL regenerated`); toast.success(t`${label} URL regenerated`);
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() }); queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
}, },
onError: () => toast.error(`Failed to regenerate ${label} URL`), onError: () => toast.error(t`Failed to regenerate ${label} URL`),
}), }),
); );
@@ -124,39 +126,39 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
if (!url) return; if (!url) return;
await Clipboard.setStringAsync(url); await Clipboard.setStringAsync(url);
setCopied(true); setCopied(true);
toast.success("URL copied to clipboard"); toast.success(t`URL copied to clipboard`);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}, [url]); }, [url, t]);
const handleRegenerate = useCallback(() => { const handleRegenerate = useCallback(() => {
Alert.alert( Alert.alert(
"Regenerate URL", t`Regenerate URL`,
`This will invalidate the current ${label} URL. You'll need to update it in ${label}.`, t`This will invalidate the current ${label} URL. You'll need to update it in ${label}.`,
[ [
{ text: "Cancel", style: "cancel" }, { text: t`Cancel`, style: "cancel" },
{ {
text: "Regenerate", text: t`Regenerate`,
style: "destructive", style: "destructive",
onPress: () => regenerateMutation.mutate({ provider: providerInput }), onPress: () => regenerateMutation.mutate({ provider: providerInput }),
}, },
], ],
); );
}, [label, providerInput, regenerateMutation]); }, [label, providerInput, regenerateMutation, t]);
const handleDisconnect = useCallback(() => { const handleDisconnect = useCallback(() => {
Alert.alert( Alert.alert(
`Disconnect ${label}`, t`Disconnect ${label}`,
`Are you sure you want to disconnect ${label}? The current URL will stop working.`, t`Are you sure you want to disconnect ${label}? The current URL will stop working.`,
[ [
{ text: "Cancel", style: "cancel" }, { text: t`Cancel`, style: "cancel" },
{ {
text: "Disconnect", text: t`Disconnect`,
style: "destructive", style: "destructive",
onPress: () => deleteMutation.mutate({ provider: providerInput }), onPress: () => deleteMutation.mutate({ provider: providerInput }),
}, },
], ],
); );
}, [label, providerInput, deleteMutation]); }, [label, providerInput, deleteMutation, t]);
const Icon = config.icon; const Icon = config.icon;
@@ -189,7 +191,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
<Text className="mt-0.5 text-muted-foreground text-xs"> <Text className="mt-0.5 text-muted-foreground text-xs">
{connection {connection
? config.connectedStatus(connection.lastEventAt) ? config.connectedStatus(connection.lastEventAt)
: "Not configured"} : t`Not configured`}
</Text> </Text>
</View> </View>
</View> </View>
@@ -231,7 +233,9 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
className="items-center rounded-lg bg-primary py-2.5 active:opacity-80" className="items-center rounded-lg bg-primary py-2.5 active:opacity-80"
> >
<Text className="font-medium font-sans text-primary-foreground"> <Text className="font-medium font-sans text-primary-foreground">
{connectMutation.isPending ? "Connecting…" : `Connect ${label}`} {connectMutation.isPending
? t`Connecting…`
: t`Connect ${label}`}
</Text> </Text>
</Pressable> </Pressable>
) : ( ) : (
@@ -282,7 +286,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
color={mutedFgColor} color={mutedFgColor}
/> />
<Text className="font-medium font-sans text-foreground text-sm"> <Text className="font-medium font-sans text-foreground text-sm">
Regenerate <Trans>Regenerate</Trans>
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable
@@ -291,7 +295,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
> >
<ScaledIcon icon={IconTrash} size={14} color="#ef4444" /> <ScaledIcon icon={IconTrash} size={14} color="#ef4444" />
<Text className="font-medium font-sans text-destructive text-sm"> <Text className="font-medium font-sans text-destructive text-sm">
Disconnect <Trans>Disconnect</Trans>
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
@@ -312,7 +316,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
/> />
</Animated.View> </Animated.View>
<Text className="text-muted-foreground text-xs"> <Text className="text-muted-foreground text-xs">
Setup instructions <Trans>Setup instructions</Trans>
</Text> </Text>
</Pressable> </Pressable>
@@ -1,4 +1,6 @@
import { formatDistanceToNow } from "date-fns/formatDistanceToNow"; 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 type { SvgProps } from "react-native-svg";
import { import {
EmbyIcon, EmbyIcon,
@@ -21,97 +23,114 @@ export interface IntegrationConfig {
setupSteps: string[]; setupSteps: string[];
} }
function webhookStatus(lastEventAt: string | null): string { function webhookStatus(i18n: I18n, lastEventAt: string | null): string {
return lastEventAt if (lastEventAt) {
? `Last event ${formatDistanceToNow(new Date(lastEventAt), { addSuffix: true })}` const timeAgo = formatRelativeTime(new Date(lastEventAt));
: "Ready — nothing received yet"; return i18n._(msg`Last event ${timeAgo}`);
}
return i18n._(msg`Ready — nothing received yet`);
} }
function listStatus(lastEventAt: string | null): string { function listStatus(i18n: I18n, lastEventAt: string | null): string {
return lastEventAt if (lastEventAt) {
? `Last polled ${formatDistanceToNow(new Date(lastEventAt), { addSuffix: true })}` const timeAgo = formatRelativeTime(new Date(lastEventAt));
: "Ready — not polled yet"; return i18n._(msg`Last polled ${timeAgo}`);
}
return i18n._(msg`Ready — not polled yet`);
} }
export const INTEGRATION_CONFIGS: IntegrationConfig[] = [ export function getIntegrationConfigs(i18n: I18n): IntegrationConfig[] {
{ return [
provider: "plex", {
label: "Plex", provider: "plex",
icon: PlexIcon, label: "Plex",
type: "webhook", icon: PlexIcon,
buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`, type: "webhook",
urlLabel: "Webhook URL", buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`,
connectedStatus: webhookStatus, urlLabel: i18n._(msg`Webhook URL`),
requirementNote: "Requires an active Plex Pass subscription.", connectedStatus: (lastEventAt) => webhookStatus(i18n, lastEventAt),
setupSteps: [ requirementNote: i18n._(msg`Requires an active Plex Pass subscription.`),
"Open Plex, go to Settings > Webhooks", setupSteps: [
'Click "Add Webhook" and paste the URL above', i18n._(msg`Open Plex, go to Settings > Webhooks`),
"Sofa will automatically log movies and episodes when you finish watching them", i18n._(msg`Click "Add Webhook" and paste the URL above`),
], i18n._(
}, msg`Sofa will automatically log movies and episodes when you finish watching them`,
{ ),
provider: "jellyfin", ],
label: "Jellyfin", },
icon: JellyfinIcon, {
type: "webhook", provider: "jellyfin",
buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`, label: "Jellyfin",
urlLabel: "Webhook URL", icon: JellyfinIcon,
connectedStatus: webhookStatus, type: "webhook",
setupSteps: [ buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`,
"Install the Webhook plugin from Jellyfin's plugin catalog", urlLabel: i18n._(msg`Webhook URL`),
"Go to Dashboard > Plugins > Webhook", connectedStatus: (lastEventAt) => webhookStatus(i18n, lastEventAt),
'Add a "Generic Destination" and paste the URL above', setupSteps: [
'Enable the "Playback Stop" notification type', i18n._(msg`Install the Webhook plugin from Jellyfin's plugin catalog`),
"Sofa will automatically log movies and episodes when you finish watching them", 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._(
provider: "emby", msg`Sofa will automatically log movies and episodes when you finish watching them`,
label: "Emby", ),
icon: EmbyIcon, ],
type: "webhook", },
buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`, {
urlLabel: "Webhook URL", provider: "emby",
connectedStatus: webhookStatus, label: "Emby",
requirementNote: icon: EmbyIcon,
"Requires Emby Server 4.7.9+ and an active Emby Premiere license.", type: "webhook",
setupSteps: [ buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`,
"Open Emby, go to Settings > Webhooks", urlLabel: i18n._(msg`Webhook URL`),
"Add a new webhook and paste the URL above", connectedStatus: (lastEventAt) => webhookStatus(i18n, lastEventAt),
'Enable the "Playback" event category', requirementNote: i18n._(
"Sofa will automatically log movies and episodes when you finish watching them", msg`Requires Emby Server 4.7.9+ and an active Emby Premiere license.`,
], ),
}, setupSteps: [
{ i18n._(msg`Open Emby, go to Settings > Webhooks`),
provider: "sonarr", i18n._(msg`Add a new webhook and paste the URL above`),
label: "Sonarr", i18n._(msg`Enable the "Playback" event category`),
icon: SonarrIcon, i18n._(
type: "list", msg`Sofa will automatically log movies and episodes when you finish watching them`,
buildUrl: (token) => `${getServerUrl()}/api/lists/${token}`, ),
urlLabel: "Sonarr List URL", ],
connectedStatus: listStatus, },
setupSteps: [ {
"Open Sonarr, go to Settings > Import Lists", provider: "sonarr",
'Click "+" and select "Custom Lists"', label: "Sonarr",
"Paste the Sonarr URL above into the List URL field", icon: SonarrIcon,
"Set your preferred quality profile and root folder", type: "list",
"Titles on your Sofa watchlist will be automatically added for download when Sonarr polls this list (every 6 hours by default)", buildUrl: (token) => `${getServerUrl()}/api/lists/${token}`,
], urlLabel: i18n._(msg`Sonarr List URL`),
}, connectedStatus: (lastEventAt) => listStatus(i18n, lastEventAt),
{ setupSteps: [
provider: "radarr", i18n._(msg`Open Sonarr, go to Settings > Import Lists`),
label: "Radarr", i18n._(msg`Click "+" and select "Custom Lists"`),
icon: RadarrIcon, i18n._(msg`Paste the Sonarr URL above into the List URL field`),
type: "list", i18n._(msg`Set your preferred quality profile and root folder`),
buildUrl: (token) => `${getServerUrl()}/api/lists/${token}`, i18n._(
urlLabel: "Radarr List URL", msg`Titles on your Sofa watchlist will be automatically added for download when Sonarr polls this list (every 6 hours by default)`,
connectedStatus: listStatus, ),
setupSteps: [ ],
"Open Radarr, go to Settings > Import Lists", },
'Click "+" and select "Custom Lists"', {
"Paste the Radarr URL above into the List URL field", provider: "radarr",
"Set your preferred quality profile and root folder", label: "Radarr",
"Titles on your Sofa watchlist will be automatically added for download when Radarr polls this list (every 12 hours by default)", icon: RadarrIcon,
], type: "list",
}, buildUrl: (token) => `${getServerUrl()}/api/lists/${token}`,
]; urlLabel: i18n._(msg`Radarr List URL`),
connectedStatus: (lastEventAt) => listStatus(i18n, lastEventAt),
setupSteps: [
i18n._(msg`Open Radarr, go to Settings > Import Lists`),
i18n._(msg`Click "+" and select "Custom Lists"`),
i18n._(msg`Paste the Radarr URL above into the List URL field`),
i18n._(msg`Set your preferred quality profile and root folder`),
i18n._(
msg`Titles on your Sofa watchlist will be automatically added for download when Radarr polls this list (every 12 hours by default)`,
),
],
},
];
}
@@ -1,8 +1,9 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { IconRefresh, IconWebhook } from "@tabler/icons-react-native"; import { IconRefresh, IconWebhook } from "@tabler/icons-react-native";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Pressable, View } from "react-native"; import { Pressable, View } from "react-native";
import { IntegrationCard } from "@/components/settings/integration-card"; import { IntegrationCard } from "@/components/settings/integration-card";
import { INTEGRATION_CONFIGS } from "@/components/settings/integration-configs"; import { getIntegrationConfigs } from "@/components/settings/integration-configs";
import { ScaledIcon } from "@/components/ui/scaled-icon"; import { ScaledIcon } from "@/components/ui/scaled-icon";
import { SectionHeader } from "@/components/ui/section-header"; import { SectionHeader } from "@/components/ui/section-header";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
@@ -10,11 +11,13 @@ import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
export function IntegrationsSection() { export function IntegrationsSection() {
const { t, i18n } = useLingui();
const configs = getIntegrationConfigs(i18n);
const integrations = useQuery(orpc.integrations.list.queryOptions()); const integrations = useQuery(orpc.integrations.list.queryOptions());
return ( return (
<View className="mb-6"> <View className="mb-6">
<SectionHeader title="Integrations" icon={IconWebhook} /> <SectionHeader title={t`Integrations`} icon={IconWebhook} />
{integrations.isPending ? ( {integrations.isPending ? (
<View className="items-center py-4"> <View className="items-center py-4">
@@ -23,7 +26,7 @@ export function IntegrationsSection() {
) : integrations.isError ? ( ) : integrations.isError ? (
<View className="items-center gap-2 py-4"> <View className="items-center gap-2 py-4">
<Text className="text-muted-foreground text-sm"> <Text className="text-muted-foreground text-sm">
Could not load integrations <Trans>Could not load integrations</Trans>
</Text> </Text>
<Pressable <Pressable
onPress={() => integrations.refetch()} onPress={() => integrations.refetch()}
@@ -36,12 +39,12 @@ export function IntegrationsSection() {
className="accent-primary" className="accent-primary"
/> />
<Text className="font-medium font-sans text-primary text-sm"> <Text className="font-medium font-sans text-primary text-sm">
Retry <Trans>Retry</Trans>
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
) : ( ) : (
INTEGRATION_CONFIGS.map((config) => { configs.map((config) => {
const connection = const connection =
integrations.data?.integrations?.find( integrations.data?.integrations?.find(
(i) => i.provider === config.provider, (i) => i.provider === config.provider,
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { Season } from "@sofa/api/schemas"; import type { Season } from "@sofa/api/schemas";
import { getNextEpisode } from "@sofa/api/utils"; import { getNextEpisode } from "@sofa/api/utils";
import { IconPlayerPlay } from "@tabler/icons-react-native"; import { IconPlayerPlay } from "@tabler/icons-react-native";
@@ -22,6 +23,7 @@ export function ContinueWatchingBanner({
backdropPath: string | null; backdropPath: string | null;
backdropThumbHash?: string | null; backdropThumbHash?: string | null;
}) { }) {
const { t } = useLingui();
const titleAccentColor = useCSSVariable("--color-title-accent") as string; const titleAccentColor = useCSSVariable("--color-title-accent") as string;
const { nextEpisode, totalEpisodes, watchedEpisodes } = useMemo( const { nextEpisode, totalEpisodes, watchedEpisodes } = useMemo(
@@ -41,7 +43,7 @@ export function ContinueWatchingBanner({
className="mt-6 px-4" className="mt-6 px-4"
> >
<SectionHeader <SectionHeader
title="Next Episode" title={t`Next Episode`}
icon={IconPlayerPlay} icon={IconPlayerPlay}
iconColor={titleAccentColor} iconColor={titleAccentColor}
/> />
@@ -74,7 +76,7 @@ export function ContinueWatchingBanner({
maxFontSizeMultiplier={1.0} maxFontSizeMultiplier={1.0}
className="font-medium font-sans text-title-accent text-xs uppercase tracking-wider" className="font-medium font-sans text-title-accent text-xs uppercase tracking-wider"
> >
Up next <Trans>Up next</Trans>
</Text> </Text>
</View> </View>
<Text <Text
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { import {
IconCircleCheckFilled, IconCircleCheckFilled,
IconCircleDashed, IconCircleDashed,
@@ -24,14 +25,15 @@ export function EpisodeRow({
accentColor: string; accentColor: string;
mutedColor: string; mutedColor: string;
}) { }) {
const episodeLabel = episode.name ?? `Episode ${episode.episodeNumber}`; const { t } = useLingui();
const episodeLabel = episode.name ?? t`Episode ${episode.episodeNumber}`;
return ( return (
<Pressable <Pressable
onPress={onToggle} onPress={onToggle}
accessibilityRole="checkbox" accessibilityRole="checkbox"
accessibilityState={{ checked: isWatched }} accessibilityState={{ checked: isWatched }}
accessibilityLabel={`Episode ${episode.episodeNumber}, ${episodeLabel}`} accessibilityLabel={t`Episode ${episode.episodeNumber}, ${episodeLabel}`}
className="flex-row items-center border-border border-b px-4 py-3" className="flex-row items-center border-border border-b px-4 py-3"
style={{ borderBottomWidth: 0.5 }} style={{ borderBottomWidth: 0.5 }}
> >
@@ -50,7 +52,7 @@ export function EpisodeRow({
numberOfLines={1} numberOfLines={1}
> >
{episode.episodeNumber}.{" "} {episode.episodeNumber}.{" "}
{episode.name ?? `Episode ${episode.episodeNumber}`} {episode.name ?? t`Episode ${episode.episodeNumber}`}
</Text> </Text>
{episode.airDate ? ( {episode.airDate ? (
<Text className="mt-0.5 text-muted-foreground text-xs"> <Text className="mt-0.5 text-muted-foreground text-xs">
@@ -1,3 +1,5 @@
import { plural } from "@lingui/core/macro";
import { Trans, useLingui } from "@lingui/react/macro";
import { IconChevronDown } from "@tabler/icons-react-native"; import { IconChevronDown } from "@tabler/icons-react-native";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { InteractionManager, Pressable, View } from "react-native"; import { InteractionManager, Pressable, View } from "react-native";
@@ -33,6 +35,7 @@ export function SeasonAccordion({
}>; }>;
watchedEpisodeIds: Set<string>; watchedEpisodeIds: Set<string>;
}) { }) {
const { t } = useLingui();
const titleAccentColor = useCSSVariable("--color-title-accent") as string; const titleAccentColor = useCSSVariable("--color-title-accent") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string; const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
@@ -116,16 +119,16 @@ export function SeasonAccordion({
<Pressable <Pressable
onPress={toggleExpanded} onPress={toggleExpanded}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`${season.name ?? `Season ${season.seasonNumber}`}, ${watchedCount} of ${episodes.length} episodes watched`} accessibilityLabel={`${season.name ?? t`Season ${season.seasonNumber}`}, ${t`${watchedCount} of ${episodes.length} episodes watched`}`}
accessibilityState={{ expanded }} accessibilityState={{ expanded }}
className="flex-row items-center justify-between p-4" className="flex-row items-center justify-between p-4"
> >
<View className="flex-1"> <View className="flex-1">
<Text className="font-medium font-sans text-base text-foreground"> <Text className="font-medium font-sans text-base text-foreground">
{season.name ?? `Season ${season.seasonNumber}`} {season.name ?? t`Season ${season.seasonNumber}`}
</Text> </Text>
<Text className="mt-0.5 text-muted-foreground text-xs"> <Text className="mt-0.5 text-muted-foreground text-xs">
{watchedCount}/{episodes.length} episodes {t`${watchedCount}/${episodes.length} ${plural(episodes.length, { one: "episode", other: "episodes" })}`}
</Text> </Text>
</View> </View>
@@ -158,7 +161,7 @@ export function SeasonAccordion({
className="mx-4 mb-2 flex-row items-center justify-center rounded-lg bg-secondary py-2" className="mx-4 mb-2 flex-row items-center justify-center rounded-lg bg-secondary py-2"
> >
<Text className="font-medium font-sans text-title-accent text-xs"> <Text className="font-medium font-sans text-title-accent text-xs">
Mark All Watched <Trans>Mark All Watched</Trans>
</Text> </Text>
</Pressable> </Pressable>
)} )}
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { import {
IconBookmarkFilled, IconBookmarkFilled,
IconCheck, IconCheck,
@@ -12,27 +13,38 @@ import * as Haptics from "@/utils/haptics";
type TitleStatus = "watchlist" | "in_progress" | "completed"; type TitleStatus = "watchlist" | "in_progress" | "completed";
const statusConfig = { const STATUS_ICONS = {
watchlist: IconBookmarkFilled,
in_progress: IconPlayerPlayFilled,
completed: IconCheck,
} as const;
const STATUS_STYLES = {
watchlist: { watchlist: {
label: "Watchlisted",
Icon: IconBookmarkFilled,
bgClass: "bg-title-accent/10 border-title-accent/20", bgClass: "bg-title-accent/10 border-title-accent/20",
textClass: "text-title-accent", textClass: "text-title-accent",
}, },
in_progress: { in_progress: {
label: "Watching",
Icon: IconPlayerPlayFilled,
bgClass: "bg-title-accent/10 border-title-accent/20", bgClass: "bg-title-accent/10 border-title-accent/20",
textClass: "text-title-accent", textClass: "text-title-accent",
}, },
completed: { completed: {
label: "Completed",
Icon: IconCheck,
bgClass: "bg-status-completed/10 border-status-completed/20", bgClass: "bg-status-completed/10 border-status-completed/20",
textClass: "text-status-completed", textClass: "text-status-completed",
}, },
} as const; } as const;
function StatusLabel({ status }: { status: TitleStatus }) {
switch (status) {
case "watchlist":
return <Trans>Watchlisted</Trans>;
case "in_progress":
return <Trans>Watching</Trans>;
case "completed":
return <Trans>Completed</Trans>;
}
}
export function StatusActionButton({ export function StatusActionButton({
currentStatus, currentStatus,
onStatusChange, onStatusChange,
@@ -42,16 +54,13 @@ export function StatusActionButton({
onStatusChange: (status: TitleStatus | null) => void; onStatusChange: (status: TitleStatus | null) => void;
isPending: boolean; isPending: boolean;
}) { }) {
const { t } = useLingui();
const [titleAccent, completedColor] = useCSSVariable([ const [titleAccent, completedColor] = useCSSVariable([
"--color-title-accent", "--color-title-accent",
"--color-status-completed", "--color-status-completed",
]) as [string, string]; ]) as [string, string];
const config = currentStatus if (!currentStatus) {
? statusConfig[currentStatus as keyof typeof statusConfig]
: null;
if (!config) {
return ( return (
<Pressable <Pressable
onPress={() => { onPress={() => {
@@ -60,7 +69,7 @@ export function StatusActionButton({
}} }}
disabled={isPending} disabled={isPending}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Add to watchlist" accessibilityLabel={t`Add to watchlist`}
className="flex-row items-center gap-1.5 rounded-lg border border-title-accent/20 bg-title-accent/10 px-4 py-2" className="flex-row items-center gap-1.5 rounded-lg border border-title-accent/20 bg-title-accent/10 px-4 py-2"
> >
<ScaledIcon <ScaledIcon
@@ -70,12 +79,14 @@ export function StatusActionButton({
strokeWidth={2.5} strokeWidth={2.5}
/> />
<Text className="font-medium font-sans text-sm text-title-accent"> <Text className="font-medium font-sans text-sm text-title-accent">
Watchlist <Trans>Watchlist</Trans>
</Text> </Text>
</Pressable> </Pressable>
); );
} }
const StatusIcon = STATUS_ICONS[currentStatus];
const styles = STATUS_STYLES[currentStatus];
const iconColor = const iconColor =
currentStatus === "completed" ? completedColor : titleAccent; currentStatus === "completed" ? completedColor : titleAccent;
@@ -87,12 +98,12 @@ export function StatusActionButton({
}} }}
disabled={isPending} disabled={isPending}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`${config.label}, double tap to remove`} accessibilityLabel={t`Remove from library`}
className={`flex-row items-center gap-1.5 rounded-lg border px-4 py-2 ${config.bgClass}`} className={`flex-row items-center gap-1.5 rounded-lg border px-4 py-2 ${styles.bgClass}`}
> >
<ScaledIcon icon={config.Icon} size={14} color={iconColor} /> <ScaledIcon icon={StatusIcon} size={14} color={iconColor} />
<Text className={`font-medium font-sans text-sm ${config.textClass}`}> <Text className={`font-medium font-sans text-sm ${styles.textClass}`}>
{config.label} <StatusLabel status={currentStatus} />
</Text> </Text>
</Pressable> </Pressable>
); );
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import type { NativeSyntheticEvent, TextLayoutEventData } from "react-native"; import type { NativeSyntheticEvent, TextLayoutEventData } from "react-native";
import { Platform, Pressable, View } from "react-native"; import { Platform, Pressable, View } from "react-native";
@@ -12,6 +13,7 @@ export function ExpandableText({
maxLines?: number; maxLines?: number;
actionColor?: string; actionColor?: string;
}) { }) {
const { t } = useLingui();
const prevTextRef = useRef(text); const prevTextRef = useRef(text);
let expanded: boolean; let expanded: boolean;
let needsTruncation: boolean; let needsTruncation: boolean;
@@ -57,14 +59,14 @@ export function ExpandableText({
<Pressable <Pressable
onPress={() => setExpanded(!expanded)} onPress={() => setExpanded(!expanded)}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={expanded ? "Show less" : "Show more"} accessibilityLabel={expanded ? t`Show less` : t`Show more`}
className="mt-1" className="mt-1"
> >
<Text <Text
className="font-medium font-sans text-primary text-sm" className="font-medium font-sans text-primary text-sm"
style={actionColor ? { color: actionColor } : undefined} style={actionColor ? { color: actionColor } : undefined}
> >
{expanded ? "Show less" : "Show more"} {expanded ? t`Show less` : t`Show more`}
</Text> </Text>
</Pressable> </Pressable>
)} )}
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { IconWifiOff } from "@tabler/icons-react-native"; import { IconWifiOff } from "@tabler/icons-react-native";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import * as Network from "expo-network"; import * as Network from "expo-network";
@@ -70,14 +71,14 @@ export function OfflineBanner() {
> >
<ScaledIcon icon={IconWifiOff} size={16} color="white" /> <ScaledIcon icon={IconWifiOff} size={16} color="white" />
<Text className="font-medium font-sans text-sm text-white"> <Text className="font-medium font-sans text-sm text-white">
No internet connection <Trans>No internet connection</Trans>
</Text> </Text>
</GlassView> </GlassView>
) : ( ) : (
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-destructive px-4 py-2.5"> <View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-destructive px-4 py-2.5">
<ScaledIcon icon={IconWifiOff} size={16} color="white" /> <ScaledIcon icon={IconWifiOff} size={16} color="white" />
<Text className="font-medium font-sans text-sm text-white"> <Text className="font-medium font-sans text-sm text-white">
No internet connection <Trans>No internet connection</Trans>
</Text> </Text>
</View> </View>
)} )}
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { IconWifiOff } from "@tabler/icons-react-native"; import { IconWifiOff } from "@tabler/icons-react-native";
import * as Network from "expo-network"; import * as Network from "expo-network";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@@ -55,7 +56,7 @@ export function OfflineBanner() {
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-destructive px-4 py-2.5"> <View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-destructive px-4 py-2.5">
<ScaledIcon icon={IconWifiOff} size={16} color="white" /> <ScaledIcon icon={IconWifiOff} size={16} color="white" />
<Text className="font-medium font-sans text-sm text-white"> <Text className="font-medium font-sans text-sm text-white">
No internet connection <Trans>No internet connection</Trans>
</Text> </Text>
</View> </View>
</Animated.View> </Animated.View>
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { import {
IconBookmarkFilled, IconBookmarkFilled,
IconCheckbox, IconCheckbox,
@@ -52,6 +53,7 @@ export function PosterCard({
onQuickAdd, onQuickAdd,
isAdding, isAdding,
}: PosterCardProps) { }: PosterCardProps) {
const { t } = useLingui();
const primaryColor = useCSSVariable("--color-primary") as string; const primaryColor = useCSSVariable("--color-primary") as string;
const watchlistColor = useCSSVariable("--color-status-watchlist") as string; const watchlistColor = useCSSVariable("--color-status-watchlist") as string;
const watchingColor = useCSSVariable("--color-status-watching") as string; const watchingColor = useCSSVariable("--color-status-watching") as string;
@@ -233,28 +235,28 @@ export function PosterCard({
<Link.Menu> <Link.Menu>
{!userStatus && ( {!userStatus && (
<Link.MenuAction <Link.MenuAction
title="Add to Watchlist" title={t`Add to Watchlist`}
icon="bookmark" icon="bookmark"
onPress={handleQuickAddPress} onPress={handleQuickAddPress}
/> />
)} )}
{userStatus !== "in_progress" && ( {userStatus !== "in_progress" && (
<Link.MenuAction <Link.MenuAction
title="Mark as Watching" title={t`Mark as Watching`}
icon="play.fill" icon="play.fill"
onPress={() => titleActions.markWatching(id)} onPress={() => titleActions.markWatching(id)}
/> />
)} )}
{type === "movie" && ( {type === "movie" && (
<Link.MenuAction <Link.MenuAction
title="Mark as Watched" title={t`Mark as Watched`}
icon="checkmark.circle" icon="checkmark.circle"
onPress={() => titleActions.markMovieWatched(id, title)} onPress={() => titleActions.markMovieWatched(id, title)}
/> />
)} )}
{userStatus && ( {userStatus && (
<Link.MenuAction <Link.MenuAction
title="Remove from Library" title={t`Remove from Library`}
icon="trash" icon="trash"
destructive destructive
onPress={() => titleActions.removeFromLibrary(id)} onPress={() => titleActions.removeFromLibrary(id)}
@@ -0,0 +1,89 @@
import { type Icon, IconCheck } from "@tabler/icons-react-native";
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 {
value: string;
label: string;
}
export interface SelectModalProps {
/** Label displayed on the trigger row */
label: string;
/** Icon displayed in the modal title */
icon?: Icon;
/** Currently selected value */
selection: string;
/** Available options */
options: SelectModalOption[];
/** Whether the modal is open */
open: boolean;
/** Called when the modal is opened or closed */
onOpenChange: (open: boolean) => void;
/** Called when an option is selected */
onSelect: (value: string) => void;
}
export function SelectModal({
label,
icon: Icon,
selection,
options,
open,
onOpenChange,
onSelect,
}: SelectModalProps) {
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string;
return (
<Modal
visible={open}
transparent
animationType="fade"
onRequestClose={() => onOpenChange(false)}
>
<Pressable
className="flex-1 items-center justify-center bg-black/60"
onPress={() => onOpenChange(false)}
>
<Pressable
className="mx-8 w-full max-w-sm overflow-hidden rounded-2xl bg-card"
onPress={(e) => e.stopPropagation()}
>
<View className="flex-row items-center border-border/50 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>
</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"
onPress={() => {
onOpenChange(false);
onSelect(option.value);
}}
>
<Text
className={`flex-1 text-base ${isSelected ? "font-medium text-primary" : "text-foreground"}`}
>
{option.label}
</Text>
{isSelected && (
<IconCheck size={18} color={primaryColor} strokeWidth={2} />
)}
</Pressable>
);
})}
</Pressable>
</Pressable>
</Modal>
);
}
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { IconCloudOff } from "@tabler/icons-react-native"; import { IconCloudOff } from "@tabler/icons-react-native";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import * as Network from "expo-network"; import * as Network from "expo-network";
@@ -62,13 +63,15 @@ export function ServerUnreachableBanner() {
<> <>
<ScaledIcon icon={IconCloudOff} size={16} color="white" /> <ScaledIcon icon={IconCloudOff} size={16} color="white" />
<Text className="font-medium font-sans text-sm text-white"> <Text className="font-medium font-sans text-sm text-white">
Can't reach server <Trans>Can't reach server</Trans>
</Text> </Text>
<Pressable <Pressable
onPress={handleRetry} onPress={handleRetry}
className="ml-1 rounded-md bg-white/20 px-2 py-0.5" className="ml-1 rounded-md bg-white/20 px-2 py-0.5"
> >
<Text className="font-medium font-sans text-white text-xs">Retry</Text> <Text className="font-medium font-sans text-white text-xs">
<Trans>Retry</Trans>
</Text>
</Pressable> </Pressable>
</> </>
); );
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { IconCloudOff } from "@tabler/icons-react-native"; import { IconCloudOff } from "@tabler/icons-react-native";
import * as Network from "expo-network"; import * as Network from "expo-network";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@@ -70,14 +71,14 @@ export function ServerUnreachableBanner() {
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-status-watching px-4 py-2.5"> <View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-status-watching px-4 py-2.5">
<ScaledIcon icon={IconCloudOff} size={16} color="white" /> <ScaledIcon icon={IconCloudOff} size={16} color="white" />
<Text className="font-medium font-sans text-sm text-white"> <Text className="font-medium font-sans text-sm text-white">
Can't reach server <Trans>Can't reach server</Trans>
</Text> </Text>
<Pressable <Pressable
onPress={handleRetry} onPress={handleRetry}
className="ml-1 rounded-md bg-white/20 px-2 py-0.5" className="ml-1 rounded-md bg-white/20 px-2 py-0.5"
> >
<Text className="font-medium font-sans text-white text-xs"> <Text className="font-medium font-sans text-white text-xs">
Retry <Trans>Retry</Trans>
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
+41 -22
View File
@@ -1,3 +1,5 @@
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
@@ -32,16 +34,19 @@ interface UseTitleActionsOptions {
* Each returned property is a full UseMutationResult with isPending, mutate, etc. * Each returned property is a full UseMutationResult with isPending, mutate, etc.
*/ */
export function useTitleActions(options?: UseTitleActionsOptions) { export function useTitleActions(options?: UseTitleActionsOptions) {
const t = options?.toasts; const { t } = useLingui();
const toastOverrides = options?.toasts;
const quickAdd = useMutation( const quickAdd = useMutation(
orpc.titles.quickAdd.mutationOptions({ orpc.titles.quickAdd.mutationOptions({
onSuccess: (_data, input) => { onSuccess: (_data, input) => {
toast.success(resolveToast(t?.quickAdd, "Added to watchlist", input)); toast.success(
resolveToast(toastOverrides?.quickAdd, t`Added to watchlist`, input),
);
invalidateTitleQueries(); invalidateTitleQueries();
}, },
onError: () => { onError: () => {
toast.error("Failed to add to watchlist"); toast.error(t`Failed to add to watchlist`);
// Refetch so optimistic local status reverts on failure // Refetch so optimistic local status reverts on failure
queryClient.invalidateQueries({ queryKey: orpc.titles.key() }); queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
}, },
@@ -52,27 +57,31 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
orpc.titles.updateStatus.mutationOptions({ orpc.titles.updateStatus.mutationOptions({
onSuccess: (_data, input) => { onSuccess: (_data, input) => {
const statusMessages: Record<string, string> = { const statusMessages: Record<string, string> = {
watchlist: "Added to watchlist", watchlist: t`Added to watchlist`,
in_progress: "Marked as watching", in_progress: t`Marked as watching`,
completed: "Marked as completed", completed: t`Marked as completed`,
}; };
const defaultMsg = input.status const defaultMsg = input.status
? (statusMessages[input.status] ?? "Status updated") ? (statusMessages[input.status] ?? t`Status updated`)
: "Removed from library"; : t`Removed from library`;
toast.success(resolveToast(t?.updateStatus, defaultMsg, input)); toast.success(
resolveToast(toastOverrides?.updateStatus, defaultMsg, input),
);
invalidateTitleQueries(); invalidateTitleQueries();
}, },
onError: () => toast.error("Failed to update status"), onError: () => toast.error(t`Failed to update status`),
}), }),
); );
const watchMovie = useMutation( const watchMovie = useMutation(
orpc.titles.watchMovie.mutationOptions({ orpc.titles.watchMovie.mutationOptions({
onSuccess: (_data, input) => { onSuccess: (_data, input) => {
toast.success(resolveToast(t?.watchMovie, "Marked as watched", input)); toast.success(
resolveToast(toastOverrides?.watchMovie, t`Marked as watched`, input),
);
invalidateTitleQueries(); invalidateTitleQueries();
}, },
onError: () => toast.error("Failed to mark as watched"), onError: () => toast.error(t`Failed to mark as watched`),
}), }),
); );
@@ -81,23 +90,27 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
onSuccess: (_data, input) => { onSuccess: (_data, input) => {
const defaultMsg = const defaultMsg =
input.stars > 0 input.stars > 0
? `Rated ${input.stars} star${input.stars > 1 ? "s" : ""}` ? t`Rated ${input.stars} ${plural(input.stars, { one: "star", other: "stars" })}`
: "Rating removed"; : t`Rating removed`;
toast.success(resolveToast(t?.updateRating, defaultMsg, input)); toast.success(
resolveToast(toastOverrides?.updateRating, defaultMsg, input),
);
// Rating only invalidates title queries, not dashboard // Rating only invalidates title queries, not dashboard
queryClient.invalidateQueries({ queryKey: orpc.titles.key() }); queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
}, },
onError: () => toast.error("Failed to update rating"), onError: () => toast.error(t`Failed to update rating`),
}), }),
); );
const watchEpisode = useMutation( const watchEpisode = useMutation(
orpc.episodes.watch.mutationOptions({ orpc.episodes.watch.mutationOptions({
onSuccess: (_data, input) => { onSuccess: (_data, input) => {
toast.success(resolveToast(t?.watchEpisode, "Episode watched", input)); toast.success(
resolveToast(toastOverrides?.watchEpisode, t`Episode watched`, input),
);
invalidateTitleQueries(); invalidateTitleQueries();
}, },
onError: () => toast.error("Failed to mark episode"), onError: () => toast.error(t`Failed to mark episode`),
}), }),
); );
@@ -105,21 +118,27 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
orpc.episodes.unwatch.mutationOptions({ orpc.episodes.unwatch.mutationOptions({
onSuccess: (_data, input) => { onSuccess: (_data, input) => {
toast.success( toast.success(
resolveToast(t?.unwatchEpisode, "Episode unwatched", input), resolveToast(
toastOverrides?.unwatchEpisode,
t`Episode unwatched`,
input,
),
); );
invalidateTitleQueries(); invalidateTitleQueries();
}, },
onError: () => toast.error("Failed to unmark episode"), onError: () => toast.error(t`Failed to unmark episode`),
}), }),
); );
const watchSeason = useMutation( const watchSeason = useMutation(
orpc.seasons.watch.mutationOptions({ orpc.seasons.watch.mutationOptions({
onSuccess: (_data, input) => { onSuccess: (_data, input) => {
toast.success(resolveToast(t?.watchSeason, "Season watched", input)); toast.success(
resolveToast(toastOverrides?.watchSeason, t`Season watched`, input),
);
invalidateTitleQueries(); invalidateTitleQueries();
}, },
onError: () => toast.error("Failed to mark some episodes"), onError: () => toast.error(t`Failed to mark some episodes`),
}), }),
); );
+48
View File
@@ -0,0 +1,48 @@
import { ORPCError } from "@orpc/client";
import type { AppErrorCode } from "@sofa/api/errors";
export function getAppErrorCode(error: unknown): AppErrorCode | null {
if (
error instanceof ORPCError &&
error.data &&
typeof error.data === "object" &&
"code" in error.data &&
typeof error.data.code === "string"
) {
return error.data.code as AppErrorCode;
}
return null;
}
export function appErrorMessages(
t: (template: TemplateStringsArray, ...args: unknown[]) => string,
): Record<AppErrorCode, string> {
return {
TITLE_NOT_FOUND: t`Title not found`,
PERSON_NOT_FOUND: t`Person not found`,
INTEGRATION_NOT_FOUND: t`Integration not found`,
BACKUP_NOT_FOUND: t`Backup not found`,
BACKUP_DELETE_FAILED: t`Failed to delete backup`,
BACKUP_RESTORE_FAILED: t`Restore failed`,
JOB_NOT_FOUND: t`Job not found`,
TMDB_NOT_CONFIGURED: t`TMDB not configured`,
IMPORT_INVALID_FILE: t`Invalid file`,
IMPORT_PAYLOAD_TOO_LARGE: t`Import too large`,
IMPORT_ALREADY_RUNNING: t`Import already running`,
IMPORT_CANNOT_CANCEL: t`Cannot cancel import`,
REGISTRATION_CLOSED: t`Registration closed`,
};
}
export function getErrorMessage(
error: unknown,
t: (template: TemplateStringsArray, ...args: unknown[]) => string,
fallback?: string,
): string {
const code = getAppErrorCode(error);
if (code) {
const messages = appErrorMessages(t);
if (code in messages) return messages[code as keyof typeof messages];
}
return fallback ?? t`Something went wrong`;
}
+36
View File
@@ -0,0 +1,36 @@
import {
activateLocale,
SUPPORTED_LOCALES,
type SupportedLocale,
} from "@sofa/i18n";
import * as Localization from "expo-localization";
import { globalStorage } from "./mmkv";
const LOCALE_STORAGE_KEY = "sofa:locale";
export function getPersistedLocale(): SupportedLocale {
const stored = globalStorage.getString(LOCALE_STORAGE_KEY);
if (stored && SUPPORTED_LOCALES.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
const deviceLocales = Localization.getLocales();
for (const loc of deviceLocales) {
const lang = loc.languageCode;
if (lang && SUPPORTED_LOCALES.includes(lang as SupportedLocale)) {
return lang as SupportedLocale;
}
}
return "en";
}
export function setPersistedLocale(locale: SupportedLocale): void {
globalStorage.set(LOCALE_STORAGE_KEY, locale);
}
export function initLocale(): Promise<void> {
const locale = getPersistedLocale();
if (locale !== "en") {
return activateLocale(locale);
}
return Promise.resolve();
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Intl polyfills for Hermes (React Native).
*
* Load order matters — each polyfill depends on the ones before it.
* See: https://formatjs.github.io/docs/guides/react-native-hermes
*
* We use `polyfill-force` to always install the polyfill regardless of
* partial Hermes Intl support, ensuring consistent behavior.
*/
// 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";
import "@formatjs/intl-pluralrules/locale-data/fr";
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";
import "@formatjs/intl-numberformat/locale-data/fr";
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";
import "@formatjs/intl-datetimeformat/locale-data/fr";
import "@formatjs/intl-datetimeformat/locale-data/de";
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";
import "@formatjs/intl-relativetimeformat/locale-data/fr";
import "@formatjs/intl-relativetimeformat/locale-data/de";
import "@formatjs/intl-relativetimeformat/locale-data/es";
import "@formatjs/intl-relativetimeformat/locale-data/it";
import "@formatjs/intl-relativetimeformat/locale-data/pt";
+3 -3
View File
@@ -1,3 +1,5 @@
import { msg } from "@lingui/core/macro";
import { i18n } from "@sofa/i18n";
import { QueryCache, QueryClient } from "@tanstack/react-query"; import { QueryCache, QueryClient } from "@tanstack/react-query";
import { posthog } from "@/lib/posthog"; import { posthog } from "@/lib/posthog";
@@ -28,9 +30,7 @@ export const queryClient = new QueryClient({
// banner is already visible — avoids flooding native toasts. // banner is already visible — avoids flooding native toasts.
if (!getIsReachable() && isNetworkError(error)) return; if (!getIsReachable() && isNetworkError(error)) return;
toast.error("Something went wrong\u2026", { toast.error(i18n._(msg`Something went wrong\u2026`));
description: error.message,
});
posthog?.captureException(error, { source: "react-query" }); posthog?.captureException(error, { source: "react-query" });
}, },
}), }),
+30 -20
View File
@@ -1,3 +1,5 @@
import { msg, plural } from "@lingui/core/macro";
import { i18n } from "@sofa/i18n";
import { client, orpc } from "@/lib/orpc"; import { client, orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
@@ -17,11 +19,13 @@ export const titleActions = {
try { try {
await client.titles.quickAdd({ id }); await client.titles.quickAdd({ id });
toast.success( toast.success(
titleName ? `Added "${titleName}" to watchlist` : "Added to watchlist", titleName
? i18n._(msg`Added "${titleName}" to watchlist`)
: i18n._(msg`Added to watchlist`),
); );
invalidateTitleQueries(); invalidateTitleQueries();
} catch { } catch {
toast.error("Failed to add to watchlist"); toast.error(i18n._(msg`Failed to add to watchlist`));
// Refetch so any optimistic local status reverts on failure // Refetch so any optimistic local status reverts on failure
queryClient.invalidateQueries({ queryKey: orpc.titles.key() }); queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
} }
@@ -30,10 +34,10 @@ export const titleActions = {
async markWatching(id: string) { async markWatching(id: string) {
try { try {
await client.titles.updateStatus({ id, status: "in_progress" }); await client.titles.updateStatus({ id, status: "in_progress" });
toast.success("Marked as watching"); toast.success(i18n._(msg`Marked as watching`));
invalidateTitleQueries(); invalidateTitleQueries();
} catch { } catch {
toast.error("Failed to update status"); toast.error(i18n._(msg`Failed to update status`));
} }
}, },
@@ -42,12 +46,12 @@ export const titleActions = {
await client.titles.updateStatus({ id, status: "completed" }); await client.titles.updateStatus({ id, status: "completed" });
toast.success( toast.success(
titleName titleName
? `Marked "${titleName}" as completed` ? i18n._(msg`Marked "${titleName}" as completed`)
: "Marked as completed", : i18n._(msg`Marked as completed`),
); );
invalidateTitleQueries(); invalidateTitleQueries();
} catch { } catch {
toast.error("Failed to update status"); toast.error(i18n._(msg`Failed to update status`));
} }
}, },
@@ -55,21 +59,23 @@ export const titleActions = {
try { try {
await client.titles.watchMovie({ id }); await client.titles.watchMovie({ id });
toast.success( toast.success(
titleName ? `Marked "${titleName}" as watched` : "Marked as watched", titleName
? i18n._(msg`Marked "${titleName}" as watched`)
: i18n._(msg`Marked as watched`),
); );
invalidateTitleQueries(); invalidateTitleQueries();
} catch { } catch {
toast.error("Failed to mark as watched"); toast.error(i18n._(msg`Failed to mark as watched`));
} }
}, },
async removeFromLibrary(id: string) { async removeFromLibrary(id: string) {
try { try {
await client.titles.updateStatus({ id, status: null }); await client.titles.updateStatus({ id, status: null });
toast.success("Removed from library"); toast.success(i18n._(msg`Removed from library`));
invalidateTitleQueries(); invalidateTitleQueries();
} catch { } catch {
toast.error("Failed to remove from library"); toast.error(i18n._(msg`Failed to remove from library`));
} }
}, },
@@ -78,32 +84,34 @@ export const titleActions = {
await client.titles.updateRating({ id, stars }); await client.titles.updateRating({ id, stars });
toast.success( toast.success(
stars > 0 stars > 0
? `Rated ${stars} star${stars > 1 ? "s" : ""}` ? i18n._(
: "Rating removed", msg`Rated ${plural(stars, { one: "# star", other: "# stars" })}`,
)
: i18n._(msg`Rating removed`),
); );
queryClient.invalidateQueries({ queryKey: orpc.titles.key() }); queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
} catch { } catch {
toast.error("Failed to update rating"); toast.error(i18n._(msg`Failed to update rating`));
} }
}, },
async watchEpisode(id: string) { async watchEpisode(id: string) {
try { try {
await client.episodes.watch({ id }); await client.episodes.watch({ id });
toast.success("Episode watched"); toast.success(i18n._(msg`Episode watched`));
invalidateTitleQueries(); invalidateTitleQueries();
} catch { } catch {
toast.error("Failed to mark episode"); toast.error(i18n._(msg`Failed to mark episode`));
} }
}, },
async unwatchEpisode(id: string) { async unwatchEpisode(id: string) {
try { try {
await client.episodes.unwatch({ id }); await client.episodes.unwatch({ id });
toast.success("Episode unwatched"); toast.success(i18n._(msg`Episode unwatched`));
invalidateTitleQueries(); invalidateTitleQueries();
} catch { } catch {
toast.error("Failed to unmark episode"); toast.error(i18n._(msg`Failed to unmark episode`));
} }
}, },
@@ -111,11 +119,13 @@ export const titleActions = {
try { try {
await client.seasons.watch({ id }); await client.seasons.watch({ id });
toast.success( toast.success(
seasonName ? `Watched all of ${seasonName}` : "Season watched", seasonName
? i18n._(msg`Watched all of ${seasonName}`)
: i18n._(msg`Season watched`),
); );
invalidateTitleQueries(); invalidateTitleQueries();
} catch { } catch {
toast.error("Failed to mark some episodes"); toast.error(i18n._(msg`Failed to mark some episodes`));
} }
}, },
}; };
+17 -4
View File
@@ -1,5 +1,6 @@
import path from "node:path"; import path from "node:path";
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { BACKUP_DIR } from "@sofa/config"; import { BACKUP_DIR } from "@sofa/config";
import { import {
createBackup, createBackup,
@@ -47,9 +48,15 @@ export const backupsDelete = os.admin.backups.delete
if (err instanceof ORPCError) throw err; if (err instanceof ORPCError) throw err;
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("not found")) { if (msg.includes("not found")) {
throw new ORPCError("NOT_FOUND", { message: msg }); throw new ORPCError("NOT_FOUND", {
message: msg,
data: { code: AppErrorCode.BACKUP_NOT_FOUND },
});
} }
throw new ORPCError("BAD_REQUEST", { message: msg }); throw new ORPCError("BAD_REQUEST", {
message: msg,
data: { code: AppErrorCode.BACKUP_DELETE_FAILED },
});
} }
}); });
@@ -71,7 +78,10 @@ export const backupsRestore = os.admin.backups.restore
if (await f.exists()) await f.delete(); if (await f.exists()) await f.delete();
if (err instanceof ORPCError) throw err; if (err instanceof ORPCError) throw err;
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
throw new ORPCError("BAD_REQUEST", { message: msg }); throw new ORPCError("BAD_REQUEST", {
message: msg,
data: { code: AppErrorCode.BACKUP_RESTORE_FAILED },
});
} }
}); });
@@ -160,7 +170,10 @@ export const triggerJob = os.admin.triggerJob
.handler(async ({ input }) => { .handler(async ({ input }) => {
const triggered = await triggerCronJob(input.name); const triggered = await triggerCronJob(input.name);
if (!triggered) { if (!triggered) {
throw new ORPCError("NOT_FOUND", { message: "Job not found" }); throw new ORPCError("NOT_FOUND", {
message: "Job not found",
data: { code: AppErrorCode.JOB_NOT_FOUND },
});
} }
return { ok: true as const }; return { ok: true as const };
}); });
@@ -1,4 +1,5 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata"; import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import { import {
getEpisodeProgressByTitleIds, getEpisodeProgressByTitleIds,
@@ -16,6 +17,7 @@ export const discover = os.discover
if (!isTmdbConfigured()) { if (!isTmdbConfigured()) {
throw new ORPCError("PRECONDITION_FAILED", { throw new ORPCError("PRECONDITION_FAILED", {
message: "TMDB API key is not configured", message: "TMDB API key is not configured",
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
}); });
} }
@@ -1,4 +1,5 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata"; import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import { import {
getEpisodeProgressByTitleIds, getEpisodeProgressByTitleIds,
@@ -14,6 +15,7 @@ function requireTmdb() {
if (!isTmdbConfigured()) { if (!isTmdbConfigured()) {
throw new ORPCError("PRECONDITION_FAILED", { throw new ORPCError("PRECONDITION_FAILED", {
message: "TMDB API key is not configured", message: "TMDB API key is not configured",
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
}); });
} }
} }
+22 -11
View File
@@ -1,3 +1,5 @@
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import type { ParseResult } from "@sofa/core/imports"; import type { ParseResult } from "@sofa/core/imports";
import { import {
countUnresolved, countUnresolved,
@@ -31,9 +33,10 @@ export const parseFile = os.imports.parseFile
try { try {
json = await file.json(); json = await file.json();
} catch { } catch {
throw new Error( throw new ORPCError("BAD_REQUEST", {
"Invalid JSON file. Ensure it is a valid Trakt export.", message: "Invalid JSON file",
); data: { code: AppErrorCode.IMPORT_INVALID_FILE },
});
} }
result = parseTraktPayload( result = parseTraktPayload(
json as Parameters<typeof parseTraktPayload>[0], json as Parameters<typeof parseTraktPayload>[0],
@@ -45,9 +48,10 @@ export const parseFile = os.imports.parseFile
try { try {
json = await file.json(); json = await file.json();
} catch { } catch {
throw new Error( throw new ORPCError("BAD_REQUEST", {
"Invalid JSON file. Ensure it is a valid Simkl export.", message: "Invalid JSON file",
); data: { code: AppErrorCode.IMPORT_INVALID_FILE },
});
} }
result = parseSimklPayload( result = parseSimklPayload(
json as Parameters<typeof parseSimklPayload>[0], json as Parameters<typeof parseSimklPayload>[0],
@@ -99,9 +103,10 @@ export const createJob = os.imports.createJob
data.watchlist.length + data.watchlist.length +
data.ratings.length; data.ratings.length;
if (totalItems > 100_000) { if (totalItems > 100_000) {
throw new Error( throw new ORPCError("BAD_REQUEST", {
`Import payload too large (${totalItems} items, max 100,000)`, message: "Import payload too large",
); data: { code: AppErrorCode.IMPORT_PAYLOAD_TOO_LARGE },
});
} }
// Prevent concurrent imports per user. // Prevent concurrent imports per user.
@@ -137,7 +142,10 @@ export const createJob = os.imports.createJob
.run(); .run();
log.warn(`Auto-cancelled stale import job ${existing.id}`); log.warn(`Auto-cancelled stale import job ${existing.id}`);
} else { } else {
throw new Error("An import is already in progress"); throw new ORPCError("CONFLICT", {
message: "An import is already in progress",
data: { code: AppErrorCode.IMPORT_ALREADY_RUNNING },
});
} }
} }
@@ -175,7 +183,10 @@ export const cancelJob = os.imports.cancelJob
.handler(({ input, context }) => { .handler(({ input, context }) => {
const job = readImportJob(input.id, context.user.id); const job = readImportJob(input.id, context.user.id);
if (job.status !== "pending" && job.status !== "running") { if (job.status !== "pending" && job.status !== "running") {
throw new Error("Can only cancel pending or running jobs"); throw new ORPCError("BAD_REQUEST", {
message: "Can only cancel pending or running jobs",
data: { code: AppErrorCode.IMPORT_CANNOT_CANCEL },
});
} }
db.update(importJobs) db.update(importJobs)
.set({ status: "cancelled" }) .set({ status: "cancelled" })
@@ -1,4 +1,5 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { db } from "@sofa/db/client"; import { db } from "@sofa/db/client";
import { and, desc, eq } from "@sofa/db/helpers"; import { and, desc, eq } from "@sofa/db/helpers";
import { integrationEvents, integrations } from "@sofa/db/schema"; import { integrationEvents, integrations } from "@sofa/db/schema";
@@ -145,7 +146,10 @@ export const regenerateToken = os.integrations.regenerateToken
.get(); .get();
if (!row) { if (!row) {
throw new ORPCError("NOT_FOUND", { message: "Integration not found" }); throw new ORPCError("NOT_FOUND", {
message: "Integration not found",
data: { code: AppErrorCode.INTEGRATION_NOT_FOUND },
});
} }
return serializeIntegration(row); return serializeIntegration(row);
+5 -1
View File
@@ -1,4 +1,5 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { fetchFullFilmography, getOrFetchPerson } from "@sofa/core/person"; import { fetchFullFilmography, getOrFetchPerson } from "@sofa/core/person";
import { getUserStatusesByTitleIds } from "@sofa/core/tracking"; import { getUserStatusesByTitleIds } from "@sofa/core/tracking";
import { os } from "../context"; import { os } from "../context";
@@ -9,7 +10,10 @@ export const detail = os.people.detail
.handler(async ({ input, context }) => { .handler(async ({ input, context }) => {
const person = await getOrFetchPerson(input.id); const person = await getOrFetchPerson(input.id);
if (!person) if (!person)
throw new ORPCError("NOT_FOUND", { message: "Person not found" }); throw new ORPCError("NOT_FOUND", {
message: "Person not found",
data: { code: AppErrorCode.PERSON_NOT_FOUND },
});
const allCredits = await fetchFullFilmography(person.id); const allCredits = await fetchFullFilmography(person.id);
@@ -1,4 +1,5 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata"; import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import { ensureBrowsePersonsExist } from "@sofa/core/person"; import { ensureBrowsePersonsExist } from "@sofa/core/person";
import { import {
@@ -16,6 +17,7 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
if (!isTmdbConfigured()) { if (!isTmdbConfigured()) {
throw new ORPCError("PRECONDITION_FAILED", { throw new ORPCError("PRECONDITION_FAILED", {
message: "TMDB API key is not configured", message: "TMDB API key is not configured",
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
}); });
} }
+9 -2
View File
@@ -1,4 +1,5 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { getRecommendationsForTitle } from "@sofa/core/discovery"; import { getRecommendationsForTitle } from "@sofa/core/discovery";
import { getOrFetchTitle, getOrFetchTitleByTmdbId } from "@sofa/core/metadata"; import { getOrFetchTitle, getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
import { import {
@@ -21,7 +22,10 @@ export const detail = os.titles.detail
.handler(async ({ input }) => { .handler(async ({ input }) => {
const result = await getOrFetchTitle(input.id); const result = await getOrFetchTitle(input.id);
if (!result) if (!result)
throw new ORPCError("NOT_FOUND", { message: "Title not found" }); throw new ORPCError("NOT_FOUND", {
message: "Title not found",
data: { code: AppErrorCode.TITLE_NOT_FOUND },
});
return result; return result;
}); });
@@ -80,7 +84,10 @@ export const quickAdd = os.titles.quickAdd
.where(eq(titles.id, input.id)) .where(eq(titles.id, input.id))
.get(); .get();
if (!title) { if (!title) {
throw new ORPCError("NOT_FOUND", { message: "Title not found" }); throw new ORPCError("NOT_FOUND", {
message: "Title not found",
data: { code: AppErrorCode.TITLE_NOT_FOUND },
});
} }
// Trigger full TMDB import if still a shell // Trigger full TMDB import if still a shell
+4 -1
View File
@@ -16,12 +16,14 @@
"@fontsource-variable/dm-sans": "5.2.8", "@fontsource-variable/dm-sans": "5.2.8",
"@fontsource-variable/geist-mono": "5.2.7", "@fontsource-variable/geist-mono": "5.2.7",
"@fontsource/dm-serif-display": "5.2.8", "@fontsource/dm-serif-display": "5.2.8",
"@lingui/react": "catalog:",
"@orpc/client": "catalog:", "@orpc/client": "catalog:",
"@orpc/contract": "catalog:", "@orpc/contract": "catalog:",
"@orpc/tanstack-query": "catalog:", "@orpc/tanstack-query": "catalog:",
"@player.style/sutro": "0.2.1", "@player.style/sutro": "0.2.1",
"@rolldown/plugin-babel": "0.2.1", "@rolldown/plugin-babel": "0.2.1",
"@sofa/api": "workspace:*", "@sofa/api": "workspace:*",
"@sofa/i18n": "workspace:*",
"@tabler/icons-react": "3.40.0", "@tabler/icons-react": "3.40.0",
"@tanstack/react-hotkeys": "0.4.1", "@tanstack/react-hotkeys": "0.4.1",
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
@@ -30,7 +32,6 @@
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
"cmdk": "1.1.1", "cmdk": "1.1.1",
"date-fns": "catalog:",
"jotai": "2.18.1", "jotai": "2.18.1",
"media-chrome": "4.18.1", "media-chrome": "4.18.1",
"motion": "12.38.0", "motion": "12.38.0",
@@ -45,6 +46,8 @@
"youtube-video-element": "1.9.0" "youtube-video-element": "1.9.0"
}, },
"devDependencies": { "devDependencies": {
"@lingui/babel-plugin-lingui-macro": "catalog:",
"@lingui/vite-plugin": "catalog:",
"@tailwindcss/vite": "0.0.0-insiders.f302fce", "@tailwindcss/vite": "0.0.0-insiders.f302fce",
"@tanstack/devtools-vite": "0.6.0", "@tanstack/devtools-vite": "0.6.0",
"@tanstack/react-devtools": "0.10.0", "@tanstack/react-devtools": "0.10.0",
+35 -18
View File
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { IconKey } from "@tabler/icons-react"; import { IconKey } from "@tabler/icons-react";
import { Link, useNavigate } from "@tanstack/react-router"; import { Link, useNavigate } from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
@@ -105,10 +106,18 @@ export function AuthForm({
<SofaLogo className="size-9" /> <SofaLogo className="size-9" />
</Link> </Link>
<h1 className="text-balance font-medium text-lg"> <h1 className="text-balance font-medium text-lg">
{isRegister ? "Create your account" : "Welcome back"} {isRegister ? (
<Trans>Create your account</Trans>
) : (
<Trans>Welcome back</Trans>
)}
</h1> </h1>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
{isRegister ? "Start tracking your watches" : "Sign in to continue"} {isRegister ? (
<Trans>Start tracking your watches</Trans>
) : (
<Trans>Sign in to continue</Trans>
)}
</p> </p>
</div> </div>
@@ -130,9 +139,13 @@ export function AuthForm({
className="h-11 w-full gap-2 rounded-lg border-border/50 bg-background/50 text-sm hover:bg-accent hover:text-foreground" className="h-11 w-full gap-2 rounded-lg border-border/50 bg-background/50 text-sm hover:bg-accent hover:text-foreground"
> >
<IconKey aria-hidden={true} className="size-4" /> <IconKey aria-hidden={true} className="size-4" />
{oidcLoading {oidcLoading ? (
? "Redirecting…" <Trans>Redirecting</Trans>
: `Sign in with ${authConfig?.oidcProviderName || "SSO"}`} ) : (
<Trans>
Sign in with {authConfig?.oidcProviderName || "SSO"}
</Trans>
)}
</Button> </Button>
</motion.div> </motion.div>
</motion.div> </motion.div>
@@ -141,7 +154,9 @@ export function AuthForm({
{showOidc && showPasswordForm && ( {showOidc && showPasswordForm && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-px flex-1 bg-border/50" /> <div className="h-px flex-1 bg-border/50" />
<span className="text-muted-foreground text-xs">or</span> <span className="text-muted-foreground text-xs">
<Trans>or</Trans>
</span>
<div className="h-px flex-1 bg-border/50" /> <div className="h-px flex-1 bg-border/50" />
</div> </div>
)} )}
@@ -163,7 +178,7 @@ export function AuthForm({
htmlFor="name" htmlFor="name"
className="text-muted-foreground uppercase tracking-wider" className="text-muted-foreground uppercase tracking-wider"
> >
Name <Trans>Name</Trans>
</Label> </Label>
<Input <Input
id="name" id="name"
@@ -183,7 +198,7 @@ export function AuthForm({
htmlFor="email" htmlFor="email"
className="text-muted-foreground uppercase tracking-wider" className="text-muted-foreground uppercase tracking-wider"
> >
Email <Trans>Email</Trans>
</Label> </Label>
<Input <Input
id="email" id="email"
@@ -203,7 +218,7 @@ export function AuthForm({
htmlFor="password" htmlFor="password"
className="text-muted-foreground uppercase tracking-wider" className="text-muted-foreground uppercase tracking-wider"
> >
Password <Trans>Password</Trans>
</Label> </Label>
<Input <Input
id="password" id="password"
@@ -226,11 +241,13 @@ export function AuthForm({
disabled={loading} disabled={loading}
className="h-11 w-full rounded-lg text-sm hover:shadow-lg hover:shadow-primary/20" className="h-11 w-full rounded-lg text-sm hover:shadow-lg hover:shadow-primary/20"
> >
{loading {loading ? (
? "Loading…" <Trans>Loading</Trans>
: isRegister ) : isRegister ? (
? "Create account" <Trans>Create account</Trans>
: "Sign in"} ) : (
<Trans>Sign in</Trans>
)}
</Button> </Button>
</motion.div> </motion.div>
</motion.form> </motion.form>
@@ -258,22 +275,22 @@ export function AuthForm({
<p className="text-center text-muted-foreground text-sm"> <p className="text-center text-muted-foreground text-sm">
{isRegister ? ( {isRegister ? (
<> <>
Already have an account?{" "} <Trans>Already have an account?</Trans>{" "}
<Link <Link
to="/login" to="/login"
className="font-medium text-primary transition-colors hover:text-primary/80" className="font-medium text-primary transition-colors hover:text-primary/80"
> >
Sign in <Trans>Sign in</Trans>
</Link> </Link>
</> </>
) : ( ) : (
<> <>
Don&apos;t have an account?{" "} <Trans>Don&apos;t have an account?</Trans>{" "}
<Link <Link
to="/register" to="/register"
className="font-medium text-primary transition-colors hover:text-primary/80" className="font-medium text-primary transition-colors hover:text-primary/80"
> >
Register <Trans>Register</Trans>
</Link> </Link>
</> </>
)} )}
+19 -11
View File
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { import {
IconDeviceTv, IconDeviceTv,
IconHome, IconHome,
@@ -81,6 +82,7 @@ interface SearchResult {
} }
export function CommandPalette() { export function CommandPalette() {
const { t } = useLingui();
const navigate = useNavigate(); const navigate = useNavigate();
const progress = useProgress(); const progress = useProgress();
const [commandPaletteOpen, setCommandPaletteOpen] = useAtom( const [commandPaletteOpen, setCommandPaletteOpen] = useAtom(
@@ -181,7 +183,7 @@ export function CommandPalette() {
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
<DialogTitle>Command Palette</DialogTitle> <DialogTitle>Command Palette</DialogTitle>
<DialogDescription> <DialogDescription>
Search for movies, TV shows, or run commands <Trans>Search for movies, TV shows, or run commands</Trans>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogContent <DialogContent
@@ -190,7 +192,7 @@ export function CommandPalette() {
> >
<Command shouldFilter={false}> <Command shouldFilter={false}>
<CommandInput <CommandInput
placeholder="Search movies & TV shows…" placeholder={t`Search movies & TV shows…`}
value={query} value={query}
onValueChange={setQuery} onValueChange={setQuery}
/> />
@@ -211,11 +213,13 @@ export function CommandPalette() {
)} )}
{hasQuery && !loading && results.length === 0 && ( {hasQuery && !loading && results.length === 0 && (
<CommandEmpty>No results found.</CommandEmpty> <CommandEmpty>
<Trans>No results found.</Trans>
</CommandEmpty>
)} )}
{hasQuery && !loading && results.length > 0 && ( {hasQuery && !loading && results.length > 0 && (
<CommandGroup heading="Results"> <CommandGroup heading={t`Results`}>
{results.map((r) => ( {results.map((r) => (
<CommandItem <CommandItem
key={r.id ?? `${r.type}-${r.tmdbId}`} key={r.id ?? `${r.type}-${r.tmdbId}`}
@@ -307,13 +311,15 @@ export function CommandPalette() {
<CommandGroup <CommandGroup
heading={ heading={
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>Recent Searches</span> <span>
<Trans>Recent Searches</Trans>
</span>
<button <button
type="button" type="button"
onClick={handleClearRecent} onClick={handleClearRecent}
className="font-normal text-[10px] text-muted-foreground transition-colors hover:text-foreground" className="font-normal text-[10px] text-muted-foreground transition-colors hover:text-foreground"
> >
Clear all <Trans>Clear all</Trans>
</button> </button>
</div> </div>
} }
@@ -350,7 +356,7 @@ export function CommandPalette() {
</CommandGroup> </CommandGroup>
)} )}
{recentSearches.length > 0 && <CommandSeparator />} {recentSearches.length > 0 && <CommandSeparator />}
<CommandGroup heading="Quick Actions"> <CommandGroup heading={t`Quick Actions`}>
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
setCommandPaletteOpen(false); setCommandPaletteOpen(false);
@@ -359,7 +365,7 @@ export function CommandPalette() {
}} }}
> >
<IconHome aria-hidden={true} className="size-3.5" /> <IconHome aria-hidden={true} className="size-3.5" />
Go to Dashboard <Trans>Go to Dashboard</Trans>
<CommandShortcut>G H</CommandShortcut> <CommandShortcut>G H</CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem <CommandItem
@@ -370,7 +376,7 @@ export function CommandPalette() {
}} }}
> >
<IconSearch aria-hidden={true} className="size-3.5" /> <IconSearch aria-hidden={true} className="size-3.5" />
Go to Explore <Trans>Go to Explore</Trans>
<CommandShortcut>G E</CommandShortcut> <CommandShortcut>G E</CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem <CommandItem
@@ -380,7 +386,7 @@ export function CommandPalette() {
}} }}
> >
<IconKeyboard aria-hidden={true} className="size-3.5" /> <IconKeyboard aria-hidden={true} className="size-3.5" />
Keyboard Shortcuts <Trans>Keyboard Shortcuts</Trans>
<CommandShortcut>?</CommandShortcut> <CommandShortcut>?</CommandShortcut>
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
@@ -394,7 +400,9 @@ export function CommandPalette() {
<Dialog open={helpOpen} onOpenChange={setHelpOpen}> <Dialog open={helpOpen} onOpenChange={setHelpOpen}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Keyboard Shortcuts</DialogTitle> <DialogTitle>
<Trans>Keyboard Shortcuts</Trans>
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-5 py-2"> <div className="space-y-5 py-2">
{Object.entries(groupedShortcuts).map(([scope, items]) => ( {Object.entries(groupedShortcuts).map(([scope, items]) => (
@@ -1,3 +1,5 @@
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { IconPlayerPlay } from "@tabler/icons-react"; import { IconPlayerPlay } from "@tabler/icons-react";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
@@ -26,6 +28,7 @@ export function ContinueWatchingCard({
}: { }: {
item: ContinueWatchingItemProps; item: ContinueWatchingItemProps;
}) { }) {
const { t } = useLingui();
const stillUrl = const stillUrl =
item.nextEpisode?.stillPath ?? item.title.backdropPath ?? null; item.nextEpisode?.stillPath ?? item.title.backdropPath ?? null;
const progress = const progress =
@@ -71,7 +74,7 @@ export function ContinueWatchingCard({
<div className="absolute right-3 bottom-2.5 left-3"> <div className="absolute right-3 bottom-2.5 left-3">
<p className="flex items-center gap-1.5 font-medium text-[10px] text-primary uppercase tracking-wider"> <p className="flex items-center gap-1.5 font-medium text-[10px] text-primary uppercase tracking-wider">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary motion-safe:animate-pulse" /> <span className="inline-block h-1.5 w-1.5 rounded-full bg-primary motion-safe:animate-pulse" />
Up next {t`Up next`}
</p> </p>
<p className="mt-0.5 truncate font-medium text-sm text-white"> <p className="mt-0.5 truncate font-medium text-sm text-white">
<span className="mr-0.5 font-mono text-white/60 text-xs [word-spacing:-0.25em]"> <span className="mr-0.5 font-mono text-white/60 text-xs [word-spacing:-0.25em]">
@@ -87,7 +90,7 @@ export function ContinueWatchingCard({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{item.title.title}</p> <p className="truncate font-medium text-sm">{item.title.title}</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{item.watchedEpisodes}/{item.totalEpisodes} episodes {t`${item.watchedEpisodes}/${item.totalEpisodes} ${plural(item.totalEpisodes, { one: "episode", other: "episodes" })}`}
</p> </p>
</div> </div>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { IconPlayerPlay } from "@tabler/icons-react"; import { IconPlayerPlay } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc/client"; import { orpc } from "@/lib/orpc/client";
@@ -12,6 +13,8 @@ export function ContinueWatchingSection() {
orpc.dashboard.continueWatching.queryOptions(), orpc.dashboard.continueWatching.queryOptions(),
); );
const { t } = useLingui();
if (isPending) return <ContinueWatchingSectionSkeleton />; if (isPending) return <ContinueWatchingSectionSkeleton />;
const items = data?.items ?? []; const items = data?.items ?? [];
@@ -19,7 +22,7 @@ export function ContinueWatchingSection() {
return ( return (
<FeedSection <FeedSection
title="Continue Watching" title={t`Continue Watching`}
icon={<IconPlayerPlay className="size-5 text-primary" />} icon={<IconPlayerPlay className="size-5 text-primary" />}
> >
<ContinueWatchingList items={items} /> <ContinueWatchingList items={items} />
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { IconBooks } from "@tabler/icons-react"; import { IconBooks } from "@tabler/icons-react";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
@@ -16,6 +17,8 @@ export function LibrarySection() {
}), }),
); );
const { t } = useLingui();
const sentinelRef = useInfiniteScroll({ const sentinelRef = useInfiniteScroll({
fetchNextPage, fetchNextPage,
hasNextPage, hasNextPage,
@@ -29,7 +32,7 @@ export function LibrarySection() {
return ( return (
<FeedSection <FeedSection
title="In Your Library" title={t`In Your Library`}
icon={<IconBooks className="size-5 text-primary" />} icon={<IconBooks className="size-5 text-primary" />}
> >
<TitleGrid items={items} /> <TitleGrid items={items} />
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { IconThumbUp } from "@tabler/icons-react"; import { IconThumbUp } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc/client"; import { orpc } from "@/lib/orpc/client";
@@ -9,6 +10,8 @@ export function RecommendationsSection() {
orpc.dashboard.recommendations.queryOptions(), orpc.dashboard.recommendations.queryOptions(),
); );
const { t } = useLingui();
if (isPending) return <TitleGridSectionSkeleton />; if (isPending) return <TitleGridSectionSkeleton />;
const items = data?.items ?? []; const items = data?.items ?? [];
@@ -16,7 +19,7 @@ export function RecommendationsSection() {
return ( return (
<FeedSection <FeedSection
title="Recommended for You" title={t`Recommended for You`}
icon={<IconThumbUp className="size-5 text-primary" />} icon={<IconThumbUp className="size-5 text-primary" />}
> >
<TitleGrid items={items} /> <TitleGrid items={items} />
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { import type {
DashboardStats, DashboardStats,
HistoryBucket, HistoryBucket,
@@ -45,13 +46,6 @@ export function StatsSectionSkeleton() {
); );
} }
const periodLabels: Record<TimePeriod, string> = {
today: "Today",
this_week: "This Week",
this_month: "This Month",
this_year: "This Year",
};
const periods: TimePeriod[] = ["today", "this_week", "this_month", "this_year"]; const periods: TimePeriod[] = ["today", "this_week", "this_month", "this_year"];
interface StatCardProps { interface StatCardProps {
@@ -103,47 +97,78 @@ function StatCard({
const inlineTriggerClass = const inlineTriggerClass =
"h-auto w-auto gap-0.5 rounded-none border-0 bg-transparent py-1 px-0.5 -my-1 -mx-0.5 sm:p-0 sm:m-0 [font-size:inherit] [line-height:inherit] shadow-none underline decoration-dotted decoration-muted-foreground/50 underline-offset-4 hover:bg-transparent hover:text-foreground hover:decoration-foreground/50 focus-visible:ring-0 focus-visible:decoration-solid focus-visible:decoration-foreground dark:bg-transparent dark:hover:bg-transparent"; "h-auto w-auto gap-0.5 rounded-none border-0 bg-transparent py-1 px-0.5 -my-1 -mx-0.5 sm:p-0 sm:m-0 [font-size:inherit] [line-height:inherit] shadow-none underline decoration-dotted decoration-muted-foreground/50 underline-offset-4 hover:bg-transparent hover:text-foreground hover:decoration-foreground/50 focus-visible:ring-0 focus-visible:decoration-solid focus-visible:decoration-foreground dark:bg-transparent dark:hover:bg-transparent";
function PeriodSelect({
period,
onPeriodChange,
periodLabels,
}: {
period: TimePeriod;
onPeriodChange: (period: TimePeriod) => void;
periodLabels: Record<TimePeriod, string>;
}) {
return (
<Select
value={period}
onValueChange={(v) => v && onPeriodChange(v as TimePeriod)}
modal={false}
>
<SelectTrigger
className={`${inlineTriggerClass} text-foreground/80 uppercase`}
>
<SelectValue>
{(value: TimePeriod | null) => (value ? periodLabels[value] : null)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" alignItemWithTrigger={false} className="p-1">
{periods.map((p) => (
<SelectItem key={p} value={p}>
{periodLabels[p]}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function PeriodSelector({ function PeriodSelector({
noun, type,
period, period,
onPeriodChange, onPeriodChange,
}: { }: {
noun: string; type: "movies" | "episodes";
period: TimePeriod; period: TimePeriod;
onPeriodChange: (period: TimePeriod) => void; onPeriodChange: (period: TimePeriod) => void;
}) { }) {
const { t } = useLingui();
const periodLabels: Record<TimePeriod, string> = {
today: t`Today`,
this_week: t`This Week`,
this_month: t`This Month`,
this_year: t`This Year`,
};
const select = (
<PeriodSelect
key="select"
period={period}
onPeriodChange={onPeriodChange}
periodLabels={periodLabels}
/>
);
return ( return (
<span className="inline-flex items-baseline gap-1"> <span className="inline-flex items-baseline gap-1">
{noun}{" "} {type === "movies" ? (
<Select <Trans>Movies {select}</Trans>
value={period} ) : (
onValueChange={(v) => v && onPeriodChange(v as TimePeriod)} <Trans>Episodes {select}</Trans>
modal={false} )}
>
<SelectTrigger
className={`${inlineTriggerClass} text-foreground/80 uppercase`}
>
<SelectValue>
{(value: TimePeriod | null) => (value ? periodLabels[value] : null)}
</SelectValue>
</SelectTrigger>
<SelectContent
align="start"
alignItemWithTrigger={false}
className="p-1"
>
{periods.map((p) => (
<SelectItem key={p} value={p}>
{periodLabels[p]}
</SelectItem>
))}
</SelectContent>
</Select>
</span> </span>
); );
} }
export function StatsDisplay({ stats }: { stats: DashboardStats }) { export function StatsDisplay({ stats }: { stats: DashboardStats }) {
const { t } = useLingui();
const [moviePeriod, setMoviePeriod] = useState<TimePeriod>("this_month"); const [moviePeriod, setMoviePeriod] = useState<TimePeriod>("this_month");
const [episodePeriod, setEpisodePeriod] = useState<TimePeriod>("this_week"); const [episodePeriod, setEpisodePeriod] = useState<TimePeriod>("this_week");
@@ -175,7 +200,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
sparklineData={movieHistory} sparklineData={movieHistory}
label={ label={
<PeriodSelector <PeriodSelector
noun="Movies" type="movies"
period={moviePeriod} period={moviePeriod}
onPeriodChange={setMoviePeriod} onPeriodChange={setMoviePeriod}
/> />
@@ -190,7 +215,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
sparklineData={episodeHistory} sparklineData={episodeHistory}
label={ label={
<PeriodSelector <PeriodSelector
noun="Episodes" type="episodes"
period={episodePeriod} period={episodePeriod}
onPeriodChange={setEpisodePeriod} onPeriodChange={setEpisodePeriod}
/> />
@@ -202,7 +227,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
bgColor="bg-status-watchlist/10" bgColor="bg-status-watchlist/10"
value={stats.librarySize} value={stats.librarySize}
index={2} index={2}
label="In Library" label={t`In Library`}
/> />
<StatCard <StatCard
icon={IconCheck} icon={IconCheck}
@@ -210,7 +235,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
bgColor="bg-status-completed/10" bgColor="bg-status-completed/10"
value={stats.completed} value={stats.completed}
index={3} index={3}
label="Completed" label={t`Completed`}
/> />
</div> </div>
); );
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { IconDeviceTv } from "@tabler/icons-react"; import { IconDeviceTv } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
@@ -27,16 +28,18 @@ export function StatsSection() {
<IconDeviceTv aria-hidden={true} className="size-8 text-primary" /> <IconDeviceTv aria-hidden={true} className="size-8 text-primary" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="font-medium">Your library is empty</p> <p className="font-medium">
<Trans>Your library is empty</Trans>
</p>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Search for movies and TV shows to start tracking <Trans>Search for movies and TV shows to start tracking</Trans>
</p> </p>
</div> </div>
<Link <Link
to="/explore" to="/explore"
className="inline-flex h-9 items-center rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20" className="inline-flex h-9 items-center rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20"
> >
Start exploring <Trans>Start exploring</Trans>
</Link> </Link>
</div> </div>
)} )}
@@ -1,11 +1,17 @@
import { Trans } from "@lingui/react/macro";
export function WelcomeHeader({ name }: { name?: string | null }) { export function WelcomeHeader({ name }: { name?: string | null }) {
return ( return (
<div> <div>
<h1 className="text-balance font-display text-3xl tracking-tight"> <h1 className="text-balance font-display text-3xl tracking-tight">
Welcome back{name ? `, ${name}` : ""} {name ? (
<Trans>Welcome back, {name}</Trans>
) : (
<Trans>Welcome back</Trans>
)}
</h1> </h1>
<p className="mt-1 text-muted-foreground text-sm"> <p className="mt-1 text-muted-foreground text-sm">
Here&apos;s what&apos;s happening with your library <Trans>Here&apos;s what&apos;s happening with your library</Trans>
</p> </p>
</div> </div>
); );
+2 -1
View File
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -47,7 +48,7 @@ export function ExpandableText({
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
className="mt-1 font-medium text-primary text-xs transition-colors hover:text-primary/80" className="mt-1 font-medium text-primary text-xs transition-colors hover:text-primary/80"
> >
{expanded ? "Show less" : "Read more"} {expanded ? <Trans>Show less</Trans> : <Trans>Read more</Trans>}
</button> </button>
)} )}
</div> </div>
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react"; import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useMemo } from "react"; import { useMemo } from "react";
@@ -41,6 +42,7 @@ function mergeMaps<T>(
} }
export function ExploreClient() { export function ExploreClient() {
const { t } = useLingui();
const { const {
data: trendingData, data: trendingData,
isPending: trendingPending, isPending: trendingPending,
@@ -107,7 +109,7 @@ export function ExploreClient() {
<div> <div>
<TitleRow <TitleRow
heading="Trending Today" heading={t`Trending Today`}
icon={ icon={
<IconFlame aria-hidden={true} className="size-5 text-primary" /> <IconFlame aria-hidden={true} className="size-5 text-primary" />
} }
@@ -121,7 +123,7 @@ export function ExploreClient() {
</div> </div>
<FilterableTitleRow <FilterableTitleRow
heading="Popular Movies" heading={t`Popular Movies`}
icon={<IconMovie aria-hidden={true} className="size-5 text-primary" />} icon={<IconMovie aria-hidden={true} className="size-5 text-primary" />}
mediaType="movie" mediaType="movie"
defaultItems={(popularMoviesData?.items ?? []).slice(0, 20)} defaultItems={(popularMoviesData?.items ?? []).slice(0, 20)}
@@ -131,7 +133,7 @@ export function ExploreClient() {
/> />
<FilterableTitleRow <FilterableTitleRow
heading="Popular TV Shows" heading={t`Popular TV Shows`}
icon={ icon={
<IconDeviceTv aria-hidden={true} className="size-5 text-primary" /> <IconDeviceTv aria-hidden={true} className="size-5 text-primary" />
} }
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { skipToken, useInfiniteQuery } from "@tanstack/react-query"; import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { TitleCard, TitleCardSkeleton } from "@/components/title-card"; import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
@@ -147,7 +148,7 @@ export function FilterableTitleRow({
{/* Empty state */} {/* Empty state */}
{!isLoading && selectedGenre !== null && items.length === 0 && ( {!isLoading && selectedGenre !== null && items.length === 0 && (
<p className="py-8 text-center text-muted-foreground text-sm"> <p className="py-8 text-center text-muted-foreground text-sm">
No titles found for this genre. <Trans>No titles found for this genre.</Trans>
</p> </p>
)} )}
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { import {
IconDeviceTv, IconDeviceTv,
IconMovie, IconMovie,
@@ -55,12 +56,12 @@ export function HeroBanner({
{type === "movie" ? ( {type === "movie" ? (
<> <>
<IconMovie aria-hidden className="size-3.5" /> <IconMovie aria-hidden className="size-3.5" />
Movie <Trans>Movie</Trans>
</> </>
) : ( ) : (
<> <>
<IconDeviceTv aria-hidden className="size-3.5" /> <IconDeviceTv aria-hidden className="size-3.5" />
TV <Trans>TV</Trans>
</> </>
)} )}
</span> </span>
@@ -74,7 +75,7 @@ export function HeroBanner({
</span> </span>
)} )}
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
Trending today <Trans>Trending today</Trans>
</span> </span>
</div> </div>
<Link <Link
@@ -95,7 +96,7 @@ export function HeroBanner({
className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20" className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20"
> >
<IconPlus aria-hidden={true} className="size-4" /> <IconPlus aria-hidden={true} className="size-4" />
Add to Library <Trans>Add to Library</Trans>
</Link> </Link>
</div> </div>
</div> </div>
+14 -7
View File
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { SofaLogo } from "@/components/sofa-logo"; import { SofaLogo } from "@/components/sofa-logo";
@@ -101,7 +102,7 @@ export function LandingPage({
damping: 20, damping: 20,
}} }}
> >
Self-hosted movie & TV tracker <Trans>Self-hosted movie & TV tracker</Trans>
</motion.p> </motion.p>
<motion.div <motion.div
className="flex justify-center text-primary" className="flex justify-center text-primary"
@@ -127,9 +128,11 @@ export function LandingPage({
delay: 0.2, delay: 0.2,
}} }}
> >
Track what you watch. Know what&apos;s next. <Trans>
<br /> Track what you watch. Know what&apos;s next.
Your library, your data, your rules. <br />
Your library, your data, your rules.
</Trans>
</motion.p> </motion.p>
</div> </div>
@@ -149,7 +152,9 @@ export function LandingPage({
to="/register" to="/register"
className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20" className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20"
> >
<span className="relative z-10">Get Started</span> <span className="relative z-10">
<Trans>Get Started</Trans>
</span>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" /> <div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
</Link> </Link>
) : ( ) : (
@@ -158,7 +163,9 @@ export function LandingPage({
to="/login" to="/login"
className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20" className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20"
> >
<span className="relative z-10">Sign In</span> <span className="relative z-10">
<Trans>Sign In</Trans>
</span>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" /> <div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
</Link> </Link>
{registrationOpen && ( {registrationOpen && (
@@ -166,7 +173,7 @@ export function LandingPage({
to="/register" to="/register"
className="inline-flex h-12 items-center justify-center rounded-lg border border-border px-8 font-medium transition-colors hover:border-primary/40 hover:bg-primary/5" className="inline-flex h-12 items-center justify-center rounded-lg border border-border px-8 font-medium transition-colors hover:border-primary/40 hover:bg-primary/5"
> >
Register <Trans>Register</Trans>
</Link> </Link>
)} )}
</> </>
+20 -15
View File
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { import {
IconCompass, IconCompass,
IconHome, IconHome,
@@ -35,16 +36,7 @@ const springTransition = {
damping: 30, damping: 30,
} as const; } as const;
const navLinks = [ // navLinks and mobileTabs moved inside components for LingUI
{ href: "/dashboard", label: "Home" },
{ href: "/explore", label: "Explore" },
] as const;
const mobileTabs = [
{ href: "/dashboard", label: "Home", icon: IconHome },
{ href: "/explore", label: "Explore", icon: IconCompass },
{ href: "/settings", label: "Settings", icon: IconSettings },
] as const;
function isLinkActive(pathname: string, href: string) { function isLinkActive(pathname: string, href: string) {
return ( return (
@@ -124,10 +116,16 @@ export function NavBar({
userImage?: string; userImage?: string;
userRole?: string; userRole?: string;
}) { }) {
const { t } = useLingui();
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname } = useLocation(); const { pathname } = useLocation();
const setCommandPaletteOpen = useSetAtom(commandPaletteOpenAtom); const setCommandPaletteOpen = useSetAtom(commandPaletteOpenAtom);
const navLinks = [
{ href: "/dashboard", label: t`Home` },
{ href: "/explore", label: t`Explore` },
] as const;
const initial = userName?.charAt(0).toUpperCase() ?? "?"; const initial = userName?.charAt(0).toUpperCase() ?? "?";
const activeIndex = navLinks.findIndex((link) => const activeIndex = navLinks.findIndex((link) =>
@@ -192,7 +190,7 @@ export function NavBar({
className="flex flex-1 items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-[13px] text-muted-foreground transition-colors hover:border-primary/20 hover:bg-card sm:hidden" className="flex flex-1 items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-[13px] text-muted-foreground transition-colors hover:border-primary/20 hover:bg-card sm:hidden"
> >
<IconSearch aria-hidden={true} className="size-3.5" /> <IconSearch aria-hidden={true} className="size-3.5" />
<span>Search</span> <span>{t`Search…`}</span>
</button> </button>
{/* Desktop search trigger pill */} {/* Desktop search trigger pill */}
<button <button
@@ -201,7 +199,7 @@ export function NavBar({
className="hidden items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-[13px] text-muted-foreground transition-colors hover:border-primary/20 hover:bg-card sm:inline-flex" className="hidden items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-[13px] text-muted-foreground transition-colors hover:border-primary/20 hover:bg-card sm:inline-flex"
> >
<IconSearch aria-hidden={true} className="size-3.5" /> <IconSearch aria-hidden={true} className="size-3.5" />
<span>Search</span> <span>{t`Search…`}</span>
<Kbd className="ml-2.5"> K</Kbd> <Kbd className="ml-2.5"> K</Kbd>
</button> </button>
<Separator <Separator
@@ -234,7 +232,7 @@ export function NavBar({
{userName} {userName}
{userRole === "admin" && ( {userRole === "admin" && (
<Badge className="mb-0.5 ml-1.5 rounded-md border-0 bg-primary/10 align-middle text-primary"> <Badge className="mb-0.5 ml-1.5 rounded-md border-0 bg-primary/10 align-middle text-primary">
Admin <Trans>Admin</Trans>
</Badge> </Badge>
)} )}
</p> </p>
@@ -249,7 +247,7 @@ export function NavBar({
className="cursor-pointer text-[13px]" className="cursor-pointer text-[13px]"
> >
<IconSettings className="size-3.5" /> <IconSettings className="size-3.5" />
Settings <Trans>Settings</Trans>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
@@ -261,7 +259,7 @@ export function NavBar({
className="cursor-pointer text-[13px]" className="cursor-pointer text-[13px]"
> >
<IconLogout className="size-3.5" /> <IconLogout className="size-3.5" />
Sign out <Trans>Sign out</Trans>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -285,8 +283,15 @@ export function NavBar({
} }
export function MobileTabBar() { export function MobileTabBar() {
const { t } = useLingui();
const { pathname } = useLocation(); const { pathname } = useLocation();
const mobileTabs = [
{ href: "/dashboard", label: t`Home`, icon: IconHome },
{ href: "/explore", label: t`Explore`, icon: IconCompass },
{ href: "/settings", label: t`Settings`, icon: IconSettings },
] as const;
const activeIndex = mobileTabs.findIndex((tab) => const activeIndex = mobileTabs.findIndex((tab) =>
isLinkActive(pathname, tab.href), isLinkActive(pathname, tab.href),
); );
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { PersonCredit } from "@sofa/api/schemas"; import type { PersonCredit } from "@sofa/api/schemas";
import { IconMovie } from "@tabler/icons-react"; import { IconMovie } from "@tabler/icons-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
@@ -23,6 +24,7 @@ export function FilmographyGrid({
credits, credits,
userStatuses, userStatuses,
}: FilmographyGridProps) { }: FilmographyGridProps) {
const { t } = useLingui();
const [filter, setFilter] = useState<Filter>("all"); const [filter, setFilter] = useState<Filter>("all");
const [sort, setSort] = useState<Sort>("newest"); const [sort, setSort] = useState<Sort>("newest");
@@ -53,9 +55,9 @@ export function FilmographyGrid({
if (credits.length === 0) return null; if (credits.length === 0) return null;
const filters: { value: Filter; label: string }[] = [ const filters: { value: Filter; label: string }[] = [
{ value: "all", label: "All" }, { value: "all", label: t`All` },
{ value: "movie", label: "Movies" }, { value: "movie", label: t`Movies` },
{ value: "tv", label: "TV" }, { value: "tv", label: t`TV` },
]; ];
return ( return (
@@ -64,7 +66,7 @@ export function FilmographyGrid({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<IconMovie aria-hidden={true} className="size-5 text-primary" /> <IconMovie aria-hidden={true} className="size-5 text-primary" />
<h2 className="text-balance font-display text-xl tracking-tight"> <h2 className="text-balance font-display text-xl tracking-tight">
Filmography <Trans>Filmography</Trans>
</h2> </h2>
<span className="text-muted-foreground text-sm"> <span className="text-muted-foreground text-sm">
({filtered.length}) ({filtered.length})
@@ -81,9 +83,9 @@ export function FilmographyGrid({
<SelectValue> <SelectValue>
{(value: string | null) => {(value: string | null) =>
value === "newest" value === "newest"
? "Newest" ? t`Newest`
: value === "rating" : value === "rating"
? "Rating" ? t`Rating`
: null : null
} }
</SelectValue> </SelectValue>
@@ -93,8 +95,12 @@ export function FilmographyGrid({
alignItemWithTrigger={false} alignItemWithTrigger={false}
className="p-1" className="p-1"
> >
<SelectItem value="newest">Newest</SelectItem> <SelectItem value="newest">
<SelectItem value="rating">Rating</SelectItem> <Trans>Newest</Trans>
</SelectItem>
<SelectItem value="rating">
<Trans>Rating</Trans>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
+10 -8
View File
@@ -1,6 +1,7 @@
import { useLingui } from "@lingui/react/macro";
import type { ResolvedPerson } from "@sofa/api/schemas"; import type { ResolvedPerson } from "@sofa/api/schemas";
import { formatDate } from "@sofa/i18n/format";
import { IconCalendar, IconMapPin } from "@tabler/icons-react"; import { IconCalendar, IconMapPin } from "@tabler/icons-react";
import { format, parseISO } from "date-fns";
import { ExpandableText } from "@/components/expandable-text"; import { ExpandableText } from "@/components/expandable-text";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -22,6 +23,7 @@ function calculateAge(birthday: string, deathday?: string | null): number {
} }
export function PersonHero({ person }: PersonHeroProps) { export function PersonHero({ person }: PersonHeroProps) {
const { t } = useLingui();
const age = person.birthday const age = person.birthday
? calculateAge(person.birthday, person.deathday) ? calculateAge(person.birthday, person.deathday)
: null; : null;
@@ -66,15 +68,15 @@ export function PersonHero({ person }: PersonHeroProps) {
{person.knownForDepartment && ( {person.knownForDepartment && (
<Badge className="border-0 bg-primary/10 px-2.5 font-semibold text-primary uppercase tracking-wider"> <Badge className="border-0 bg-primary/10 px-2.5 font-semibold text-primary uppercase tracking-wider">
{person.knownForDepartment === "Acting" {person.knownForDepartment === "Acting"
? "Actor" ? t`Actor`
: person.knownForDepartment === "Directing" : person.knownForDepartment === "Directing"
? "Director" ? t`Director`
: person.knownForDepartment === "Writing" : person.knownForDepartment === "Writing"
? "Writer" ? t`Writer`
: person.knownForDepartment === "Production" : person.knownForDepartment === "Production"
? "Producer" ? t`Producer`
: person.knownForDepartment === "Editing" : person.knownForDepartment === "Editing"
? "Editor" ? t`Editor`
: person.knownForDepartment} : person.knownForDepartment}
</Badge> </Badge>
)} )}
@@ -83,10 +85,10 @@ export function PersonHero({ person }: PersonHeroProps) {
{person.birthday && ( {person.birthday && (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<IconCalendar aria-hidden={true} className="size-3.5" /> <IconCalendar aria-hidden={true} className="size-3.5" />
{format(parseISO(person.birthday), "MMMM d, yyyy")} {formatDate(person.birthday)}
{age !== null && ( {age !== null && (
<span className="text-muted-foreground/60"> <span className="text-muted-foreground/60">
({person.deathday ? `died at ${age}` : `age ${age}`}) ({person.deathday ? t`died at ${age}` : t`age ${age}`})
</span> </span>
)} )}
</span> </span>
@@ -1,3 +1,5 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { formatDate } from "@sofa/i18n/format";
import { import {
IconAlertTriangle, IconAlertTriangle,
IconCamera, IconCamera,
@@ -43,6 +45,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { authClient, signOut } from "@/lib/auth/client"; import { authClient, signOut } from "@/lib/auth/client";
import { getErrorMessage } from "@/lib/error-messages";
import { orpc } from "@/lib/orpc/client"; import { orpc } from "@/lib/orpc/client";
export function AccountSection({ export function AccountSection({
@@ -56,6 +59,7 @@ export function AccountSection({
role?: string; role?: string;
}; };
}) { }) {
const { t } = useLingui();
const navigate = useNavigate(); const navigate = useNavigate();
const router = useRouter(); const router = useRouter();
const [avatarUrl, setAvatarUrl] = useState(user.image); const [avatarUrl, setAvatarUrl] = useState(user.image);
@@ -73,12 +77,11 @@ export function AccountSection({
const trimmed = editValue.trim(); const trimmed = editValue.trim();
setDisplayName(trimmed); setDisplayName(trimmed);
setIsEditingName(false); setIsEditingName(false);
toast.success("Name updated"); toast.success(t`Name updated`);
router.invalidate(); router.invalidate();
}, },
onError: (err) => { onError: (err) => {
const message = err instanceof Error ? err.message : "Update failed"; toast.error(getErrorMessage(err, t, t`Update failed`));
toast.error(message);
}, },
}), }),
); );
@@ -91,9 +94,10 @@ export function AccountSection({
} }
}, [isEditingName]); }, [isEditingName]);
const memberSince = new Date(user.createdAt).toLocaleDateString(undefined, { const memberSince = formatDate(user.createdAt, {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: undefined,
}); });
const initial = displayName?.charAt(0).toUpperCase() ?? "?"; const initial = displayName?.charAt(0).toUpperCase() ?? "?";
@@ -101,12 +105,11 @@ export function AccountSection({
orpc.account.uploadAvatar.mutationOptions({ orpc.account.uploadAvatar.mutationOptions({
onSuccess: (data) => { onSuccess: (data) => {
setAvatarUrl(data.imageUrl); setAvatarUrl(data.imageUrl);
toast.success("Profile picture updated"); toast.success(t`Profile picture updated`);
router.invalidate(); router.invalidate();
}, },
onError: (err) => { onError: (err) => {
const message = err instanceof Error ? err.message : "Upload failed"; toast.error(getErrorMessage(err, t, t`Upload failed`));
toast.error(message);
}, },
onSettled: () => { onSettled: () => {
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
@@ -124,11 +127,11 @@ export function AccountSection({
orpc.account.removeAvatar.mutationOptions({ orpc.account.removeAvatar.mutationOptions({
onSuccess: () => { onSuccess: () => {
setAvatarUrl(undefined); setAvatarUrl(undefined);
toast.success("Profile picture removed"); toast.success(t`Profile picture removed`);
router.invalidate(); router.invalidate();
}, },
onError: () => { onError: () => {
toast.error("Failed to remove profile picture"); toast.error(t`Failed to remove profile picture`);
}, },
}), }),
); );
@@ -169,7 +172,7 @@ export function AccountSection({
<div className="mb-3 flex items-center gap-2"> <div className="mb-3 flex items-center gap-2">
<IconUser aria-hidden={true} className="size-4 text-muted-foreground" /> <IconUser aria-hidden={true} className="size-4 text-muted-foreground" />
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider"> <h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
Account <Trans>Account</Trans>
</h2> </h2>
</div> </div>
<Card> <Card>
@@ -192,7 +195,9 @@ export function AccountSection({
} }
className="relative shrink-0 cursor-pointer rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" className="relative shrink-0 cursor-pointer rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
aria-label={ aria-label={
avatarUrl ? "Remove profile picture" : "Upload profile picture" avatarUrl
? t`Remove profile picture`
: t`Upload profile picture`
} }
> >
<Avatar className="size-12 overflow-hidden"> <Avatar className="size-12 overflow-hidden">
@@ -230,7 +235,11 @@ export function AccountSection({
</AnimatePresence> </AnimatePresence>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{avatarUrl ? "Remove picture" : "Upload picture"} {avatarUrl ? (
<Trans>Remove picture</Trans>
) : (
<Trans>Upload picture</Trans>
)}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -284,7 +293,7 @@ export function AccountSection({
handleNameSave(); handleNameSave();
}} }}
className="shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:text-primary" className="shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:text-primary"
aria-label="Save name" aria-label={t`Save name`}
> >
<IconCheck className="size-3.5" /> <IconCheck className="size-3.5" />
</button> </button>
@@ -295,7 +304,7 @@ export function AccountSection({
handleNameCancel(); handleNameCancel();
}} }}
className="shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:text-destructive" className="shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:text-destructive"
aria-label="Cancel editing" aria-label={t`Cancel editing`}
> >
<IconX className="size-3.5" /> <IconX className="size-3.5" />
</button> </button>
@@ -323,12 +332,12 @@ export function AccountSection({
{user.email} {user.email}
{user.role === "admin" && ( {user.role === "admin" && (
<Badge className="ml-1.5 rounded-md border-0 bg-primary/10 align-middle text-primary"> <Badge className="ml-1.5 rounded-md border-0 bg-primary/10 align-middle text-primary">
Admin <Trans>Admin</Trans>
</Badge> </Badge>
)} )}
</CardDescription> </CardDescription>
<p className="mt-0.5 text-muted-foreground/60 text-xs"> <p className="mt-0.5 text-muted-foreground/60 text-xs">
Member since {memberSince} <Trans>Member since {memberSince}</Trans>
</p> </p>
</div> </div>
@@ -342,7 +351,7 @@ export function AccountSection({
}} }}
> >
<IconLogout aria-hidden={true} /> <IconLogout aria-hidden={true} />
Sign out <Trans>Sign out</Trans>
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -352,6 +361,7 @@ export function AccountSection({
} }
function ChangePasswordDialog() { function ChangePasswordDialog() {
const { t } = useLingui();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
@@ -378,15 +388,15 @@ function ChangePasswordDialog() {
setError(""); setError("");
if (!currentPassword) { if (!currentPassword) {
setError("Current password is required"); setError(t`Current password is required`);
return; return;
} }
if (newPassword.length < 8) { if (newPassword.length < 8) {
setError("New password must be at least 8 characters"); setError(t`New password must be at least 8 characters`);
return; return;
} }
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setError("Passwords do not match"); setError(t`Passwords do not match`);
return; return;
} }
@@ -398,13 +408,13 @@ function ChangePasswordDialog() {
revokeOtherSessions, revokeOtherSessions,
}); });
if (result.error) { if (result.error) {
setError(result.error.message ?? "Failed to change password"); setError(t`Failed to change password`);
return; return;
} }
toast.success("Password updated"); toast.success(t`Password updated`);
handleOpenChange(false); handleOpenChange(false);
} catch { } catch {
setError("Something went wrong"); setError(t`Something went wrong`);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -414,13 +424,15 @@ function ChangePasswordDialog() {
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<Button variant="outline" onClick={() => setOpen(true)}> <Button variant="outline" onClick={() => setOpen(true)}>
<IconLockPassword aria-hidden={true} /> <IconLockPassword aria-hidden={true} />
Change password <Trans>Change password</Trans>
</Button> </Button>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Change password</DialogTitle> <DialogTitle>
<Trans>Change password</Trans>
</DialogTitle>
<DialogDescription> <DialogDescription>
Enter your current password and choose a new one. <Trans>Enter your current password and choose a new one.</Trans>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -433,7 +445,9 @@ function ChangePasswordDialog() {
)} )}
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label htmlFor="current-password">Current password</Label> <Label htmlFor="current-password">
<Trans>Current password</Trans>
</Label>
<Input <Input
id="current-password" id="current-password"
type="password" type="password"
@@ -445,7 +459,9 @@ function ChangePasswordDialog() {
</div> </div>
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label htmlFor="new-password">New password</Label> <Label htmlFor="new-password">
<Trans>New password</Trans>
</Label>
<Input <Input
id="new-password" id="new-password"
type="password" type="password"
@@ -457,7 +473,9 @@ function ChangePasswordDialog() {
</div> </div>
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label htmlFor="confirm-password">Confirm new password</Label> <Label htmlFor="confirm-password">
<Trans>Confirm new password</Trans>
</Label>
<Input <Input
id="confirm-password" id="confirm-password"
type="password" type="password"
@@ -478,17 +496,17 @@ function ChangePasswordDialog() {
disabled={isSubmitting} disabled={isSubmitting}
/> />
<Label htmlFor="revoke-sessions" className="cursor-pointer"> <Label htmlFor="revoke-sessions" className="cursor-pointer">
Sign out of other sessions <Trans>Sign out of other sessions</Trans>
</Label> </Label>
</div> </div>
<DialogFooter className="pt-2"> <DialogFooter className="pt-2">
<DialogClose render={<Button variant="outline" />}> <DialogClose render={<Button variant="outline" />}>
Cancel <Trans>Cancel</Trans>
</DialogClose> </DialogClose>
<Button type="submit" disabled={isSubmitting}> <Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Spinner className="size-3.5" />} {isSubmitting && <Spinner className="size-3.5" />}
Update password <Trans>Update password</Trans>
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { IconCloudUpload } from "@tabler/icons-react"; import { IconCloudUpload } from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
@@ -15,9 +16,11 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card"; import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { getErrorMessage } from "@/lib/error-messages";
import { orpc } from "@/lib/orpc/client"; import { orpc } from "@/lib/orpc/client";
export function BackupRestoreSection() { export function BackupRestoreSection() {
const { t } = useLingui();
const [restoreDialogOpen, setRestoreDialogOpen] = useState(false); const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -25,12 +28,11 @@ export function BackupRestoreSection() {
const restoreMutation = useMutation( const restoreMutation = useMutation(
orpc.admin.backups.restore.mutationOptions({ orpc.admin.backups.restore.mutationOptions({
onSuccess: () => { onSuccess: () => {
toast.success("Database restored. Reloading..."); toast.success(t`Database restored. Reloading...`);
setTimeout(() => window.location.reload(), 1500); setTimeout(() => window.location.reload(), 1500);
}, },
onError: (err) => { onError: (err) => {
const message = err instanceof Error ? err.message : "Restore failed"; toast.error(getErrorMessage(err, t, t`Restore failed`));
toast.error(message);
}, },
onSettled: () => { onSettled: () => {
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
@@ -53,10 +55,14 @@ export function BackupRestoreSection() {
/> />
</div> </div>
<div> <div>
<CardTitle>Restore</CardTitle> <CardTitle>
<Trans>Restore</Trans>
</CardTitle>
<CardDescription> <CardDescription>
Upload a .db file to replace the current database. A safety backup <Trans>
is created first. Upload a .db file to replace the current database. A safety
backup is created first.
</Trans>
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
@@ -79,11 +85,15 @@ export function BackupRestoreSection() {
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Restore database?</AlertDialogTitle> <AlertDialogTitle>
<Trans>Restore database?</Trans>
</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This will replace your entire database with the uploaded file. A <Trans>
safety backup of your current data will be created first. Active This will replace your entire database with the uploaded file.
sessions may need to refresh after restore. A safety backup of your current data will be created first.
Active sessions may need to refresh after restore.
</Trans>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -94,7 +104,7 @@ export function BackupRestoreSection() {
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
}} }}
> >
Cancel <Trans>Cancel</Trans>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={() => { onClick={() => {
@@ -103,7 +113,7 @@ export function BackupRestoreSection() {
setSelectedFile(null); setSelectedFile(null);
}} }}
> >
Restore <Trans>Restore</Trans>
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -118,7 +128,11 @@ export function BackupRestoreSection() {
) : ( ) : (
<IconCloudUpload aria-hidden={true} /> <IconCloudUpload aria-hidden={true} />
)} )}
{restoreMutation.isPending ? "Restoring…" : "Upload"} {restoreMutation.isPending ? (
<Trans>Restoring</Trans>
) : (
<Trans>Upload</Trans>
)}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -1,7 +1,8 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { BackupFrequency } from "@sofa/api/schemas"; import type { BackupFrequency } from "@sofa/api/schemas";
import { formatDate, formatRelativeTime } from "@sofa/i18n/format";
import { IconCalendarWeek } from "@tabler/icons-react"; import { IconCalendarWeek } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { format, formatDistanceToNow } from "date-fns";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -28,16 +29,6 @@ const FREQUENCY_OPTIONS: { value: BackupFrequency; label: string }[] = [
const HOURS = Array.from({ length: 24 }, (_, i) => i); const HOURS = Array.from({ length: 24 }, (_, i) => i);
const DAYS_OF_WEEK = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
] as const;
interface BackupScheduleState { interface BackupScheduleState {
enabled: boolean; enabled: boolean;
maxRetention: number; maxRetention: number;
@@ -98,16 +89,19 @@ function getNextBackupDate(
return next; return next;
} }
function formatNextBackup(
frequency: BackupFrequency,
time: string,
dayOfWeek: number,
): string {
const next = getNextBackupDate(frequency, time, dayOfWeek);
return `Next backup ${formatDistanceToNow(next, { addSuffix: true })}`;
}
export function BackupScheduleSection() { export function BackupScheduleSection() {
const { t } = useLingui();
const DAYS_OF_WEEK = [
t`Sunday`,
t`Monday`,
t`Tuesday`,
t`Wednesday`,
t`Thursday`,
t`Friday`,
t`Saturday`,
];
const { const {
data: scheduleData, data: scheduleData,
isPending, isPending,
@@ -127,6 +121,16 @@ export function BackupScheduleSection() {
const { enabled, maxRetention, frequency, time, dow } = current; const { enabled, maxRetention, frequency, time, dow } = current;
function formatNextBackup(
frequency: BackupFrequency,
time: string,
dayOfWeek: number,
): string {
const next = getNextBackupDate(frequency, time, dayOfWeek);
const distance = formatRelativeTime(next);
return t`Next backup ${distance}`;
}
const updateScheduleMutation = useMutation( const updateScheduleMutation = useMutation(
orpc.admin.backups.updateSchedule.mutationOptions({ orpc.admin.backups.updateSchedule.mutationOptions({
onMutate: (input) => { onMutate: (input) => {
@@ -145,11 +149,11 @@ export function BackupScheduleSection() {
onError: (_, input, ctx) => { onError: (_, input, ctx) => {
if (ctx?.previous) setSchedule(ctx.previous); if (ctx?.previous) setSchedule(ctx.previous);
if (input.enabled !== undefined) { if (input.enabled !== undefined) {
toast.error("Failed to update scheduled backup setting"); toast.error(t`Failed to update scheduled backup setting`);
} else if (input.maxRetention !== undefined) { } else if (input.maxRetention !== undefined) {
toast.error("Failed to update retention setting"); toast.error(t`Failed to update retention setting`);
} else { } else {
toast.error("Failed to update schedule"); toast.error(t`Failed to update schedule`);
} }
}, },
}), }),
@@ -170,13 +174,13 @@ export function BackupScheduleSection() {
onSuccess: () => onSuccess: () =>
toast.success( toast.success(
checked checked
? "Scheduled backups enabled" ? t`Scheduled backups enabled`
: "Scheduled backups disabled", : t`Scheduled backups disabled`,
), ),
}, },
); );
}, },
[updateScheduleMutation], [updateScheduleMutation, t],
); );
const changeMaxRetention = useCallback( const changeMaxRetention = useCallback(
@@ -194,10 +198,10 @@ export function BackupScheduleSection() {
time: newTime, time: newTime,
dayOfWeek: newDow, dayOfWeek: newDow,
}, },
{ onSuccess: () => toast.success("Schedule updated") }, { onSuccess: () => toast.success(t`Schedule updated`) },
); );
}, },
[updateScheduleMutation, current.dow], [updateScheduleMutation, current.dow, t],
); );
if (isPending) { if (isPending) {
@@ -208,7 +212,7 @@ export function BackupScheduleSection() {
return ( return (
<CardContent> <CardContent>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Failed to load backup schedule settings. <Trans>Failed to load backup schedule settings.</Trans>
</p> </p>
</CardContent> </CardContent>
); );
@@ -226,46 +230,55 @@ export function BackupScheduleSection() {
/> />
</div> </div>
<div> <div>
<CardTitle>Backup schedule</CardTitle> <CardTitle>
<Trans>Backup schedule</Trans>
</CardTitle>
<CardDescription> <CardDescription>
{enabled ? ( {enabled ? (
<span <span
className="inline-flex flex-wrap items-baseline" className="inline-flex flex-wrap items-baseline"
suppressHydrationWarning suppressHydrationWarning
> >
{formatNextBackup(frequency, time, dow)}. Keeping{" "} {formatNextBackup(frequency, time, dow)}.{" "}
<Select <Trans>
value={String(maxRetention)} Keeping{" "}
onValueChange={(v) => v && changeMaxRetention(Number(v))} <Select
modal={false} value={String(maxRetention)}
> onValueChange={(v) =>
<SelectTrigger className="!h-auto mr-0.5 ml-1.5 w-auto gap-0.5 rounded-none border-0 bg-transparent p-0 underline decoration-muted-foreground/50 decoration-dotted underline-offset-4 shadow-none hover:bg-transparent hover:text-foreground hover:decoration-foreground/50 focus-visible:decoration-foreground focus-visible:decoration-solid focus-visible:ring-0 dark:bg-transparent dark:hover:bg-transparent"> v && changeMaxRetention(Number(v))
<SelectValue> }
{(value: string | null) => modal={false}
value === "0"
? "unlimited"
: value
? `last ${value}`
: null
}
</SelectValue>
</SelectTrigger>
<SelectContent
align="start"
alignItemWithTrigger={false}
className="p-1"
> >
{[3, 5, 7, 14, 30, 0].map((n) => ( <SelectTrigger className="!h-auto mr-0.5 ml-1.5 w-auto gap-0.5 rounded-none border-0 bg-transparent p-0 underline decoration-muted-foreground/50 decoration-dotted underline-offset-4 shadow-none hover:bg-transparent hover:text-foreground hover:decoration-foreground/50 focus-visible:decoration-foreground focus-visible:decoration-solid focus-visible:ring-0 dark:bg-transparent dark:hover:bg-transparent">
<SelectItem key={n} value={String(n)}> <SelectValue>
{n === 0 ? "unlimited" : `last ${n}`} {(value: string | null) =>
</SelectItem> value === "0"
))} ? t`unlimited`
</SelectContent> : value
</Select>{" "} ? t`last ${value}`
backups. : null
}
</SelectValue>
</SelectTrigger>
<SelectContent
align="start"
alignItemWithTrigger={false}
className="p-1"
>
{[3, 5, 7, 14, 30, 0].map((n) => (
<SelectItem key={n} value={String(n)}>
{n === 0 ? t`unlimited` : t`last ${n}`}
</SelectItem>
))}
</SelectContent>
</Select>{" "}
backups.
</Trans>
</span> </span>
) : ( ) : (
"Automatically back up your database on a schedule" <Trans>
Automatically back up your database on a schedule
</Trans>
)} )}
</CardDescription> </CardDescription>
</div> </div>
@@ -274,7 +287,7 @@ export function BackupScheduleSection() {
checked={enabled} checked={enabled}
onCheckedChange={toggleScheduled} onCheckedChange={toggleScheduled}
disabled={togglingSchedule} disabled={togglingSchedule}
aria-label="Toggle scheduled backups" aria-label={t`Toggle scheduled backups`}
/> />
</div> </div>
</CardContent> </CardContent>
@@ -293,7 +306,7 @@ export function BackupScheduleSection() {
{/* Frequency selector */} {/* Frequency selector */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<span className="inline-block font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider"> <span className="inline-block font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
Frequency <Trans>Frequency</Trans>
</span> </span>
<ButtonGroup> <ButtonGroup>
{FREQUENCY_OPTIONS.map((opt) => ( {FREQUENCY_OPTIONS.map((opt) => (
@@ -326,7 +339,7 @@ export function BackupScheduleSection() {
className="overflow-hidden" className="overflow-hidden"
> >
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider"> <span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
Day:{" "} <Trans>Day:</Trans>{" "}
</span> </span>
<Select <Select
value={String(dow)} value={String(dow)}
@@ -371,7 +384,11 @@ export function BackupScheduleSection() {
className="overflow-hidden" className="overflow-hidden"
> >
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider"> <span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
{frequency === "12h" ? "Starting at" : "Time:"}{" "} {frequency === "12h" ? (
<Trans>Starting at</Trans>
) : (
<Trans>Time:</Trans>
)}{" "}
</span> </span>
<Select <Select
value={time} value={time}
@@ -382,7 +399,7 @@ export function BackupScheduleSection() {
<SelectValue> <SelectValue>
{(value: string | null) => {(value: string | null) =>
value value
? format( ? formatDate(
new Date( new Date(
2000, 2000,
0, 0,
@@ -390,7 +407,13 @@ export function BackupScheduleSection() {
Number(value.split(":")[0]), Number(value.split(":")[0]),
0, 0,
), ),
"h:mm a", {
hour: "numeric",
minute: "2-digit",
year: undefined,
month: undefined,
day: undefined,
},
) )
: null : null
} }
@@ -405,7 +428,13 @@ export function BackupScheduleSection() {
const val = `${String(h).padStart(2, "0")}:00`; const val = `${String(h).padStart(2, "0")}:00`;
return ( return (
<SelectItem key={h} value={val}> <SelectItem key={h} value={val}>
{format(new Date(2000, 0, 1, h, 0), "h:mm a")} {formatDate(new Date(2000, 0, 1, h, 0), {
hour: "numeric",
minute: "2-digit",
year: undefined,
month: undefined,
day: undefined,
})}
</SelectItem> </SelectItem>
); );
})} })}
@@ -1,4 +1,11 @@
import { plural } from "@lingui/core/macro";
import { Trans, useLingui } from "@lingui/react/macro";
import type { BackupInfo } from "@sofa/api/schemas"; import type { BackupInfo } from "@sofa/api/schemas";
import {
formatBytes as formatBytesI18n,
formatDate,
formatRelativeTime,
} from "@sofa/i18n/format";
import { import {
IconClock, IconClock,
IconCloudDownload, IconCloudDownload,
@@ -9,7 +16,6 @@ import {
IconTrash, IconTrash,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { format, formatDistanceToNow } from "date-fns";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -34,17 +40,17 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { orpc } from "@/lib/orpc/client"; import { orpc } from "@/lib/orpc/client";
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatBackupDate(dateStr: string): string { function formatBackupDate(dateStr: string): string {
return format(new Date(dateStr), "MMM d, h:mm a"); return formatDate(dateStr, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
} }
export function BackupSection() { export function BackupSection() {
const { t } = useLingui();
const { data } = useQuery(orpc.admin.backups.list.queryOptions()); const { data } = useQuery(orpc.admin.backups.list.queryOptions());
const [backups, setBackups] = useState<BackupInfo[] | null>(null); const [backups, setBackups] = useState<BackupInfo[] | null>(null);
@@ -60,9 +66,9 @@ export function BackupSection() {
(b: BackupInfo) => b.filename !== backup.filename, (b: BackupInfo) => b.filename !== backup.filename,
), ),
]); ]);
toast.success("Backup created", { toast.success(t`Backup created`, {
action: { action: {
label: "Download", label: t`Download`,
onClick: () => { onClick: () => {
const a = document.createElement("a"); const a = document.createElement("a");
a.href = `/api/backup/${backup.filename}`; a.href = `/api/backup/${backup.filename}`;
@@ -72,7 +78,7 @@ export function BackupSection() {
}, },
}); });
}, },
onError: () => toast.error("Failed to create backup"), onError: () => toast.error(t`Failed to create backup`),
}), }),
); );
@@ -85,10 +91,10 @@ export function BackupSection() {
); );
return { previous }; return { previous };
}, },
onSuccess: () => toast.success("Backup deleted"), onSuccess: () => toast.success(t`Backup deleted`),
onError: (_, __, ctx) => { onError: (_, __, ctx) => {
if (ctx?.previous) setBackups(ctx.previous); if (ctx?.previous) setBackups(ctx.previous);
toast.error("Failed to delete backup"); toast.error(t`Failed to delete backup`);
}, },
}), }),
); );
@@ -98,6 +104,11 @@ export function BackupSection() {
? (deleteMutation.variables?.filename ?? null) ? (deleteMutation.variables?.filename ?? null)
: null; : null;
const backupCountLabel =
displayBackups.length > 0
? t`${displayBackups.length} ${plural(displayBackups.length, { one: "backup", other: "backups" })} stored`
: t`No backups yet`;
return ( return (
<> <>
{/* Header */} {/* Header */}
@@ -111,12 +122,10 @@ export function BackupSection() {
/> />
</div> </div>
<div> <div>
<CardTitle>Database backups</CardTitle> <CardTitle>
<CardDescription> <Trans>Database backups</Trans>
{displayBackups.length > 0 </CardTitle>
? `${displayBackups.length} backup${displayBackups.length !== 1 ? "s" : ""} stored` <CardDescription>{backupCountLabel}</CardDescription>
: "No backups yet"}
</CardDescription>
</div> </div>
</div> </div>
<Button onClick={() => createMutation.mutate()} disabled={creating}> <Button onClick={() => createMutation.mutate()} disabled={creating}>
@@ -125,7 +134,7 @@ export function BackupSection() {
) : ( ) : (
<IconPlus aria-hidden={true} /> <IconPlus aria-hidden={true} />
)} )}
{creating ? "Creating…" : "New backup"} {creating ? <Trans>Creating</Trans> : <Trans>New backup</Trans>}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -167,10 +176,10 @@ export function BackupSection() {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{backup.source === "scheduled" {backup.source === "scheduled"
? "Scheduled backup" ? t`Scheduled backup`
: backup.source === "pre-restore" : backup.source === "pre-restore"
? "Pre-restore backup" ? t`Pre-restore backup`
: "Manual backup"} : t`Manual backup`}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -179,15 +188,13 @@ export function BackupSection() {
{formatBackupDate(backup.createdAt)} {formatBackupDate(backup.createdAt)}
</span> </span>
<span className="text-[11px] text-muted-foreground"> <span className="text-[11px] text-muted-foreground">
{formatBytes(backup.sizeBytes)} {formatBytesI18n(backup.sizeBytes)}
</span> </span>
<span <span
className="text-[11px] text-muted-foreground/50" className="text-[11px] text-muted-foreground/50"
suppressHydrationWarning suppressHydrationWarning
> >
{formatDistanceToNow(new Date(backup.createdAt), { {formatRelativeTime(backup.createdAt)}
addSuffix: true,
})}
</span> </span>
</div> </div>
</div> </div>
@@ -204,7 +211,7 @@ export function BackupSection() {
<a <a
href={`/api/backup/${backup.filename}`} href={`/api/backup/${backup.filename}`}
download download
aria-label="Download backup" aria-label={t`Download backup`}
> >
<IconCloudDownload /> <IconCloudDownload />
</a> </a>
@@ -212,7 +219,9 @@ export function BackupSection() {
/> />
} }
/> />
<TooltipContent>Download</TooltipContent> <TooltipContent>
<Trans>Download</Trans>
</TooltipContent>
</Tooltip> </Tooltip>
<AlertDialog> <AlertDialog>
<Tooltip> <Tooltip>
@@ -223,7 +232,7 @@ export function BackupSection() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label="Delete backup" aria-label={t`Delete backup`}
className="text-muted-foreground hover:text-destructive" className="text-muted-foreground hover:text-destructive"
disabled={deleting === backup.filename} disabled={deleting === backup.filename}
/> />
@@ -237,21 +246,29 @@ export function BackupSection() {
<IconTrash /> <IconTrash />
)} )}
</AlertDialogTrigger> </AlertDialogTrigger>
<TooltipContent>Delete</TooltipContent> <TooltipContent>
<Trans>Delete</Trans>
</TooltipContent>
</Tooltip> </Tooltip>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete backup?</AlertDialogTitle> <AlertDialogTitle>
<Trans>Delete backup?</Trans>
</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This will permanently delete the backup from{" "} <Trans>
<strong> This will permanently delete the backup from{" "}
{formatBackupDate(backup.createdAt)} <strong>
</strong> {formatBackupDate(backup.createdAt)}
. This cannot be undone. </strong>
. This cannot be undone.
</Trans>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
variant="destructive" variant="destructive"
onClick={() => onClick={() =>
@@ -260,7 +277,7 @@ export function BackupSection() {
}) })
} }
> >
Delete <Trans>Delete</Trans>
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -1,3 +1,5 @@
import { plural } from "@lingui/core/macro";
import { Trans, useLingui } from "@lingui/react/macro";
import { IconDatabase, IconPhoto, IconTrash } from "@tabler/icons-react"; import { IconDatabase, IconPhoto, IconTrash } from "@tabler/icons-react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -26,6 +28,7 @@ function formatBytes(bytes: number): string {
} }
export function CacheSection() { export function CacheSection() {
const { t } = useLingui();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidateHealth = () => const invalidateHealth = () =>
@@ -35,23 +38,24 @@ export function CacheSection() {
orpc.admin.purgeMetadataCache.mutationOptions({ orpc.admin.purgeMetadataCache.mutationOptions({
onSuccess: (data) => { onSuccess: (data) => {
toast.success( toast.success(
`Purged ${data.deletedTitles} stale title${data.deletedTitles !== 1 ? "s" : ""} and ${data.deletedPersons} orphaned person${data.deletedPersons !== 1 ? "s" : ""}`, t`Purged ${plural(data.deletedTitles, { one: "# stale title", other: "# stale titles" })} and ${plural(data.deletedPersons, { one: "# orphaned person", other: "# orphaned persons" })}`,
); );
invalidateHealth(); invalidateHealth();
}, },
onError: () => toast.error("Failed to purge metadata cache"), onError: () => toast.error(t`Failed to purge metadata cache`),
}), }),
); );
const purgeImages = useMutation( const purgeImages = useMutation(
orpc.admin.purgeImageCache.mutationOptions({ orpc.admin.purgeImageCache.mutationOptions({
onSuccess: (data) => { onSuccess: (data) => {
const freed = formatBytes(data.freedBytes);
toast.success( toast.success(
`Deleted ${data.deletedFiles.toLocaleString()} file${data.deletedFiles !== 1 ? "s" : ""}, freed ${formatBytes(data.freedBytes)}`, t`Deleted ${plural(data.deletedFiles, { one: "# file", other: "# files" })}, freed ${freed}`,
); );
invalidateHealth(); invalidateHealth();
}, },
onError: () => toast.error("Failed to purge image cache"), onError: () => toast.error(t`Failed to purge image cache`),
}), }),
); );
@@ -62,12 +66,13 @@ export function CacheSection() {
client.admin.purgeImageCache(), client.admin.purgeImageCache(),
]), ]),
onSuccess: ([metaResult, imageResult]) => { onSuccess: ([metaResult, imageResult]) => {
const freed = formatBytes(imageResult.freedBytes);
toast.success( toast.success(
`Purged ${metaResult.deletedTitles} title${metaResult.deletedTitles !== 1 ? "s" : ""}, ${metaResult.deletedPersons} person${metaResult.deletedPersons !== 1 ? "s" : ""}, ${imageResult.deletedFiles.toLocaleString()} file${imageResult.deletedFiles !== 1 ? "s" : ""} (${formatBytes(imageResult.freedBytes)} freed)`, t`Purged ${plural(metaResult.deletedTitles, { one: "# title", other: "# titles" })}, ${plural(metaResult.deletedPersons, { one: "# person", other: "# persons" })}, ${plural(imageResult.deletedFiles, { one: "# file", other: "# files" })} (${freed} freed)`,
); );
invalidateHealth(); invalidateHealth();
}, },
onError: () => toast.error("Failed to purge caches"), onError: () => toast.error(t`Failed to purge caches`),
}); });
const disabled = const disabled =
@@ -80,42 +85,54 @@ export function CacheSection() {
<IconTrash aria-hidden={true} className="size-4 text-primary" /> <IconTrash aria-hidden={true} className="size-4 text-primary" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<CardTitle>Cache management</CardTitle> <CardTitle>
<Trans>Cache management</Trans>
</CardTitle>
<CardDescription> <CardDescription>
Free up disk space by clearing cached metadata and images <Trans>
Free up disk space by clearing cached metadata and images
</Trans>
</CardDescription> </CardDescription>
<div className="mt-4 flex flex-wrap gap-2"> <div className="mt-4 flex flex-wrap gap-2">
{/* Purge metadata */} {/* Purge metadata */}
<AlertDialog> <AlertDialog>
<AlertDialogTrigger <AlertDialogTrigger
render={ render={<Button variant="outline" disabled={disabled} />}
<Button variant="outline" size="sm" disabled={disabled} />
}
> >
{purgeMetadata.isPending ? ( {purgeMetadata.isPending ? (
<Spinner className="size-3" /> <Spinner className="size-3" />
) : ( ) : (
<IconDatabase aria-hidden={true} /> <IconDatabase aria-hidden={true} />
)} )}
{purgeMetadata.isPending ? "Purging…" : "Purge metadata"} {purgeMetadata.isPending ? (
<Trans>Purging...</Trans>
) : (
<Trans>Purge metadata</Trans>
)}
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Purge metadata cache?</AlertDialogTitle> <AlertDialogTitle>
<Trans>Purge metadata cache?</Trans>
</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This will delete un-enriched stub titles that aren't in any <Trans>
user's library and clean up orphaned person records. Deleted This will delete un-enriched stub titles that aren't in
titles will be re-imported if accessed again. any user's library and clean up orphaned person records.
Deleted titles will be re-imported if accessed again.
</Trans>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
variant="destructive" variant="destructive"
onClick={() => purgeMetadata.mutate()} onClick={() => purgeMetadata.mutate()}
> >
Purge metadata <Trans>Purge metadata</Trans>
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -124,32 +141,40 @@ export function CacheSection() {
{/* Purge images */} {/* Purge images */}
<AlertDialog> <AlertDialog>
<AlertDialogTrigger <AlertDialogTrigger
render={ render={<Button variant="outline" disabled={disabled} />}
<Button variant="outline" size="sm" disabled={disabled} />
}
> >
{purgeImages.isPending ? ( {purgeImages.isPending ? (
<Spinner className="size-3" /> <Spinner className="size-3" />
) : ( ) : (
<IconPhoto aria-hidden={true} /> <IconPhoto aria-hidden={true} />
)} )}
{purgeImages.isPending ? "Purging…" : "Purge images"} {purgeImages.isPending ? (
<Trans>Purging...</Trans>
) : (
<Trans>Purge images</Trans>
)}
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Purge image cache?</AlertDialogTitle> <AlertDialogTitle>
<Trans>Purge image cache?</Trans>
</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This will delete all cached TMDB images from disk. Images <Trans>
will be re-downloaded automatically as needed. This will delete all cached TMDB images from disk. Images
will be re-downloaded automatically as needed.
</Trans>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
variant="destructive" variant="destructive"
onClick={() => purgeImages.mutate()} onClick={() => purgeImages.mutate()}
> >
Purge images <Trans>Purge images</Trans>
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -158,33 +183,41 @@ export function CacheSection() {
{/* Purge all */} {/* Purge all */}
<AlertDialog> <AlertDialog>
<AlertDialogTrigger <AlertDialogTrigger
render={ render={<Button variant="destructive" disabled={disabled} />}
<Button variant="destructive" size="sm" disabled={disabled} />
}
> >
{purgeAll.isPending ? ( {purgeAll.isPending ? (
<Spinner className="size-3" /> <Spinner className="size-3" />
) : ( ) : (
<IconTrash aria-hidden={true} /> <IconTrash aria-hidden={true} />
)} )}
{purgeAll.isPending ? "Purging…" : "Purge all"} {purgeAll.isPending ? (
<Trans>Purging...</Trans>
) : (
<Trans>Purge all</Trans>
)}
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Purge all caches?</AlertDialogTitle> <AlertDialogTitle>
<Trans>Purge all caches?</Trans>
</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This will delete all un-enriched stub titles and all cached <Trans>
images from disk. Everything will be re-imported and This will delete all un-enriched stub titles and all
re-downloaded as needed. cached images from disk. Everything will be re-imported
and re-downloaded as needed.
</Trans>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
variant="destructive" variant="destructive"
onClick={() => purgeAll.mutate()} onClick={() => purgeAll.mutate()}
> >
Purge all <Trans>Purge all</Trans>
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { NormalizedImport } from "@sofa/api/schemas"; import type { NormalizedImport } from "@sofa/api/schemas";
import { IconCloudUpload, IconFileImport, IconLink } from "@tabler/icons-react"; import { IconCloudUpload, IconFileImport, IconLink } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
@@ -23,6 +24,7 @@ import {
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { getErrorMessage } from "@/lib/error-messages";
import { client, orpc } from "@/lib/orpc/client"; import { client, orpc } from "@/lib/orpc/client";
// ─── Source Configs ────────────────────────────────────────── // ─── Source Configs ──────────────────────────────────────────
@@ -174,7 +176,7 @@ export function ImportsSection() {
<div className="mb-3 flex items-center gap-2"> <div className="mb-3 flex items-center gap-2">
<IconFileImport aria-hidden className="size-4 text-muted-foreground" /> <IconFileImport aria-hidden className="size-4 text-muted-foreground" />
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider"> <h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
Import <Trans>Import</Trans>
</h2> </h2>
</div> </div>
<div className="space-y-2.5"> <div className="space-y-2.5">
@@ -189,6 +191,7 @@ export function ImportsSection() {
// ─── Source Card ───────────────────────────────────────────── // ─── Source Card ─────────────────────────────────────────────
function ImportSourceCard({ config }: { config: SourceConfig }) { function ImportSourceCard({ config }: { config: SourceConfig }) {
const { t } = useLingui();
const { data: systemStatus } = useQuery(orpc.system.status.queryOptions()); const { data: systemStatus } = useQuery(orpc.system.status.queryOptions());
const publicApiUrl = const publicApiUrl =
systemStatus?.publicApiUrl ?? "https://public-api.sofa.watch"; systemStatus?.publicApiUrl ?? "https://public-api.sofa.watch";
@@ -219,9 +222,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
setDialogOpen(true); setDialogOpen(true);
}, },
onError: (err) => { onError: (err) => {
toast.error( toast.error(getErrorMessage(err, t, t`Failed to parse file`));
err instanceof Error ? err.message : "Failed to parse file",
);
}, },
onSettled: () => { onSettled: () => {
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
@@ -236,9 +237,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
setStep("preview"); setStep("preview");
}, },
onError: (err) => { onError: (err) => {
toast.error( toast.error(getErrorMessage(err, t, t`Failed to parse import data`));
err instanceof Error ? err.message : "Failed to parse import data",
);
setStep("choose"); setStep("choose");
}, },
}), }),
@@ -290,13 +289,13 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
setStep("done"); setStep("done");
if (event.job.importedCount > 0) { if (event.job.importedCount > 0) {
toast.success( toast.success(
`Imported ${event.job.importedCount} items from ${config.label}`, t`Imported ${event.job.importedCount} items from ${config.label}`,
); );
} }
} else if (event.type === "timeout") { } else if (event.type === "timeout") {
receivedComplete = true; receivedComplete = true;
toast.info( toast.info(
"Import is still running in the background. Check back later.", t`Import is still running in the background. Check back later.`,
); );
setStep("preview"); setStep("preview");
} else { } else {
@@ -327,18 +326,18 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
setStep("done"); setStep("done");
} else { } else {
toast.info( toast.info(
"Import is still running in the background. Check back later.", t`Import is still running in the background. Check back later.`,
); );
setStep("preview"); setStep("preview");
} }
} catch { } catch {
toast.error("Lost connection to import. Check status in settings."); toast.error(t`Lost connection to import. Check status in settings.`);
setStep("preview"); setStep("preview");
} }
} }
} catch (err) { } catch (err) {
if (abort.signal.aborted) return; if (abort.signal.aborted) return;
toast.error(err instanceof Error ? err.message : "Import failed"); toast.error(getErrorMessage(err, t, t`Import failed`));
setStep("preview"); setStep("preview");
} finally { } finally {
importAbortRef.current = null; importAbortRef.current = null;
@@ -386,7 +385,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error( throw new Error(
(err as { error?: string }).error ?? (err as { error?: string }).error ??
`Failed to start ${config.label} connection`, t`Failed to start ${config.label} connection`,
); );
} }
const data = (await res.json()) as DeviceCodeInfo; const data = (await res.json()) as DeviceCodeInfo;
@@ -396,7 +395,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
// Start polling // Start polling
startPolling(data); startPolling(data);
} catch (e) { } catch (e) {
setOauthError(e instanceof Error ? e.message : "Failed to connect"); setOauthError(e instanceof Error ? e.message : t`Failed to connect`);
setStep("choose"); setStep("choose");
setDialogOpen(true); setDialogOpen(true);
} }
@@ -410,7 +409,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
pollTimerRef.current = setInterval(async () => { pollTimerRef.current = setInterval(async () => {
if (Date.now() > expiresAt) { if (Date.now() > expiresAt) {
stopPolling(); stopPolling();
setOauthError("Device code expired. Please try again."); setOauthError(t`Device code expired. Please try again.`);
setStep("choose"); setStep("choose");
return; return;
} }
@@ -438,17 +437,17 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
parsePayloadMutation.mutate({ data: data.data as NormalizedImport }); parsePayloadMutation.mutate({ data: data.data as NormalizedImport });
} else if (data.status === "denied") { } else if (data.status === "denied") {
stopPolling(); stopPolling();
setOauthError("Authorization was denied. Please try again."); setOauthError(t`Authorization was denied. Please try again.`);
setStep("choose"); setStep("choose");
} else if (data.status === "expired") { } else if (data.status === "expired") {
stopPolling(); stopPolling();
setOauthError("Device code expired. Please try again."); setOauthError(t`Device code expired. Please try again.`);
setStep("choose"); setStep("choose");
} else if (data.status === "fetch_error") { } else if (data.status === "fetch_error") {
stopPolling(); stopPolling();
setOauthError( setOauthError(
data.error || data.error ||
"Authorization succeeded but failed to fetch your library. Please try again.", t`Authorization succeeded but failed to fetch your library. Please try again.`,
); );
setStep("choose"); setStep("choose");
} }
@@ -497,7 +496,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
}} }}
> >
<IconLink aria-hidden /> <IconLink aria-hidden />
Connect <Trans>Connect</Trans>
</Button> </Button>
)} )}
{!config.supportsOAuth && ( {!config.supportsOAuth && (
@@ -507,7 +506,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
disabled={isParsing} disabled={isParsing}
> >
{isParsing ? <Spinner /> : <IconCloudUpload aria-hidden />} {isParsing ? <Spinner /> : <IconCloudUpload aria-hidden />}
{isParsing ? "Parsing…" : "Upload"} {isParsing ? <Trans>Parsing...</Trans> : <Trans>Upload</Trans>}
</Button> </Button>
)} )}
</div> </div>
@@ -577,12 +576,15 @@ function ChooseStep({
isParsing: boolean; isParsing: boolean;
onCancel: () => void; onCancel: () => void;
}) { }) {
const { t } = useLingui();
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Import from {config.label}</DialogTitle> <DialogTitle>
<Trans>Import from {config.label}</Trans>
</DialogTitle>
<DialogDescription> <DialogDescription>
Choose how to import your {config.label} data. <Trans>Choose how to import your {config.label} data.</Trans>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -602,10 +604,14 @@ function ChooseStep({
<IconLink aria-hidden className="size-5 text-primary" /> <IconLink aria-hidden className="size-5 text-primary" />
</div> </div>
<div> <div>
<p className="font-medium text-sm">Connect with {config.label}</p> <p className="font-medium text-sm">
<Trans>Connect with {config.label}</Trans>
</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
Authorize Sofa to read your {config.label} library. No password <Trans>
shared. Authorize Sofa to read your {config.label} library. No password
shared.
</Trans>
</p> </p>
</div> </div>
</button> </button>
@@ -624,10 +630,11 @@ function ChooseStep({
<IconCloudUpload aria-hidden className="size-5 text-primary" /> <IconCloudUpload aria-hidden className="size-5 text-primary" />
</div> </div>
<div> <div>
<p className="font-medium text-sm">Upload export file</p> <p className="font-medium text-sm">
<Trans>Upload export file</Trans>
</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
Upload a {config.accept} export from your {config.label} account {t`Upload a ${config.accept} export from your ${config.label} account settings.`}
settings.
</p> </p>
</div> </div>
</button> </button>
@@ -635,7 +642,7 @@ function ChooseStep({
<DialogFooter> <DialogFooter>
<DialogClose render={<Button variant="outline" />} onClick={onCancel}> <DialogClose render={<Button variant="outline" />} onClick={onCancel}>
Cancel <Trans>Cancel</Trans>
</DialogClose> </DialogClose>
</DialogFooter> </DialogFooter>
</> </>
@@ -654,16 +661,22 @@ function DeviceCodeStep({
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Connect to {source}</DialogTitle> <DialogTitle>
<Trans>Connect to {source}</Trans>
</DialogTitle>
<DialogDescription> <DialogDescription>
Enter the code below on {source}'s website to authorize Sofa. <Trans>
Enter the code below on {source}'s website to authorize Sofa.
</Trans>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{deviceCode ? ( {deviceCode ? (
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<p className="text-muted-foreground text-sm">Your code:</p> <p className="text-muted-foreground text-sm">
<Trans>Your code:</Trans>
</p>
<p className="rounded-lg bg-muted px-6 py-3 font-bold font-mono text-2xl tracking-widest"> <p className="rounded-lg bg-muted px-6 py-3 font-bold font-mono text-2xl tracking-widest">
{deviceCode.user_code} {deviceCode.user_code}
</p> </p>
@@ -677,13 +690,13 @@ function DeviceCodeStep({
className="inline-flex items-center gap-1.5 font-medium text-primary text-sm underline-offset-4 hover:underline" className="inline-flex items-center gap-1.5 font-medium text-primary text-sm underline-offset-4 hover:underline"
> >
<IconLink aria-hidden className="size-4" /> <IconLink aria-hidden className="size-4" />
Open {source} to enter code <Trans>Open {source} to enter code</Trans>
</a> </a>
</div> </div>
<div className="flex items-center justify-center gap-2 text-muted-foreground text-xs"> <div className="flex items-center justify-center gap-2 text-muted-foreground text-xs">
<Spinner className="size-3" /> <Spinner className="size-3" />
Waiting for authorization... <Trans>Waiting for authorization...</Trans>
</div> </div>
</div> </div>
) : ( ) : (
@@ -694,7 +707,7 @@ function DeviceCodeStep({
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onCancel}> <Button variant="outline" onClick={onCancel}>
Cancel <Trans>Cancel</Trans>
</Button> </Button>
</DialogFooter> </DialogFooter>
</> </>
@@ -705,15 +718,19 @@ function FetchingStep({ source }: { source: string }) {
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Connected to {source}</DialogTitle> <DialogTitle>
<Trans>Connected to {source}</Trans>
</DialogTitle>
<DialogDescription> <DialogDescription>
Fetching your library data from {source}... <Trans>Fetching your library data from {source}...</Trans>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col items-center gap-3 py-8"> <div className="flex flex-col items-center gap-3 py-8">
<Spinner className="size-8" /> <Spinner className="size-8" />
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Retrieving your watch history, watchlist, and ratings... <Trans>
Retrieving your watch history, watchlist, and ratings...
</Trans>
</p> </p>
</div> </div>
</> </>
@@ -739,6 +756,7 @@ function PreviewStep({
onImport: () => void; onImport: () => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const { t } = useLingui();
const { stats, warnings } = preview; const { stats, warnings } = preview;
const totalItems = const totalItems =
(options.importWatches ? stats.movies + stats.episodes : 0) + (options.importWatches ? stats.movies + stats.episodes : 0) +
@@ -748,46 +766,52 @@ function PreviewStep({
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Import from {source}</DialogTitle> <DialogTitle>
<Trans>Import from {source}</Trans>
</DialogTitle>
<DialogDescription> <DialogDescription>
Review what was found and choose what to import. <Trans>Review what was found and choose what to import.</Trans>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<StatBadge label="Movies" count={stats.movies} /> <StatBadge label={t`Movies`} count={stats.movies} />
<StatBadge label="Episodes" count={stats.episodes} /> <StatBadge label={t`Episodes`} count={stats.episodes} />
<StatBadge label="Watchlist" count={stats.watchlist} /> <StatBadge label={t`Watchlist`} count={stats.watchlist} />
<StatBadge label="Ratings" count={stats.ratings} /> <StatBadge label={t`Ratings`} count={stats.ratings} />
</div> </div>
{preview.diagnostics && preview.diagnostics.unresolved > 0 && ( {preview.diagnostics && preview.diagnostics.unresolved > 0 && (
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-3">
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{preview.diagnostics.unresolved} items have no external IDs and <Trans>
will be resolved by title search, which may be less accurate. {preview.diagnostics.unresolved} items have no external IDs and
will be resolved by title search, which may be less accurate.
</Trans>
</p> </p>
</div> </div>
)} )}
<div className="space-y-2"> <div className="space-y-2">
<p className="font-medium text-sm">Import options</p> <p className="font-medium text-sm">
<Trans>Import options</Trans>
</p>
<OptionCheckbox <OptionCheckbox
label="Watch history" label={t`Watch history`}
description={`${stats.movies} movies, ${stats.episodes} episodes`} description={t`${stats.movies} movies, ${stats.episodes} episodes`}
checked={options.importWatches} checked={options.importWatches}
onChange={(v) => setOptions({ ...options, importWatches: v })} onChange={(v) => setOptions({ ...options, importWatches: v })}
/> />
<OptionCheckbox <OptionCheckbox
label="Watchlist" label={t`Watchlist`}
description={`${stats.watchlist} items`} description={t`${stats.watchlist} items`}
checked={options.importWatchlist} checked={options.importWatchlist}
onChange={(v) => setOptions({ ...options, importWatchlist: v })} onChange={(v) => setOptions({ ...options, importWatchlist: v })}
/> />
<OptionCheckbox <OptionCheckbox
label="Ratings" label={t`Ratings`}
description={`${stats.ratings} ratings`} description={t`${stats.ratings} ratings`}
checked={options.importRatings} checked={options.importRatings}
onChange={(v) => setOptions({ ...options, importRatings: v })} onChange={(v) => setOptions({ ...options, importRatings: v })}
/> />
@@ -795,7 +819,9 @@ function PreviewStep({
{warnings.length > 0 && ( {warnings.length > 0 && (
<div className="rounded-lg bg-yellow-500/10 p-3"> <div className="rounded-lg bg-yellow-500/10 p-3">
<p className="mb-1 font-medium text-xs text-yellow-600">Warnings</p> <p className="mb-1 font-medium text-xs text-yellow-600">
<Trans>Warnings</Trans>
</p>
<ul className="space-y-0.5 text-xs text-yellow-600/80"> <ul className="space-y-0.5 text-xs text-yellow-600/80">
{warnings.map((w, i) => ( {warnings.map((w, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static display list // biome-ignore lint/suspicious/noArrayIndexKey: static display list
@@ -808,10 +834,10 @@ function PreviewStep({
<DialogFooter> <DialogFooter>
<DialogClose render={<Button variant="outline" />} onClick={onCancel}> <DialogClose render={<Button variant="outline" />} onClick={onCancel}>
Cancel <Trans>Cancel</Trans>
</DialogClose> </DialogClose>
<Button onClick={onImport} disabled={totalItems === 0}> <Button onClick={onImport} disabled={totalItems === 0}>
Import {totalItems} items <Trans>Import {totalItems} items</Trans>
</Button> </Button>
</DialogFooter> </DialogFooter>
</> </>
@@ -833,10 +859,14 @@ function ImportingStep({
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Importing from {source}</DialogTitle> <DialogTitle>
<Trans>Importing from {source}</Trans>
</DialogTitle>
<DialogDescription> <DialogDescription>
This may take a few minutes for large libraries. Please don't close <Trans>
this tab. This may take a few minutes for large libraries. Please don't close
this tab.
</Trans>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col items-center gap-4 py-8"> <div className="flex flex-col items-center gap-4 py-8">
@@ -855,7 +885,7 @@ function ImportingStep({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Spinner className="size-3" /> <Spinner className="size-3" />
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Starting import... <Trans>Starting import...</Trans>
</p> </p>
</div> </div>
)} )}
@@ -874,24 +904,29 @@ function DoneStep({
result: ImportResult; result: ImportResult;
onClose: () => void; onClose: () => void;
}) { }) {
const { t } = useLingui();
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Import complete</DialogTitle> <DialogTitle>
<DialogDescription>Finished importing from {source}.</DialogDescription> <Trans>Import complete</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Finished importing from {source}.</Trans>
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<StatBadge label="Imported" count={result.imported} /> <StatBadge label={t`Imported`} count={result.imported} />
<StatBadge label="Skipped" count={result.skipped} /> <StatBadge label={t`Skipped`} count={result.skipped} />
<StatBadge label="Failed" count={result.failed} /> <StatBadge label={t`Failed`} count={result.failed} />
</div> </div>
{result.errors.length > 0 && ( {result.errors.length > 0 && (
<div className="max-h-40 overflow-y-auto rounded-lg bg-destructive/10 p-3"> <div className="max-h-40 overflow-y-auto rounded-lg bg-destructive/10 p-3">
<p className="mb-1 font-medium text-destructive text-xs"> <p className="mb-1 font-medium text-destructive text-xs">
Errors ({result.errors.length}) <Trans>Errors ({result.errors.length})</Trans>
</p> </p>
<ul className="space-y-0.5 text-destructive/80 text-xs"> <ul className="space-y-0.5 text-destructive/80 text-xs">
{result.errors.slice(0, 50).map((e, i) => ( {result.errors.slice(0, 50).map((e, i) => (
@@ -899,7 +934,9 @@ function DoneStep({
<li key={i}>{e}</li> <li key={i}>{e}</li>
))} ))}
{result.errors.length > 50 && ( {result.errors.length > 50 && (
<li>...and {result.errors.length - 50} more</li> <li>
<Trans>...and {result.errors.length - 50} more</Trans>
</li>
)} )}
</ul> </ul>
</div> </div>
@@ -908,7 +945,7 @@ function DoneStep({
{result.warnings.length > 0 && ( {result.warnings.length > 0 && (
<div className="max-h-32 overflow-y-auto rounded-lg bg-yellow-500/10 p-3"> <div className="max-h-32 overflow-y-auto rounded-lg bg-yellow-500/10 p-3">
<p className="mb-1 font-medium text-xs text-yellow-600"> <p className="mb-1 font-medium text-xs text-yellow-600">
Warnings ({result.warnings.length}) <Trans>Warnings ({result.warnings.length})</Trans>
</p> </p>
<ul className="space-y-0.5 text-xs text-yellow-600/80"> <ul className="space-y-0.5 text-xs text-yellow-600/80">
{result.warnings.slice(0, 20).map((w, i) => ( {result.warnings.slice(0, 20).map((w, i) => (
@@ -921,7 +958,9 @@ function DoneStep({
</div> </div>
<DialogFooter> <DialogFooter>
<Button onClick={onClose}>Done</Button> <Button onClick={onClose}>
<Trans>Done</Trans>
</Button>
</DialogFooter> </DialogFooter>
</> </>
); );
@@ -1,3 +1,7 @@
import { msg } from "@lingui/core/macro";
import { Trans, useLingui } from "@lingui/react/macro";
import { i18n } from "@sofa/i18n";
import { formatRelativeTime } from "@sofa/i18n/format";
import { import {
IconBook2, IconBook2,
IconCheck, IconCheck,
@@ -8,7 +12,6 @@ import {
IconTrash, IconTrash,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { formatDistanceToNow } from "date-fns";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import type { ComponentType, ReactNode } from "react"; import type { ComponentType, ReactNode } from "react";
import { useState } from "react"; import { useState } from "react";
@@ -87,6 +90,7 @@ export function IntegrationCard({
connection: IntegrationConnection | null; connection: IntegrationConnection | null;
setConnections: React.Dispatch<React.SetStateAction<IntegrationConnection[]>>; setConnections: React.Dispatch<React.SetStateAction<IntegrationConnection[]>>;
}) { }) {
const { t } = useLingui();
const { provider, label } = config; const { provider, label } = config;
const providerInput = provider as const providerInput = provider as
| "plex" | "plex"
@@ -99,9 +103,9 @@ export function IntegrationCard({
orpc.integrations.create.mutationOptions({ orpc.integrations.create.mutationOptions({
onSuccess: (result) => { onSuccess: (result) => {
setConnections((prev) => [...prev, { ...result, recentEvents: [] }]); setConnections((prev) => [...prev, { ...result, recentEvents: [] }]);
toast.success(`${label} connected`); toast.success(t`${label} connected`);
}, },
onError: () => toast.error(`Failed to connect ${label}`), onError: () => toast.error(t`Failed to connect ${label}`),
}), }),
); );
@@ -115,10 +119,10 @@ export function IntegrationCard({
}); });
return { previous }; return { previous };
}, },
onSuccess: () => toast.success(`${label} disconnected`), onSuccess: () => toast.success(t`${label} disconnected`),
onError: (_, __, ctx) => { onError: (_, __, ctx) => {
if (ctx?.previous) setConnections(ctx.previous); if (ctx?.previous) setConnections(ctx.previous);
toast.error(`Failed to disconnect ${label}`); toast.error(t`Failed to disconnect ${label}`);
}, },
}), }),
); );
@@ -131,9 +135,9 @@ export function IntegrationCard({
c.provider === provider ? { ...c, token: result.token } : c, c.provider === provider ? { ...c, token: result.token } : c,
), ),
); );
toast.success(`${label} URL regenerated`); toast.success(t`${label} URL regenerated`);
}, },
onError: () => toast.error(`Failed to regenerate ${label} URL`), onError: () => toast.error(t`Failed to regenerate ${label} URL`),
}), }),
); );
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -169,7 +173,7 @@ export function IntegrationCard({
<CardDescription> <CardDescription>
{connection {connection
? config.connectedStatus(connection.lastEventAt) ? config.connectedStatus(connection.lastEventAt)
: "Not configured"} : t`Not configured`}
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
@@ -193,7 +197,11 @@ export function IntegrationCard({
size="lg" size="lg"
className="w-full" className="w-full"
> >
{connecting ? "Connecting\u2026" : `Connect ${config.label}`} {connecting ? (
<Trans>Connecting...</Trans>
) : (
<Trans>Connect {config.label}</Trans>
)}
</Button> </Button>
) : ( ) : (
<AnimatePresence> <AnimatePresence>
@@ -234,7 +242,9 @@ export function IntegrationCard({
<IconCopy /> <IconCopy />
)} )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Copy URL</TooltipContent> <TooltipContent>
<Trans>Copy URL</Trans>
</TooltipContent>
</Tooltip> </Tooltip>
</InputGroupAddon> </InputGroupAddon>
</InputGroup> </InputGroup>
@@ -251,7 +261,7 @@ export function IntegrationCard({
} }
> >
<IconRefresh /> <IconRefresh />
Regenerate URL <Trans>Regenerate URL</Trans>
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
@@ -261,7 +271,7 @@ export function IntegrationCard({
} }
> >
<IconTrash /> <IconTrash />
Disconnect <Trans>Disconnect</Trans>
</Button> </Button>
</div> </div>
</motion.div> </motion.div>
@@ -275,7 +285,7 @@ export function IntegrationCard({
aria-hidden={true} aria-hidden={true}
className={`size-3 transition-transform ${setupOpen ? "rotate-0" : "-rotate-90"}`} className={`size-3 transition-transform ${setupOpen ? "rotate-0" : "-rotate-90"}`}
/> />
Setup instructions <Trans>Setup instructions</Trans>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0"> <CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
<div className="mt-2 rounded-lg border border-border/50 bg-muted/30 p-3 text-muted-foreground text-xs leading-relaxed"> <div className="mt-2 rounded-lg border border-border/50 bg-muted/30 p-3 text-muted-foreground text-xs leading-relaxed">
@@ -288,14 +298,14 @@ export function IntegrationCard({
aria-hidden={true} aria-hidden={true}
className="mr-1 inline-block size-3 translate-y-[-1px]" className="mr-1 inline-block size-3 translate-y-[-1px]"
/> />
Need more help?{" "} <Trans>Need more help?</Trans>{" "}
<a <a
href={config.docsUrl} href={config.docsUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline" className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
> >
Open docs{" "} <Trans>Open docs</Trans>{" "}
<IconExternalLink <IconExternalLink
aria-hidden={true} aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]" className="inline-block size-3 translate-y-[-1px]"
@@ -318,13 +328,13 @@ export function IntegrationCard({
/** Status line for webhook integrations (shows last event time). */ /** Status line for webhook integrations (shows last event time). */
export function webhookStatus(lastEventAt: string | null): string { export function webhookStatus(lastEventAt: string | null): string {
return lastEventAt return lastEventAt
? `Last event ${formatDistanceToNow(new Date(lastEventAt), { addSuffix: true })}` ? i18n._(msg`Last event ${formatRelativeTime(lastEventAt)}`)
: "Ready \u2014 nothing received yet"; : i18n._(msg`Ready — nothing received yet`);
} }
/** Status line for list integrations (shows last event time). */ /** Status line for list integrations (shows last event time). */
export function listStatus(lastEventAt: string | null): string { export function listStatus(lastEventAt: string | null): string {
return lastEventAt return lastEventAt
? `Last polled ${formatDistanceToNow(new Date(lastEventAt), { addSuffix: true })}` ? i18n._(msg`Last polled ${formatRelativeTime(lastEventAt)}`)
: "Ready \u2014 not polled yet"; : i18n._(msg`Ready — not polled yet`);
} }
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { IconExternalLink, IconInfoCircle } from "@tabler/icons-react"; import { IconExternalLink, IconInfoCircle } from "@tabler/icons-react";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { import {
@@ -37,46 +38,55 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
connectedStatus: webhookStatus, connectedStatus: webhookStatus,
alert: ( alert: (
<RequirementAlert> <RequirementAlert>
Requires an active{" "} <Trans>
<a Requires an active{" "}
href="https://www.plex.tv/plex-pass/" <a
target="_blank" href="https://www.plex.tv/plex-pass/"
rel="noopener noreferrer" target="_blank"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline" rel="noopener noreferrer"
> className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
Plex Pass >
<IconExternalLink Plex Pass
aria-hidden={true} <IconExternalLink
className="inline-block size-3 translate-y-[-1px]" aria-hidden={true}
/> className="inline-block size-3 translate-y-[-1px]"
</a>{" "} />
subscription. </a>{" "}
subscription.
</Trans>
</RequirementAlert> </RequirementAlert>
), ),
setupSteps: ( setupSteps: (
<> <>
<li> <li>
Open Plex, go to{" "} <Trans>
<a Open Plex, go to{" "}
href="https://app.plex.tv/desktop/#!/settings/webhooks" <a
target="_blank" href="https://app.plex.tv/desktop/#!/settings/webhooks"
rel="noopener noreferrer" target="_blank"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline" rel="noopener noreferrer"
> className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
Settings &gt; Webhooks >
<IconExternalLink Settings &gt; Webhooks
aria-hidden={true} <IconExternalLink
className="inline-block size-3 translate-y-[-1px]" aria-hidden={true}
/> className="inline-block size-3 translate-y-[-1px]"
</a> />
</a>
</Trans>
</li> </li>
<li> <li>
Click <span className="font-medium text-foreground">Add Webhook</span>{" "} <Trans>
and paste the URL above Click{" "}
<span className="font-medium text-foreground">Add Webhook</span> and
paste the URL above
</Trans>
</li> </li>
<li> <li>
Sofa will automatically log movies and episodes when you finish <Trans>
watching them Sofa will automatically log movies and episodes when you finish
watching them
</Trans>
</li> </li>
</> </>
), ),
@@ -92,42 +102,52 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
setupSteps: ( setupSteps: (
<> <>
<li> <li>
Install the{" "} <Trans>
<a Install the{" "}
href="https://github.com/jellyfin/jellyfin-plugin-webhook/tree/master" <a
target="_blank" href="https://github.com/jellyfin/jellyfin-plugin-webhook/tree/master"
rel="noopener noreferrer" target="_blank"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline" rel="noopener noreferrer"
> className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
Webhook plugin >
<IconExternalLink Webhook plugin
aria-hidden={true} <IconExternalLink
className="inline-block size-3 translate-y-[-1px]" aria-hidden={true}
/> className="inline-block size-3 translate-y-[-1px]"
</a>{" "} />
from Jellyfin&apos;s plugin catalog </a>{" "}
from Jellyfin's plugin catalog
</Trans>
</li> </li>
<li> <li>
Go to{" "} <Trans>
<span className="font-medium text-foreground"> Go to{" "}
Dashboard &gt; Plugins &gt; Webhook <span className="font-medium text-foreground">
</span> Dashboard &gt; Plugins &gt; Webhook
</span>
</Trans>
</li> </li>
<li> <li>
Add a{" "} <Trans>
<span className="font-medium text-foreground"> Add a{" "}
Generic Destination <span className="font-medium text-foreground">
</span>{" "} Generic Destination
and paste the URL above </span>{" "}
and paste the URL above
</Trans>
</li> </li>
<li> <li>
Enable the{" "} <Trans>
<span className="font-medium text-foreground">Playback Stop</span>{" "} Enable the{" "}
notification type <span className="font-medium text-foreground">Playback Stop</span>{" "}
notification type
</Trans>
</li> </li>
<li> <li>
Sofa will automatically log movies and episodes when you finish <Trans>
watching them Sofa will automatically log movies and episodes when you finish
watching them
</Trans>
</li> </li>
</> </>
), ),
@@ -142,41 +162,53 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
connectedStatus: webhookStatus, connectedStatus: webhookStatus,
alert: ( alert: (
<RequirementAlert> <RequirementAlert>
Requires{" "} <Trans>
<span className="font-medium text-foreground">Emby Server 4.7.9+</span>{" "} Requires{" "}
and an active{" "} <span className="font-medium text-foreground">
<a Emby Server 4.7.9+
href="https://emby.media/premiere.html" </span>{" "}
target="_blank" and an active{" "}
rel="noopener noreferrer" <a
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline" href="https://emby.media/premiere.html"
> target="_blank"
Emby Premiere rel="noopener noreferrer"
<IconExternalLink className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
aria-hidden={true} >
className="inline-block size-3 translate-y-[-1px]" Emby Premiere
/> <IconExternalLink
</a>{" "} aria-hidden={true}
license. className="inline-block size-3 translate-y-[-1px]"
/>
</a>{" "}
license.
</Trans>
</RequirementAlert> </RequirementAlert>
), ),
setupSteps: ( setupSteps: (
<> <>
<li> <li>
Open Emby, go to{" "} <Trans>
<span className="font-medium text-foreground"> Open Emby, go to{" "}
Settings &gt; Webhooks <span className="font-medium text-foreground">
</span> Settings &gt; Webhooks
</li> </span>
<li>Add a new webhook and paste the URL above</li> </Trans>
<li>
Enable the{" "}
<span className="font-medium text-foreground">Playback</span> event
category
</li> </li>
<li> <li>
Sofa will automatically log movies and episodes when you finish <Trans>Add a new webhook and paste the URL above</Trans>
watching them </li>
<li>
<Trans>
Enable the{" "}
<span className="font-medium text-foreground">Playback</span> event
category
</Trans>
</li>
<li>
<Trans>
Sofa will automatically log movies and episodes when you finish
watching them
</Trans>
</li> </li>
</> </>
), ),
@@ -194,21 +226,31 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
setupSteps: ( setupSteps: (
<> <>
<li> <li>
Open Sonarr, go to{" "} <Trans>
<span className="font-medium text-foreground"> Open Sonarr, go to{" "}
Settings &gt; Import Lists <span className="font-medium text-foreground">
</span> Settings &gt; Import Lists
</span>
</Trans>
</li> </li>
<li> <li>
Click <span className="font-medium text-foreground">+</span> and <Trans>
select{" "} Click <span className="font-medium text-foreground">+</span> and
<span className="font-medium text-foreground">Custom Lists</span> select{" "}
<span className="font-medium text-foreground">Custom Lists</span>
</Trans>
</li> </li>
<li>Paste the Sonarr URL above into the List URL field</li>
<li>Set your preferred quality profile and root folder</li>
<li> <li>
Titles on your Sofa watchlist will be automatically added for download <Trans>Paste the Sonarr URL above into the List URL field</Trans>
when Sonarr polls this list (every 6 hours by default) </li>
<li>
<Trans>Set your preferred quality profile and root folder</Trans>
</li>
<li>
<Trans>
Titles on your Sofa watchlist will be automatically added for
download when Sonarr polls this list (every 6 hours by default)
</Trans>
</li> </li>
</> </>
), ),
@@ -224,21 +266,31 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
setupSteps: ( setupSteps: (
<> <>
<li> <li>
Open Radarr, go to{" "} <Trans>
<span className="font-medium text-foreground"> Open Radarr, go to{" "}
Settings &gt; Import Lists <span className="font-medium text-foreground">
</span> Settings &gt; Import Lists
</span>
</Trans>
</li> </li>
<li> <li>
Click <span className="font-medium text-foreground">+</span> and <Trans>
select{" "} Click <span className="font-medium text-foreground">+</span> and
<span className="font-medium text-foreground">Custom Lists</span> select{" "}
<span className="font-medium text-foreground">Custom Lists</span>
</Trans>
</li> </li>
<li>Paste the Radarr URL above into the List URL field</li>
<li>Set your preferred quality profile and root folder</li>
<li> <li>
Titles on your Sofa watchlist will be automatically added for download <Trans>Paste the Radarr URL above into the List URL field</Trans>
when Radarr polls this list (every 12 hours by default) </li>
<li>
<Trans>Set your preferred quality profile and root folder</Trans>
</li>
<li>
<Trans>
Titles on your Sofa watchlist will be automatically added for
download when Radarr polls this list (every 12 hours by default)
</Trans>
</li> </li>
</> </>
), ),
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { IconWebhook } from "@tabler/icons-react"; import { IconWebhook } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
@@ -37,7 +38,7 @@ export function IntegrationsSection() {
className="size-4 text-muted-foreground" className="size-4 text-muted-foreground"
/> />
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider"> <h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
Integrations <Trans>Integrations</Trans>
</h2> </h2>
</div> </div>
{isPending ? ( {isPending ? (
@@ -0,0 +1,91 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { activateLocale, type SupportedLocale } from "@sofa/i18n";
import { LOCALE_INFO } from "@sofa/i18n/locales";
import { IconCheck, IconChevronDown, IconLanguage } from "@tabler/icons-react";
import { useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardTitle,
} from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { setPersistedLocale } from "@/lib/i18n";
export function LanguageSection() {
const { t, i18n } = useLingui();
const currentLocale = i18n.locale;
const [open, setOpen] = useState(false);
const currentInfo = LOCALE_INFO.find((l) => l.code === currentLocale);
async function handleLocaleChange(locale: SupportedLocale) {
await activateLocale(locale);
setPersistedLocale(locale);
}
return (
<Card>
<Collapsible open={open} onOpenChange={setOpen}>
<CardContent className={open ? "pb-4" : ""}>
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconLanguage className="size-4 text-primary" />
</div>
<div className="text-left">
<CardTitle>
<Trans>Language</Trans>
</CardTitle>
<CardDescription>
{currentInfo?.nativeName ?? "English"}
</CardDescription>
</div>
</div>
<IconChevronDown
aria-hidden={true}
className={`size-4 text-muted-foreground transition-transform duration-200 ${open ? "rotate-180" : ""}`}
/>
</CollapsibleTrigger>
</CardContent>
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
<CardContent className="border-border/30 border-t pt-4">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{LOCALE_INFO.map((info) => (
<button
key={info.code}
type="button"
onClick={() => handleLocaleChange(info.code)}
className={`relative flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
currentLocale === info.code
? "border-primary bg-primary/5 text-foreground"
: "border-border hover:border-primary/40 hover:bg-primary/5"
}`}
aria-label={t`Switch to ${info.name}`}
aria-pressed={currentLocale === info.code}
>
<div className="min-w-0">
<div className="truncate font-medium">
{info.nativeName}
</div>
<div className="truncate text-muted-foreground text-xs">
{info.name}
</div>
</div>
{currentLocale === info.code && (
<IconCheck className="ml-auto size-4 shrink-0 text-primary" />
)}
</button>
))}
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { IconDoorEnter } from "@tabler/icons-react"; import { IconDoorEnter } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { useOptimistic, useState, useTransition } from "react"; import { useOptimistic, useState, useTransition } from "react";
@@ -8,6 +9,7 @@ import { Switch } from "@/components/ui/switch";
import { orpc } from "@/lib/orpc/client"; import { orpc } from "@/lib/orpc/client";
export function RegistrationSection() { export function RegistrationSection() {
const { t } = useLingui();
const { data, isPending: isLoading } = useQuery( const { data, isPending: isLoading } = useQuery(
orpc.admin.registration.queryOptions(), orpc.admin.registration.queryOptions(),
); );
@@ -35,9 +37,11 @@ export function RegistrationSection() {
try { try {
await toggleMutation.mutateAsync({ open: checked }); await toggleMutation.mutateAsync({ open: checked });
setRegistrationOpen(checked); setRegistrationOpen(checked);
toast.success(checked ? "Registration opened" : "Registration closed"); toast.success(
checked ? t`Registration opened` : t`Registration closed`,
);
} catch { } catch {
toast.error("Failed to update registration setting"); toast.error(t`Failed to update registration setting`);
} }
}); });
} }
@@ -50,9 +54,11 @@ export function RegistrationSection() {
<IconDoorEnter aria-hidden={true} className="size-4 text-primary" /> <IconDoorEnter aria-hidden={true} className="size-4 text-primary" />
</div> </div>
<div> <div>
<CardTitle>Open registration</CardTitle> <CardTitle>
<Trans>Open registration</Trans>
</CardTitle>
<CardDescription> <CardDescription>
Allow new users to create accounts <Trans>Allow new users to create accounts</Trans>
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
@@ -60,7 +66,7 @@ export function RegistrationSection() {
checked={optimisticOpen} checked={optimisticOpen}
onCheckedChange={handleToggle} onCheckedChange={handleToggle}
disabled={isPending} disabled={isPending}
aria-label="Toggle open registration" aria-label={t`Toggle open registration`}
/> />
</div> </div>
</CardContent> </CardContent>
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { IconSettings } from "@tabler/icons-react"; import { IconSettings } from "@tabler/icons-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { Children, type ReactNode } from "react"; import { Children, type ReactNode } from "react";
@@ -32,11 +33,11 @@ export function SettingsShell({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<IconSettings aria-hidden={true} className="size-5 text-primary" /> <IconSettings aria-hidden={true} className="size-5 text-primary" />
<h1 className="text-balance font-display text-3xl tracking-tight"> <h1 className="text-balance font-display text-3xl tracking-tight">
Settings <Trans>Settings</Trans>
</h1> </h1>
</div> </div>
<p className="mt-1 text-muted-foreground text-sm"> <p className="mt-1 text-muted-foreground text-sm">
Manage your account and preferences <Trans>Manage your account and preferences</Trans>
</p> </p>
</motion.div> </motion.div>
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { CronJobName, SystemHealthData } from "@sofa/api/schemas"; import type { CronJobName, SystemHealthData } from "@sofa/api/schemas";
import { import {
IconActivity, IconActivity,
@@ -36,16 +37,6 @@ import {
import { useTimeAgo } from "@/hooks/use-time-ago"; import { useTimeAgo } from "@/hooks/use-time-ago";
import { orpc } from "@/lib/orpc/client"; import { orpc } from "@/lib/orpc/client";
const JOB_LABELS: Record<string, string> = {
nightlyRefreshLibrary: "Library refresh",
refreshAvailability: "Availability",
refreshRecommendations: "Recommendations",
refreshTvChildren: "TV episodes",
cacheImages: "Image cache",
scheduledBackup: "Backup",
updateCheck: "Update check",
};
/** Convert a cron pattern to a short human-readable string */ /** Convert a cron pattern to a short human-readable string */
function cronToHuman(pattern: string): string { function cronToHuman(pattern: string): string {
const parts = pattern.split(" "); const parts = pattern.split(" ");
@@ -170,6 +161,7 @@ function RefreshButton({
isRefreshing: boolean; isRefreshing: boolean;
onRefresh: () => void; onRefresh: () => void;
}) { }) {
const { t } = useLingui();
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
@@ -177,7 +169,7 @@ function RefreshButton({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label="Refresh system health" aria-label={t`Refresh system health`}
onClick={onRefresh} onClick={onRefresh}
disabled={isRefreshing} disabled={isRefreshing}
className="text-muted-foreground" className="text-muted-foreground"
@@ -186,7 +178,9 @@ function RefreshButton({
> >
{isRefreshing ? <Spinner /> : <IconRefresh />} {isRefreshing ? <Spinner /> : <IconRefresh />}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Refresh</TooltipContent> <TooltipContent>
<Trans>Refresh</Trans>
</TooltipContent>
</Tooltip> </Tooltip>
); );
} }
@@ -214,9 +208,13 @@ function SystemStatusCard({
/> />
</div> </div>
<div> <div>
<CardTitle>Health status</CardTitle> <CardTitle>
<Trans>Health status</Trans>
</CardTitle>
<CardDescription suppressHydrationWarning> <CardDescription suppressHydrationWarning>
Checked <LiveTimeAgo date={checkedAt} /> <Trans>
Checked <LiveTimeAgo date={checkedAt} />
</Trans>
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
@@ -228,7 +226,7 @@ function SystemStatusCard({
<CardContent className="border-border/30 border-t pt-4"> <CardContent className="border-border/30 border-t pt-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider"> <span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
Database <Trans>Database</Trans>
</span> </span>
<span className="font-mono text-[11px] text-muted-foreground"> <span className="font-mono text-[11px] text-muted-foreground">
{formatBytes(database.dbSizeBytes)} {formatBytes(database.dbSizeBytes)}
@@ -248,13 +246,15 @@ function SystemStatusCard({
<> <>
<StatusDot status="error" /> <StatusDot status="error" />
<span className="text-muted-foreground/50 text-xs"> <span className="text-muted-foreground/50 text-xs">
Not configured <Trans>Not configured</Trans>
</span> </span>
</> </>
) : tmdb.connected && tmdb.tokenValid ? ( ) : tmdb.connected && tmdb.tokenValid ? (
<> <>
<StatusDot status="ok" /> <StatusDot status="ok" />
<span className="text-muted-foreground text-xs">Connected</span> <span className="text-muted-foreground text-xs">
<Trans>Connected</Trans>
</span>
<span className="font-mono text-[11px] text-muted-foreground/80"> <span className="font-mono text-[11px] text-muted-foreground/80">
{tmdb.responseTimeMs}ms {tmdb.responseTimeMs}ms
</span> </span>
@@ -262,12 +262,16 @@ function SystemStatusCard({
) : tmdb.connected && !tmdb.tokenValid ? ( ) : tmdb.connected && !tmdb.tokenValid ? (
<> <>
<StatusDot status="error" /> <StatusDot status="error" />
<span className="text-destructive text-xs">Invalid token</span> <span className="text-destructive text-xs">
<Trans>Invalid token</Trans>
</span>
</> </>
) : ( ) : (
<> <>
<StatusDot status="error" /> <StatusDot status="error" />
<span className="text-destructive text-xs">Unreachable</span> <span className="text-destructive text-xs">
<Trans>Unreachable</Trans>
</span>
{tmdb.error && ( {tmdb.error && (
<span className="text-[11px] text-muted-foreground/50"> <span className="text-[11px] text-muted-foreground/50">
{tmdb.error} {tmdb.error}
@@ -282,7 +286,7 @@ function SystemStatusCard({
<CardContent className="border-border/30 border-t pt-4"> <CardContent className="border-border/30 border-t pt-4">
<div className="space-y-2"> <div className="space-y-2">
<span className="inline-flex items-center gap-1.5 font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider"> <span className="inline-flex items-center gap-1.5 font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
Environment <Trans>Environment</Trans>
{environment.dataDirWritable ? ( {environment.dataDirWritable ? (
<IconCheck aria-hidden={true} className="size-3 text-green-500" /> <IconCheck aria-hidden={true} className="size-3 text-green-500" />
) : ( ) : (
@@ -321,15 +325,27 @@ function BackgroundJobsCard({
isRefreshing: boolean; isRefreshing: boolean;
onRefresh: () => void; onRefresh: () => void;
}) { }) {
const { t } = useLingui();
const JOB_LABELS: Record<string, string> = {
nightlyRefreshLibrary: t`Library refresh`,
refreshAvailability: t`Availability`,
refreshRecommendations: t`Recommendations`,
refreshTvChildren: t`TV episodes`,
cacheImages: t`Image cache`,
scheduledBackup: t`Backup`,
updateCheck: t`Update check`,
};
const triggerJobMutation = useMutation( const triggerJobMutation = useMutation(
orpc.admin.triggerJob.mutationOptions({ orpc.admin.triggerJob.mutationOptions({
onSuccess: (_, { name }) => { onSuccess: (_, { name }) => {
toast.success(`${JOB_LABELS[name] ?? name} triggered`); toast.success(t`${JOB_LABELS[name] ?? name} triggered`);
setTimeout(onRefresh, 1500); setTimeout(onRefresh, 1500);
}, },
onError: (err) => { onError: (err) => {
toast.error( toast.error(
err instanceof Error ? err.message : "Failed to trigger job", err instanceof Error ? err.message : t`Failed to trigger job`,
); );
}, },
}), }),
@@ -362,9 +378,13 @@ function BackgroundJobsCard({
/> />
</div> </div>
<div> <div>
<CardTitle>Background jobs</CardTitle> <CardTitle>
<Trans>Background jobs</Trans>
</CardTitle>
<CardDescription> <CardDescription>
{healthyCount} of {activeJobs.length} jobs healthy <Trans>
{healthyCount} of {activeJobs.length} jobs healthy
</Trans>
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
@@ -376,19 +396,21 @@ function BackgroundJobsCard({
<TableHeader> <TableHeader>
<TableRow className="border-b-border/30 hover:bg-transparent"> <TableRow className="border-b-border/30 hover:bg-transparent">
<TableHead className="h-8 pl-5 font-medium text-[10px] text-muted-foreground uppercase tracking-wider"> <TableHead className="h-8 pl-5 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
Job <Trans>Job</Trans>
</TableHead> </TableHead>
<TableHead className="h-8 font-medium text-[10px] text-muted-foreground uppercase tracking-wider"> <TableHead className="h-8 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
Schedule <Trans>Schedule</Trans>
</TableHead> </TableHead>
<TableHead className="h-8 font-medium text-[10px] text-muted-foreground uppercase tracking-wider"> <TableHead className="h-8 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
Last run <Trans>Last run</Trans>
</TableHead> </TableHead>
<TableHead className="h-8 font-medium text-[10px] text-muted-foreground uppercase tracking-wider"> <TableHead className="h-8 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
Next run <Trans>Next run</Trans>
</TableHead> </TableHead>
<TableHead className="h-8 pr-5 text-right font-medium text-[10px] text-muted-foreground uppercase tracking-wider"> <TableHead className="h-8 pr-5 text-right font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
<span className="sr-only">Actions</span> <span className="sr-only">
<Trans>Actions</Trans>
</span>
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -406,17 +428,17 @@ function BackgroundJobsCard({
<TableCell className="pl-5"> <TableCell className="pl-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{job.disabled ? ( {job.disabled ? (
<StatusDot status="inactive" label="Disabled" /> <StatusDot status="inactive" label={t`Disabled`} />
) : isRunning ? ( ) : isRunning ? (
<Spinner className="size-2.5" /> <Spinner className="size-2.5" />
) : job.lastStatus === null ? ( ) : job.lastStatus === null ? (
<StatusDot status="warn" label="Never run" /> <StatusDot status="warn" label={t`Never run`} />
) : job.lastStatus === "success" ? ( ) : job.lastStatus === "success" ? (
<StatusDot status="ok" label="Last run succeeded" /> <StatusDot status="ok" label={t`Last run succeeded`} />
) : ( ) : (
<StatusDot <StatusDot
status="error" status="error"
label={job.lastError ?? "Last run failed"} label={job.lastError ?? t`Last run failed`}
/> />
)} )}
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
@@ -476,7 +498,7 @@ function BackgroundJobsCard({
</Tooltip> </Tooltip>
) : ( ) : (
<span className="text-muted-foreground/50 text-xs"> <span className="text-muted-foreground/50 text-xs">
Never <Trans>Never</Trans>
</span> </span>
)} )}
</TableCell> </TableCell>
@@ -512,7 +534,7 @@ function BackgroundJobsCard({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label="Trigger job" aria-label={t`Trigger job`}
className="size-6" className="size-6"
disabled={isRunning || job.disabled} disabled={isRunning || job.disabled}
onClick={() => onClick={() =>
@@ -532,7 +554,9 @@ function BackgroundJobsCard({
/> />
)} )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Run now</TooltipContent> <TooltipContent>
<Trans>Run now</Trans>
</TooltipContent>
</Tooltip> </Tooltip>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -554,6 +578,7 @@ function StorageCard({
isRefreshing: boolean; isRefreshing: boolean;
onRefresh: () => void; onRefresh: () => void;
}) { }) {
const { t } = useLingui();
return ( return (
<Card className="border-l-2 border-l-primary/30"> <Card className="border-l-2 border-l-primary/30">
<CardContent> <CardContent>
@@ -566,9 +591,11 @@ function StorageCard({
/> />
</div> </div>
<div> <div>
<CardTitle>Storage</CardTitle> <CardTitle>
<Trans>Storage</Trans>
</CardTitle>
<CardDescription> <CardDescription>
Image cache and backup disk usage <Trans>Image cache and backup disk usage</Trans>
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
@@ -580,7 +607,7 @@ function StorageCard({
<CardContent className="border-border/30 border-t pt-4"> <CardContent className="border-border/30 border-t pt-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider"> <span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
Image cache <Trans>Image cache</Trans>
</span> </span>
{imageCache.enabled ? ( {imageCache.enabled ? (
<span className="font-mono text-[11px] text-muted-foreground/50"> <span className="font-mono text-[11px] text-muted-foreground/50">
@@ -591,7 +618,7 @@ function StorageCard({
{imageCache.enabled ? ( {imageCache.enabled ? (
<> <>
<p className="mt-1 text-muted-foreground text-xs"> <p className="mt-1 text-muted-foreground text-xs">
{imageCache.imageCount.toLocaleString()} cached images {t`${imageCache.imageCount.toLocaleString()} cached images`}
</p> </p>
<p className="mt-0.5 text-[10px] text-muted-foreground/50 leading-relaxed"> <p className="mt-0.5 text-[10px] text-muted-foreground/50 leading-relaxed">
{Object.entries(imageCache.categories) {Object.entries(imageCache.categories)
@@ -602,7 +629,7 @@ function StorageCard({
) : ( ) : (
<p className="mt-1 flex items-center gap-1.5 text-muted-foreground/50 text-xs"> <p className="mt-1 flex items-center gap-1.5 text-muted-foreground/50 text-xs">
<StatusDot status="inactive" /> <StatusDot status="inactive" />
Disabled <Trans>Disabled</Trans>
</p> </p>
)} )}
</CardContent> </CardContent>
@@ -611,7 +638,7 @@ function StorageCard({
<CardContent className="border-border/30 border-t pt-4"> <CardContent className="border-border/30 border-t pt-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider"> <span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
Backups <Trans>Backups</Trans>
</span> </span>
{backups.backupCount > 0 && ( {backups.backupCount > 0 && (
<span className="font-mono text-[11px] text-muted-foreground/50"> <span className="font-mono text-[11px] text-muted-foreground/50">
@@ -624,13 +651,15 @@ function StorageCard({
className="mt-1 text-muted-foreground text-xs" className="mt-1 text-muted-foreground text-xs"
suppressHydrationWarning suppressHydrationWarning
> >
{backups.backupCount} backups · last{" "} <Trans>
<LiveTimeAgo date={backups.lastBackupAt} fallback="unknown" /> {backups.backupCount} backups · last{" "}
<LiveTimeAgo date={backups.lastBackupAt} fallback={t`unknown`} />
</Trans>
</p> </p>
) : ( ) : (
<p className="mt-1 flex items-center gap-1.5 text-muted-foreground/50 text-xs"> <p className="mt-1 flex items-center gap-1.5 text-muted-foreground/50 text-xs">
<StatusDot status="inactive" /> <StatusDot status="inactive" />
No backups yet <Trans>No backups yet</Trans>
</p> </p>
)} )}
</CardContent> </CardContent>
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { IconWorldUpload } from "@tabler/icons-react"; import { IconWorldUpload } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { useOptimistic, useState, useTransition } from "react"; import { useOptimistic, useState, useTransition } from "react";
@@ -8,6 +9,7 @@ import { Switch } from "@/components/ui/switch";
import { orpc } from "@/lib/orpc/client"; import { orpc } from "@/lib/orpc/client";
export function UpdateCheckSection() { export function UpdateCheckSection() {
const { t } = useLingui();
const { data, isPending: isLoading } = useQuery( const { data, isPending: isLoading } = useQuery(
orpc.admin.updateCheck.queryOptions(), orpc.admin.updateCheck.queryOptions(),
); );
@@ -35,10 +37,10 @@ export function UpdateCheckSection() {
await toggleMutation.mutateAsync({ enabled: checked }); await toggleMutation.mutateAsync({ enabled: checked });
setLocalEnabled(checked); setLocalEnabled(checked);
toast.success( toast.success(
checked ? "Update checks enabled" : "Update checks disabled", checked ? t`Update checks enabled` : t`Update checks disabled`,
); );
} catch { } catch {
toast.error("Failed to update setting"); toast.error(t`Failed to update setting`);
} }
}); });
} }
@@ -54,9 +56,11 @@ export function UpdateCheckSection() {
/> />
</div> </div>
<div> <div>
<CardTitle>Automatic update checks</CardTitle> <CardTitle>
<Trans>Automatic update checks</Trans>
</CardTitle>
<CardDescription> <CardDescription>
Periodically check GitHub for new Sofa releases <Trans>Periodically check GitHub for new Sofa releases</Trans>
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
@@ -64,7 +68,7 @@ export function UpdateCheckSection() {
checked={optimisticEnabled} checked={optimisticEnabled}
onCheckedChange={handleToggle} onCheckedChange={handleToggle}
disabled={isPending} disabled={isPending}
aria-label="Toggle automatic update checks" aria-label={t`Toggle automatic update checks`}
/> />
</div> </div>
</CardContent> </CardContent>
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { IconCheck, IconCopy } from "@tabler/icons-react"; import { IconCheck, IconCopy } from "@tabler/icons-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -21,12 +22,12 @@ export function CopyButton({ code }: { code: string }) {
{copied ? ( {copied ? (
<> <>
<IconCheck aria-hidden={true} className="size-3 text-green-400" /> <IconCheck aria-hidden={true} className="size-3 text-green-400" />
Copied <Trans>Copied</Trans>
</> </>
) : ( ) : (
<> <>
<IconCopy aria-hidden={true} className="size-3" /> <IconCopy aria-hidden={true} className="size-3" />
Copy <Trans>Copy</Trans>
</> </>
)} )}
</Button> </Button>
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { IconRefresh } from "@tabler/icons-react"; import { IconRefresh } from "@tabler/icons-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -23,7 +24,11 @@ export function RefreshButton() {
) : ( ) : (
<IconRefresh aria-hidden={true} className="size-3.5" /> <IconRefresh aria-hidden={true} className="size-3.5" />
)} )}
{isRefreshing ? "Checking…" : "Check configuration"} {isRefreshing ? (
<Trans>Checking</Trans>
) : (
<Trans>Check configuration</Trans>
)}
</Button> </Button>
); );
} }
+27 -19
View File
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { import {
IconBookmarkFilled, IconBookmarkFilled,
IconCircleCheckFilled, IconCircleCheckFilled,
@@ -58,23 +59,26 @@ export interface TitleCardProps extends CardInnerProps {
id: string; id: string;
} }
const statusConfig = { function useStatusConfig() {
watchlist: { const { t } = useLingui();
icon: IconBookmarkFilled, return {
label: "On Watchlist", watchlist: {
badgeClass: "bg-status-watching/90 text-white", icon: IconBookmarkFilled,
}, label: t`On Watchlist`,
in_progress: { badgeClass: "bg-status-watching/90 text-white",
icon: IconPlayerPlayFilled, },
label: "Watching", in_progress: {
badgeClass: "bg-status-watching/90 text-white", icon: IconPlayerPlayFilled,
}, label: t`Watching`,
completed: { badgeClass: "bg-status-watching/90 text-white",
icon: IconCircleCheckFilled, },
label: "Completed", completed: {
badgeClass: "bg-status-completed/90 text-white", icon: IconCircleCheckFilled,
}, label: t`Completed`,
} as const; badgeClass: "bg-status-completed/90 text-white",
},
} as const;
}
function QuickAddButton({ function QuickAddButton({
id, id,
@@ -83,6 +87,8 @@ function QuickAddButton({
id: string; id: string;
userStatus?: TitleStatus | null; userStatus?: TitleStatus | null;
}) { }) {
const { t } = useLingui();
const statusConfig = useStatusConfig();
const [addedStatus, setAddedStatus] = useState<TitleStatus | null>( const [addedStatus, setAddedStatus] = useState<TitleStatus | null>(
userStatus ?? null, userStatus ?? null,
); );
@@ -137,12 +143,13 @@ function QuickAddButton({
<IconLoader className="size-4 animate-spin" /> <IconLoader className="size-4 animate-spin" />
)} )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom">Add to Watchlist</TooltipContent> <TooltipContent side="bottom">{t`Add to Watchlist`}</TooltipContent>
</Tooltip> </Tooltip>
); );
} }
function ProgressBar({ watched, total }: { watched: number; total: number }) { function ProgressBar({ watched, total }: { watched: number; total: number }) {
const { t } = useLingui();
const pct = total > 0 ? (watched / total) * 100 : 0; const pct = total > 0 ? (watched / total) * 100 : 0;
return ( return (
<Tooltip> <Tooltip>
@@ -156,7 +163,7 @@ function ProgressBar({ watched, total }: { watched: number; total: number }) {
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">
{watched}/{total} episodes {t`${watched}/${total} episodes`}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );
@@ -173,6 +180,7 @@ function CardInner({
episodeProgress, episodeProgress,
tiltStyles, tiltStyles,
}: CardInnerProps) { }: CardInnerProps) {
const statusConfig = useStatusConfig();
const year = releaseDate?.slice(0, 4); const year = releaseDate?.slice(0, 4);
const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv; const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv;
const placeholderUrl = thumbHashToUrl(posterThumbHash); const placeholderUrl = thumbHashToUrl(posterThumbHash);
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { CastMember } from "@sofa/api/schemas"; import type { CastMember } from "@sofa/api/schemas";
import { IconUser, IconUsers } from "@tabler/icons-react"; import { IconUser, IconUsers } from "@tabler/icons-react";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
@@ -11,11 +12,14 @@ interface CastCarouselProps {
} }
export function CastCarousel({ actors, titleType }: CastCarouselProps) { export function CastCarousel({ actors, titleType }: CastCarouselProps) {
const { t } = useLingui();
return ( return (
<section className="space-y-4"> <section className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<IconUsers aria-hidden={true} className="size-5 text-primary" /> <IconUsers aria-hidden={true} className="size-5 text-primary" />
<h2 className="font-display text-xl tracking-tight">Cast</h2> <h2 className="font-display text-xl tracking-tight">
<Trans>Cast</Trans>
</h2>
</div> </div>
{actors.length > 0 && ( {actors.length > 0 && (
@@ -73,8 +77,7 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
)} )}
{titleType === "tv" && member.episodeCount && ( {titleType === "tv" && member.episodeCount && (
<p className="text-[10px] text-muted-foreground/70"> <p className="text-[10px] text-muted-foreground/70">
{member.episodeCount} ep {t`${member.episodeCount} ep${member.episodeCount !== 1 ? "s" : ""}`}
{member.episodeCount !== 1 ? "s" : ""}
</p> </p>
)} )}
</div> </div>
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { import {
IconCheck, IconCheck,
IconPlayerPlayFilled, IconPlayerPlayFilled,
@@ -6,32 +7,34 @@ import {
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
const watchingStyle = {
label: "Watching",
icon: IconPlayerPlayFilled,
class: "text-status-watching",
bgClass: "bg-status-watching/10 hover:bg-status-watching/15",
borderClass: "ring-status-watching/20",
};
const statusConfig = {
watchlist: watchingStyle,
in_progress: watchingStyle,
completed: {
label: "Completed",
icon: IconCheck,
class: "text-status-completed",
bgClass: "bg-status-completed/10 hover:bg-status-completed/15",
borderClass: "ring-status-completed/20",
},
} as const;
interface StatusButtonProps { interface StatusButtonProps {
currentStatus: string | null; currentStatus: string | null;
onChange: (status: string | null) => void; onChange: (status: string | null) => void;
} }
export function StatusButton({ currentStatus, onChange }: StatusButtonProps) { export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
const { t } = useLingui();
const watchingStyle = {
label: t`Watching`,
icon: IconPlayerPlayFilled,
class: "text-status-watching",
bgClass: "bg-status-watching/10 hover:bg-status-watching/15",
borderClass: "ring-status-watching/20",
};
const statusConfig = {
watchlist: watchingStyle,
in_progress: watchingStyle,
completed: {
label: t`Completed`,
icon: IconCheck,
class: "text-status-completed",
bgClass: "bg-status-completed/10 hover:bg-status-completed/15",
borderClass: "ring-status-completed/20",
},
} as const;
const config = const config =
statusConfig[currentStatus as keyof typeof statusConfig] ?? null; statusConfig[currentStatus as keyof typeof statusConfig] ?? null;
@@ -49,7 +52,7 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary/10 px-4 font-medium text-primary text-sm ring-1 ring-primary/20 transition-all hover:bg-primary/15 hover:ring-primary/30 active:scale-[0.97]" className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary/10 px-4 font-medium text-primary text-sm ring-1 ring-primary/20 transition-all hover:bg-primary/15 hover:ring-primary/30 active:scale-[0.97]"
> >
<IconPlus aria-hidden={true} className="size-3.5" strokeWidth={2.5} /> <IconPlus aria-hidden={true} className="size-3.5" strokeWidth={2.5} />
Watchlist {t`Watchlist`}
</motion.button> </motion.button>
) : ( ) : (
<motion.button <motion.button
@@ -60,7 +63,7 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }} exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }} transition={{ duration: 0.15 }}
title="Remove from library" title={t`Remove from library`}
className={`group inline-flex h-9 items-center gap-2 rounded-lg px-4 font-medium text-sm ring-1 transition-all active:scale-[0.97] ${config.class} ${config.bgClass} ${config.borderClass} hover:!bg-destructive/10 hover:!text-destructive hover:!ring-destructive/30`} className={`group inline-flex h-9 items-center gap-2 rounded-lg px-4 font-medium text-sm ring-1 transition-all active:scale-[0.97] ${config.class} ${config.bgClass} ${config.borderClass} hover:!bg-destructive/10 hover:!text-destructive hover:!ring-destructive/30`}
> >
<span className="grid [&>svg]:col-start-1 [&>svg]:row-start-1"> <span className="grid [&>svg]:col-start-1 [&>svg]:row-start-1">
@@ -78,7 +81,7 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
{config.label} {config.label}
</span> </span>
<span className="opacity-0 transition-opacity group-hover:opacity-100"> <span className="opacity-0 transition-opacity group-hover:opacity-100">
Remove {t`Remove`}
</span> </span>
</span> </span>
</motion.button> </motion.button>
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { IconCheck } from "@tabler/icons-react"; import { IconCheck } from "@tabler/icons-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@@ -25,7 +26,7 @@ export function TitleActions() {
className="h-9 rounded-lg px-4 text-sm hover:shadow-md hover:shadow-primary/20 active:scale-[0.97]" className="h-9 rounded-lg px-4 text-sm hover:shadow-md hover:shadow-primary/20 active:scale-[0.97]"
> >
<IconCheck aria-hidden={true} className="size-3.5" /> <IconCheck aria-hidden={true} className="size-3.5" />
Mark Watched <Trans>Mark Watched</Trans>
</Button> </Button>
)} )}
<Separator <Separator
@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { AvailabilityOffer } from "@sofa/api/schemas"; import type { AvailabilityOffer } from "@sofa/api/schemas";
import { import {
@@ -13,13 +14,7 @@ import {
const MAX_VISIBLE = 4; const MAX_VISIBLE = 4;
const offerLabels: Record<string, string> = { // offerLabels moved into component for LingUI
flatrate: "Stream",
rent: "Rent",
buy: "Buy",
free: "Free",
ads: "With Ads",
};
function ProviderBadge({ function ProviderBadge({
name, name,
@@ -30,6 +25,7 @@ function ProviderBadge({
logoPath: string | null; logoPath: string | null;
watchUrl: string | null; watchUrl: string | null;
}) { }) {
const { t } = useLingui();
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
@@ -60,7 +56,7 @@ function ProviderBadge({
)} )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="bg-popover px-2 py-1 font-medium text-[10px] text-popover-foreground shadow-md [&>:last-child]:hidden"> <TooltipContent className="bg-popover px-2 py-1 font-medium text-[10px] text-popover-foreground shadow-md [&>:last-child]:hidden">
{watchUrl ? `Watch on ${name}` : name} {watchUrl ? t`Watch on ${name}` : name}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );
@@ -136,6 +132,14 @@ export function TitleAvailability({
}: { }: {
availability: AvailabilityOffer[]; availability: AvailabilityOffer[];
}) { }) {
const { t } = useLingui();
const offerLabels: Record<string, string> = {
flatrate: t`Stream`,
rent: t`Rent`,
buy: t`Buy`,
free: t`Free`,
ads: t`With Ads`,
};
const availByType: Record<string, AvailabilityOffer[]> = {}; const availByType: Record<string, AvailabilityOffer[]> = {};
for (const offer of availability) { for (const offer of availability) {
if (!availByType[offer.offerType]) availByType[offer.offerType] = []; if (!availByType[offer.offerType]) availByType[offer.offerType] = [];
@@ -147,7 +151,7 @@ export function TitleAvailability({
return ( return (
<div className="space-y-2 pt-1"> <div className="space-y-2 pt-1">
<h2 className="font-semibold text-muted-foreground text-xs uppercase tracking-wider"> <h2 className="font-semibold text-muted-foreground text-xs uppercase tracking-wider">
Where to Watch <Trans>Where to Watch</Trans>
</h2> </h2>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
{Object.entries(availByType).map(([type, offers]) => { {Object.entries(availByType).map(([type, offers]) => {
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import type { ColorPalette, ResolvedTitle } from "@sofa/api/schemas"; import type { ColorPalette, ResolvedTitle } from "@sofa/api/schemas";
import { import {
IconCalendarEvent, IconCalendarEvent,
@@ -138,7 +139,9 @@ export function TitleHero({
aria-hidden aria-hidden
className="size-3.5 translate-y-[-0.5px]" className="size-3.5 translate-y-[-0.5px]"
/> />
<span className="hidden md:inline">Movie</span> <span className="hidden md:inline">
<Trans>Movie</Trans>
</span>
</> </>
) : ( ) : (
<> <>
@@ -146,7 +149,9 @@ export function TitleHero({
aria-hidden aria-hidden
className="size-3.5 translate-y-[-0.5px]" className="size-3.5 translate-y-[-0.5px]"
/> />
<span className="hidden md:inline">TV</span> <span className="hidden md:inline">
<Trans>TV</Trans>
</span>
</> </>
)} )}
</div> </div>
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { IconThumbUp } from "@tabler/icons-react"; import { IconThumbUp } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { TitleCard, TitleCardSkeleton } from "@/components/title-card"; import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
@@ -35,7 +36,9 @@ export function TitleRecommendations({ titleId }: { titleId: string }) {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<IconThumbUp aria-hidden={true} className="size-5 text-primary" /> <IconThumbUp aria-hidden={true} className="size-5 text-primary" />
<h2 className="font-display text-xl tracking-tight">Recommended</h2> <h2 className="font-display text-xl tracking-tight">
<Trans>Recommended</Trans>
</h2>
</div> </div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{data.recommendations.slice(0, 12).map((rec, i) => ( {data.recommendations.slice(0, 12).map((rec, i) => (
@@ -1,4 +1,6 @@
import { Trans } from "@lingui/react/macro";
import type { Season } from "@sofa/api/schemas"; import type { Season } from "@sofa/api/schemas";
import { formatShortDate } from "@sofa/i18n/format";
import { import {
IconCheck, IconCheck,
IconChecks, IconChecks,
@@ -6,7 +8,6 @@ import {
IconChevronUp, IconChevronUp,
IconDeviceTvOld, IconDeviceTvOld,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { format, parseISO } from "date-fns";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
@@ -97,7 +98,9 @@ export function TitleSeasons({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<IconDeviceTvOld aria-hidden={true} className="size-5 text-primary" /> <IconDeviceTvOld aria-hidden={true} className="size-5 text-primary" />
<h2 className="font-display text-2xl tracking-tight">Episodes</h2> <h2 className="font-display text-2xl tracking-tight">
<Trans>Episodes</Trans>
</h2>
</div> </div>
{userStatus !== "completed" && ( {userStatus !== "completed" && (
<AlertDialog open={markAllOpen} onOpenChange={setMarkAllOpen}> <AlertDialog open={markAllOpen} onOpenChange={setMarkAllOpen}>
@@ -109,29 +112,33 @@ export function TitleSeasons({
className="text-muted-foreground uppercase tracking-wider" className="text-muted-foreground uppercase tracking-wider"
> >
<IconChecks aria-hidden={true} className="size-3.5" /> <IconChecks aria-hidden={true} className="size-3.5" />
Mark All Watched <Trans>Mark All Watched</Trans>
</Button> </Button>
} }
/> />
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>
Mark all episodes as watched? <Trans>Mark all episodes as watched?</Trans>
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This will mark every episode of this show as watched. You can <Trans>
undo this later by unmarking individual seasons. This will mark every episode of this show as watched. You
can undo this later by unmarking individual seasons.
</Trans>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={() => { onClick={() => {
handleMarkAllWatched(); handleMarkAllWatched();
setMarkAllOpen(false); setMarkAllOpen(false);
}} }}
> >
Mark All Watched <Trans>Mark All Watched</Trans>
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -191,7 +198,9 @@ export function TitleSeasons({
aria-hidden={true} aria-hidden={true}
className="size-3.5 sm:hidden" className="size-3.5 sm:hidden"
/> />
<span className="hidden sm:inline">Watch all</span> <span className="hidden sm:inline">
<Trans>Watch all</Trans>
</span>
</Button> </Button>
)} )}
{totalCount > 0 && watchedCount === totalCount && ( {totalCount > 0 && watchedCount === totalCount && (
@@ -208,7 +217,9 @@ export function TitleSeasons({
aria-hidden={true} aria-hidden={true}
className="size-3.5 sm:hidden" className="size-3.5 sm:hidden"
/> />
<span className="hidden sm:inline">Unwatch all</span> <span className="hidden sm:inline">
<Trans>Unwatch all</Trans>
</span>
</Button> </Button>
)} )}
{totalCount > 0 && ( {totalCount > 0 && (
@@ -326,13 +337,11 @@ export function TitleSeasons({
</span> </span>
<span className="hidden sm:inline"> </span> <span className="hidden sm:inline"> </span>
<span className="font-medium"> <span className="font-medium">
{ep.name ?? "Untitled"} {ep.name ?? <Trans>Untitled</Trans>}
</span> </span>
</p> </p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{ep.airDate {ep.airDate ? formatShortDate(ep.airDate) : ""}
? format(parseISO(ep.airDate), "MMM d, yyyy")
: ""}
{ep.airDate && ep.runtimeMinutes ? " · " : ""} {ep.airDate && ep.runtimeMinutes ? " · " : ""}
{ep.runtimeMinutes {ep.runtimeMinutes
? `${ep.runtimeMinutes}m` ? `${ep.runtimeMinutes}m`
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { IconPlayerPlayFilled } from "@tabler/icons-react"; import { IconPlayerPlayFilled } from "@tabler/icons-react";
import { lazy, Suspense, useState } from "react"; import { lazy, Suspense, useState } from "react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
@@ -12,6 +13,7 @@ export function TrailerDialog({
videoKey: string; videoKey: string;
variant?: "badge" | "backdrop"; variant?: "badge" | "backdrop";
}) { }) {
const { t } = useLingui();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
@@ -19,7 +21,7 @@ export function TrailerDialog({
{variant === "backdrop" ? ( {variant === "backdrop" ? (
<button <button
type="button" type="button"
aria-label="Play trailer" aria-label={t`Play trailer`}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className="group flex size-12 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/20 backdrop-blur-md transition-[transform,background-color,box-shadow] duration-300 hover:scale-105 hover:bg-white/20 hover:ring-white/30 active:scale-100 sm:size-16" className="group flex size-12 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/20 backdrop-blur-md transition-[transform,background-color,box-shadow] duration-300 hover:scale-105 hover:bg-white/20 hover:ring-white/30 active:scale-100 sm:size-16"
> >
@@ -32,11 +34,11 @@ export function TrailerDialog({
className="inline-flex h-5 items-center gap-1 rounded border border-border/50 px-2 text-muted-foreground text-xs transition-colors hover:border-border hover:text-foreground" className="inline-flex h-5 items-center gap-1 rounded border border-border/50 px-2 text-muted-foreground text-xs transition-colors hover:border-border hover:text-foreground"
> >
<IconPlayerPlayFilled aria-hidden={true} className="h-2.5 w-2.5" /> <IconPlayerPlayFilled aria-hidden={true} className="h-2.5 w-2.5" />
Trailer {t`Trailer`}
</button> </button>
)} )}
<DialogContent className="overflow-hidden border-white/10 bg-black p-0 sm:max-w-4xl"> <DialogContent className="overflow-hidden border-white/10 bg-black p-0 sm:max-w-4xl">
<DialogTitle className="sr-only">Trailer</DialogTitle> <DialogTitle className="sr-only">{t`Trailer`}</DialogTitle>
<div className="aspect-video w-full"> <div className="aspect-video w-full">
<Suspense> <Suspense>
<MediaThemeSutro <MediaThemeSutro
@@ -1,3 +1,5 @@
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import type { Season } from "@sofa/api/schemas"; import type { Season } from "@sofa/api/schemas";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react"; import { useCallback } from "react";
@@ -12,6 +14,7 @@ type UserInfo = {
}; };
export function useTitleActions() { export function useTitleActions() {
const { t } = useLingui();
const { titleId, titleName, seasons, setWatchingEp } = useTitleContext(); const { titleId, titleName, seasons, setWatchingEp } = useTitleContext();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const userInfoKey = orpc.titles.userInfo.queryKey({ input: { id: titleId } }); const userInfoKey = orpc.titles.userInfo.queryKey({ input: { id: titleId } });
@@ -76,7 +79,7 @@ export function useTitleActions() {
try { try {
await batchWatchMutation.mutateAsync({ episodeIds }); await batchWatchMutation.mutateAsync({ episodeIds });
toast.success( toast.success(
`Caught up — marked ${episodeIds.length} episode${episodeIds.length > 1 ? "s" : ""} as watched`, t`Caught up — marked ${episodeIds.length} ${plural(episodeIds.length, { one: "episode", other: "episodes" })} as watched`,
); );
} catch { } catch {
setUserInfo((old) => ({ setUserInfo((old) => ({
@@ -84,10 +87,10 @@ export function useTitleActions() {
episodeWatches: prev.episodeWatches, episodeWatches: prev.episodeWatches,
status: prev.status, status: prev.status,
})); }));
toast.error("Failed to catch up"); toast.error(t`Failed to catch up`);
} }
}, },
[getUserInfo, setUserInfo, seasons, batchWatchMutation], [getUserInfo, setUserInfo, seasons, batchWatchMutation, t],
); );
const handleStatusChange = useCallback( const handleStatusChange = useCallback(
@@ -105,13 +108,13 @@ export function useTitleActions() {
id: titleId, id: titleId,
status: status ? "in_progress" : null, status: status ? "in_progress" : null,
}); });
toast.success(status ? "Added to watchlist" : "Removed from library"); toast.success(status ? t`Added to watchlist` : t`Removed from library`);
} catch { } catch {
setUserInfo((old) => ({ ...old, status: prevStatus })); setUserInfo((old) => ({ ...old, status: prevStatus }));
toast.error("Failed to update status"); toast.error(t`Failed to update status`);
} }
}, },
[getUserInfo, setUserInfo, titleId, updateStatusMutation], [getUserInfo, setUserInfo, titleId, updateStatusMutation, t],
); );
const handleRating = useCallback( const handleRating = useCallback(
@@ -125,15 +128,15 @@ export function useTitleActions() {
}); });
toast.success( toast.success(
ratingStars > 0 ratingStars > 0
? `Rated ${ratingStars} star${ratingStars > 1 ? "s" : ""}` ? t`Rated ${ratingStars} ${plural(ratingStars, { one: "star", other: "stars" })}`
: "Rating removed", : t`Rating removed`,
); );
} catch { } catch {
setUserInfo((old) => ({ ...old, rating: prevRating })); setUserInfo((old) => ({ ...old, rating: prevRating }));
toast.error("Failed to update rating"); toast.error(t`Failed to update rating`);
} }
}, },
[getUserInfo, setUserInfo, titleId, updateRatingMutation], [getUserInfo, setUserInfo, titleId, updateRatingMutation, t],
); );
const handleWatchMovie = useCallback(async () => { const handleWatchMovie = useCallback(async () => {
@@ -141,12 +144,12 @@ export function useTitleActions() {
setUserInfo((old) => ({ ...old, status: "completed" })); setUserInfo((old) => ({ ...old, status: "completed" }));
try { try {
await watchMovieMutation.mutateAsync({ id: titleId }); await watchMovieMutation.mutateAsync({ id: titleId });
toast.success(`Marked "${titleName}" as watched`); toast.success(t`Marked "${titleName}" as watched`);
} catch { } catch {
setUserInfo((old) => ({ ...old, status: prevStatus })); setUserInfo((old) => ({ ...old, status: prevStatus }));
toast.error("Failed to mark as watched"); toast.error(t`Failed to mark as watched`);
} }
}, [getUserInfo, setUserInfo, titleId, titleName, watchMovieMutation]); }, [getUserInfo, setUserInfo, titleId, titleName, watchMovieMutation, t]);
const handleWatchEpisode = useCallback( const handleWatchEpisode = useCallback(
async ( async (
@@ -168,14 +171,14 @@ export function useTitleActions() {
try { try {
await unwatchEpMutation.mutateAsync({ id: episodeId }); await unwatchEpMutation.mutateAsync({ id: episodeId });
toast.success(`Unwatched S${seasonNum} E${epNum}`); toast.success(t`Unwatched S${seasonNum} E${epNum}`);
} catch { } catch {
setUserInfo((old) => ({ setUserInfo((old) => ({
...old, ...old,
episodeWatches: prevWatches, episodeWatches: prevWatches,
status: prevStatus, status: prevStatus,
})); }));
toast.error("Failed to unmark episode"); toast.error(t`Failed to unmark episode`);
} }
} else { } else {
const prevWatches = getUserInfo().episodeWatches; const prevWatches = getUserInfo().episodeWatches;
@@ -214,16 +217,16 @@ export function useTitleActions() {
if (previousUnwatched.length > 0) { if (previousUnwatched.length > 0) {
const count = previousUnwatched.length; const count = previousUnwatched.length;
toast.success(`Watched S${seasonNum} E${epNum}`, { toast.success(t`Watched S${seasonNum} E${epNum}`, {
description: `${count} earlier episode${count > 1 ? "s" : ""} unwatched`, description: t`${count} earlier ${plural(count, { one: "episode", other: "episodes" })} unwatched`,
action: { action: {
label: "Catch up", label: t`Catch up`,
onClick: () => catchUp(previousUnwatched), onClick: () => catchUp(previousUnwatched),
}, },
duration: 8000, duration: 8000,
}); });
} else { } else {
toast.success(`Watched S${seasonNum} E${epNum}`); toast.success(t`Watched S${seasonNum} E${epNum}`);
} }
} catch { } catch {
setUserInfo((old) => ({ setUserInfo((old) => ({
@@ -231,7 +234,7 @@ export function useTitleActions() {
episodeWatches: prevWatches, episodeWatches: prevWatches,
status: prevStatus, status: prevStatus,
})); }));
toast.error("Failed to mark episode"); toast.error(t`Failed to mark episode`);
} }
} }
@@ -245,6 +248,7 @@ export function useTitleActions() {
catchUp, catchUp,
unwatchEpMutation, unwatchEpMutation,
watchEpMutation, watchEpMutation,
t,
], ],
); );
@@ -288,19 +292,19 @@ export function useTitleActions() {
} }
} }
const seasonLabel = season.name ?? `Season ${season.seasonNumber}`; const seasonLabel = season.name ?? t`Season ${season.seasonNumber}`;
if (previousUnwatched.length > 0) { if (previousUnwatched.length > 0) {
const count = previousUnwatched.length; const count = previousUnwatched.length;
toast.success(`Watched all of ${seasonLabel}`, { toast.success(t`Watched all of ${seasonLabel}`, {
description: `${count} earlier episode${count > 1 ? "s" : ""} unwatched`, description: t`${count} earlier ${plural(count, { one: "episode", other: "episodes" })} unwatched`,
action: { action: {
label: "Catch up", label: t`Catch up`,
onClick: () => catchUp(previousUnwatched), onClick: () => catchUp(previousUnwatched),
}, },
duration: 8000, duration: 8000,
}); });
} else { } else {
toast.success(`Watched all of ${seasonLabel}`); toast.success(t`Watched all of ${seasonLabel}`);
} }
} catch { } catch {
setUserInfo((old) => ({ setUserInfo((old) => ({
@@ -308,10 +312,10 @@ export function useTitleActions() {
episodeWatches: prevWatches, episodeWatches: prevWatches,
status: prevStatus, status: prevStatus,
})); }));
toast.error("Failed to mark some episodes"); toast.error(t`Failed to mark some episodes`);
} }
}, },
[getUserInfo, setUserInfo, seasons, catchUp, watchSeasonMutation], [getUserInfo, setUserInfo, seasons, catchUp, watchSeasonMutation, t],
); );
const handleUnmarkSeason = useCallback( const handleUnmarkSeason = useCallback(
@@ -328,7 +332,7 @@ export function useTitleActions() {
try { try {
await unwatchSeasonMutation.mutateAsync({ id: season.id }); await unwatchSeasonMutation.mutateAsync({ id: season.id });
toast.success( toast.success(
`Unwatched all of ${season.name ?? `Season ${season.seasonNumber}`}`, t`Unwatched all of ${season.name ?? t`Season ${season.seasonNumber}`}`,
); );
} catch { } catch {
setUserInfo((old) => ({ setUserInfo((old) => ({
@@ -336,10 +340,10 @@ export function useTitleActions() {
episodeWatches: prevWatches, episodeWatches: prevWatches,
status: prevStatus, status: prevStatus,
})); }));
toast.error("Failed to unmark some episodes"); toast.error(t`Failed to unmark some episodes`);
} }
}, },
[getUserInfo, setUserInfo, unwatchSeasonMutation], [getUserInfo, setUserInfo, unwatchSeasonMutation, t],
); );
const handleMarkAllWatched = useCallback(async () => { const handleMarkAllWatched = useCallback(async () => {
@@ -353,16 +357,16 @@ export function useTitleActions() {
})); }));
try { try {
await watchAllMutation.mutateAsync({ id: titleId }); await watchAllMutation.mutateAsync({ id: titleId });
toast.success("Marked all episodes as watched"); toast.success(t`Marked all episodes as watched`);
} catch { } catch {
setUserInfo((old) => ({ setUserInfo((old) => ({
...old, ...old,
episodeWatches: prevWatches, episodeWatches: prevWatches,
status: prevStatus, status: prevStatus,
})); }));
toast.error("Failed to mark all episodes as watched"); toast.error(t`Failed to mark all episodes as watched`);
} }
}, [getUserInfo, setUserInfo, seasons, titleId, watchAllMutation]); }, [getUserInfo, setUserInfo, seasons, titleId, watchAllMutation, t]);
return { return {
handleStatusChange, handleStatusChange,

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