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

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