mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
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:
@@ -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
|
||||||
@@ -23,6 +23,7 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- apps/native/**
|
- apps/native/**
|
||||||
- packages/api/**
|
- packages/api/**
|
||||||
|
- packages/i18n/**
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ coverage
|
|||||||
.cache
|
.cache
|
||||||
tmp
|
tmp
|
||||||
temp
|
temp
|
||||||
|
crowdin-context.jsonl
|
||||||
|
|
||||||
# local data
|
# local data
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = (api) => {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ["babel-preset-expo"],
|
||||||
|
plugins: ["@lingui/babel-plugin-lingui-macro"],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -7,9 +7,28 @@ const {
|
|||||||
/** @type {import('expo/metro-config').MetroConfig} */
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
const config = getDefaultConfig(__dirname);
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
const uniwindConfig = withUniwindConfig(wrapWithReanimatedMetroConfig(config), {
|
config.transformer = {
|
||||||
|
...config.transformer,
|
||||||
|
babelTransformerPath: require.resolve("@lingui/metro-transformer/expo"),
|
||||||
|
};
|
||||||
|
config.resolver = {
|
||||||
|
...config.resolver,
|
||||||
|
sourceExts: [...config.resolver.sourceExts, "po", "pot"],
|
||||||
|
// Use pre-compiled ICU message parser for smaller bundles
|
||||||
|
// https://formatjs.github.io/docs/guides/react-native-hermes
|
||||||
|
resolveRequest(context, moduleName, platform) {
|
||||||
|
if (moduleName === "@formatjs/icu-messageformat-parser") {
|
||||||
|
return context.resolveRequest(
|
||||||
|
context,
|
||||||
|
"@formatjs/icu-messageformat-parser/no-parser",
|
||||||
|
platform,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context.resolveRequest(context, moduleName, platform);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = withUniwindConfig(wrapWithReanimatedMetroConfig(config), {
|
||||||
cssEntryFile: "./src/global.css",
|
cssEntryFile: "./src/global.css",
|
||||||
dtsFile: "./uniwind-types.d.ts",
|
dtsFile: "./uniwind-types.d.ts",
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = uniwindConfig;
|
|
||||||
|
|||||||
@@ -17,6 +17,14 @@
|
|||||||
"@better-auth/expo": "catalog:",
|
"@better-auth/expo": "catalog:",
|
||||||
"@expo/metro-runtime": "55.0.6",
|
"@expo/metro-runtime": "55.0.6",
|
||||||
"@expo/ui": "55.0.3",
|
"@expo/ui": "55.0.3",
|
||||||
|
"@formatjs/intl-datetimeformat": "7.2.6",
|
||||||
|
"@formatjs/intl-getcanonicallocales": "3.2.2",
|
||||||
|
"@formatjs/intl-locale": "5.2.2",
|
||||||
|
"@formatjs/intl-numberformat": "9.2.4",
|
||||||
|
"@formatjs/intl-pluralrules": "6.2.4",
|
||||||
|
"@formatjs/intl-relativetimeformat": "12.2.4",
|
||||||
|
"@lingui/core": "catalog:",
|
||||||
|
"@lingui/react": "catalog:",
|
||||||
"@orpc/client": "catalog:",
|
"@orpc/client": "catalog:",
|
||||||
"@orpc/contract": "catalog:",
|
"@orpc/contract": "catalog:",
|
||||||
"@orpc/tanstack-query": "catalog:",
|
"@orpc/tanstack-query": "catalog:",
|
||||||
@@ -25,6 +33,7 @@
|
|||||||
"@react-navigation/native": "7.1.33",
|
"@react-navigation/native": "7.1.33",
|
||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@sofa/api": "workspace:*",
|
"@sofa/api": "workspace:*",
|
||||||
|
"@sofa/i18n": "workspace:*",
|
||||||
"@tabler/icons-react-native": "3.40.0",
|
"@tabler/icons-react-native": "3.40.0",
|
||||||
"@tanstack/query-async-storage-persister": "5.90.24",
|
"@tanstack/query-async-storage-persister": "5.90.24",
|
||||||
"@tanstack/react-form": "1.28.5",
|
"@tanstack/react-form": "1.28.5",
|
||||||
@@ -32,7 +41,6 @@
|
|||||||
"@tanstack/react-query-persist-client": "5.90.24",
|
"@tanstack/react-query-persist-client": "5.90.24",
|
||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
"burnt": "0.13.0",
|
"burnt": "0.13.0",
|
||||||
"date-fns": "catalog:",
|
|
||||||
"expo": "55.0.7",
|
"expo": "55.0.7",
|
||||||
"expo-application": "55.0.10",
|
"expo-application": "55.0.10",
|
||||||
"expo-clipboard": "55.0.9",
|
"expo-clipboard": "55.0.9",
|
||||||
@@ -77,6 +85,8 @@
|
|||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@lingui/babel-plugin-lingui-macro": "catalog:",
|
||||||
|
"@lingui/metro-transformer": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { IconServer2 } from "@tabler/icons-react-native";
|
import { IconServer2 } from "@tabler/icons-react-native";
|
||||||
import { useForm, useStore } from "@tanstack/react-form";
|
import { useForm, useStore } from "@tanstack/react-form";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -30,6 +31,7 @@ const signInSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
|
const { t } = useLingui();
|
||||||
const passwordRef = useRef<TextInput>(null);
|
const passwordRef = useRef<TextInput>(null);
|
||||||
const [errorFields, setErrorFields] = useState<Set<string>>(new Set());
|
const [errorFields, setErrorFields] = useState<Set<string>>(new Set());
|
||||||
const [isSignedIn, setIsSignedIn] = useState(false);
|
const [isSignedIn, setIsSignedIn] = useState(false);
|
||||||
@@ -51,7 +53,7 @@ export default function LoginScreen() {
|
|||||||
{ email: result.data.email, password: result.data.password },
|
{ email: result.data.email, password: result.data.password },
|
||||||
{
|
{
|
||||||
onError(error) {
|
onError(error) {
|
||||||
toast.error(error.error?.message || "Failed to sign in");
|
toast.error(error.error?.message || t`Failed to sign in`);
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
@@ -86,7 +88,7 @@ export default function LoginScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthScreen title="Sofa" subtitle="Sign in to continue">
|
<AuthScreen title="Sofa" subtitle={t`Sign in to continue`}>
|
||||||
{showOidc && (
|
{showOidc && (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.duration(300).delay(100)}
|
entering={FadeInDown.duration(300).delay(100)}
|
||||||
@@ -103,14 +105,18 @@ export default function LoginScreen() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<ButtonLabel>
|
<ButtonLabel>
|
||||||
Sign in with {authConfig.data?.oidcProviderName ?? "SSO"}
|
<Trans>
|
||||||
|
Sign in with {authConfig.data?.oidcProviderName ?? "SSO"}
|
||||||
|
</Trans>
|
||||||
</ButtonLabel>
|
</ButtonLabel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{showPasswordLogin && (
|
{showPasswordLogin && (
|
||||||
<View className="my-4 flex-row items-center">
|
<View className="my-4 flex-row items-center">
|
||||||
<View className="h-px flex-1 bg-border" />
|
<View className="h-px flex-1 bg-border" />
|
||||||
<Text className="px-3 text-muted-foreground text-xs">OR</Text>
|
<Text className="px-3 text-muted-foreground text-xs">
|
||||||
|
<Trans>OR</Trans>
|
||||||
|
</Text>
|
||||||
<View className="h-px flex-1 bg-border" />
|
<View className="h-px flex-1 bg-border" />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -123,7 +129,9 @@ export default function LoginScreen() {
|
|||||||
<form.Field name="email">
|
<form.Field name="email">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<TextField>
|
<TextField>
|
||||||
<Label>Email</Label>
|
<Label>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
accessibilityLabel="Email"
|
accessibilityLabel="Email"
|
||||||
@@ -155,7 +163,9 @@ export default function LoginScreen() {
|
|||||||
<form.Field name="password">
|
<form.Field name="password">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<TextField>
|
<TextField>
|
||||||
<Label>Password</Label>
|
<Label>
|
||||||
|
<Trans>Password</Trans>
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
ref={passwordRef}
|
ref={passwordRef}
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
@@ -191,7 +201,9 @@ export default function LoginScreen() {
|
|||||||
{busy ? (
|
{busy ? (
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
) : (
|
) : (
|
||||||
<ButtonLabel>Sign In</ButtonLabel>
|
<ButtonLabel>
|
||||||
|
<Trans>Sign In</Trans>
|
||||||
|
</ButtonLabel>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -200,7 +212,9 @@ export default function LoginScreen() {
|
|||||||
<Animated.View entering={FadeIn.duration(300).delay(500)}>
|
<Animated.View entering={FadeIn.duration(300).delay(500)}>
|
||||||
<Link href="/(auth)/register" asChild>
|
<Link href="/(auth)/register" asChild>
|
||||||
<Button disabled={busy} variant="secondary">
|
<Button disabled={busy} variant="secondary">
|
||||||
<ButtonLabel>Create an account</ButtonLabel>
|
<ButtonLabel>
|
||||||
|
<Trans>Create an account</Trans>
|
||||||
|
</ButtonLabel>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -223,8 +237,11 @@ export default function LoginScreen() {
|
|||||||
color={statusCompletedColor}
|
color={statusCompletedColor}
|
||||||
/>
|
/>
|
||||||
<Text className="font-sans text-muted-foreground text-xs">
|
<Text className="font-sans text-muted-foreground text-xs">
|
||||||
Connected to <Text className="font-medium">{serverHost}</Text>
|
<Trans>
|
||||||
. Tap to change.
|
Connected to{" "}
|
||||||
|
<Text className="font-medium">{serverHost}</Text>. Tap to
|
||||||
|
change.
|
||||||
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { useForm, useStore } from "@tanstack/react-form";
|
import { useForm, useStore } from "@tanstack/react-form";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link } from "expo-router";
|
import { Link } from "expo-router";
|
||||||
@@ -35,6 +36,7 @@ const signUpSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function RegisterScreen() {
|
export default function RegisterScreen() {
|
||||||
|
const { t } = useLingui();
|
||||||
const emailRef = useRef<TextInput>(null);
|
const emailRef = useRef<TextInput>(null);
|
||||||
const passwordRef = useRef<TextInput>(null);
|
const passwordRef = useRef<TextInput>(null);
|
||||||
const [errorFields, setErrorFields] = useState<Set<string>>(new Set());
|
const [errorFields, setErrorFields] = useState<Set<string>>(new Set());
|
||||||
@@ -62,7 +64,7 @@ export default function RegisterScreen() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onError(error) {
|
onError(error) {
|
||||||
toast.error(error.error?.message || "Failed to create account");
|
toast.error(error.error?.message || t`Failed to create account`);
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
@@ -92,14 +94,14 @@ export default function RegisterScreen() {
|
|||||||
if (!registrationOpen && !publicInfo.isPending) {
|
if (!registrationOpen && !publicInfo.isPending) {
|
||||||
return (
|
return (
|
||||||
<AuthScreen
|
<AuthScreen
|
||||||
title="Registration Closed"
|
title={t`Registration Closed`}
|
||||||
subtitle="New account creation is currently disabled."
|
subtitle={t`New account creation is currently disabled.`}
|
||||||
>
|
>
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||||
<Link href="/(auth)/login" asChild>
|
<Link href="/(auth)/login" asChild>
|
||||||
<Button className="mt-6 bg-primary">
|
<Button className="mt-6 bg-primary">
|
||||||
<ButtonLabel className="text-primary-foreground">
|
<ButtonLabel className="text-primary-foreground">
|
||||||
Back to Login
|
<Trans>Back to Login</Trans>
|
||||||
</ButtonLabel>
|
</ButtonLabel>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -110,15 +112,17 @@ export default function RegisterScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthScreen
|
<AuthScreen
|
||||||
title="Create Account"
|
title={t`Create Account`}
|
||||||
subtitle={`Registering on ${serverHost}`}
|
subtitle={t`Registering on ${serverHost}`}
|
||||||
>
|
>
|
||||||
<View className="gap-3">
|
<View className="gap-3">
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||||
<form.Field name="name">
|
<form.Field name="name">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<TextField>
|
<TextField>
|
||||||
<Label>Name</Label>
|
<Label>
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
accessibilityLabel="Name"
|
accessibilityLabel="Name"
|
||||||
@@ -127,7 +131,7 @@ export default function RegisterScreen() {
|
|||||||
field.handleChange(text);
|
field.handleChange(text);
|
||||||
clearFieldError("name");
|
clearFieldError("name");
|
||||||
}}
|
}}
|
||||||
placeholder="Your name"
|
placeholder={t`Your name`}
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
textContentType="name"
|
textContentType="name"
|
||||||
returnKeyType="next"
|
returnKeyType="next"
|
||||||
@@ -146,7 +150,9 @@ export default function RegisterScreen() {
|
|||||||
<form.Field name="email">
|
<form.Field name="email">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<TextField>
|
<TextField>
|
||||||
<Label>Email</Label>
|
<Label>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
ref={emailRef}
|
ref={emailRef}
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
@@ -177,7 +183,9 @@ export default function RegisterScreen() {
|
|||||||
<form.Field name="password">
|
<form.Field name="password">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<TextField>
|
<TextField>
|
||||||
<Label>Password</Label>
|
<Label>
|
||||||
|
<Trans>Password</Trans>
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
ref={passwordRef}
|
ref={passwordRef}
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
@@ -213,7 +221,9 @@ export default function RegisterScreen() {
|
|||||||
{busy ? (
|
{busy ? (
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
) : (
|
) : (
|
||||||
<ButtonLabel>Create Account</ButtonLabel>
|
<ButtonLabel>
|
||||||
|
<Trans>Create Account</Trans>
|
||||||
|
</ButtonLabel>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -226,7 +236,7 @@ export default function RegisterScreen() {
|
|||||||
<Link href="/(auth)/login" asChild>
|
<Link href="/(auth)/login" asChild>
|
||||||
<Pressable disabled={busy}>
|
<Pressable disabled={busy}>
|
||||||
<Text className="text-primary text-sm">
|
<Text className="text-primary text-sm">
|
||||||
Already have an account? Sign in
|
<Trans>Already have an account? Sign in</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import {
|
import {
|
||||||
IconAlertCircle,
|
IconAlertCircle,
|
||||||
IconCircleCheck,
|
IconCircleCheck,
|
||||||
@@ -33,18 +34,20 @@ type ConnectionState =
|
|||||||
| { phase: "success" }
|
| { phase: "success" }
|
||||||
| { phase: "error"; error: ValidationError };
|
| { phase: "error"; error: ValidationError };
|
||||||
|
|
||||||
const ERROR_MESSAGES: Record<ValidationError, string> = {
|
function getErrorMessages(
|
||||||
network_unreachable:
|
t: (strings: TemplateStringsArray, ...values: unknown[]) => string,
|
||||||
"Could not reach the server. Check your connection and the URL.",
|
): Record<ValidationError, string> {
|
||||||
timeout: "Connection timed out. The server might be down or unreachable.",
|
return {
|
||||||
not_sofa_server:
|
network_unreachable: t`Could not reach the server. Check your connection and the URL.`,
|
||||||
"This doesn't appear to be a Sofa server. Double-check the URL.",
|
timeout: t`Connection timed out. The server might be down or unreachable.`,
|
||||||
server_unhealthy:
|
not_sofa_server: t`This doesn't appear to be a Sofa server. Double-check the URL.`,
|
||||||
"Server found but reporting an issue. Try again in a moment.",
|
server_unhealthy: t`Server found but reporting an issue. Try again in a moment.`,
|
||||||
invalid_url: "That URL doesn't look right. Include the full server address.",
|
invalid_url: t`That URL doesn't look right. Include the full server address.`,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function ServerUrlScreen() {
|
export default function ServerUrlScreen() {
|
||||||
|
const { t } = useLingui();
|
||||||
const { replace } = useRouter();
|
const { replace } = useRouter();
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const successTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const successTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -145,7 +148,7 @@ export default function ServerUrlScreen() {
|
|||||||
return (
|
return (
|
||||||
<AuthScreen
|
<AuthScreen
|
||||||
title="Sofa"
|
title="Sofa"
|
||||||
subtitle="Enter your Sofa server URL to get started"
|
subtitle={t`Enter your Sofa server URL to get started`}
|
||||||
logoStyle={iconAnimatedStyle}
|
logoStyle={iconAnimatedStyle}
|
||||||
>
|
>
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||||
@@ -180,7 +183,7 @@ export default function ServerUrlScreen() {
|
|||||||
style={dotAnimatedStyle}
|
style={dotAnimatedStyle}
|
||||||
/>
|
/>
|
||||||
<Text className="text-muted-foreground text-sm">
|
<Text className="text-muted-foreground text-sm">
|
||||||
Connecting to server...
|
<Trans>Connecting to server...</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : isSuccess ? (
|
) : isSuccess ? (
|
||||||
@@ -191,7 +194,7 @@ export default function ServerUrlScreen() {
|
|||||||
color={statusCompletedColor}
|
color={statusCompletedColor}
|
||||||
/>
|
/>
|
||||||
<Text className="font-medium font-sans text-sm text-status-completed">
|
<Text className="font-medium font-sans text-sm text-status-completed">
|
||||||
Connected
|
<Trans>Connected</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
@@ -200,7 +203,9 @@ export default function ServerUrlScreen() {
|
|||||||
disabled={!isValidUrl}
|
disabled={!isValidUrl}
|
||||||
className="bg-primary"
|
className="bg-primary"
|
||||||
>
|
>
|
||||||
<ButtonLabel>Connect</ButtonLabel>
|
<ButtonLabel>
|
||||||
|
<Trans>Connect</Trans>
|
||||||
|
</ButtonLabel>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -218,7 +223,7 @@ export default function ServerUrlScreen() {
|
|||||||
style={{ marginTop: 1 }}
|
style={{ marginTop: 1 }}
|
||||||
/>
|
/>
|
||||||
<Text selectable className="flex-1 text-destructive text-sm">
|
<Text selectable className="flex-1 text-destructive text-sm">
|
||||||
{ERROR_MESSAGES[connection.error]}
|
{getErrorMessages(t)[connection.error]}
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
@@ -234,7 +239,7 @@ export default function ServerUrlScreen() {
|
|||||||
>
|
>
|
||||||
<ScaledIcon icon={IconInfoCircle} size={16} color={mutedFgColor} />
|
<ScaledIcon icon={IconInfoCircle} size={16} color={mutedFgColor} />
|
||||||
<Text className="font-medium font-sans text-primary text-sm">
|
<Text className="font-medium font-sans text-primary text-sm">
|
||||||
Don't have a server?
|
<Trans>Don't have a server?</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react-native";
|
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react-native";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
@@ -15,6 +16,7 @@ const exploreContentContainerStyle = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ExploreScreen() {
|
export default function ExploreScreen() {
|
||||||
|
const { t } = useLingui();
|
||||||
const trending = useInfiniteQuery(
|
const trending = useInfiniteQuery(
|
||||||
orpc.explore.trending.infiniteOptions({
|
orpc.explore.trending.infiniteOptions({
|
||||||
input: (pageParam: number) => ({ type: "all" as const, page: pageParam }),
|
input: (pageParam: number) => ({ type: "all" as const, page: pageParam }),
|
||||||
@@ -81,14 +83,16 @@ export default function ExploreScreen() {
|
|||||||
>
|
>
|
||||||
<View className="gap-8">
|
<View className="gap-8">
|
||||||
{heroItem && (
|
{heroItem && (
|
||||||
<Animated.View entering={FadeIn.duration(400)}>
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(400).withInitialValues({ opacity: 0.01 })}
|
||||||
|
>
|
||||||
<HeroBanner item={heroItem} />
|
<HeroBanner item={heroItem} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||||
<FilterableTitleRow
|
<FilterableTitleRow
|
||||||
title="Trending Today"
|
title={t`Trending Today`}
|
||||||
icon={IconFlame}
|
icon={IconFlame}
|
||||||
mediaType="movie"
|
mediaType="movie"
|
||||||
defaultItems={trendingItems}
|
defaultItems={trendingItems}
|
||||||
@@ -100,7 +104,7 @@ export default function ExploreScreen() {
|
|||||||
|
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||||
<FilterableTitleRow
|
<FilterableTitleRow
|
||||||
title="Popular Movies"
|
title={t`Popular Movies`}
|
||||||
icon={IconMovie}
|
icon={IconMovie}
|
||||||
mediaType="movie"
|
mediaType="movie"
|
||||||
defaultItems={popularMovies.data?.items ?? []}
|
defaultItems={popularMovies.data?.items ?? []}
|
||||||
@@ -113,7 +117,7 @@ export default function ExploreScreen() {
|
|||||||
|
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
|
||||||
<FilterableTitleRow
|
<FilterableTitleRow
|
||||||
title="Popular TV Shows"
|
title={t`Popular TV Shows`}
|
||||||
icon={IconDeviceTv}
|
icon={IconDeviceTv}
|
||||||
mediaType="tv"
|
mediaType="tv"
|
||||||
defaultItems={popularTv.data?.items ?? []}
|
defaultItems={popularTv.data?.items ?? []}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import {
|
import {
|
||||||
IconBooks,
|
IconBooks,
|
||||||
@@ -30,6 +31,7 @@ const dashboardContentContainerStyle = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function DashboardScreen() {
|
export default function DashboardScreen() {
|
||||||
|
const { t } = useLingui();
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
authClient.useSession();
|
authClient.useSession();
|
||||||
|
|
||||||
@@ -55,12 +57,12 @@ export default function DashboardScreen() {
|
|||||||
|
|
||||||
const statsData = useMemo(
|
const statsData = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ label: "Movies this month", value: stats.data?.moviesThisMonth },
|
{ label: t`Movies this month`, value: stats.data?.moviesThisMonth },
|
||||||
{ label: "Episodes this week", value: stats.data?.episodesThisWeek },
|
{ label: t`Episodes this week`, value: stats.data?.episodesThisWeek },
|
||||||
{ label: "In library", value: stats.data?.librarySize },
|
{ label: t`In library`, value: stats.data?.librarySize },
|
||||||
{ label: "Completed", value: stats.data?.completed },
|
{ label: t`Completed`, value: stats.data?.completed },
|
||||||
],
|
],
|
||||||
[stats.data],
|
[stats.data, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderStatItem = useCallback(
|
const renderStatItem = useCallback(
|
||||||
@@ -107,7 +109,10 @@ export default function DashboardScreen() {
|
|||||||
{hasContinueWatching && (
|
{hasContinueWatching && (
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||||
<View className="px-4">
|
<View className="px-4">
|
||||||
<SectionHeader title="Continue Watching" icon={IconPlayerPlay} />
|
<SectionHeader
|
||||||
|
title={t`Continue Watching`}
|
||||||
|
icon={IconPlayerPlay}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<FlashList
|
<FlashList
|
||||||
horizontal
|
horizontal
|
||||||
@@ -125,7 +130,7 @@ export default function DashboardScreen() {
|
|||||||
{/* Library */}
|
{/* Library */}
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
|
||||||
<View className="px-4">
|
<View className="px-4">
|
||||||
<SectionHeader title="In Your Library" icon={IconBooks} />
|
<SectionHeader title={t`In Your Library`} icon={IconBooks} />
|
||||||
</View>
|
</View>
|
||||||
{library.isPending ? (
|
{library.isPending ? (
|
||||||
<HorizontalPosterRow items={[]} isLoading />
|
<HorizontalPosterRow items={[]} isLoading />
|
||||||
@@ -133,9 +138,9 @@ export default function DashboardScreen() {
|
|||||||
<HorizontalPosterRow items={library.data?.items ?? []} />
|
<HorizontalPosterRow items={library.data?.items ?? []} />
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Your library is empty"
|
title={t`Your library is empty`}
|
||||||
description="Start tracking movies and shows"
|
description={t`Start tracking movies and shows`}
|
||||||
actionLabel="Explore"
|
actionLabel={t`Explore`}
|
||||||
onAction={() => push("/(tabs)/(explore)")}
|
onAction={() => push("/(tabs)/(explore)")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -145,7 +150,10 @@ export default function DashboardScreen() {
|
|||||||
{hasRecommendations && (
|
{hasRecommendations && (
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
||||||
<View className="px-4">
|
<View className="px-4">
|
||||||
<SectionHeader title="Recommended for You" icon={IconThumbUp} />
|
<SectionHeader
|
||||||
|
title={t`Recommended for You`}
|
||||||
|
icon={IconThumbUp}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<HorizontalPosterRow
|
<HorizontalPosterRow
|
||||||
items={recommendations.data?.items ?? []}
|
items={recommendations.data?.items ?? []}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
|
import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
@@ -16,6 +17,7 @@ import { useTitleActions } from "@/hooks/use-title-actions";
|
|||||||
import { orpc } from "@/lib/orpc";
|
import { orpc } from "@/lib/orpc";
|
||||||
|
|
||||||
export default function SearchScreen() {
|
export default function SearchScreen() {
|
||||||
|
const { t } = useLingui();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const debouncedQuery = useDebounce(query.trim(), 300);
|
const debouncedQuery = useDebounce(query.trim(), 300);
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ export default function SearchScreen() {
|
|||||||
/>
|
/>
|
||||||
<Stack.Screen.Title>Search</Stack.Screen.Title>
|
<Stack.Screen.Title>Search</Stack.Screen.Title>
|
||||||
<Stack.SearchBar
|
<Stack.SearchBar
|
||||||
placeholder="Search movies, shows, people..."
|
placeholder={t`Search movies, shows, people...`}
|
||||||
onChangeText={(e) => setQuery(e.nativeEvent.text)}
|
onChangeText={(e) => setQuery(e.nativeEvent.text)}
|
||||||
onCancelButtonPress={() => setQuery("")}
|
onCancelButtonPress={() => setQuery("")}
|
||||||
onClose={() => setQuery("")}
|
onClose={() => setQuery("")}
|
||||||
@@ -108,7 +110,7 @@ export default function SearchScreen() {
|
|||||||
className="flex-1 items-center justify-center"
|
className="flex-1 items-center justify-center"
|
||||||
>
|
>
|
||||||
<Text className="text-base text-muted-foreground">
|
<Text className="text-base text-muted-foreground">
|
||||||
No results for "{debouncedQuery}"
|
<Trans>No results for "{debouncedQuery}"</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { plural } from "@lingui/core/macro";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import { activateLocale, type SupportedLocale } from "@sofa/i18n";
|
||||||
|
import { LOCALE_INFO } from "@sofa/i18n/locales";
|
||||||
import {
|
import {
|
||||||
IconArrowUpRight,
|
IconArrowUpRight,
|
||||||
IconBrandGithub,
|
IconBrandGithub,
|
||||||
@@ -7,6 +11,7 @@ import {
|
|||||||
IconDatabase,
|
IconDatabase,
|
||||||
IconDeviceMobileCog,
|
IconDeviceMobileCog,
|
||||||
IconDots,
|
IconDots,
|
||||||
|
IconLanguage,
|
||||||
IconLink,
|
IconLink,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
@@ -40,9 +45,11 @@ import { SettingsSection } from "@/components/settings/settings-section";
|
|||||||
import { TmdbLogo } from "@/components/tmdb-logo";
|
import { TmdbLogo } from "@/components/tmdb-logo";
|
||||||
import { Image } from "@/components/ui/image";
|
import { Image } from "@/components/ui/image";
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
|
import { SelectModal } from "@/components/ui/select-modal";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { setPersistedLocale } from "@/lib/i18n";
|
||||||
import { orpc } from "@/lib/orpc";
|
import { orpc } from "@/lib/orpc";
|
||||||
import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
|
import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
@@ -56,6 +63,7 @@ const settingsContentContainerStyle = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
|
const { t, i18n } = useLingui();
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
const { data: session, refetch: refetchSession } = authClient.useSession();
|
const { data: session, refetch: refetchSession } = authClient.useSession();
|
||||||
const [isEditingName, setIsEditingName] = useState(false);
|
const [isEditingName, setIsEditingName] = useState(false);
|
||||||
@@ -65,7 +73,11 @@ export default function SettingsScreen() {
|
|||||||
if (!isEditingName && session?.user?.name) setNameInput(session.user.name);
|
if (!isEditingName && session?.user?.name) setNameInput(session.user.name);
|
||||||
}, [session?.user?.name, isEditingName]);
|
}, [session?.user?.name, isEditingName]);
|
||||||
|
|
||||||
|
const [languageModalOpen, setLanguageModalOpen] = useState(false);
|
||||||
|
const languageLabel =
|
||||||
|
LOCALE_INFO.find((o) => o.code === i18n.locale)?.nativeName ?? i18n.locale;
|
||||||
const [analyticsEnabled, setAnalyticsToggle] = useState(isAnalyticsEnabled);
|
const [analyticsEnabled, setAnalyticsToggle] = useState(isAnalyticsEnabled);
|
||||||
|
|
||||||
const isAdmin = session?.user?.role === "admin";
|
const isAdmin = session?.user?.role === "admin";
|
||||||
const serverUrl = getServerUrl();
|
const serverUrl = getServerUrl();
|
||||||
|
|
||||||
@@ -92,34 +104,34 @@ export default function SettingsScreen() {
|
|||||||
const updateName = useMutation(
|
const updateName = useMutation(
|
||||||
orpc.account.updateName.mutationOptions({
|
orpc.account.updateName.mutationOptions({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Name updated");
|
toast.success(t`Name updated`);
|
||||||
setIsEditingName(false);
|
setIsEditingName(false);
|
||||||
queryClient.invalidateQueries({ queryKey: orpc.account.key() });
|
queryClient.invalidateQueries({ queryKey: orpc.account.key() });
|
||||||
refetchSession();
|
refetchSession();
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Failed to update name"),
|
onError: () => toast.error(t`Failed to update name`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadAvatar = useMutation(
|
const uploadAvatar = useMutation(
|
||||||
orpc.account.uploadAvatar.mutationOptions({
|
orpc.account.uploadAvatar.mutationOptions({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Profile picture updated");
|
toast.success(t`Profile picture updated`);
|
||||||
queryClient.invalidateQueries({ queryKey: orpc.account.key() });
|
queryClient.invalidateQueries({ queryKey: orpc.account.key() });
|
||||||
refetchSession();
|
refetchSession();
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Failed to upload avatar"),
|
onError: () => toast.error(t`Failed to upload avatar`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeAvatar = useMutation(
|
const removeAvatar = useMutation(
|
||||||
orpc.account.removeAvatar.mutationOptions({
|
orpc.account.removeAvatar.mutationOptions({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Profile picture removed");
|
toast.success(t`Profile picture removed`);
|
||||||
queryClient.invalidateQueries({ queryKey: orpc.account.key() });
|
queryClient.invalidateQueries({ queryKey: orpc.account.key() });
|
||||||
refetchSession();
|
refetchSession();
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Failed to remove profile picture"),
|
onError: () => toast.error(t`Failed to remove profile picture`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -152,12 +164,12 @@ export default function SettingsScreen() {
|
|||||||
const toggleRegistration = useMutation(
|
const toggleRegistration = useMutation(
|
||||||
orpc.admin.toggleRegistration.mutationOptions({
|
orpc.admin.toggleRegistration.mutationOptions({
|
||||||
onSuccess: (_data, { open }) => {
|
onSuccess: (_data, { open }) => {
|
||||||
toast.success(open ? "Registration opened" : "Registration closed");
|
toast.success(open ? t`Registration opened` : t`Registration closed`);
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: orpc.admin.registration.key(),
|
queryKey: orpc.admin.registration.key(),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Failed to update registration setting"),
|
onError: () => toast.error(t`Failed to update registration setting`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -170,13 +182,13 @@ export default function SettingsScreen() {
|
|||||||
orpc.admin.toggleUpdateCheck.mutationOptions({
|
orpc.admin.toggleUpdateCheck.mutationOptions({
|
||||||
onSuccess: (_data, { enabled }) => {
|
onSuccess: (_data, { enabled }) => {
|
||||||
toast.success(
|
toast.success(
|
||||||
enabled ? "Update checks enabled" : "Update checks disabled",
|
enabled ? t`Update checks enabled` : t`Update checks disabled`,
|
||||||
);
|
);
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: orpc.admin.updateCheck.key(),
|
queryKey: orpc.admin.updateCheck.key(),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Failed to update setting"),
|
onError: () => toast.error(t`Failed to update setting`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -191,10 +203,10 @@ export default function SettingsScreen() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSignOut = () => {
|
const handleSignOut = () => {
|
||||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
Alert.alert(t`Sign Out`, t`Are you sure you want to sign out?`, [
|
||||||
{ text: "Cancel", style: "cancel" },
|
{ text: t`Cancel`, style: "cancel" },
|
||||||
{
|
{
|
||||||
text: "Sign Out",
|
text: t`Sign Out`,
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
authClient.signOut();
|
authClient.signOut();
|
||||||
@@ -216,7 +228,7 @@ export default function SettingsScreen() {
|
|||||||
>
|
>
|
||||||
{/* Account */}
|
{/* Account */}
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||||
<SettingsSection title="Account" icon={IconUser}>
|
<SettingsSection title={t`Account`} icon={IconUser}>
|
||||||
<View className="flex-row items-center py-3.5">
|
<View className="flex-row items-center py-3.5">
|
||||||
{hasAvatarImage ? (
|
{hasAvatarImage ? (
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
@@ -252,7 +264,7 @@ export default function SettingsScreen() {
|
|||||||
ios={{ name: "photo.on.rectangle.angled" }}
|
ios={{ name: "photo.on.rectangle.angled" }}
|
||||||
/>
|
/>
|
||||||
<DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
Change Photo
|
<Trans>Change Photo</Trans>
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
@@ -262,7 +274,7 @@ export default function SettingsScreen() {
|
|||||||
>
|
>
|
||||||
<DropdownMenu.ItemIcon ios={{ name: "trash" }} />
|
<DropdownMenu.ItemIcon ios={{ name: "trash" }} />
|
||||||
<DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
Remove Photo
|
<Trans>Remove Photo</Trans>
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
@@ -306,7 +318,9 @@ export default function SettingsScreen() {
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => updateName.mutate({ name: nameInput })}
|
onPress={() => updateName.mutate({ name: nameInput })}
|
||||||
>
|
>
|
||||||
<Text className="text-primary text-sm">Save</Text>
|
<Text className="text-primary text-sm">
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -315,7 +329,7 @@ export default function SettingsScreen() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className="text-muted-foreground text-sm">
|
<Text className="text-muted-foreground text-sm">
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -336,7 +350,7 @@ export default function SettingsScreen() {
|
|||||||
maxFontSizeMultiplier={1.0}
|
maxFontSizeMultiplier={1.0}
|
||||||
className="font-medium font-sans text-primary text-xs"
|
className="font-medium font-sans text-primary text-xs"
|
||||||
>
|
>
|
||||||
Admin
|
<Trans>Admin</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -344,14 +358,14 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
{showPasswordOption && (
|
{showPasswordOption && (
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label={hasPassword ? "Change password" : "Set password"}
|
label={hasPassword ? t`Change password` : t`Set password`}
|
||||||
icon={IconLock}
|
icon={IconLock}
|
||||||
onPress={() => push("/change-password")}
|
onPress={() => push("/change-password")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Sign out"
|
label={t`Sign out`}
|
||||||
icon={IconLogout}
|
icon={IconLogout}
|
||||||
onPress={handleSignOut}
|
onPress={handleSignOut}
|
||||||
destructive
|
destructive
|
||||||
@@ -361,19 +375,19 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
{/* Server */}
|
{/* Server */}
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||||
<SettingsSection title="Application" icon={IconDeviceMobileCog}>
|
<SettingsSection title={t`Application`} icon={IconDeviceMobileCog}>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Server URL"
|
label={t`Server URL`}
|
||||||
value={serverUrl}
|
value={serverUrl}
|
||||||
icon={IconLink}
|
icon={IconLink}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Change Server",
|
t`Change Server`,
|
||||||
"You'll be signed out to change the server URL.",
|
t`You'll be signed out to change the server URL.`,
|
||||||
[
|
[
|
||||||
{ text: "Cancel", style: "cancel" },
|
{ text: t`Cancel`, style: "cancel" },
|
||||||
{
|
{
|
||||||
text: "Continue",
|
text: t`Continue`,
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
await authClient.signOut();
|
await authClient.signOut();
|
||||||
@@ -386,7 +400,31 @@ export default function SettingsScreen() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Anonymous usage reporting"
|
label={t`Language`}
|
||||||
|
value={languageLabel}
|
||||||
|
icon={IconLanguage}
|
||||||
|
onPress={() => setLanguageModalOpen(true)}
|
||||||
|
/>
|
||||||
|
<SelectModal
|
||||||
|
open={languageModalOpen}
|
||||||
|
onOpenChange={setLanguageModalOpen}
|
||||||
|
label={t`Language`}
|
||||||
|
icon={IconLanguage}
|
||||||
|
selection={i18n.locale}
|
||||||
|
options={LOCALE_INFO.map((info) => ({
|
||||||
|
value: info.code,
|
||||||
|
label: info.nativeName,
|
||||||
|
}))}
|
||||||
|
onSelect={(locale) => {
|
||||||
|
setLanguageModalOpen(false);
|
||||||
|
activateLocale(locale as SupportedLocale).then(
|
||||||
|
() => setPersistedLocale(locale as SupportedLocale),
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label={t`Anonymous usage reporting`}
|
||||||
icon={IconChartBar}
|
icon={IconChartBar}
|
||||||
right={
|
right={
|
||||||
<Switch
|
<Switch
|
||||||
@@ -411,9 +449,9 @@ export default function SettingsScreen() {
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Server Health"
|
title={t`Server Health`}
|
||||||
icon={IconServer}
|
icon={IconServer}
|
||||||
badge="Admin"
|
badge={t`Admin`}
|
||||||
>
|
>
|
||||||
{systemHealth.isPending ? (
|
{systemHealth.isPending ? (
|
||||||
<View className="items-center py-4">
|
<View className="items-center py-4">
|
||||||
@@ -422,24 +460,26 @@ export default function SettingsScreen() {
|
|||||||
) : systemHealth.data ? (
|
) : systemHealth.data ? (
|
||||||
<>
|
<>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Database"
|
label={t`Database`}
|
||||||
value={
|
value={
|
||||||
systemHealth.data?.database
|
systemHealth.data?.database
|
||||||
? `${systemHealth.data.database.titleCount} titles`
|
? t`${plural(systemHealth.data.database.titleCount, { one: "# title", other: "# titles" })}`
|
||||||
: "—"
|
: "—"
|
||||||
}
|
}
|
||||||
icon={IconDatabase}
|
icon={IconDatabase}
|
||||||
/>
|
/>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="TMDB"
|
label="TMDB"
|
||||||
value={systemHealth.data?.tmdb?.connected ? "Connected" : "—"}
|
value={
|
||||||
|
systemHealth.data?.tmdb?.connected ? t`Connected` : "—"
|
||||||
|
}
|
||||||
icon={IconCloud}
|
icon={IconCloud}
|
||||||
/>
|
/>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Image Cache"
|
label={t`Image Cache`}
|
||||||
value={
|
value={
|
||||||
systemHealth.data?.imageCache
|
systemHealth.data?.imageCache
|
||||||
? `${systemHealth.data.imageCache.imageCount} images`
|
? t`${plural(systemHealth.data.imageCache.imageCount, { one: "# image", other: "# images" })}`
|
||||||
: "—"
|
: "—"
|
||||||
}
|
}
|
||||||
icon={IconPhoto}
|
icon={IconPhoto}
|
||||||
@@ -453,9 +493,13 @@ export default function SettingsScreen() {
|
|||||||
{/* Admin: Security */}
|
{/* Admin: Security */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(500)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(500)}>
|
||||||
<SettingsSection title="Security" icon={IconShield} badge="Admin">
|
<SettingsSection
|
||||||
|
title={t`Security`}
|
||||||
|
icon={IconShield}
|
||||||
|
badge={t`Admin`}
|
||||||
|
>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Open registration"
|
label={t`Open registration`}
|
||||||
icon={IconUserPlus}
|
icon={IconUserPlus}
|
||||||
right={
|
right={
|
||||||
<Switch
|
<Switch
|
||||||
@@ -466,7 +510,7 @@ export default function SettingsScreen() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Check for updates"
|
label={t`Check for updates`}
|
||||||
icon={IconCloud}
|
icon={IconCloud}
|
||||||
right={
|
right={
|
||||||
<Switch
|
<Switch
|
||||||
@@ -481,7 +525,10 @@ export default function SettingsScreen() {
|
|||||||
{updateCheck.data?.updateCheck?.updateAvailable && (
|
{updateCheck.data?.updateCheck?.updateAvailable && (
|
||||||
<View className="py-3.5">
|
<View className="py-3.5">
|
||||||
<Text className="font-medium font-sans text-sm text-status-completed">
|
<Text className="font-medium font-sans text-sm text-status-completed">
|
||||||
Update available: {updateCheck.data.updateCheck.latestVersion}
|
<Trans>
|
||||||
|
Update available:{" "}
|
||||||
|
{updateCheck.data.updateCheck.latestVersion}
|
||||||
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -491,14 +538,14 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
{/* More Settings */}
|
{/* More Settings */}
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
|
||||||
<SettingsSection title="More Settings" icon={IconDots}>
|
<SettingsSection title={t`More Settings`} icon={IconDots}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => Linking.openURL(`${serverUrl}/settings`)}
|
onPress={() => Linking.openURL(`${serverUrl}/settings`)}
|
||||||
className="flex-row items-center justify-center py-3.5 active:opacity-70"
|
className="flex-row items-center justify-center py-3.5 active:opacity-70"
|
||||||
>
|
>
|
||||||
<ScaledIcon icon={IconWorld} size={18} color={mutedFgColor} />
|
<ScaledIcon icon={IconWorld} size={18} color={mutedFgColor} />
|
||||||
<Text className="ml-2 flex-1 text-base text-foreground">
|
<Text className="ml-2 flex-1 text-base text-foreground">
|
||||||
Open in browser…
|
<Trans>Open in browser…</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<ScaledIcon
|
<ScaledIcon
|
||||||
icon={IconArrowUpRight}
|
icon={IconArrowUpRight}
|
||||||
@@ -556,8 +603,10 @@ export default function SettingsScreen() {
|
|||||||
maxFontSizeMultiplier={1.0}
|
maxFontSizeMultiplier={1.0}
|
||||||
className="text-center text-muted-foreground text-xs leading-relaxed"
|
className="text-center text-muted-foreground text-xs leading-relaxed"
|
||||||
>
|
>
|
||||||
This product uses the TMDB API but is not endorsed or certified by
|
<Trans>
|
||||||
TMDB.
|
This product uses the TMDB API but is not endorsed or certified by
|
||||||
|
TMDB.
|
||||||
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { IconAlertTriangle } from "@tabler/icons-react-native";
|
import { IconAlertTriangle } from "@tabler/icons-react-native";
|
||||||
import { Link, Stack } from "expo-router";
|
import { Link, Stack } from "expo-router";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
@@ -12,7 +13,9 @@ export default function NotFoundScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen.Title>Not Found</Stack.Screen.Title>
|
<Stack.Screen.Title>
|
||||||
|
<Trans>Not Found</Trans>
|
||||||
|
</Stack.Screen.Title>
|
||||||
<Container>
|
<Container>
|
||||||
<View className="flex-1 items-center justify-center p-4">
|
<View className="flex-1 items-center justify-center p-4">
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -21,17 +24,17 @@ export default function NotFoundScreen() {
|
|||||||
>
|
>
|
||||||
<IconAlertTriangle size={48} color={mutedForeground} />
|
<IconAlertTriangle size={48} color={mutedForeground} />
|
||||||
<Text className="mt-3 font-display text-foreground text-xl">
|
<Text className="mt-3 font-display text-foreground text-xl">
|
||||||
Page Not Found
|
<Trans>Page Not Found</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="mt-1 mb-4 text-center text-muted-foreground text-sm">
|
<Text className="mt-1 mb-4 text-center text-muted-foreground text-sm">
|
||||||
The page you're looking for doesn't exist.
|
<Trans>The page you're looking for doesn't exist.</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||||
<Link href="/" asChild>
|
<Link href="/" asChild>
|
||||||
<Button size="sm" className="bg-primary">
|
<Button size="sm" className="bg-primary">
|
||||||
<ButtonLabel className="text-primary-foreground">
|
<ButtonLabel className="text-primary-foreground">
|
||||||
Go Home
|
<Trans>Go Home</Trans>
|
||||||
</ButtonLabel>
|
</ButtonLabel>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import "@/lib/intl-polyfills";
|
||||||
import "@/global.css";
|
import "@/global.css";
|
||||||
|
import { I18nProvider } from "@lingui/react";
|
||||||
import { ThemeProvider } from "@react-navigation/native";
|
import { ThemeProvider } from "@react-navigation/native";
|
||||||
|
import { i18n } from "@sofa/i18n";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
persistQueryClientRestore,
|
persistQueryClientRestore,
|
||||||
@@ -17,11 +20,13 @@ import { PostHogErrorBoundary, PostHogProvider } from "posthog-react-native";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { enableFreeze } from "react-native-screens";
|
import { enableFreeze } from "react-native-screens";
|
||||||
import { Uniwind, useResolveClassNames } from "uniwind";
|
import { Uniwind, useResolveClassNames } from "uniwind";
|
||||||
import { OfflineBanner } from "@/components/ui/offline-banner";
|
import { OfflineBanner } from "@/components/ui/offline-banner";
|
||||||
import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner";
|
import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner";
|
||||||
import { useServerConnection } from "@/hooks/use-server-connection";
|
import { useServerConnection } from "@/hooks/use-server-connection";
|
||||||
|
import { initLocale } from "@/lib/i18n";
|
||||||
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
|
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +40,7 @@ import { sofaTheme } from "@/lib/theme";
|
|||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
enableFreeze(true);
|
enableFreeze(true);
|
||||||
initialize();
|
initialize();
|
||||||
|
const localeReady = initLocale();
|
||||||
|
|
||||||
export const unstable_settings = {
|
export const unstable_settings = {
|
||||||
initialRouteName: "(tabs)",
|
initialRouteName: "(tabs)",
|
||||||
@@ -63,6 +69,15 @@ function AppContent() {
|
|||||||
const contentStyle = useResolveClassNames("bg-background");
|
const contentStyle = useResolveClassNames("bg-background");
|
||||||
const { session, isPending, hasServerUrl } = useServerConnection();
|
const { session, isPending, hasServerUrl } = useServerConnection();
|
||||||
|
|
||||||
|
// --- Locale readiness (wait for async catalog load before showing UI) ---
|
||||||
|
const [isLocaleReady, setLocaleReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localeReady
|
||||||
|
.then(() => setLocaleReady(true))
|
||||||
|
.catch(() => setLocaleReady(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// --- App Tracking Transparency (must resolve before screen tracking) ---
|
// --- App Tracking Transparency (must resolve before screen tracking) ---
|
||||||
const [trackingReady, setTrackingReady] = useState(false);
|
const [trackingReady, setTrackingReady] = useState(false);
|
||||||
|
|
||||||
@@ -111,10 +126,10 @@ function AppContent() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPending || !hasServerUrl) {
|
if (isLocaleReady && (!isPending || !hasServerUrl)) {
|
||||||
SplashScreen.hideAsync();
|
SplashScreen.hideAsync();
|
||||||
}
|
}
|
||||||
}, [isPending, hasServerUrl]);
|
}, [isPending, hasServerUrl, isLocaleReady]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider value={sofaTheme}>
|
<ThemeProvider value={sofaTheme}>
|
||||||
@@ -215,13 +230,17 @@ function QueryProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const inner = (
|
const inner = (
|
||||||
<QueryProvider>
|
<I18nProvider i18n={i18n}>
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<QueryProvider>
|
||||||
<KeyboardProvider>
|
<SafeAreaProvider>
|
||||||
<AppContent />
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
</KeyboardProvider>
|
<KeyboardProvider>
|
||||||
</GestureHandlerRootView>
|
<AppContent />
|
||||||
</QueryProvider>
|
</KeyboardProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</QueryProvider>
|
||||||
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!posthog) return inner;
|
if (!posthog) return inner;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
@@ -43,6 +44,7 @@ function formatFormErrors(errors: unknown): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ChangePasswordScreen() {
|
export default function ChangePasswordScreen() {
|
||||||
|
const { t } = useLingui();
|
||||||
const { back } = useRouter();
|
const { back } = useRouter();
|
||||||
const newPasswordRef = useRef<TextInput>(null);
|
const newPasswordRef = useRef<TextInput>(null);
|
||||||
const confirmPasswordRef = useRef<TextInput>(null);
|
const confirmPasswordRef = useRef<TextInput>(null);
|
||||||
@@ -66,20 +68,17 @@ export default function ChangePasswordScreen() {
|
|||||||
});
|
});
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
Alert.alert(
|
Alert.alert(t`Error`, t`Failed to change password`);
|
||||||
"Error",
|
|
||||||
result.error.message ?? "Failed to change password",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
toast.success("Password updated");
|
toast.success(t`Password updated`);
|
||||||
formApi.reset();
|
formApi.reset();
|
||||||
back();
|
back();
|
||||||
} catch {
|
} catch {
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
Alert.alert("Error", "Something went wrong");
|
Alert.alert(t`Error`, t`Something went wrong`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -96,7 +95,7 @@ export default function ChangePasswordScreen() {
|
|||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
<Stack.Screen.Title style={headerTitleStyle as Record<string, unknown>}>
|
<Stack.Screen.Title style={headerTitleStyle as Record<string, unknown>}>
|
||||||
Change Password
|
<Trans>Change Password</Trans>
|
||||||
</Stack.Screen.Title>
|
</Stack.Screen.Title>
|
||||||
|
|
||||||
<form.Subscribe
|
<form.Subscribe
|
||||||
@@ -117,7 +116,9 @@ export default function ChangePasswordScreen() {
|
|||||||
<form.Field name="currentPassword">
|
<form.Field name="currentPassword">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<TextField>
|
<TextField>
|
||||||
<Label>Current password</Label>
|
<Label>
|
||||||
|
<Trans>Current password</Trans>
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
accessibilityLabel="Current password"
|
accessibilityLabel="Current password"
|
||||||
@@ -140,7 +141,9 @@ export default function ChangePasswordScreen() {
|
|||||||
<form.Field name="newPassword">
|
<form.Field name="newPassword">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<TextField>
|
<TextField>
|
||||||
<Label>New password</Label>
|
<Label>
|
||||||
|
<Trans>New password</Trans>
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
ref={newPasswordRef}
|
ref={newPasswordRef}
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
@@ -166,7 +169,9 @@ export default function ChangePasswordScreen() {
|
|||||||
<form.Field name="confirmPassword">
|
<form.Field name="confirmPassword">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<TextField>
|
<TextField>
|
||||||
<Label>Confirm new password</Label>
|
<Label>
|
||||||
|
<Trans>Confirm new password</Trans>
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
ref={confirmPasswordRef}
|
ref={confirmPasswordRef}
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
@@ -191,7 +196,7 @@ export default function ChangePasswordScreen() {
|
|||||||
style={{ borderCurve: "continuous" }}
|
style={{ borderCurve: "continuous" }}
|
||||||
>
|
>
|
||||||
<Text className="text-base text-foreground">
|
<Text className="text-base text-foreground">
|
||||||
Sign out of other sessions
|
<Trans>Sign out of other sessions</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Switch
|
<Switch
|
||||||
value={revokeOtherSessions}
|
value={revokeOtherSessions}
|
||||||
@@ -209,7 +214,9 @@ export default function ChangePasswordScreen() {
|
|||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
) : (
|
) : (
|
||||||
<ButtonLabel>Update Password</ButtonLabel>
|
<ButtonLabel>
|
||||||
|
<Trans>Update Password</Trans>
|
||||||
|
</ButtonLabel>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { useHeaderHeight } from "@react-navigation/elements";
|
import { useHeaderHeight } from "@react-navigation/elements";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { formatDate } from "@sofa/i18n/format";
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
@@ -8,8 +10,6 @@ import {
|
|||||||
IconUser,
|
IconUser,
|
||||||
} from "@tabler/icons-react-native";
|
} from "@tabler/icons-react-native";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { format } from "date-fns/format";
|
|
||||||
import { parseISO } from "date-fns/parseISO";
|
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -48,15 +48,20 @@ function calculateAge(birthday: string, deathday?: string | null): number {
|
|||||||
return age;
|
return age;
|
||||||
}
|
}
|
||||||
|
|
||||||
const departmentLabels: Record<string, string> = {
|
function getDepartmentLabels(
|
||||||
Acting: "Actor",
|
t: (strings: TemplateStringsArray, ...values: unknown[]) => string,
|
||||||
Directing: "Director",
|
): Record<string, string> {
|
||||||
Writing: "Writer",
|
return {
|
||||||
Production: "Producer",
|
Acting: t`Actor`,
|
||||||
Editing: "Editor",
|
Directing: t`Director`,
|
||||||
};
|
Writing: t`Writer`,
|
||||||
|
Production: t`Producer`,
|
||||||
|
Editing: t`Editor`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function PersonDetailScreen() {
|
export default function PersonDetailScreen() {
|
||||||
|
const { t } = useLingui();
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const headerHeight = useHeaderHeight();
|
const headerHeight = useHeaderHeight();
|
||||||
@@ -205,13 +210,15 @@ export default function PersonDetailScreen() {
|
|||||||
>
|
>
|
||||||
<IconAlertTriangle size={48} color={mutedForeground} />
|
<IconAlertTriangle size={48} color={mutedForeground} />
|
||||||
<Text className="mt-3 font-display text-foreground text-xl">
|
<Text className="mt-3 font-display text-foreground text-xl">
|
||||||
Something went wrong
|
<Trans>Something went wrong</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="mt-1 text-center text-muted-foreground text-sm">
|
<Text className="mt-1 text-center text-muted-foreground text-sm">
|
||||||
Could not load person details
|
<Trans>Could not load person details</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable onPress={() => back()} className="mt-4">
|
<Pressable onPress={() => back()} className="mt-4">
|
||||||
<Text className="text-primary">Go back</Text>
|
<Text className="text-primary">
|
||||||
|
<Trans>Go back</Trans>
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
@@ -233,10 +240,12 @@ export default function PersonDetailScreen() {
|
|||||||
>
|
>
|
||||||
<IconUser size={48} color={mutedForeground} />
|
<IconUser size={48} color={mutedForeground} />
|
||||||
<Text className="mt-3 font-display text-foreground text-xl">
|
<Text className="mt-3 font-display text-foreground text-xl">
|
||||||
Person not found
|
<Trans>Person not found</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable onPress={() => back()} className="mt-4">
|
<Pressable onPress={() => back()} className="mt-4">
|
||||||
<Text className="text-primary">Go back</Text>
|
<Text className="text-primary">
|
||||||
|
<Trans>Go back</Trans>
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
@@ -275,7 +284,7 @@ export default function PersonDetailScreen() {
|
|||||||
{person.knownForDepartment ? (
|
{person.knownForDepartment ? (
|
||||||
<View className="mt-2 rounded-full bg-secondary px-3 py-1">
|
<View className="mt-2 rounded-full bg-secondary px-3 py-1">
|
||||||
<Text className="text-muted-foreground text-xs uppercase tracking-wider">
|
<Text className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||||
{departmentLabels[person.knownForDepartment] ??
|
{getDepartmentLabels(t)[person.knownForDepartment] ??
|
||||||
person.knownForDepartment}
|
person.knownForDepartment}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -291,12 +300,12 @@ export default function PersonDetailScreen() {
|
|||||||
color={primaryColor}
|
color={primaryColor}
|
||||||
/>
|
/>
|
||||||
<Text selectable className="text-muted-foreground text-sm">
|
<Text selectable className="text-muted-foreground text-sm">
|
||||||
{format(parseISO(person.birthday), "MMMM d, yyyy")}
|
{formatDate(person.birthday)}
|
||||||
{(() => {
|
{(() => {
|
||||||
const age = calculateAge(person.birthday, person.deathday);
|
const age = calculateAge(person.birthday, person.deathday);
|
||||||
return person.deathday
|
return person.deathday
|
||||||
? ` (died at ${age})`
|
? ` (${t`died at ${age}`})`
|
||||||
: ` (age ${age})`;
|
: ` (${t`age ${age}`})`;
|
||||||
})()}
|
})()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -328,7 +337,7 @@ export default function PersonDetailScreen() {
|
|||||||
entering={FadeInDown.duration(300).delay(200)}
|
entering={FadeInDown.duration(300).delay(200)}
|
||||||
className="px-4"
|
className="px-4"
|
||||||
>
|
>
|
||||||
<SectionHeader title="Filmography" icon={IconMovie} />
|
<SectionHeader title={t`Filmography`} icon={IconMovie} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { useHeaderHeight } from "@react-navigation/elements";
|
import { useHeaderHeight } from "@react-navigation/elements";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import {
|
import {
|
||||||
@@ -87,6 +88,7 @@ const titleDetailStyles = StyleSheet.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function TitleDetailScreen() {
|
export default function TitleDetailScreen() {
|
||||||
|
const { t } = useLingui();
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const headerHeight = useHeaderHeight();
|
const headerHeight = useHeaderHeight();
|
||||||
@@ -246,10 +248,12 @@ export default function TitleDetailScreen() {
|
|||||||
<View className="flex-1 items-center justify-center bg-background px-6">
|
<View className="flex-1 items-center justify-center bg-background px-6">
|
||||||
<IconMovie size={48} color={mutedForeground} />
|
<IconMovie size={48} color={mutedForeground} />
|
||||||
<Text className="mt-3 font-display text-foreground text-xl">
|
<Text className="mt-3 font-display text-foreground text-xl">
|
||||||
Title not found
|
<Trans>Title not found</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable onPress={() => back()} className="mt-4">
|
<Pressable onPress={() => back()} className="mt-4">
|
||||||
<Text className="text-primary">Go back</Text>
|
<Text className="text-primary">
|
||||||
|
<Trans>Go back</Trans>
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
@@ -355,7 +359,7 @@ export default function TitleDetailScreen() {
|
|||||||
maxFontSizeMultiplier={1.0}
|
maxFontSizeMultiplier={1.0}
|
||||||
className="font-medium font-sans text-title-accent-foreground text-xs"
|
className="font-medium font-sans text-title-accent-foreground text-xs"
|
||||||
>
|
>
|
||||||
{title.type === "movie" ? "Movie" : "TV"}
|
{title.type === "movie" ? t`Movie` : t`TV`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{year ? (
|
{year ? (
|
||||||
@@ -442,7 +446,7 @@ export default function TitleDetailScreen() {
|
|||||||
color={titleAccentForeground}
|
color={titleAccentForeground}
|
||||||
/>
|
/>
|
||||||
<Text className="font-medium font-sans text-sm text-title-accent-foreground">
|
<Text className="font-medium font-sans text-sm text-title-accent-foreground">
|
||||||
Mark Watched
|
<Trans>Mark Watched</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -476,7 +480,7 @@ export default function TitleDetailScreen() {
|
|||||||
>
|
>
|
||||||
<View className="px-4">
|
<View className="px-4">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Where to Watch"
|
title={t`Where to Watch`}
|
||||||
icon={providerIcon}
|
icon={providerIcon}
|
||||||
iconColor={titleAccent}
|
iconColor={titleAccent}
|
||||||
/>
|
/>
|
||||||
@@ -529,7 +533,7 @@ export default function TitleDetailScreen() {
|
|||||||
className="mt-6 px-4"
|
className="mt-6 px-4"
|
||||||
>
|
>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Seasons"
|
title={t`Seasons`}
|
||||||
icon={IconList}
|
icon={IconList}
|
||||||
iconColor={titleAccent}
|
iconColor={titleAccent}
|
||||||
/>
|
/>
|
||||||
@@ -552,7 +556,7 @@ export default function TitleDetailScreen() {
|
|||||||
>
|
>
|
||||||
<View className="px-4">
|
<View className="px-4">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Cast"
|
title={t`Cast`}
|
||||||
icon={IconUsers}
|
icon={IconUsers}
|
||||||
iconColor={titleAccent}
|
iconColor={titleAccent}
|
||||||
/>
|
/>
|
||||||
@@ -578,7 +582,7 @@ export default function TitleDetailScreen() {
|
|||||||
>
|
>
|
||||||
<View className="px-4">
|
<View className="px-4">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="More Like This"
|
title={t`More Like This`}
|
||||||
icon={IconThumbUp}
|
icon={IconThumbUp}
|
||||||
iconColor={titleAccent}
|
iconColor={titleAccent}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { Link } from "expo-router";
|
import { Link } from "expo-router";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { GestureDetector } from "react-native-gesture-handler";
|
import { GestureDetector } from "react-native-gesture-handler";
|
||||||
@@ -26,6 +27,7 @@ export interface ContinueWatchingItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
||||||
|
const { t } = useLingui();
|
||||||
const { animatedStyle, gesture: tapGesture } = usePressAnimation();
|
const { animatedStyle, gesture: tapGesture } = usePressAnimation();
|
||||||
|
|
||||||
const progressLabel = `${item.watchedEpisodes} of ${item.totalEpisodes} episodes`;
|
const progressLabel = `${item.watchedEpisodes} of ${item.totalEpisodes} episodes`;
|
||||||
@@ -117,14 +119,14 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
|||||||
<Link.Preview />
|
<Link.Preview />
|
||||||
<Link.Menu>
|
<Link.Menu>
|
||||||
<Link.MenuAction
|
<Link.MenuAction
|
||||||
title="Mark as Completed"
|
title={t`Mark as Completed`}
|
||||||
icon="checkmark.circle"
|
icon="checkmark.circle"
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
titleActions.markCompleted(item.title.id, item.title.title)
|
titleActions.markCompleted(item.title.id, item.title.title)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Link.MenuAction
|
<Link.MenuAction
|
||||||
title="Remove from Library"
|
title={t`Remove from Library`}
|
||||||
icon="trash"
|
icon="trash"
|
||||||
destructive
|
destructive
|
||||||
onPress={() => titleActions.removeFromLibrary(item.title.id)}
|
onPress={() => titleActions.removeFromLibrary(item.title.id)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { Icon } from "@tabler/icons-react-native";
|
import type { Icon } from "@tabler/icons-react-native";
|
||||||
import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
|
import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
@@ -42,6 +43,7 @@ export function FilterableTitleRow({
|
|||||||
genres?: Array<{ id: number; name: string }>;
|
genres?: Array<{ id: number; name: string }>;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
|
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
|
||||||
|
|
||||||
const discover = useInfiniteQuery({
|
const discover = useInfiniteQuery({
|
||||||
@@ -114,7 +116,7 @@ export function FilterableTitleRow({
|
|||||||
contentContainerStyle={genreChipsContentStyle}
|
contentContainerStyle={genreChipsContentStyle}
|
||||||
>
|
>
|
||||||
<GenreChip
|
<GenreChip
|
||||||
label="All"
|
label={t`All`}
|
||||||
isSelected={selectedGenre === null}
|
isSelected={selectedGenre === null}
|
||||||
onPress={() => setSelectedGenre(null)}
|
onPress={() => setSelectedGenre(null)}
|
||||||
/>
|
/>
|
||||||
@@ -134,7 +136,7 @@ export function FilterableTitleRow({
|
|||||||
{!showLoading && items.length === 0 && selectedGenre !== null ? (
|
{!showLoading && items.length === 0 && selectedGenre !== null ? (
|
||||||
<View className="items-center py-6">
|
<View className="items-center py-6">
|
||||||
<Text className="text-muted-foreground text-sm">
|
<Text className="text-muted-foreground text-sm">
|
||||||
No titles found for this genre.
|
<Trans>No titles found for this genre.</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { Pressable } from "react-native";
|
import { Pressable } from "react-native";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
@@ -11,6 +12,7 @@ export function GenreChip({
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -22,8 +24,8 @@ export function GenreChip({
|
|||||||
accessibilityState={{ selected: isSelected }}
|
accessibilityState={{ selected: isSelected }}
|
||||||
accessibilityHint={
|
accessibilityHint={
|
||||||
isSelected
|
isSelected
|
||||||
? "Double tap to clear this filter"
|
? t`Double tap to clear this filter`
|
||||||
: `Double tap to filter by ${label}`
|
: t`Double tap to filter by ${label}`
|
||||||
}
|
}
|
||||||
className={`mr-2 rounded-full px-3 py-1.5 ${isSelected ? "bg-primary" : "bg-secondary"}`}
|
className={`mr-2 rounded-full px-3 py-1.5 ${isSelected ? "bg-primary" : "bg-secondary"}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { IconStarFilled } from "@tabler/icons-react-native";
|
import { IconStarFilled } from "@tabler/icons-react-native";
|
||||||
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||||
import { Link } from "expo-router";
|
import { Link } from "expo-router";
|
||||||
@@ -22,6 +23,7 @@ export interface HeroBannerItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
||||||
|
const { t } = useLingui();
|
||||||
const primary = useCSSVariable("--color-primary") as string;
|
const primary = useCSSVariable("--color-primary") as string;
|
||||||
const { animatedStyle, gesture: tapGesture } = usePressAnimation(0.98);
|
const { animatedStyle, gesture: tapGesture } = usePressAnimation(0.98);
|
||||||
|
|
||||||
@@ -152,7 +154,7 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
|||||||
<Link.Preview />
|
<Link.Preview />
|
||||||
<Link.Menu>
|
<Link.Menu>
|
||||||
<Link.MenuAction
|
<Link.MenuAction
|
||||||
title="Add to Watchlist"
|
title={t`Add to Watchlist`}
|
||||||
icon="bookmark"
|
icon="bookmark"
|
||||||
onPress={() => titleActions.quickAdd(item.id, item.title)}
|
onPress={() => titleActions.quickAdd(item.id, item.title)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { Alert, Pressable, View } from "react-native";
|
import { Alert, Pressable, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
@@ -8,6 +9,7 @@ import { authClient } from "@/lib/server";
|
|||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
export function HeaderAvatar() {
|
export function HeaderAvatar() {
|
||||||
|
const { t } = useLingui();
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
const { navigate } = useRouter();
|
const { navigate } = useRouter();
|
||||||
|
|
||||||
@@ -58,17 +60,17 @@ export function HeaderAvatar() {
|
|||||||
ios={{ name: "gear" }}
|
ios={{ name: "gear" }}
|
||||||
androidIconName="ic_menu_preferences"
|
androidIconName="ic_menu_preferences"
|
||||||
/>
|
/>
|
||||||
<DropdownMenu.ItemTitle>Settings</DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>{t`Settings`}</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key="sign-out"
|
key="sign-out"
|
||||||
destructive
|
destructive
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
Alert.alert(t`Sign Out`, t`Are you sure you want to sign out?`, [
|
||||||
{ text: "Cancel", style: "cancel" },
|
{ text: t`Cancel`, style: "cancel" },
|
||||||
{
|
{
|
||||||
text: "Sign Out",
|
text: t`Sign Out`,
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
authClient.signOut();
|
authClient.signOut();
|
||||||
@@ -82,7 +84,7 @@ export function HeaderAvatar() {
|
|||||||
ios={{ name: "rectangle.portrait.and.arrow.right" }}
|
ios={{ name: "rectangle.portrait.and.arrow.right" }}
|
||||||
androidIconName="ic_menu_close_clear_cancel"
|
androidIconName="ic_menu_close_clear_cancel"
|
||||||
/>
|
/>
|
||||||
<DropdownMenu.ItemTitle>Sign out</DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>{t`Sign out`}</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
|
||||||
export function DetailStackHeader() {
|
export function DetailStackHeader() {
|
||||||
const { dismissAll } = useRouter();
|
const { dismissAll } = useRouter();
|
||||||
|
const { t } = useLingui();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Header transparent blurEffect="none" />
|
<Stack.Header transparent blurEffect="none" />
|
||||||
<Stack.Toolbar placement="right">
|
<Stack.Toolbar placement="right">
|
||||||
<Stack.Toolbar.Button onPress={() => dismissAll()}>
|
<Stack.Toolbar.Button onPress={() => dismissAll()}>
|
||||||
<Stack.Toolbar.Icon sf="xmark" />
|
<Stack.Toolbar.Icon sf="xmark" />
|
||||||
<Stack.Toolbar.Label>Close</Stack.Toolbar.Label>
|
<Stack.Toolbar.Label>{t`Close`}</Stack.Toolbar.Label>
|
||||||
</Stack.Toolbar.Button>
|
</Stack.Toolbar.Button>
|
||||||
</Stack.Toolbar>
|
</Stack.Toolbar>
|
||||||
<Stack.Screen.Title asChild>
|
<Stack.Screen.Title asChild>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
onTapGesture,
|
onTapGesture,
|
||||||
scrollContentBackground,
|
scrollContentBackground,
|
||||||
} from "@expo/ui/swift-ui/modifiers";
|
} from "@expo/ui/swift-ui/modifiers";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { IconHistory, IconSearch } from "@tabler/icons-react-native";
|
import { IconHistory, IconSearch } from "@tabler/icons-react-native";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
export function RecentlyViewedList() {
|
export function RecentlyViewedList() {
|
||||||
|
const { t } = useLingui();
|
||||||
const { navigate } = useRouter();
|
const { navigate } = useRouter();
|
||||||
const { items, removeItem, clearAll } = useRecentlyViewed();
|
const { items, removeItem, clearAll } = useRecentlyViewed();
|
||||||
const [mutedForeground, primaryColor] = useCSSVariable([
|
const [mutedForeground, primaryColor] = useCSSVariable([
|
||||||
@@ -55,18 +57,18 @@ export function RecentlyViewedList() {
|
|||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Clear Recently Viewed?",
|
t`Clear Recently Viewed?`,
|
||||||
"This will remove all items from your history.",
|
t`This will remove all items from your history.`,
|
||||||
[
|
[
|
||||||
{ text: "Cancel", style: "cancel" },
|
{ text: t`Cancel`, style: "cancel" },
|
||||||
{
|
{
|
||||||
text: "Clear",
|
text: t`Clear`,
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: () => clearAll(),
|
onPress: () => clearAll(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}, [clearAll]);
|
}, [clearAll, t]);
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -76,7 +78,7 @@ export function RecentlyViewedList() {
|
|||||||
>
|
>
|
||||||
<IconSearch size={64} color={mutedForeground} />
|
<IconSearch size={64} color={mutedForeground} />
|
||||||
<Text className="mt-3 text-base text-muted-foreground">
|
<Text className="mt-3 text-base text-muted-foreground">
|
||||||
Search for movies, shows, or people
|
<Trans>Search for movies, shows, or people</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
@@ -101,7 +103,7 @@ export function RecentlyViewedList() {
|
|||||||
<View className="flex-row items-center gap-2">
|
<View className="flex-row items-center gap-2">
|
||||||
<IconHistory size={20} color={primaryColor} />
|
<IconHistory size={20} color={primaryColor} />
|
||||||
<Text className="font-display text-foreground text-xl tracking-tight">
|
<Text className="font-display text-foreground text-xl tracking-tight">
|
||||||
Recently Viewed
|
<Trans>Recently Viewed</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -109,7 +111,9 @@ export function RecentlyViewedList() {
|
|||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
||||||
>
|
>
|
||||||
<Text className="text-primary text-sm">Clear</Text>
|
<Text className="text-primary text-sm">
|
||||||
|
<Trans>Clear</Trans>
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</RNHostView>
|
</RNHostView>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { IconHistory, IconSearch } from "@tabler/icons-react-native";
|
import { IconHistory, IconSearch } from "@tabler/icons-react-native";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
export function RecentlyViewedList() {
|
export function RecentlyViewedList() {
|
||||||
|
const { t } = useLingui();
|
||||||
const { items, removeItem, clearAll } = useRecentlyViewed();
|
const { items, removeItem, clearAll } = useRecentlyViewed();
|
||||||
const [mutedForeground, primaryColor] = useCSSVariable([
|
const [mutedForeground, primaryColor] = useCSSVariable([
|
||||||
"--color-muted-foreground",
|
"--color-muted-foreground",
|
||||||
@@ -29,18 +31,18 @@ export function RecentlyViewedList() {
|
|||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Clear Recently Viewed?",
|
t`Clear Recently Viewed?`,
|
||||||
"This will remove all items from your history.",
|
t`This will remove all items from your history.`,
|
||||||
[
|
[
|
||||||
{ text: "Cancel", style: "cancel" },
|
{ text: t`Cancel`, style: "cancel" },
|
||||||
{
|
{
|
||||||
text: "Clear",
|
text: t`Clear`,
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: () => clearAll(),
|
onPress: () => clearAll(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}, [clearAll]);
|
}, [clearAll, t]);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item }: { item: RecentlyViewedItem }) => (
|
({ item }: { item: RecentlyViewedItem }) => (
|
||||||
@@ -59,7 +61,7 @@ export function RecentlyViewedList() {
|
|||||||
>
|
>
|
||||||
<IconSearch size={64} color={mutedForeground} />
|
<IconSearch size={64} color={mutedForeground} />
|
||||||
<Text className="mt-3 text-base text-muted-foreground">
|
<Text className="mt-3 text-base text-muted-foreground">
|
||||||
Search for movies, shows, or people
|
<Trans>Search for movies, shows, or people</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
@@ -81,7 +83,7 @@ export function RecentlyViewedList() {
|
|||||||
<View className="flex-row items-center gap-2">
|
<View className="flex-row items-center gap-2">
|
||||||
<IconHistory size={20} color={primaryColor} />
|
<IconHistory size={20} color={primaryColor} />
|
||||||
<Text className="font-display text-foreground text-xl tracking-tight">
|
<Text className="font-display text-foreground text-xl tracking-tight">
|
||||||
Recently Viewed
|
<Trans>Recently Viewed</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -89,7 +91,9 @@ export function RecentlyViewedList() {
|
|||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
||||||
>
|
>
|
||||||
<Text className="text-primary text-sm">Clear</Text>
|
<Text className="text-primary text-sm">
|
||||||
|
<Trans>Clear</Trans>
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { IconDeviceTv, IconMovie, IconUser } from "@tabler/icons-react-native";
|
import { IconDeviceTv, IconMovie, IconUser } from "@tabler/icons-react-native";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
@@ -11,17 +12,22 @@ const TypeIcon = {
|
|||||||
person: IconUser,
|
person: IconUser,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const TypeLabel = {
|
function getTypeLabel(
|
||||||
movie: "Movie",
|
t: (strings: TemplateStringsArray, ...values: unknown[]) => string,
|
||||||
tv: "TV",
|
) {
|
||||||
person: "Person",
|
return {
|
||||||
} as const;
|
movie: t`Movie`,
|
||||||
|
tv: t`TV`,
|
||||||
|
person: t`Person`,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
export function RecentlyViewedRowContent({
|
export function RecentlyViewedRowContent({
|
||||||
item,
|
item,
|
||||||
}: {
|
}: {
|
||||||
item: RecentlyViewedItem;
|
item: RecentlyViewedItem;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
||||||
const Icon = TypeIcon[item.type];
|
const Icon = TypeIcon[item.type];
|
||||||
|
|
||||||
@@ -63,7 +69,7 @@ export function RecentlyViewedRowContent({
|
|||||||
maxFontSizeMultiplier={1.0}
|
maxFontSizeMultiplier={1.0}
|
||||||
className="text-muted-foreground text-xs"
|
className="text-muted-foreground text-xs"
|
||||||
>
|
>
|
||||||
{TypeLabel[item.type]}
|
{getTypeLabel(t)[item.type]}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{item.subtitle ? (
|
{item.subtitle ? (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { IconLoader, IconPlus } from "@tabler/icons-react-native";
|
import { IconLoader, IconPlus } from "@tabler/icons-react-native";
|
||||||
import { Link } from "expo-router";
|
import { Link } from "expo-router";
|
||||||
import { memo, useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
@@ -25,11 +26,16 @@ export const SearchResultRow = memo(function SearchResultRow({
|
|||||||
onQuickAdd: (id: string) => void;
|
onQuickAdd: (id: string) => void;
|
||||||
isAdding: boolean;
|
isAdding: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
const primary = useCSSVariable("--color-primary") as string;
|
const primary = useCSSVariable("--color-primary") as string;
|
||||||
const imageSrc = item.posterPath ?? item.profilePath;
|
const imageSrc = item.posterPath ?? item.profilePath;
|
||||||
|
|
||||||
const typeLabel =
|
const typeLabel =
|
||||||
item.type === "movie" ? "Movie" : item.type === "tv" ? "TV show" : "Person";
|
item.type === "movie"
|
||||||
|
? t`Movie`
|
||||||
|
: item.type === "tv"
|
||||||
|
? t`TV show`
|
||||||
|
: t`Person`;
|
||||||
const accessibilityLabel = [
|
const accessibilityLabel = [
|
||||||
item.title,
|
item.title,
|
||||||
typeLabel,
|
typeLabel,
|
||||||
@@ -87,10 +93,10 @@ export const SearchResultRow = memo(function SearchResultRow({
|
|||||||
className="text-muted-foreground text-xs"
|
className="text-muted-foreground text-xs"
|
||||||
>
|
>
|
||||||
{item.type === "movie"
|
{item.type === "movie"
|
||||||
? "Movie"
|
? t`Movie`
|
||||||
: item.type === "tv"
|
: item.type === "tv"
|
||||||
? "TV"
|
? t`TV`
|
||||||
: "Person"}
|
: t`Person`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{item.releaseDate ? (
|
{item.releaseDate ? (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import {
|
import {
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
@@ -46,6 +47,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
|||||||
| "sonarr"
|
| "sonarr"
|
||||||
| "radarr";
|
| "radarr";
|
||||||
|
|
||||||
|
const { t } = useLingui();
|
||||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||||
|
|
||||||
@@ -91,30 +93,30 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
|||||||
const connectMutation = useMutation(
|
const connectMutation = useMutation(
|
||||||
orpc.integrations.create.mutationOptions({
|
orpc.integrations.create.mutationOptions({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(`${label} connected`);
|
toast.success(t`${label} connected`);
|
||||||
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
|
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
|
||||||
},
|
},
|
||||||
onError: () => toast.error(`Failed to connect ${label}`),
|
onError: () => toast.error(t`Failed to connect ${label}`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteMutation = useMutation(
|
const deleteMutation = useMutation(
|
||||||
orpc.integrations.delete.mutationOptions({
|
orpc.integrations.delete.mutationOptions({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(`${label} disconnected`);
|
toast.success(t`${label} disconnected`);
|
||||||
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
|
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
|
||||||
},
|
},
|
||||||
onError: () => toast.error(`Failed to disconnect ${label}`),
|
onError: () => toast.error(t`Failed to disconnect ${label}`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const regenerateMutation = useMutation(
|
const regenerateMutation = useMutation(
|
||||||
orpc.integrations.regenerateToken.mutationOptions({
|
orpc.integrations.regenerateToken.mutationOptions({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(`${label} URL regenerated`);
|
toast.success(t`${label} URL regenerated`);
|
||||||
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
|
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
|
||||||
},
|
},
|
||||||
onError: () => toast.error(`Failed to regenerate ${label} URL`),
|
onError: () => toast.error(t`Failed to regenerate ${label} URL`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -124,39 +126,39 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
|||||||
if (!url) return;
|
if (!url) return;
|
||||||
await Clipboard.setStringAsync(url);
|
await Clipboard.setStringAsync(url);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
toast.success("URL copied to clipboard");
|
toast.success(t`URL copied to clipboard`);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
}, [url]);
|
}, [url, t]);
|
||||||
|
|
||||||
const handleRegenerate = useCallback(() => {
|
const handleRegenerate = useCallback(() => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Regenerate URL",
|
t`Regenerate URL`,
|
||||||
`This will invalidate the current ${label} URL. You'll need to update it in ${label}.`,
|
t`This will invalidate the current ${label} URL. You'll need to update it in ${label}.`,
|
||||||
[
|
[
|
||||||
{ text: "Cancel", style: "cancel" },
|
{ text: t`Cancel`, style: "cancel" },
|
||||||
{
|
{
|
||||||
text: "Regenerate",
|
text: t`Regenerate`,
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: () => regenerateMutation.mutate({ provider: providerInput }),
|
onPress: () => regenerateMutation.mutate({ provider: providerInput }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}, [label, providerInput, regenerateMutation]);
|
}, [label, providerInput, regenerateMutation, t]);
|
||||||
|
|
||||||
const handleDisconnect = useCallback(() => {
|
const handleDisconnect = useCallback(() => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
`Disconnect ${label}`,
|
t`Disconnect ${label}`,
|
||||||
`Are you sure you want to disconnect ${label}? The current URL will stop working.`,
|
t`Are you sure you want to disconnect ${label}? The current URL will stop working.`,
|
||||||
[
|
[
|
||||||
{ text: "Cancel", style: "cancel" },
|
{ text: t`Cancel`, style: "cancel" },
|
||||||
{
|
{
|
||||||
text: "Disconnect",
|
text: t`Disconnect`,
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: () => deleteMutation.mutate({ provider: providerInput }),
|
onPress: () => deleteMutation.mutate({ provider: providerInput }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}, [label, providerInput, deleteMutation]);
|
}, [label, providerInput, deleteMutation, t]);
|
||||||
|
|
||||||
const Icon = config.icon;
|
const Icon = config.icon;
|
||||||
|
|
||||||
@@ -189,7 +191,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
|||||||
<Text className="mt-0.5 text-muted-foreground text-xs">
|
<Text className="mt-0.5 text-muted-foreground text-xs">
|
||||||
{connection
|
{connection
|
||||||
? config.connectedStatus(connection.lastEventAt)
|
? config.connectedStatus(connection.lastEventAt)
|
||||||
: "Not configured"}
|
: t`Not configured`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -231,7 +233,9 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
|||||||
className="items-center rounded-lg bg-primary py-2.5 active:opacity-80"
|
className="items-center rounded-lg bg-primary py-2.5 active:opacity-80"
|
||||||
>
|
>
|
||||||
<Text className="font-medium font-sans text-primary-foreground">
|
<Text className="font-medium font-sans text-primary-foreground">
|
||||||
{connectMutation.isPending ? "Connecting…" : `Connect ${label}`}
|
{connectMutation.isPending
|
||||||
|
? t`Connecting…`
|
||||||
|
: t`Connect ${label}`}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
@@ -282,7 +286,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
|||||||
color={mutedFgColor}
|
color={mutedFgColor}
|
||||||
/>
|
/>
|
||||||
<Text className="font-medium font-sans text-foreground text-sm">
|
<Text className="font-medium font-sans text-foreground text-sm">
|
||||||
Regenerate
|
<Trans>Regenerate</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -291,7 +295,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
|||||||
>
|
>
|
||||||
<ScaledIcon icon={IconTrash} size={14} color="#ef4444" />
|
<ScaledIcon icon={IconTrash} size={14} color="#ef4444" />
|
||||||
<Text className="font-medium font-sans text-destructive text-sm">
|
<Text className="font-medium font-sans text-destructive text-sm">
|
||||||
Disconnect
|
<Trans>Disconnect</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -312,7 +316,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
|||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<Text className="text-muted-foreground text-xs">
|
<Text className="text-muted-foreground text-xs">
|
||||||
Setup instructions
|
<Trans>Setup instructions</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { formatDistanceToNow } from "date-fns/formatDistanceToNow";
|
import type { I18n } from "@lingui/core";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
import { formatRelativeTime } from "@sofa/i18n/format";
|
||||||
import type { SvgProps } from "react-native-svg";
|
import type { SvgProps } from "react-native-svg";
|
||||||
import {
|
import {
|
||||||
EmbyIcon,
|
EmbyIcon,
|
||||||
@@ -21,97 +23,114 @@ export interface IntegrationConfig {
|
|||||||
setupSteps: string[];
|
setupSteps: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function webhookStatus(lastEventAt: string | null): string {
|
function webhookStatus(i18n: I18n, lastEventAt: string | null): string {
|
||||||
return lastEventAt
|
if (lastEventAt) {
|
||||||
? `Last event ${formatDistanceToNow(new Date(lastEventAt), { addSuffix: true })}`
|
const timeAgo = formatRelativeTime(new Date(lastEventAt));
|
||||||
: "Ready — nothing received yet";
|
return i18n._(msg`Last event ${timeAgo}`);
|
||||||
|
}
|
||||||
|
return i18n._(msg`Ready — nothing received yet`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function listStatus(lastEventAt: string | null): string {
|
function listStatus(i18n: I18n, lastEventAt: string | null): string {
|
||||||
return lastEventAt
|
if (lastEventAt) {
|
||||||
? `Last polled ${formatDistanceToNow(new Date(lastEventAt), { addSuffix: true })}`
|
const timeAgo = formatRelativeTime(new Date(lastEventAt));
|
||||||
: "Ready — not polled yet";
|
return i18n._(msg`Last polled ${timeAgo}`);
|
||||||
|
}
|
||||||
|
return i18n._(msg`Ready — not polled yet`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
|
export function getIntegrationConfigs(i18n: I18n): IntegrationConfig[] {
|
||||||
{
|
return [
|
||||||
provider: "plex",
|
{
|
||||||
label: "Plex",
|
provider: "plex",
|
||||||
icon: PlexIcon,
|
label: "Plex",
|
||||||
type: "webhook",
|
icon: PlexIcon,
|
||||||
buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`,
|
type: "webhook",
|
||||||
urlLabel: "Webhook URL",
|
buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`,
|
||||||
connectedStatus: webhookStatus,
|
urlLabel: i18n._(msg`Webhook URL`),
|
||||||
requirementNote: "Requires an active Plex Pass subscription.",
|
connectedStatus: (lastEventAt) => webhookStatus(i18n, lastEventAt),
|
||||||
setupSteps: [
|
requirementNote: i18n._(msg`Requires an active Plex Pass subscription.`),
|
||||||
"Open Plex, go to Settings > Webhooks",
|
setupSteps: [
|
||||||
'Click "Add Webhook" and paste the URL above',
|
i18n._(msg`Open Plex, go to Settings > Webhooks`),
|
||||||
"Sofa will automatically log movies and episodes when you finish watching them",
|
i18n._(msg`Click "Add Webhook" and paste the URL above`),
|
||||||
],
|
i18n._(
|
||||||
},
|
msg`Sofa will automatically log movies and episodes when you finish watching them`,
|
||||||
{
|
),
|
||||||
provider: "jellyfin",
|
],
|
||||||
label: "Jellyfin",
|
},
|
||||||
icon: JellyfinIcon,
|
{
|
||||||
type: "webhook",
|
provider: "jellyfin",
|
||||||
buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`,
|
label: "Jellyfin",
|
||||||
urlLabel: "Webhook URL",
|
icon: JellyfinIcon,
|
||||||
connectedStatus: webhookStatus,
|
type: "webhook",
|
||||||
setupSteps: [
|
buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`,
|
||||||
"Install the Webhook plugin from Jellyfin's plugin catalog",
|
urlLabel: i18n._(msg`Webhook URL`),
|
||||||
"Go to Dashboard > Plugins > Webhook",
|
connectedStatus: (lastEventAt) => webhookStatus(i18n, lastEventAt),
|
||||||
'Add a "Generic Destination" and paste the URL above',
|
setupSteps: [
|
||||||
'Enable the "Playback Stop" notification type',
|
i18n._(msg`Install the Webhook plugin from Jellyfin's plugin catalog`),
|
||||||
"Sofa will automatically log movies and episodes when you finish watching them",
|
i18n._(msg`Go to Dashboard > Plugins > Webhook`),
|
||||||
],
|
i18n._(msg`Add a "Generic Destination" and paste the URL above`),
|
||||||
},
|
i18n._(msg`Enable the "Playback Stop" notification type`),
|
||||||
{
|
i18n._(
|
||||||
provider: "emby",
|
msg`Sofa will automatically log movies and episodes when you finish watching them`,
|
||||||
label: "Emby",
|
),
|
||||||
icon: EmbyIcon,
|
],
|
||||||
type: "webhook",
|
},
|
||||||
buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`,
|
{
|
||||||
urlLabel: "Webhook URL",
|
provider: "emby",
|
||||||
connectedStatus: webhookStatus,
|
label: "Emby",
|
||||||
requirementNote:
|
icon: EmbyIcon,
|
||||||
"Requires Emby Server 4.7.9+ and an active Emby Premiere license.",
|
type: "webhook",
|
||||||
setupSteps: [
|
buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`,
|
||||||
"Open Emby, go to Settings > Webhooks",
|
urlLabel: i18n._(msg`Webhook URL`),
|
||||||
"Add a new webhook and paste the URL above",
|
connectedStatus: (lastEventAt) => webhookStatus(i18n, lastEventAt),
|
||||||
'Enable the "Playback" event category',
|
requirementNote: i18n._(
|
||||||
"Sofa will automatically log movies and episodes when you finish watching them",
|
msg`Requires Emby Server 4.7.9+ and an active Emby Premiere license.`,
|
||||||
],
|
),
|
||||||
},
|
setupSteps: [
|
||||||
{
|
i18n._(msg`Open Emby, go to Settings > Webhooks`),
|
||||||
provider: "sonarr",
|
i18n._(msg`Add a new webhook and paste the URL above`),
|
||||||
label: "Sonarr",
|
i18n._(msg`Enable the "Playback" event category`),
|
||||||
icon: SonarrIcon,
|
i18n._(
|
||||||
type: "list",
|
msg`Sofa will automatically log movies and episodes when you finish watching them`,
|
||||||
buildUrl: (token) => `${getServerUrl()}/api/lists/${token}`,
|
),
|
||||||
urlLabel: "Sonarr List URL",
|
],
|
||||||
connectedStatus: listStatus,
|
},
|
||||||
setupSteps: [
|
{
|
||||||
"Open Sonarr, go to Settings > Import Lists",
|
provider: "sonarr",
|
||||||
'Click "+" and select "Custom Lists"',
|
label: "Sonarr",
|
||||||
"Paste the Sonarr URL above into the List URL field",
|
icon: SonarrIcon,
|
||||||
"Set your preferred quality profile and root folder",
|
type: "list",
|
||||||
"Titles on your Sofa watchlist will be automatically added for download when Sonarr polls this list (every 6 hours by default)",
|
buildUrl: (token) => `${getServerUrl()}/api/lists/${token}`,
|
||||||
],
|
urlLabel: i18n._(msg`Sonarr List URL`),
|
||||||
},
|
connectedStatus: (lastEventAt) => listStatus(i18n, lastEventAt),
|
||||||
{
|
setupSteps: [
|
||||||
provider: "radarr",
|
i18n._(msg`Open Sonarr, go to Settings > Import Lists`),
|
||||||
label: "Radarr",
|
i18n._(msg`Click "+" and select "Custom Lists"`),
|
||||||
icon: RadarrIcon,
|
i18n._(msg`Paste the Sonarr URL above into the List URL field`),
|
||||||
type: "list",
|
i18n._(msg`Set your preferred quality profile and root folder`),
|
||||||
buildUrl: (token) => `${getServerUrl()}/api/lists/${token}`,
|
i18n._(
|
||||||
urlLabel: "Radarr List URL",
|
msg`Titles on your Sofa watchlist will be automatically added for download when Sonarr polls this list (every 6 hours by default)`,
|
||||||
connectedStatus: listStatus,
|
),
|
||||||
setupSteps: [
|
],
|
||||||
"Open Radarr, go to Settings > Import Lists",
|
},
|
||||||
'Click "+" and select "Custom Lists"',
|
{
|
||||||
"Paste the Radarr URL above into the List URL field",
|
provider: "radarr",
|
||||||
"Set your preferred quality profile and root folder",
|
label: "Radarr",
|
||||||
"Titles on your Sofa watchlist will be automatically added for download when Radarr polls this list (every 12 hours by default)",
|
icon: RadarrIcon,
|
||||||
],
|
type: "list",
|
||||||
},
|
buildUrl: (token) => `${getServerUrl()}/api/lists/${token}`,
|
||||||
];
|
urlLabel: i18n._(msg`Radarr List URL`),
|
||||||
|
connectedStatus: (lastEventAt) => listStatus(i18n, lastEventAt),
|
||||||
|
setupSteps: [
|
||||||
|
i18n._(msg`Open Radarr, go to Settings > Import Lists`),
|
||||||
|
i18n._(msg`Click "+" and select "Custom Lists"`),
|
||||||
|
i18n._(msg`Paste the Radarr URL above into the List URL field`),
|
||||||
|
i18n._(msg`Set your preferred quality profile and root folder`),
|
||||||
|
i18n._(
|
||||||
|
msg`Titles on your Sofa watchlist will be automatically added for download when Radarr polls this list (every 12 hours by default)`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { IconRefresh, IconWebhook } from "@tabler/icons-react-native";
|
import { IconRefresh, IconWebhook } from "@tabler/icons-react-native";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { IntegrationCard } from "@/components/settings/integration-card";
|
import { IntegrationCard } from "@/components/settings/integration-card";
|
||||||
import { INTEGRATION_CONFIGS } from "@/components/settings/integration-configs";
|
import { getIntegrationConfigs } from "@/components/settings/integration-configs";
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { SectionHeader } from "@/components/ui/section-header";
|
import { SectionHeader } from "@/components/ui/section-header";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
@@ -10,11 +11,13 @@ import { Text } from "@/components/ui/text";
|
|||||||
import { orpc } from "@/lib/orpc";
|
import { orpc } from "@/lib/orpc";
|
||||||
|
|
||||||
export function IntegrationsSection() {
|
export function IntegrationsSection() {
|
||||||
|
const { t, i18n } = useLingui();
|
||||||
|
const configs = getIntegrationConfigs(i18n);
|
||||||
const integrations = useQuery(orpc.integrations.list.queryOptions());
|
const integrations = useQuery(orpc.integrations.list.queryOptions());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="mb-6">
|
<View className="mb-6">
|
||||||
<SectionHeader title="Integrations" icon={IconWebhook} />
|
<SectionHeader title={t`Integrations`} icon={IconWebhook} />
|
||||||
|
|
||||||
{integrations.isPending ? (
|
{integrations.isPending ? (
|
||||||
<View className="items-center py-4">
|
<View className="items-center py-4">
|
||||||
@@ -23,7 +26,7 @@ export function IntegrationsSection() {
|
|||||||
) : integrations.isError ? (
|
) : integrations.isError ? (
|
||||||
<View className="items-center gap-2 py-4">
|
<View className="items-center gap-2 py-4">
|
||||||
<Text className="text-muted-foreground text-sm">
|
<Text className="text-muted-foreground text-sm">
|
||||||
Could not load integrations
|
<Trans>Could not load integrations</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => integrations.refetch()}
|
onPress={() => integrations.refetch()}
|
||||||
@@ -36,12 +39,12 @@ export function IntegrationsSection() {
|
|||||||
className="accent-primary"
|
className="accent-primary"
|
||||||
/>
|
/>
|
||||||
<Text className="font-medium font-sans text-primary text-sm">
|
<Text className="font-medium font-sans text-primary text-sm">
|
||||||
Retry
|
<Trans>Retry</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
INTEGRATION_CONFIGS.map((config) => {
|
configs.map((config) => {
|
||||||
const connection =
|
const connection =
|
||||||
integrations.data?.integrations?.find(
|
integrations.data?.integrations?.find(
|
||||||
(i) => i.provider === config.provider,
|
(i) => i.provider === config.provider,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { Season } from "@sofa/api/schemas";
|
import type { Season } from "@sofa/api/schemas";
|
||||||
import { getNextEpisode } from "@sofa/api/utils";
|
import { getNextEpisode } from "@sofa/api/utils";
|
||||||
import { IconPlayerPlay } from "@tabler/icons-react-native";
|
import { IconPlayerPlay } from "@tabler/icons-react-native";
|
||||||
@@ -22,6 +23,7 @@ export function ContinueWatchingBanner({
|
|||||||
backdropPath: string | null;
|
backdropPath: string | null;
|
||||||
backdropThumbHash?: string | null;
|
backdropThumbHash?: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
const titleAccentColor = useCSSVariable("--color-title-accent") as string;
|
const titleAccentColor = useCSSVariable("--color-title-accent") as string;
|
||||||
|
|
||||||
const { nextEpisode, totalEpisodes, watchedEpisodes } = useMemo(
|
const { nextEpisode, totalEpisodes, watchedEpisodes } = useMemo(
|
||||||
@@ -41,7 +43,7 @@ export function ContinueWatchingBanner({
|
|||||||
className="mt-6 px-4"
|
className="mt-6 px-4"
|
||||||
>
|
>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Next Episode"
|
title={t`Next Episode`}
|
||||||
icon={IconPlayerPlay}
|
icon={IconPlayerPlay}
|
||||||
iconColor={titleAccentColor}
|
iconColor={titleAccentColor}
|
||||||
/>
|
/>
|
||||||
@@ -74,7 +76,7 @@ export function ContinueWatchingBanner({
|
|||||||
maxFontSizeMultiplier={1.0}
|
maxFontSizeMultiplier={1.0}
|
||||||
className="font-medium font-sans text-title-accent text-xs uppercase tracking-wider"
|
className="font-medium font-sans text-title-accent text-xs uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Up next
|
<Trans>Up next</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import {
|
import {
|
||||||
IconCircleCheckFilled,
|
IconCircleCheckFilled,
|
||||||
IconCircleDashed,
|
IconCircleDashed,
|
||||||
@@ -24,14 +25,15 @@ export function EpisodeRow({
|
|||||||
accentColor: string;
|
accentColor: string;
|
||||||
mutedColor: string;
|
mutedColor: string;
|
||||||
}) {
|
}) {
|
||||||
const episodeLabel = episode.name ?? `Episode ${episode.episodeNumber}`;
|
const { t } = useLingui();
|
||||||
|
const episodeLabel = episode.name ?? t`Episode ${episode.episodeNumber}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onToggle}
|
onPress={onToggle}
|
||||||
accessibilityRole="checkbox"
|
accessibilityRole="checkbox"
|
||||||
accessibilityState={{ checked: isWatched }}
|
accessibilityState={{ checked: isWatched }}
|
||||||
accessibilityLabel={`Episode ${episode.episodeNumber}, ${episodeLabel}`}
|
accessibilityLabel={t`Episode ${episode.episodeNumber}, ${episodeLabel}`}
|
||||||
className="flex-row items-center border-border border-b px-4 py-3"
|
className="flex-row items-center border-border border-b px-4 py-3"
|
||||||
style={{ borderBottomWidth: 0.5 }}
|
style={{ borderBottomWidth: 0.5 }}
|
||||||
>
|
>
|
||||||
@@ -50,7 +52,7 @@ export function EpisodeRow({
|
|||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{episode.episodeNumber}.{" "}
|
{episode.episodeNumber}.{" "}
|
||||||
{episode.name ?? `Episode ${episode.episodeNumber}`}
|
{episode.name ?? t`Episode ${episode.episodeNumber}`}
|
||||||
</Text>
|
</Text>
|
||||||
{episode.airDate ? (
|
{episode.airDate ? (
|
||||||
<Text className="mt-0.5 text-muted-foreground text-xs">
|
<Text className="mt-0.5 text-muted-foreground text-xs">
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { plural } from "@lingui/core/macro";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { IconChevronDown } from "@tabler/icons-react-native";
|
import { IconChevronDown } from "@tabler/icons-react-native";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { InteractionManager, Pressable, View } from "react-native";
|
import { InteractionManager, Pressable, View } from "react-native";
|
||||||
@@ -33,6 +35,7 @@ export function SeasonAccordion({
|
|||||||
}>;
|
}>;
|
||||||
watchedEpisodeIds: Set<string>;
|
watchedEpisodeIds: Set<string>;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
const titleAccentColor = useCSSVariable("--color-title-accent") as string;
|
const titleAccentColor = useCSSVariable("--color-title-accent") as string;
|
||||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
|
|
||||||
@@ -116,16 +119,16 @@ export function SeasonAccordion({
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={toggleExpanded}
|
onPress={toggleExpanded}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`${season.name ?? `Season ${season.seasonNumber}`}, ${watchedCount} of ${episodes.length} episodes watched`}
|
accessibilityLabel={`${season.name ?? t`Season ${season.seasonNumber}`}, ${t`${watchedCount} of ${episodes.length} episodes watched`}`}
|
||||||
accessibilityState={{ expanded }}
|
accessibilityState={{ expanded }}
|
||||||
className="flex-row items-center justify-between p-4"
|
className="flex-row items-center justify-between p-4"
|
||||||
>
|
>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text className="font-medium font-sans text-base text-foreground">
|
<Text className="font-medium font-sans text-base text-foreground">
|
||||||
{season.name ?? `Season ${season.seasonNumber}`}
|
{season.name ?? t`Season ${season.seasonNumber}`}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="mt-0.5 text-muted-foreground text-xs">
|
<Text className="mt-0.5 text-muted-foreground text-xs">
|
||||||
{watchedCount}/{episodes.length} episodes
|
{t`${watchedCount}/${episodes.length} ${plural(episodes.length, { one: "episode", other: "episodes" })}`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -158,7 +161,7 @@ export function SeasonAccordion({
|
|||||||
className="mx-4 mb-2 flex-row items-center justify-center rounded-lg bg-secondary py-2"
|
className="mx-4 mb-2 flex-row items-center justify-center rounded-lg bg-secondary py-2"
|
||||||
>
|
>
|
||||||
<Text className="font-medium font-sans text-title-accent text-xs">
|
<Text className="font-medium font-sans text-title-accent text-xs">
|
||||||
Mark All Watched
|
<Trans>Mark All Watched</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import {
|
import {
|
||||||
IconBookmarkFilled,
|
IconBookmarkFilled,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
@@ -12,27 +13,38 @@ import * as Haptics from "@/utils/haptics";
|
|||||||
|
|
||||||
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
||||||
|
|
||||||
const statusConfig = {
|
const STATUS_ICONS = {
|
||||||
|
watchlist: IconBookmarkFilled,
|
||||||
|
in_progress: IconPlayerPlayFilled,
|
||||||
|
completed: IconCheck,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const STATUS_STYLES = {
|
||||||
watchlist: {
|
watchlist: {
|
||||||
label: "Watchlisted",
|
|
||||||
Icon: IconBookmarkFilled,
|
|
||||||
bgClass: "bg-title-accent/10 border-title-accent/20",
|
bgClass: "bg-title-accent/10 border-title-accent/20",
|
||||||
textClass: "text-title-accent",
|
textClass: "text-title-accent",
|
||||||
},
|
},
|
||||||
in_progress: {
|
in_progress: {
|
||||||
label: "Watching",
|
|
||||||
Icon: IconPlayerPlayFilled,
|
|
||||||
bgClass: "bg-title-accent/10 border-title-accent/20",
|
bgClass: "bg-title-accent/10 border-title-accent/20",
|
||||||
textClass: "text-title-accent",
|
textClass: "text-title-accent",
|
||||||
},
|
},
|
||||||
completed: {
|
completed: {
|
||||||
label: "Completed",
|
|
||||||
Icon: IconCheck,
|
|
||||||
bgClass: "bg-status-completed/10 border-status-completed/20",
|
bgClass: "bg-status-completed/10 border-status-completed/20",
|
||||||
textClass: "text-status-completed",
|
textClass: "text-status-completed",
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
function StatusLabel({ status }: { status: TitleStatus }) {
|
||||||
|
switch (status) {
|
||||||
|
case "watchlist":
|
||||||
|
return <Trans>Watchlisted</Trans>;
|
||||||
|
case "in_progress":
|
||||||
|
return <Trans>Watching</Trans>;
|
||||||
|
case "completed":
|
||||||
|
return <Trans>Completed</Trans>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function StatusActionButton({
|
export function StatusActionButton({
|
||||||
currentStatus,
|
currentStatus,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
@@ -42,16 +54,13 @@ export function StatusActionButton({
|
|||||||
onStatusChange: (status: TitleStatus | null) => void;
|
onStatusChange: (status: TitleStatus | null) => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
const [titleAccent, completedColor] = useCSSVariable([
|
const [titleAccent, completedColor] = useCSSVariable([
|
||||||
"--color-title-accent",
|
"--color-title-accent",
|
||||||
"--color-status-completed",
|
"--color-status-completed",
|
||||||
]) as [string, string];
|
]) as [string, string];
|
||||||
|
|
||||||
const config = currentStatus
|
if (!currentStatus) {
|
||||||
? statusConfig[currentStatus as keyof typeof statusConfig]
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!config) {
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -60,7 +69,7 @@ export function StatusActionButton({
|
|||||||
}}
|
}}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Add to watchlist"
|
accessibilityLabel={t`Add to watchlist`}
|
||||||
className="flex-row items-center gap-1.5 rounded-lg border border-title-accent/20 bg-title-accent/10 px-4 py-2"
|
className="flex-row items-center gap-1.5 rounded-lg border border-title-accent/20 bg-title-accent/10 px-4 py-2"
|
||||||
>
|
>
|
||||||
<ScaledIcon
|
<ScaledIcon
|
||||||
@@ -70,12 +79,14 @@ export function StatusActionButton({
|
|||||||
strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
/>
|
/>
|
||||||
<Text className="font-medium font-sans text-sm text-title-accent">
|
<Text className="font-medium font-sans text-sm text-title-accent">
|
||||||
Watchlist
|
<Trans>Watchlist</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const StatusIcon = STATUS_ICONS[currentStatus];
|
||||||
|
const styles = STATUS_STYLES[currentStatus];
|
||||||
const iconColor =
|
const iconColor =
|
||||||
currentStatus === "completed" ? completedColor : titleAccent;
|
currentStatus === "completed" ? completedColor : titleAccent;
|
||||||
|
|
||||||
@@ -87,12 +98,12 @@ export function StatusActionButton({
|
|||||||
}}
|
}}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`${config.label}, double tap to remove`}
|
accessibilityLabel={t`Remove from library`}
|
||||||
className={`flex-row items-center gap-1.5 rounded-lg border px-4 py-2 ${config.bgClass}`}
|
className={`flex-row items-center gap-1.5 rounded-lg border px-4 py-2 ${styles.bgClass}`}
|
||||||
>
|
>
|
||||||
<ScaledIcon icon={config.Icon} size={14} color={iconColor} />
|
<ScaledIcon icon={StatusIcon} size={14} color={iconColor} />
|
||||||
<Text className={`font-medium font-sans text-sm ${config.textClass}`}>
|
<Text className={`font-medium font-sans text-sm ${styles.textClass}`}>
|
||||||
{config.label}
|
<StatusLabel status={currentStatus} />
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import type { NativeSyntheticEvent, TextLayoutEventData } from "react-native";
|
import type { NativeSyntheticEvent, TextLayoutEventData } from "react-native";
|
||||||
import { Platform, Pressable, View } from "react-native";
|
import { Platform, Pressable, View } from "react-native";
|
||||||
@@ -12,6 +13,7 @@ export function ExpandableText({
|
|||||||
maxLines?: number;
|
maxLines?: number;
|
||||||
actionColor?: string;
|
actionColor?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
const prevTextRef = useRef(text);
|
const prevTextRef = useRef(text);
|
||||||
let expanded: boolean;
|
let expanded: boolean;
|
||||||
let needsTruncation: boolean;
|
let needsTruncation: boolean;
|
||||||
@@ -57,14 +59,14 @@ export function ExpandableText({
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setExpanded(!expanded)}
|
onPress={() => setExpanded(!expanded)}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={expanded ? "Show less" : "Show more"}
|
accessibilityLabel={expanded ? t`Show less` : t`Show more`}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
className="font-medium font-sans text-primary text-sm"
|
className="font-medium font-sans text-primary text-sm"
|
||||||
style={actionColor ? { color: actionColor } : undefined}
|
style={actionColor ? { color: actionColor } : undefined}
|
||||||
>
|
>
|
||||||
{expanded ? "Show less" : "Show more"}
|
{expanded ? t`Show less` : t`Show more`}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { IconWifiOff } from "@tabler/icons-react-native";
|
import { IconWifiOff } from "@tabler/icons-react-native";
|
||||||
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||||
import * as Network from "expo-network";
|
import * as Network from "expo-network";
|
||||||
@@ -70,14 +71,14 @@ export function OfflineBanner() {
|
|||||||
>
|
>
|
||||||
<ScaledIcon icon={IconWifiOff} size={16} color="white" />
|
<ScaledIcon icon={IconWifiOff} size={16} color="white" />
|
||||||
<Text className="font-medium font-sans text-sm text-white">
|
<Text className="font-medium font-sans text-sm text-white">
|
||||||
No internet connection
|
<Trans>No internet connection</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</GlassView>
|
</GlassView>
|
||||||
) : (
|
) : (
|
||||||
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-destructive px-4 py-2.5">
|
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-destructive px-4 py-2.5">
|
||||||
<ScaledIcon icon={IconWifiOff} size={16} color="white" />
|
<ScaledIcon icon={IconWifiOff} size={16} color="white" />
|
||||||
<Text className="font-medium font-sans text-sm text-white">
|
<Text className="font-medium font-sans text-sm text-white">
|
||||||
No internet connection
|
<Trans>No internet connection</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { IconWifiOff } from "@tabler/icons-react-native";
|
import { IconWifiOff } from "@tabler/icons-react-native";
|
||||||
import * as Network from "expo-network";
|
import * as Network from "expo-network";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
@@ -55,7 +56,7 @@ export function OfflineBanner() {
|
|||||||
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-destructive px-4 py-2.5">
|
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-destructive px-4 py-2.5">
|
||||||
<ScaledIcon icon={IconWifiOff} size={16} color="white" />
|
<ScaledIcon icon={IconWifiOff} size={16} color="white" />
|
||||||
<Text className="font-medium font-sans text-sm text-white">
|
<Text className="font-medium font-sans text-sm text-white">
|
||||||
No internet connection
|
<Trans>No internet connection</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import {
|
import {
|
||||||
IconBookmarkFilled,
|
IconBookmarkFilled,
|
||||||
IconCheckbox,
|
IconCheckbox,
|
||||||
@@ -52,6 +53,7 @@ export function PosterCard({
|
|||||||
onQuickAdd,
|
onQuickAdd,
|
||||||
isAdding,
|
isAdding,
|
||||||
}: PosterCardProps) {
|
}: PosterCardProps) {
|
||||||
|
const { t } = useLingui();
|
||||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||||
const watchlistColor = useCSSVariable("--color-status-watchlist") as string;
|
const watchlistColor = useCSSVariable("--color-status-watchlist") as string;
|
||||||
const watchingColor = useCSSVariable("--color-status-watching") as string;
|
const watchingColor = useCSSVariable("--color-status-watching") as string;
|
||||||
@@ -233,28 +235,28 @@ export function PosterCard({
|
|||||||
<Link.Menu>
|
<Link.Menu>
|
||||||
{!userStatus && (
|
{!userStatus && (
|
||||||
<Link.MenuAction
|
<Link.MenuAction
|
||||||
title="Add to Watchlist"
|
title={t`Add to Watchlist`}
|
||||||
icon="bookmark"
|
icon="bookmark"
|
||||||
onPress={handleQuickAddPress}
|
onPress={handleQuickAddPress}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{userStatus !== "in_progress" && (
|
{userStatus !== "in_progress" && (
|
||||||
<Link.MenuAction
|
<Link.MenuAction
|
||||||
title="Mark as Watching"
|
title={t`Mark as Watching`}
|
||||||
icon="play.fill"
|
icon="play.fill"
|
||||||
onPress={() => titleActions.markWatching(id)}
|
onPress={() => titleActions.markWatching(id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{type === "movie" && (
|
{type === "movie" && (
|
||||||
<Link.MenuAction
|
<Link.MenuAction
|
||||||
title="Mark as Watched"
|
title={t`Mark as Watched`}
|
||||||
icon="checkmark.circle"
|
icon="checkmark.circle"
|
||||||
onPress={() => titleActions.markMovieWatched(id, title)}
|
onPress={() => titleActions.markMovieWatched(id, title)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{userStatus && (
|
{userStatus && (
|
||||||
<Link.MenuAction
|
<Link.MenuAction
|
||||||
title="Remove from Library"
|
title={t`Remove from Library`}
|
||||||
icon="trash"
|
icon="trash"
|
||||||
destructive
|
destructive
|
||||||
onPress={() => titleActions.removeFromLibrary(id)}
|
onPress={() => titleActions.removeFromLibrary(id)}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { type Icon, IconCheck } from "@tabler/icons-react-native";
|
||||||
|
import { Modal, Pressable, View } from "react-native";
|
||||||
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { ScaledIcon } from "./scaled-icon";
|
||||||
|
|
||||||
|
export interface SelectModalOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectModalProps {
|
||||||
|
/** Label displayed on the trigger row */
|
||||||
|
label: string;
|
||||||
|
/** Icon displayed in the modal title */
|
||||||
|
icon?: Icon;
|
||||||
|
/** Currently selected value */
|
||||||
|
selection: string;
|
||||||
|
/** Available options */
|
||||||
|
options: SelectModalOption[];
|
||||||
|
/** Whether the modal is open */
|
||||||
|
open: boolean;
|
||||||
|
/** Called when the modal is opened or closed */
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** Called when an option is selected */
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectModal({
|
||||||
|
label,
|
||||||
|
icon: Icon,
|
||||||
|
selection,
|
||||||
|
options,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSelect,
|
||||||
|
}: SelectModalProps) {
|
||||||
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
|
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={open}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
className="flex-1 items-center justify-center bg-black/60"
|
||||||
|
onPress={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
className="mx-8 w-full max-w-sm overflow-hidden rounded-2xl bg-card"
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center border-border/50 border-b px-5 py-4">
|
||||||
|
{Icon && <ScaledIcon icon={Icon} size={20} color={mutedFgColor} />}
|
||||||
|
<Text className="ml-1.5 font-medium text-base text-foreground">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = option.value === selection;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.value}
|
||||||
|
className="flex-row items-center px-5 py-3.5 active:bg-primary/5"
|
||||||
|
onPress={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
onSelect(option.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`flex-1 text-base ${isSelected ? "font-medium text-primary" : "text-foreground"}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
{isSelected && (
|
||||||
|
<IconCheck size={18} color={primaryColor} strokeWidth={2} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { IconCloudOff } from "@tabler/icons-react-native";
|
import { IconCloudOff } from "@tabler/icons-react-native";
|
||||||
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||||
import * as Network from "expo-network";
|
import * as Network from "expo-network";
|
||||||
@@ -62,13 +63,15 @@ export function ServerUnreachableBanner() {
|
|||||||
<>
|
<>
|
||||||
<ScaledIcon icon={IconCloudOff} size={16} color="white" />
|
<ScaledIcon icon={IconCloudOff} size={16} color="white" />
|
||||||
<Text className="font-medium font-sans text-sm text-white">
|
<Text className="font-medium font-sans text-sm text-white">
|
||||||
Can't reach server
|
<Trans>Can't reach server</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleRetry}
|
onPress={handleRetry}
|
||||||
className="ml-1 rounded-md bg-white/20 px-2 py-0.5"
|
className="ml-1 rounded-md bg-white/20 px-2 py-0.5"
|
||||||
>
|
>
|
||||||
<Text className="font-medium font-sans text-white text-xs">Retry</Text>
|
<Text className="font-medium font-sans text-white text-xs">
|
||||||
|
<Trans>Retry</Trans>
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { IconCloudOff } from "@tabler/icons-react-native";
|
import { IconCloudOff } from "@tabler/icons-react-native";
|
||||||
import * as Network from "expo-network";
|
import * as Network from "expo-network";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
@@ -70,14 +71,14 @@ export function ServerUnreachableBanner() {
|
|||||||
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-status-watching px-4 py-2.5">
|
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-status-watching px-4 py-2.5">
|
||||||
<ScaledIcon icon={IconCloudOff} size={16} color="white" />
|
<ScaledIcon icon={IconCloudOff} size={16} color="white" />
|
||||||
<Text className="font-medium font-sans text-sm text-white">
|
<Text className="font-medium font-sans text-sm text-white">
|
||||||
Can't reach server
|
<Trans>Can't reach server</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleRetry}
|
onPress={handleRetry}
|
||||||
className="ml-1 rounded-md bg-white/20 px-2 py-0.5"
|
className="ml-1 rounded-md bg-white/20 px-2 py-0.5"
|
||||||
>
|
>
|
||||||
<Text className="font-medium font-sans text-white text-xs">
|
<Text className="font-medium font-sans text-white text-xs">
|
||||||
Retry
|
<Trans>Retry</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { plural } from "@lingui/core/macro";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { orpc } from "@/lib/orpc";
|
import { orpc } from "@/lib/orpc";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
@@ -32,16 +34,19 @@ interface UseTitleActionsOptions {
|
|||||||
* Each returned property is a full UseMutationResult with isPending, mutate, etc.
|
* Each returned property is a full UseMutationResult with isPending, mutate, etc.
|
||||||
*/
|
*/
|
||||||
export function useTitleActions(options?: UseTitleActionsOptions) {
|
export function useTitleActions(options?: UseTitleActionsOptions) {
|
||||||
const t = options?.toasts;
|
const { t } = useLingui();
|
||||||
|
const toastOverrides = options?.toasts;
|
||||||
|
|
||||||
const quickAdd = useMutation(
|
const quickAdd = useMutation(
|
||||||
orpc.titles.quickAdd.mutationOptions({
|
orpc.titles.quickAdd.mutationOptions({
|
||||||
onSuccess: (_data, input) => {
|
onSuccess: (_data, input) => {
|
||||||
toast.success(resolveToast(t?.quickAdd, "Added to watchlist", input));
|
toast.success(
|
||||||
|
resolveToast(toastOverrides?.quickAdd, t`Added to watchlist`, input),
|
||||||
|
);
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error("Failed to add to watchlist");
|
toast.error(t`Failed to add to watchlist`);
|
||||||
// Refetch so optimistic local status reverts on failure
|
// Refetch so optimistic local status reverts on failure
|
||||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||||
},
|
},
|
||||||
@@ -52,27 +57,31 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
|||||||
orpc.titles.updateStatus.mutationOptions({
|
orpc.titles.updateStatus.mutationOptions({
|
||||||
onSuccess: (_data, input) => {
|
onSuccess: (_data, input) => {
|
||||||
const statusMessages: Record<string, string> = {
|
const statusMessages: Record<string, string> = {
|
||||||
watchlist: "Added to watchlist",
|
watchlist: t`Added to watchlist`,
|
||||||
in_progress: "Marked as watching",
|
in_progress: t`Marked as watching`,
|
||||||
completed: "Marked as completed",
|
completed: t`Marked as completed`,
|
||||||
};
|
};
|
||||||
const defaultMsg = input.status
|
const defaultMsg = input.status
|
||||||
? (statusMessages[input.status] ?? "Status updated")
|
? (statusMessages[input.status] ?? t`Status updated`)
|
||||||
: "Removed from library";
|
: t`Removed from library`;
|
||||||
toast.success(resolveToast(t?.updateStatus, defaultMsg, input));
|
toast.success(
|
||||||
|
resolveToast(toastOverrides?.updateStatus, defaultMsg, input),
|
||||||
|
);
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Failed to update status"),
|
onError: () => toast.error(t`Failed to update status`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const watchMovie = useMutation(
|
const watchMovie = useMutation(
|
||||||
orpc.titles.watchMovie.mutationOptions({
|
orpc.titles.watchMovie.mutationOptions({
|
||||||
onSuccess: (_data, input) => {
|
onSuccess: (_data, input) => {
|
||||||
toast.success(resolveToast(t?.watchMovie, "Marked as watched", input));
|
toast.success(
|
||||||
|
resolveToast(toastOverrides?.watchMovie, t`Marked as watched`, input),
|
||||||
|
);
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Failed to mark as watched"),
|
onError: () => toast.error(t`Failed to mark as watched`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -81,23 +90,27 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
|||||||
onSuccess: (_data, input) => {
|
onSuccess: (_data, input) => {
|
||||||
const defaultMsg =
|
const defaultMsg =
|
||||||
input.stars > 0
|
input.stars > 0
|
||||||
? `Rated ${input.stars} star${input.stars > 1 ? "s" : ""}`
|
? t`Rated ${input.stars} ${plural(input.stars, { one: "star", other: "stars" })}`
|
||||||
: "Rating removed";
|
: t`Rating removed`;
|
||||||
toast.success(resolveToast(t?.updateRating, defaultMsg, input));
|
toast.success(
|
||||||
|
resolveToast(toastOverrides?.updateRating, defaultMsg, input),
|
||||||
|
);
|
||||||
// Rating only invalidates title queries, not dashboard
|
// Rating only invalidates title queries, not dashboard
|
||||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Failed to update rating"),
|
onError: () => toast.error(t`Failed to update rating`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const watchEpisode = useMutation(
|
const watchEpisode = useMutation(
|
||||||
orpc.episodes.watch.mutationOptions({
|
orpc.episodes.watch.mutationOptions({
|
||||||
onSuccess: (_data, input) => {
|
onSuccess: (_data, input) => {
|
||||||
toast.success(resolveToast(t?.watchEpisode, "Episode watched", input));
|
toast.success(
|
||||||
|
resolveToast(toastOverrides?.watchEpisode, t`Episode watched`, input),
|
||||||
|
);
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Failed to mark episode"),
|
onError: () => toast.error(t`Failed to mark episode`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -105,21 +118,27 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
|||||||
orpc.episodes.unwatch.mutationOptions({
|
orpc.episodes.unwatch.mutationOptions({
|
||||||
onSuccess: (_data, input) => {
|
onSuccess: (_data, input) => {
|
||||||
toast.success(
|
toast.success(
|
||||||
resolveToast(t?.unwatchEpisode, "Episode unwatched", input),
|
resolveToast(
|
||||||
|
toastOverrides?.unwatchEpisode,
|
||||||
|
t`Episode unwatched`,
|
||||||
|
input,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Failed to unmark episode"),
|
onError: () => toast.error(t`Failed to unmark episode`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const watchSeason = useMutation(
|
const watchSeason = useMutation(
|
||||||
orpc.seasons.watch.mutationOptions({
|
orpc.seasons.watch.mutationOptions({
|
||||||
onSuccess: (_data, input) => {
|
onSuccess: (_data, input) => {
|
||||||
toast.success(resolveToast(t?.watchSeason, "Season watched", input));
|
toast.success(
|
||||||
|
resolveToast(toastOverrides?.watchSeason, t`Season watched`, input),
|
||||||
|
);
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Failed to mark some episodes"),
|
onError: () => toast.error(t`Failed to mark some episodes`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
import { i18n } from "@sofa/i18n";
|
||||||
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { posthog } from "@/lib/posthog";
|
import { posthog } from "@/lib/posthog";
|
||||||
@@ -28,9 +30,7 @@ export const queryClient = new QueryClient({
|
|||||||
// banner is already visible — avoids flooding native toasts.
|
// banner is already visible — avoids flooding native toasts.
|
||||||
if (!getIsReachable() && isNetworkError(error)) return;
|
if (!getIsReachable() && isNetworkError(error)) return;
|
||||||
|
|
||||||
toast.error("Something went wrong\u2026", {
|
toast.error(i18n._(msg`Something went wrong\u2026`));
|
||||||
description: error.message,
|
|
||||||
});
|
|
||||||
posthog?.captureException(error, { source: "react-query" });
|
posthog?.captureException(error, { source: "react-query" });
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { msg, plural } from "@lingui/core/macro";
|
||||||
|
import { i18n } from "@sofa/i18n";
|
||||||
import { client, orpc } from "@/lib/orpc";
|
import { client, orpc } from "@/lib/orpc";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
@@ -17,11 +19,13 @@ export const titleActions = {
|
|||||||
try {
|
try {
|
||||||
await client.titles.quickAdd({ id });
|
await client.titles.quickAdd({ id });
|
||||||
toast.success(
|
toast.success(
|
||||||
titleName ? `Added "${titleName}" to watchlist` : "Added to watchlist",
|
titleName
|
||||||
|
? i18n._(msg`Added "${titleName}" to watchlist`)
|
||||||
|
: i18n._(msg`Added to watchlist`),
|
||||||
);
|
);
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to add to watchlist");
|
toast.error(i18n._(msg`Failed to add to watchlist`));
|
||||||
// Refetch so any optimistic local status reverts on failure
|
// Refetch so any optimistic local status reverts on failure
|
||||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||||
}
|
}
|
||||||
@@ -30,10 +34,10 @@ export const titleActions = {
|
|||||||
async markWatching(id: string) {
|
async markWatching(id: string) {
|
||||||
try {
|
try {
|
||||||
await client.titles.updateStatus({ id, status: "in_progress" });
|
await client.titles.updateStatus({ id, status: "in_progress" });
|
||||||
toast.success("Marked as watching");
|
toast.success(i18n._(msg`Marked as watching`));
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to update status");
|
toast.error(i18n._(msg`Failed to update status`));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -42,12 +46,12 @@ export const titleActions = {
|
|||||||
await client.titles.updateStatus({ id, status: "completed" });
|
await client.titles.updateStatus({ id, status: "completed" });
|
||||||
toast.success(
|
toast.success(
|
||||||
titleName
|
titleName
|
||||||
? `Marked "${titleName}" as completed`
|
? i18n._(msg`Marked "${titleName}" as completed`)
|
||||||
: "Marked as completed",
|
: i18n._(msg`Marked as completed`),
|
||||||
);
|
);
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to update status");
|
toast.error(i18n._(msg`Failed to update status`));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -55,21 +59,23 @@ export const titleActions = {
|
|||||||
try {
|
try {
|
||||||
await client.titles.watchMovie({ id });
|
await client.titles.watchMovie({ id });
|
||||||
toast.success(
|
toast.success(
|
||||||
titleName ? `Marked "${titleName}" as watched` : "Marked as watched",
|
titleName
|
||||||
|
? i18n._(msg`Marked "${titleName}" as watched`)
|
||||||
|
: i18n._(msg`Marked as watched`),
|
||||||
);
|
);
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to mark as watched");
|
toast.error(i18n._(msg`Failed to mark as watched`));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async removeFromLibrary(id: string) {
|
async removeFromLibrary(id: string) {
|
||||||
try {
|
try {
|
||||||
await client.titles.updateStatus({ id, status: null });
|
await client.titles.updateStatus({ id, status: null });
|
||||||
toast.success("Removed from library");
|
toast.success(i18n._(msg`Removed from library`));
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to remove from library");
|
toast.error(i18n._(msg`Failed to remove from library`));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -78,32 +84,34 @@ export const titleActions = {
|
|||||||
await client.titles.updateRating({ id, stars });
|
await client.titles.updateRating({ id, stars });
|
||||||
toast.success(
|
toast.success(
|
||||||
stars > 0
|
stars > 0
|
||||||
? `Rated ${stars} star${stars > 1 ? "s" : ""}`
|
? i18n._(
|
||||||
: "Rating removed",
|
msg`Rated ${plural(stars, { one: "# star", other: "# stars" })}`,
|
||||||
|
)
|
||||||
|
: i18n._(msg`Rating removed`),
|
||||||
);
|
);
|
||||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to update rating");
|
toast.error(i18n._(msg`Failed to update rating`));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async watchEpisode(id: string) {
|
async watchEpisode(id: string) {
|
||||||
try {
|
try {
|
||||||
await client.episodes.watch({ id });
|
await client.episodes.watch({ id });
|
||||||
toast.success("Episode watched");
|
toast.success(i18n._(msg`Episode watched`));
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to mark episode");
|
toast.error(i18n._(msg`Failed to mark episode`));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async unwatchEpisode(id: string) {
|
async unwatchEpisode(id: string) {
|
||||||
try {
|
try {
|
||||||
await client.episodes.unwatch({ id });
|
await client.episodes.unwatch({ id });
|
||||||
toast.success("Episode unwatched");
|
toast.success(i18n._(msg`Episode unwatched`));
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to unmark episode");
|
toast.error(i18n._(msg`Failed to unmark episode`));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -111,11 +119,13 @@ export const titleActions = {
|
|||||||
try {
|
try {
|
||||||
await client.seasons.watch({ id });
|
await client.seasons.watch({ id });
|
||||||
toast.success(
|
toast.success(
|
||||||
seasonName ? `Watched all of ${seasonName}` : "Season watched",
|
seasonName
|
||||||
|
? i18n._(msg`Watched all of ${seasonName}`)
|
||||||
|
: i18n._(msg`Season watched`),
|
||||||
);
|
);
|
||||||
invalidateTitleQueries();
|
invalidateTitleQueries();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to mark some episodes");
|
toast.error(i18n._(msg`Failed to mark some episodes`));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { AppErrorCode } from "@sofa/api/errors";
|
||||||
import { BACKUP_DIR } from "@sofa/config";
|
import { BACKUP_DIR } from "@sofa/config";
|
||||||
import {
|
import {
|
||||||
createBackup,
|
createBackup,
|
||||||
@@ -47,9 +48,15 @@ export const backupsDelete = os.admin.backups.delete
|
|||||||
if (err instanceof ORPCError) throw err;
|
if (err instanceof ORPCError) throw err;
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
if (msg.includes("not found")) {
|
if (msg.includes("not found")) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: msg });
|
throw new ORPCError("NOT_FOUND", {
|
||||||
|
message: msg,
|
||||||
|
data: { code: AppErrorCode.BACKUP_NOT_FOUND },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
throw new ORPCError("BAD_REQUEST", { message: msg });
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: msg,
|
||||||
|
data: { code: AppErrorCode.BACKUP_DELETE_FAILED },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,7 +78,10 @@ export const backupsRestore = os.admin.backups.restore
|
|||||||
if (await f.exists()) await f.delete();
|
if (await f.exists()) await f.delete();
|
||||||
if (err instanceof ORPCError) throw err;
|
if (err instanceof ORPCError) throw err;
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
throw new ORPCError("BAD_REQUEST", { message: msg });
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: msg,
|
||||||
|
data: { code: AppErrorCode.BACKUP_RESTORE_FAILED },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,7 +170,10 @@ export const triggerJob = os.admin.triggerJob
|
|||||||
.handler(async ({ input }) => {
|
.handler(async ({ input }) => {
|
||||||
const triggered = await triggerCronJob(input.name);
|
const triggered = await triggerCronJob(input.name);
|
||||||
if (!triggered) {
|
if (!triggered) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Job not found" });
|
throw new ORPCError("NOT_FOUND", {
|
||||||
|
message: "Job not found",
|
||||||
|
data: { code: AppErrorCode.JOB_NOT_FOUND },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { AppErrorCode } from "@sofa/api/errors";
|
||||||
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
||||||
import {
|
import {
|
||||||
getEpisodeProgressByTitleIds,
|
getEpisodeProgressByTitleIds,
|
||||||
@@ -16,6 +17,7 @@ export const discover = os.discover
|
|||||||
if (!isTmdbConfigured()) {
|
if (!isTmdbConfigured()) {
|
||||||
throw new ORPCError("PRECONDITION_FAILED", {
|
throw new ORPCError("PRECONDITION_FAILED", {
|
||||||
message: "TMDB API key is not configured",
|
message: "TMDB API key is not configured",
|
||||||
|
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { AppErrorCode } from "@sofa/api/errors";
|
||||||
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
||||||
import {
|
import {
|
||||||
getEpisodeProgressByTitleIds,
|
getEpisodeProgressByTitleIds,
|
||||||
@@ -14,6 +15,7 @@ function requireTmdb() {
|
|||||||
if (!isTmdbConfigured()) {
|
if (!isTmdbConfigured()) {
|
||||||
throw new ORPCError("PRECONDITION_FAILED", {
|
throw new ORPCError("PRECONDITION_FAILED", {
|
||||||
message: "TMDB API key is not configured",
|
message: "TMDB API key is not configured",
|
||||||
|
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { AppErrorCode } from "@sofa/api/errors";
|
||||||
import type { ParseResult } from "@sofa/core/imports";
|
import type { ParseResult } from "@sofa/core/imports";
|
||||||
import {
|
import {
|
||||||
countUnresolved,
|
countUnresolved,
|
||||||
@@ -31,9 +33,10 @@ export const parseFile = os.imports.parseFile
|
|||||||
try {
|
try {
|
||||||
json = await file.json();
|
json = await file.json();
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
"Invalid JSON file. Ensure it is a valid Trakt export.",
|
message: "Invalid JSON file",
|
||||||
);
|
data: { code: AppErrorCode.IMPORT_INVALID_FILE },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
result = parseTraktPayload(
|
result = parseTraktPayload(
|
||||||
json as Parameters<typeof parseTraktPayload>[0],
|
json as Parameters<typeof parseTraktPayload>[0],
|
||||||
@@ -45,9 +48,10 @@ export const parseFile = os.imports.parseFile
|
|||||||
try {
|
try {
|
||||||
json = await file.json();
|
json = await file.json();
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
"Invalid JSON file. Ensure it is a valid Simkl export.",
|
message: "Invalid JSON file",
|
||||||
);
|
data: { code: AppErrorCode.IMPORT_INVALID_FILE },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
result = parseSimklPayload(
|
result = parseSimklPayload(
|
||||||
json as Parameters<typeof parseSimklPayload>[0],
|
json as Parameters<typeof parseSimklPayload>[0],
|
||||||
@@ -99,9 +103,10 @@ export const createJob = os.imports.createJob
|
|||||||
data.watchlist.length +
|
data.watchlist.length +
|
||||||
data.ratings.length;
|
data.ratings.length;
|
||||||
if (totalItems > 100_000) {
|
if (totalItems > 100_000) {
|
||||||
throw new Error(
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
`Import payload too large (${totalItems} items, max 100,000)`,
|
message: "Import payload too large",
|
||||||
);
|
data: { code: AppErrorCode.IMPORT_PAYLOAD_TOO_LARGE },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent concurrent imports per user.
|
// Prevent concurrent imports per user.
|
||||||
@@ -137,7 +142,10 @@ export const createJob = os.imports.createJob
|
|||||||
.run();
|
.run();
|
||||||
log.warn(`Auto-cancelled stale import job ${existing.id}`);
|
log.warn(`Auto-cancelled stale import job ${existing.id}`);
|
||||||
} else {
|
} else {
|
||||||
throw new Error("An import is already in progress");
|
throw new ORPCError("CONFLICT", {
|
||||||
|
message: "An import is already in progress",
|
||||||
|
data: { code: AppErrorCode.IMPORT_ALREADY_RUNNING },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +183,10 @@ export const cancelJob = os.imports.cancelJob
|
|||||||
.handler(({ input, context }) => {
|
.handler(({ input, context }) => {
|
||||||
const job = readImportJob(input.id, context.user.id);
|
const job = readImportJob(input.id, context.user.id);
|
||||||
if (job.status !== "pending" && job.status !== "running") {
|
if (job.status !== "pending" && job.status !== "running") {
|
||||||
throw new Error("Can only cancel pending or running jobs");
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "Can only cancel pending or running jobs",
|
||||||
|
data: { code: AppErrorCode.IMPORT_CANNOT_CANCEL },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
db.update(importJobs)
|
db.update(importJobs)
|
||||||
.set({ status: "cancelled" })
|
.set({ status: "cancelled" })
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { AppErrorCode } from "@sofa/api/errors";
|
||||||
import { db } from "@sofa/db/client";
|
import { db } from "@sofa/db/client";
|
||||||
import { and, desc, eq } from "@sofa/db/helpers";
|
import { and, desc, eq } from "@sofa/db/helpers";
|
||||||
import { integrationEvents, integrations } from "@sofa/db/schema";
|
import { integrationEvents, integrations } from "@sofa/db/schema";
|
||||||
@@ -145,7 +146,10 @@ export const regenerateToken = os.integrations.regenerateToken
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Integration not found" });
|
throw new ORPCError("NOT_FOUND", {
|
||||||
|
message: "Integration not found",
|
||||||
|
data: { code: AppErrorCode.INTEGRATION_NOT_FOUND },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return serializeIntegration(row);
|
return serializeIntegration(row);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { AppErrorCode } from "@sofa/api/errors";
|
||||||
import { fetchFullFilmography, getOrFetchPerson } from "@sofa/core/person";
|
import { fetchFullFilmography, getOrFetchPerson } from "@sofa/core/person";
|
||||||
import { getUserStatusesByTitleIds } from "@sofa/core/tracking";
|
import { getUserStatusesByTitleIds } from "@sofa/core/tracking";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
@@ -9,7 +10,10 @@ export const detail = os.people.detail
|
|||||||
.handler(async ({ input, context }) => {
|
.handler(async ({ input, context }) => {
|
||||||
const person = await getOrFetchPerson(input.id);
|
const person = await getOrFetchPerson(input.id);
|
||||||
if (!person)
|
if (!person)
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Person not found" });
|
throw new ORPCError("NOT_FOUND", {
|
||||||
|
message: "Person not found",
|
||||||
|
data: { code: AppErrorCode.PERSON_NOT_FOUND },
|
||||||
|
});
|
||||||
|
|
||||||
const allCredits = await fetchFullFilmography(person.id);
|
const allCredits = await fetchFullFilmography(person.id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { AppErrorCode } from "@sofa/api/errors";
|
||||||
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
||||||
import { ensureBrowsePersonsExist } from "@sofa/core/person";
|
import { ensureBrowsePersonsExist } from "@sofa/core/person";
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +17,7 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
|
|||||||
if (!isTmdbConfigured()) {
|
if (!isTmdbConfigured()) {
|
||||||
throw new ORPCError("PRECONDITION_FAILED", {
|
throw new ORPCError("PRECONDITION_FAILED", {
|
||||||
message: "TMDB API key is not configured",
|
message: "TMDB API key is not configured",
|
||||||
|
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { AppErrorCode } from "@sofa/api/errors";
|
||||||
import { getRecommendationsForTitle } from "@sofa/core/discovery";
|
import { getRecommendationsForTitle } from "@sofa/core/discovery";
|
||||||
import { getOrFetchTitle, getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
|
import { getOrFetchTitle, getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +22,10 @@ export const detail = os.titles.detail
|
|||||||
.handler(async ({ input }) => {
|
.handler(async ({ input }) => {
|
||||||
const result = await getOrFetchTitle(input.id);
|
const result = await getOrFetchTitle(input.id);
|
||||||
if (!result)
|
if (!result)
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Title not found" });
|
throw new ORPCError("NOT_FOUND", {
|
||||||
|
message: "Title not found",
|
||||||
|
data: { code: AppErrorCode.TITLE_NOT_FOUND },
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,7 +84,10 @@ export const quickAdd = os.titles.quickAdd
|
|||||||
.where(eq(titles.id, input.id))
|
.where(eq(titles.id, input.id))
|
||||||
.get();
|
.get();
|
||||||
if (!title) {
|
if (!title) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Title not found" });
|
throw new ORPCError("NOT_FOUND", {
|
||||||
|
message: "Title not found",
|
||||||
|
data: { code: AppErrorCode.TITLE_NOT_FOUND },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger full TMDB import if still a shell
|
// Trigger full TMDB import if still a shell
|
||||||
|
|||||||
@@ -16,12 +16,14 @@
|
|||||||
"@fontsource-variable/dm-sans": "5.2.8",
|
"@fontsource-variable/dm-sans": "5.2.8",
|
||||||
"@fontsource-variable/geist-mono": "5.2.7",
|
"@fontsource-variable/geist-mono": "5.2.7",
|
||||||
"@fontsource/dm-serif-display": "5.2.8",
|
"@fontsource/dm-serif-display": "5.2.8",
|
||||||
|
"@lingui/react": "catalog:",
|
||||||
"@orpc/client": "catalog:",
|
"@orpc/client": "catalog:",
|
||||||
"@orpc/contract": "catalog:",
|
"@orpc/contract": "catalog:",
|
||||||
"@orpc/tanstack-query": "catalog:",
|
"@orpc/tanstack-query": "catalog:",
|
||||||
"@player.style/sutro": "0.2.1",
|
"@player.style/sutro": "0.2.1",
|
||||||
"@rolldown/plugin-babel": "0.2.1",
|
"@rolldown/plugin-babel": "0.2.1",
|
||||||
"@sofa/api": "workspace:*",
|
"@sofa/api": "workspace:*",
|
||||||
|
"@sofa/i18n": "workspace:*",
|
||||||
"@tabler/icons-react": "3.40.0",
|
"@tabler/icons-react": "3.40.0",
|
||||||
"@tanstack/react-hotkeys": "0.4.1",
|
"@tanstack/react-hotkeys": "0.4.1",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
@@ -30,7 +32,6 @@
|
|||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"date-fns": "catalog:",
|
|
||||||
"jotai": "2.18.1",
|
"jotai": "2.18.1",
|
||||||
"media-chrome": "4.18.1",
|
"media-chrome": "4.18.1",
|
||||||
"motion": "12.38.0",
|
"motion": "12.38.0",
|
||||||
@@ -45,6 +46,8 @@
|
|||||||
"youtube-video-element": "1.9.0"
|
"youtube-video-element": "1.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@lingui/babel-plugin-lingui-macro": "catalog:",
|
||||||
|
"@lingui/vite-plugin": "catalog:",
|
||||||
"@tailwindcss/vite": "0.0.0-insiders.f302fce",
|
"@tailwindcss/vite": "0.0.0-insiders.f302fce",
|
||||||
"@tanstack/devtools-vite": "0.6.0",
|
"@tanstack/devtools-vite": "0.6.0",
|
||||||
"@tanstack/react-devtools": "0.10.0",
|
"@tanstack/react-devtools": "0.10.0",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { IconKey } from "@tabler/icons-react";
|
import { IconKey } from "@tabler/icons-react";
|
||||||
import { Link, useNavigate } from "@tanstack/react-router";
|
import { Link, useNavigate } from "@tanstack/react-router";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
@@ -105,10 +106,18 @@ export function AuthForm({
|
|||||||
<SofaLogo className="size-9" />
|
<SofaLogo className="size-9" />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-balance font-medium text-lg">
|
<h1 className="text-balance font-medium text-lg">
|
||||||
{isRegister ? "Create your account" : "Welcome back"}
|
{isRegister ? (
|
||||||
|
<Trans>Create your account</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Welcome back</Trans>
|
||||||
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{isRegister ? "Start tracking your watches" : "Sign in to continue"}
|
{isRegister ? (
|
||||||
|
<Trans>Start tracking your watches</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Sign in to continue</Trans>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -130,9 +139,13 @@ export function AuthForm({
|
|||||||
className="h-11 w-full gap-2 rounded-lg border-border/50 bg-background/50 text-sm hover:bg-accent hover:text-foreground"
|
className="h-11 w-full gap-2 rounded-lg border-border/50 bg-background/50 text-sm hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<IconKey aria-hidden={true} className="size-4" />
|
<IconKey aria-hidden={true} className="size-4" />
|
||||||
{oidcLoading
|
{oidcLoading ? (
|
||||||
? "Redirecting…"
|
<Trans>Redirecting…</Trans>
|
||||||
: `Sign in with ${authConfig?.oidcProviderName || "SSO"}`}
|
) : (
|
||||||
|
<Trans>
|
||||||
|
Sign in with {authConfig?.oidcProviderName || "SSO"}
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -141,7 +154,9 @@ export function AuthForm({
|
|||||||
{showOidc && showPasswordForm && (
|
{showOidc && showPasswordForm && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-px flex-1 bg-border/50" />
|
<div className="h-px flex-1 bg-border/50" />
|
||||||
<span className="text-muted-foreground text-xs">or</span>
|
<span className="text-muted-foreground text-xs">
|
||||||
|
<Trans>or</Trans>
|
||||||
|
</span>
|
||||||
<div className="h-px flex-1 bg-border/50" />
|
<div className="h-px flex-1 bg-border/50" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -163,7 +178,7 @@ export function AuthForm({
|
|||||||
htmlFor="name"
|
htmlFor="name"
|
||||||
className="text-muted-foreground uppercase tracking-wider"
|
className="text-muted-foreground uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Name
|
<Trans>Name</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
@@ -183,7 +198,7 @@ export function AuthForm({
|
|||||||
htmlFor="email"
|
htmlFor="email"
|
||||||
className="text-muted-foreground uppercase tracking-wider"
|
className="text-muted-foreground uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Email
|
<Trans>Email</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
@@ -203,7 +218,7 @@ export function AuthForm({
|
|||||||
htmlFor="password"
|
htmlFor="password"
|
||||||
className="text-muted-foreground uppercase tracking-wider"
|
className="text-muted-foreground uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Password
|
<Trans>Password</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
@@ -226,11 +241,13 @@ export function AuthForm({
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="h-11 w-full rounded-lg text-sm hover:shadow-lg hover:shadow-primary/20"
|
className="h-11 w-full rounded-lg text-sm hover:shadow-lg hover:shadow-primary/20"
|
||||||
>
|
>
|
||||||
{loading
|
{loading ? (
|
||||||
? "Loading…"
|
<Trans>Loading…</Trans>
|
||||||
: isRegister
|
) : isRegister ? (
|
||||||
? "Create account"
|
<Trans>Create account</Trans>
|
||||||
: "Sign in"}
|
) : (
|
||||||
|
<Trans>Sign in</Trans>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.form>
|
</motion.form>
|
||||||
@@ -258,22 +275,22 @@ export function AuthForm({
|
|||||||
<p className="text-center text-muted-foreground text-sm">
|
<p className="text-center text-muted-foreground text-sm">
|
||||||
{isRegister ? (
|
{isRegister ? (
|
||||||
<>
|
<>
|
||||||
Already have an account?{" "}
|
<Trans>Already have an account?</Trans>{" "}
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
className="font-medium text-primary transition-colors hover:text-primary/80"
|
className="font-medium text-primary transition-colors hover:text-primary/80"
|
||||||
>
|
>
|
||||||
Sign in
|
<Trans>Sign in</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Don't have an account?{" "}
|
<Trans>Don't have an account?</Trans>{" "}
|
||||||
<Link
|
<Link
|
||||||
to="/register"
|
to="/register"
|
||||||
className="font-medium text-primary transition-colors hover:text-primary/80"
|
className="font-medium text-primary transition-colors hover:text-primary/80"
|
||||||
>
|
>
|
||||||
Register
|
<Trans>Register</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import {
|
import {
|
||||||
IconDeviceTv,
|
IconDeviceTv,
|
||||||
IconHome,
|
IconHome,
|
||||||
@@ -81,6 +82,7 @@ interface SearchResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CommandPalette() {
|
export function CommandPalette() {
|
||||||
|
const { t } = useLingui();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const progress = useProgress();
|
const progress = useProgress();
|
||||||
const [commandPaletteOpen, setCommandPaletteOpen] = useAtom(
|
const [commandPaletteOpen, setCommandPaletteOpen] = useAtom(
|
||||||
@@ -181,7 +183,7 @@ export function CommandPalette() {
|
|||||||
<DialogHeader className="sr-only">
|
<DialogHeader className="sr-only">
|
||||||
<DialogTitle>Command Palette</DialogTitle>
|
<DialogTitle>Command Palette</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Search for movies, TV shows, or run commands
|
<Trans>Search for movies, TV shows, or run commands</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
@@ -190,7 +192,7 @@ export function CommandPalette() {
|
|||||||
>
|
>
|
||||||
<Command shouldFilter={false}>
|
<Command shouldFilter={false}>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search movies & TV shows…"
|
placeholder={t`Search movies & TV shows…`}
|
||||||
value={query}
|
value={query}
|
||||||
onValueChange={setQuery}
|
onValueChange={setQuery}
|
||||||
/>
|
/>
|
||||||
@@ -211,11 +213,13 @@ export function CommandPalette() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasQuery && !loading && results.length === 0 && (
|
{hasQuery && !loading && results.length === 0 && (
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
<CommandEmpty>
|
||||||
|
<Trans>No results found.</Trans>
|
||||||
|
</CommandEmpty>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasQuery && !loading && results.length > 0 && (
|
{hasQuery && !loading && results.length > 0 && (
|
||||||
<CommandGroup heading="Results">
|
<CommandGroup heading={t`Results`}>
|
||||||
{results.map((r) => (
|
{results.map((r) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={r.id ?? `${r.type}-${r.tmdbId}`}
|
key={r.id ?? `${r.type}-${r.tmdbId}`}
|
||||||
@@ -307,13 +311,15 @@ export function CommandPalette() {
|
|||||||
<CommandGroup
|
<CommandGroup
|
||||||
heading={
|
heading={
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>Recent Searches</span>
|
<span>
|
||||||
|
<Trans>Recent Searches</Trans>
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClearRecent}
|
onClick={handleClearRecent}
|
||||||
className="font-normal text-[10px] text-muted-foreground transition-colors hover:text-foreground"
|
className="font-normal text-[10px] text-muted-foreground transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
Clear all
|
<Trans>Clear all</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -350,7 +356,7 @@ export function CommandPalette() {
|
|||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
{recentSearches.length > 0 && <CommandSeparator />}
|
{recentSearches.length > 0 && <CommandSeparator />}
|
||||||
<CommandGroup heading="Quick Actions">
|
<CommandGroup heading={t`Quick Actions`}>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setCommandPaletteOpen(false);
|
setCommandPaletteOpen(false);
|
||||||
@@ -359,7 +365,7 @@ export function CommandPalette() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconHome aria-hidden={true} className="size-3.5" />
|
<IconHome aria-hidden={true} className="size-3.5" />
|
||||||
Go to Dashboard
|
<Trans>Go to Dashboard</Trans>
|
||||||
<CommandShortcut>G H</CommandShortcut>
|
<CommandShortcut>G H</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
@@ -370,7 +376,7 @@ export function CommandPalette() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconSearch aria-hidden={true} className="size-3.5" />
|
<IconSearch aria-hidden={true} className="size-3.5" />
|
||||||
Go to Explore
|
<Trans>Go to Explore</Trans>
|
||||||
<CommandShortcut>G E</CommandShortcut>
|
<CommandShortcut>G E</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
@@ -380,7 +386,7 @@ export function CommandPalette() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconKeyboard aria-hidden={true} className="size-3.5" />
|
<IconKeyboard aria-hidden={true} className="size-3.5" />
|
||||||
Keyboard Shortcuts
|
<Trans>Keyboard Shortcuts</Trans>
|
||||||
<CommandShortcut>?</CommandShortcut>
|
<CommandShortcut>?</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
@@ -394,7 +400,9 @@ export function CommandPalette() {
|
|||||||
<Dialog open={helpOpen} onOpenChange={setHelpOpen}>
|
<Dialog open={helpOpen} onOpenChange={setHelpOpen}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Keyboard Shortcuts</Trans>
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-5 py-2">
|
<div className="space-y-5 py-2">
|
||||||
{Object.entries(groupedShortcuts).map(([scope, items]) => (
|
{Object.entries(groupedShortcuts).map(([scope, items]) => (
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { plural } from "@lingui/core/macro";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { IconPlayerPlay } from "@tabler/icons-react";
|
import { IconPlayerPlay } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
@@ -26,6 +28,7 @@ export function ContinueWatchingCard({
|
|||||||
}: {
|
}: {
|
||||||
item: ContinueWatchingItemProps;
|
item: ContinueWatchingItemProps;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
const stillUrl =
|
const stillUrl =
|
||||||
item.nextEpisode?.stillPath ?? item.title.backdropPath ?? null;
|
item.nextEpisode?.stillPath ?? item.title.backdropPath ?? null;
|
||||||
const progress =
|
const progress =
|
||||||
@@ -71,7 +74,7 @@ export function ContinueWatchingCard({
|
|||||||
<div className="absolute right-3 bottom-2.5 left-3">
|
<div className="absolute right-3 bottom-2.5 left-3">
|
||||||
<p className="flex items-center gap-1.5 font-medium text-[10px] text-primary uppercase tracking-wider">
|
<p className="flex items-center gap-1.5 font-medium text-[10px] text-primary uppercase tracking-wider">
|
||||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary motion-safe:animate-pulse" />
|
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary motion-safe:animate-pulse" />
|
||||||
Up next
|
{t`Up next`}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 truncate font-medium text-sm text-white">
|
<p className="mt-0.5 truncate font-medium text-sm text-white">
|
||||||
<span className="mr-0.5 font-mono text-white/60 text-xs [word-spacing:-0.25em]">
|
<span className="mr-0.5 font-mono text-white/60 text-xs [word-spacing:-0.25em]">
|
||||||
@@ -87,7 +90,7 @@ export function ContinueWatchingCard({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate font-medium text-sm">{item.title.title}</p>
|
<p className="truncate font-medium text-sm">{item.title.title}</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{item.watchedEpisodes}/{item.totalEpisodes} episodes
|
{t`${item.watchedEpisodes}/${item.totalEpisodes} ${plural(item.totalEpisodes, { one: "episode", other: "episodes" })}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { IconPlayerPlay } from "@tabler/icons-react";
|
import { IconPlayerPlay } from "@tabler/icons-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { orpc } from "@/lib/orpc/client";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
@@ -12,6 +13,8 @@ export function ContinueWatchingSection() {
|
|||||||
orpc.dashboard.continueWatching.queryOptions(),
|
orpc.dashboard.continueWatching.queryOptions(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
if (isPending) return <ContinueWatchingSectionSkeleton />;
|
if (isPending) return <ContinueWatchingSectionSkeleton />;
|
||||||
|
|
||||||
const items = data?.items ?? [];
|
const items = data?.items ?? [];
|
||||||
@@ -19,7 +22,7 @@ export function ContinueWatchingSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FeedSection
|
<FeedSection
|
||||||
title="Continue Watching"
|
title={t`Continue Watching`}
|
||||||
icon={<IconPlayerPlay className="size-5 text-primary" />}
|
icon={<IconPlayerPlay className="size-5 text-primary" />}
|
||||||
>
|
>
|
||||||
<ContinueWatchingList items={items} />
|
<ContinueWatchingList items={items} />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { IconBooks } from "@tabler/icons-react";
|
import { IconBooks } from "@tabler/icons-react";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||||
@@ -16,6 +17,8 @@ export function LibrarySection() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
const sentinelRef = useInfiniteScroll({
|
const sentinelRef = useInfiniteScroll({
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
@@ -29,7 +32,7 @@ export function LibrarySection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FeedSection
|
<FeedSection
|
||||||
title="In Your Library"
|
title={t`In Your Library`}
|
||||||
icon={<IconBooks className="size-5 text-primary" />}
|
icon={<IconBooks className="size-5 text-primary" />}
|
||||||
>
|
>
|
||||||
<TitleGrid items={items} />
|
<TitleGrid items={items} />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { IconThumbUp } from "@tabler/icons-react";
|
import { IconThumbUp } from "@tabler/icons-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { orpc } from "@/lib/orpc/client";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
@@ -9,6 +10,8 @@ export function RecommendationsSection() {
|
|||||||
orpc.dashboard.recommendations.queryOptions(),
|
orpc.dashboard.recommendations.queryOptions(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
if (isPending) return <TitleGridSectionSkeleton />;
|
if (isPending) return <TitleGridSectionSkeleton />;
|
||||||
|
|
||||||
const items = data?.items ?? [];
|
const items = data?.items ?? [];
|
||||||
@@ -16,7 +19,7 @@ export function RecommendationsSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FeedSection
|
<FeedSection
|
||||||
title="Recommended for You"
|
title={t`Recommended for You`}
|
||||||
icon={<IconThumbUp className="size-5 text-primary" />}
|
icon={<IconThumbUp className="size-5 text-primary" />}
|
||||||
>
|
>
|
||||||
<TitleGrid items={items} />
|
<TitleGrid items={items} />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type {
|
import type {
|
||||||
DashboardStats,
|
DashboardStats,
|
||||||
HistoryBucket,
|
HistoryBucket,
|
||||||
@@ -45,13 +46,6 @@ export function StatsSectionSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const periodLabels: Record<TimePeriod, string> = {
|
|
||||||
today: "Today",
|
|
||||||
this_week: "This Week",
|
|
||||||
this_month: "This Month",
|
|
||||||
this_year: "This Year",
|
|
||||||
};
|
|
||||||
|
|
||||||
const periods: TimePeriod[] = ["today", "this_week", "this_month", "this_year"];
|
const periods: TimePeriod[] = ["today", "this_week", "this_month", "this_year"];
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
@@ -103,47 +97,78 @@ function StatCard({
|
|||||||
const inlineTriggerClass =
|
const inlineTriggerClass =
|
||||||
"h-auto w-auto gap-0.5 rounded-none border-0 bg-transparent py-1 px-0.5 -my-1 -mx-0.5 sm:p-0 sm:m-0 [font-size:inherit] [line-height:inherit] shadow-none underline decoration-dotted decoration-muted-foreground/50 underline-offset-4 hover:bg-transparent hover:text-foreground hover:decoration-foreground/50 focus-visible:ring-0 focus-visible:decoration-solid focus-visible:decoration-foreground dark:bg-transparent dark:hover:bg-transparent";
|
"h-auto w-auto gap-0.5 rounded-none border-0 bg-transparent py-1 px-0.5 -my-1 -mx-0.5 sm:p-0 sm:m-0 [font-size:inherit] [line-height:inherit] shadow-none underline decoration-dotted decoration-muted-foreground/50 underline-offset-4 hover:bg-transparent hover:text-foreground hover:decoration-foreground/50 focus-visible:ring-0 focus-visible:decoration-solid focus-visible:decoration-foreground dark:bg-transparent dark:hover:bg-transparent";
|
||||||
|
|
||||||
|
function PeriodSelect({
|
||||||
|
period,
|
||||||
|
onPeriodChange,
|
||||||
|
periodLabels,
|
||||||
|
}: {
|
||||||
|
period: TimePeriod;
|
||||||
|
onPeriodChange: (period: TimePeriod) => void;
|
||||||
|
periodLabels: Record<TimePeriod, string>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={period}
|
||||||
|
onValueChange={(v) => v && onPeriodChange(v as TimePeriod)}
|
||||||
|
modal={false}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className={`${inlineTriggerClass} text-foreground/80 uppercase`}
|
||||||
|
>
|
||||||
|
<SelectValue>
|
||||||
|
{(value: TimePeriod | null) => (value ? periodLabels[value] : null)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent align="start" alignItemWithTrigger={false} className="p-1">
|
||||||
|
{periods.map((p) => (
|
||||||
|
<SelectItem key={p} value={p}>
|
||||||
|
{periodLabels[p]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PeriodSelector({
|
function PeriodSelector({
|
||||||
noun,
|
type,
|
||||||
period,
|
period,
|
||||||
onPeriodChange,
|
onPeriodChange,
|
||||||
}: {
|
}: {
|
||||||
noun: string;
|
type: "movies" | "episodes";
|
||||||
period: TimePeriod;
|
period: TimePeriod;
|
||||||
onPeriodChange: (period: TimePeriod) => void;
|
onPeriodChange: (period: TimePeriod) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const periodLabels: Record<TimePeriod, string> = {
|
||||||
|
today: t`Today`,
|
||||||
|
this_week: t`This Week`,
|
||||||
|
this_month: t`This Month`,
|
||||||
|
this_year: t`This Year`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const select = (
|
||||||
|
<PeriodSelect
|
||||||
|
key="select"
|
||||||
|
period={period}
|
||||||
|
onPeriodChange={onPeriodChange}
|
||||||
|
periodLabels={periodLabels}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-baseline gap-1">
|
<span className="inline-flex items-baseline gap-1">
|
||||||
{noun}{" "}
|
{type === "movies" ? (
|
||||||
<Select
|
<Trans>Movies {select}</Trans>
|
||||||
value={period}
|
) : (
|
||||||
onValueChange={(v) => v && onPeriodChange(v as TimePeriod)}
|
<Trans>Episodes {select}</Trans>
|
||||||
modal={false}
|
)}
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
className={`${inlineTriggerClass} text-foreground/80 uppercase`}
|
|
||||||
>
|
|
||||||
<SelectValue>
|
|
||||||
{(value: TimePeriod | null) => (value ? periodLabels[value] : null)}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent
|
|
||||||
align="start"
|
|
||||||
alignItemWithTrigger={false}
|
|
||||||
className="p-1"
|
|
||||||
>
|
|
||||||
{periods.map((p) => (
|
|
||||||
<SelectItem key={p} value={p}>
|
|
||||||
{periodLabels[p]}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatsDisplay({ stats }: { stats: DashboardStats }) {
|
export function StatsDisplay({ stats }: { stats: DashboardStats }) {
|
||||||
|
const { t } = useLingui();
|
||||||
const [moviePeriod, setMoviePeriod] = useState<TimePeriod>("this_month");
|
const [moviePeriod, setMoviePeriod] = useState<TimePeriod>("this_month");
|
||||||
const [episodePeriod, setEpisodePeriod] = useState<TimePeriod>("this_week");
|
const [episodePeriod, setEpisodePeriod] = useState<TimePeriod>("this_week");
|
||||||
|
|
||||||
@@ -175,7 +200,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
|
|||||||
sparklineData={movieHistory}
|
sparklineData={movieHistory}
|
||||||
label={
|
label={
|
||||||
<PeriodSelector
|
<PeriodSelector
|
||||||
noun="Movies"
|
type="movies"
|
||||||
period={moviePeriod}
|
period={moviePeriod}
|
||||||
onPeriodChange={setMoviePeriod}
|
onPeriodChange={setMoviePeriod}
|
||||||
/>
|
/>
|
||||||
@@ -190,7 +215,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
|
|||||||
sparklineData={episodeHistory}
|
sparklineData={episodeHistory}
|
||||||
label={
|
label={
|
||||||
<PeriodSelector
|
<PeriodSelector
|
||||||
noun="Episodes"
|
type="episodes"
|
||||||
period={episodePeriod}
|
period={episodePeriod}
|
||||||
onPeriodChange={setEpisodePeriod}
|
onPeriodChange={setEpisodePeriod}
|
||||||
/>
|
/>
|
||||||
@@ -202,7 +227,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
|
|||||||
bgColor="bg-status-watchlist/10"
|
bgColor="bg-status-watchlist/10"
|
||||||
value={stats.librarySize}
|
value={stats.librarySize}
|
||||||
index={2}
|
index={2}
|
||||||
label="In Library"
|
label={t`In Library`}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={IconCheck}
|
icon={IconCheck}
|
||||||
@@ -210,7 +235,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
|
|||||||
bgColor="bg-status-completed/10"
|
bgColor="bg-status-completed/10"
|
||||||
value={stats.completed}
|
value={stats.completed}
|
||||||
index={3}
|
index={3}
|
||||||
label="Completed"
|
label={t`Completed`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { IconDeviceTv } from "@tabler/icons-react";
|
import { IconDeviceTv } from "@tabler/icons-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
@@ -27,16 +28,18 @@ export function StatsSection() {
|
|||||||
<IconDeviceTv aria-hidden={true} className="size-8 text-primary" />
|
<IconDeviceTv aria-hidden={true} className="size-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="font-medium">Your library is empty</p>
|
<p className="font-medium">
|
||||||
|
<Trans>Your library is empty</Trans>
|
||||||
|
</p>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Search for movies and TV shows to start tracking
|
<Trans>Search for movies and TV shows to start tracking</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/explore"
|
to="/explore"
|
||||||
className="inline-flex h-9 items-center rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20"
|
className="inline-flex h-9 items-center rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20"
|
||||||
>
|
>
|
||||||
Start exploring
|
<Trans>Start exploring</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
export function WelcomeHeader({ name }: { name?: string | null }) {
|
export function WelcomeHeader({ name }: { name?: string | null }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-balance font-display text-3xl tracking-tight">
|
<h1 className="text-balance font-display text-3xl tracking-tight">
|
||||||
Welcome back{name ? `, ${name}` : ""}
|
{name ? (
|
||||||
|
<Trans>Welcome back, {name}</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Welcome back</Trans>
|
||||||
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-muted-foreground text-sm">
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
Here's what's happening with your library
|
<Trans>Here's what's happening with your library</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ export function ExpandableText({
|
|||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
className="mt-1 font-medium text-primary text-xs transition-colors hover:text-primary/80"
|
className="mt-1 font-medium text-primary text-xs transition-colors hover:text-primary/80"
|
||||||
>
|
>
|
||||||
{expanded ? "Show less" : "Read more"}
|
{expanded ? <Trans>Show less</Trans> : <Trans>Read more</Trans>}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
|
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@@ -41,6 +42,7 @@ function mergeMaps<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ExploreClient() {
|
export function ExploreClient() {
|
||||||
|
const { t } = useLingui();
|
||||||
const {
|
const {
|
||||||
data: trendingData,
|
data: trendingData,
|
||||||
isPending: trendingPending,
|
isPending: trendingPending,
|
||||||
@@ -107,7 +109,7 @@ export function ExploreClient() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<TitleRow
|
<TitleRow
|
||||||
heading="Trending Today"
|
heading={t`Trending Today`}
|
||||||
icon={
|
icon={
|
||||||
<IconFlame aria-hidden={true} className="size-5 text-primary" />
|
<IconFlame aria-hidden={true} className="size-5 text-primary" />
|
||||||
}
|
}
|
||||||
@@ -121,7 +123,7 @@ export function ExploreClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterableTitleRow
|
<FilterableTitleRow
|
||||||
heading="Popular Movies"
|
heading={t`Popular Movies`}
|
||||||
icon={<IconMovie aria-hidden={true} className="size-5 text-primary" />}
|
icon={<IconMovie aria-hidden={true} className="size-5 text-primary" />}
|
||||||
mediaType="movie"
|
mediaType="movie"
|
||||||
defaultItems={(popularMoviesData?.items ?? []).slice(0, 20)}
|
defaultItems={(popularMoviesData?.items ?? []).slice(0, 20)}
|
||||||
@@ -131,7 +133,7 @@ export function ExploreClient() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<FilterableTitleRow
|
<FilterableTitleRow
|
||||||
heading="Popular TV Shows"
|
heading={t`Popular TV Shows`}
|
||||||
icon={
|
icon={
|
||||||
<IconDeviceTv aria-hidden={true} className="size-5 text-primary" />
|
<IconDeviceTv aria-hidden={true} className="size-5 text-primary" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
|
import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
|
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
|
||||||
@@ -147,7 +148,7 @@ export function FilterableTitleRow({
|
|||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{!isLoading && selectedGenre !== null && items.length === 0 && (
|
{!isLoading && selectedGenre !== null && items.length === 0 && (
|
||||||
<p className="py-8 text-center text-muted-foreground text-sm">
|
<p className="py-8 text-center text-muted-foreground text-sm">
|
||||||
No titles found for this genre.
|
<Trans>No titles found for this genre.</Trans>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import {
|
import {
|
||||||
IconDeviceTv,
|
IconDeviceTv,
|
||||||
IconMovie,
|
IconMovie,
|
||||||
@@ -55,12 +56,12 @@ export function HeroBanner({
|
|||||||
{type === "movie" ? (
|
{type === "movie" ? (
|
||||||
<>
|
<>
|
||||||
<IconMovie aria-hidden className="size-3.5" />
|
<IconMovie aria-hidden className="size-3.5" />
|
||||||
Movie
|
<Trans>Movie</Trans>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<IconDeviceTv aria-hidden className="size-3.5" />
|
<IconDeviceTv aria-hidden className="size-3.5" />
|
||||||
TV
|
<Trans>TV</Trans>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -74,7 +75,7 @@ export function HeroBanner({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
Trending today
|
<Trans>Trending today</Trans>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
@@ -95,7 +96,7 @@ export function HeroBanner({
|
|||||||
className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20"
|
className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20"
|
||||||
>
|
>
|
||||||
<IconPlus aria-hidden={true} className="size-4" />
|
<IconPlus aria-hidden={true} className="size-4" />
|
||||||
Add to Library
|
<Trans>Add to Library</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { SofaLogo } from "@/components/sofa-logo";
|
import { SofaLogo } from "@/components/sofa-logo";
|
||||||
@@ -101,7 +102,7 @@ export function LandingPage({
|
|||||||
damping: 20,
|
damping: 20,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Self-hosted movie & TV tracker
|
<Trans>Self-hosted movie & TV tracker</Trans>
|
||||||
</motion.p>
|
</motion.p>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="flex justify-center text-primary"
|
className="flex justify-center text-primary"
|
||||||
@@ -127,9 +128,11 @@ export function LandingPage({
|
|||||||
delay: 0.2,
|
delay: 0.2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Track what you watch. Know what's next.
|
<Trans>
|
||||||
<br />
|
Track what you watch. Know what's next.
|
||||||
Your library, your data, your rules.
|
<br />
|
||||||
|
Your library, your data, your rules.
|
||||||
|
</Trans>
|
||||||
</motion.p>
|
</motion.p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -149,7 +152,9 @@ export function LandingPage({
|
|||||||
to="/register"
|
to="/register"
|
||||||
className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20"
|
className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20"
|
||||||
>
|
>
|
||||||
<span className="relative z-10">Get Started</span>
|
<span className="relative z-10">
|
||||||
|
<Trans>Get Started</Trans>
|
||||||
|
</span>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
@@ -158,7 +163,9 @@ export function LandingPage({
|
|||||||
to="/login"
|
to="/login"
|
||||||
className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20"
|
className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20"
|
||||||
>
|
>
|
||||||
<span className="relative z-10">Sign In</span>
|
<span className="relative z-10">
|
||||||
|
<Trans>Sign In</Trans>
|
||||||
|
</span>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
</Link>
|
</Link>
|
||||||
{registrationOpen && (
|
{registrationOpen && (
|
||||||
@@ -166,7 +173,7 @@ export function LandingPage({
|
|||||||
to="/register"
|
to="/register"
|
||||||
className="inline-flex h-12 items-center justify-center rounded-lg border border-border px-8 font-medium transition-colors hover:border-primary/40 hover:bg-primary/5"
|
className="inline-flex h-12 items-center justify-center rounded-lg border border-border px-8 font-medium transition-colors hover:border-primary/40 hover:bg-primary/5"
|
||||||
>
|
>
|
||||||
Register
|
<Trans>Register</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import {
|
import {
|
||||||
IconCompass,
|
IconCompass,
|
||||||
IconHome,
|
IconHome,
|
||||||
@@ -35,16 +36,7 @@ const springTransition = {
|
|||||||
damping: 30,
|
damping: 30,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const navLinks = [
|
// navLinks and mobileTabs moved inside components for LingUI
|
||||||
{ href: "/dashboard", label: "Home" },
|
|
||||||
{ href: "/explore", label: "Explore" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const mobileTabs = [
|
|
||||||
{ href: "/dashboard", label: "Home", icon: IconHome },
|
|
||||||
{ href: "/explore", label: "Explore", icon: IconCompass },
|
|
||||||
{ href: "/settings", label: "Settings", icon: IconSettings },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function isLinkActive(pathname: string, href: string) {
|
function isLinkActive(pathname: string, href: string) {
|
||||||
return (
|
return (
|
||||||
@@ -124,10 +116,16 @@ export function NavBar({
|
|||||||
userImage?: string;
|
userImage?: string;
|
||||||
userRole?: string;
|
userRole?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const setCommandPaletteOpen = useSetAtom(commandPaletteOpenAtom);
|
const setCommandPaletteOpen = useSetAtom(commandPaletteOpenAtom);
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ href: "/dashboard", label: t`Home` },
|
||||||
|
{ href: "/explore", label: t`Explore` },
|
||||||
|
] as const;
|
||||||
|
|
||||||
const initial = userName?.charAt(0).toUpperCase() ?? "?";
|
const initial = userName?.charAt(0).toUpperCase() ?? "?";
|
||||||
|
|
||||||
const activeIndex = navLinks.findIndex((link) =>
|
const activeIndex = navLinks.findIndex((link) =>
|
||||||
@@ -192,7 +190,7 @@ export function NavBar({
|
|||||||
className="flex flex-1 items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-[13px] text-muted-foreground transition-colors hover:border-primary/20 hover:bg-card sm:hidden"
|
className="flex flex-1 items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-[13px] text-muted-foreground transition-colors hover:border-primary/20 hover:bg-card sm:hidden"
|
||||||
>
|
>
|
||||||
<IconSearch aria-hidden={true} className="size-3.5" />
|
<IconSearch aria-hidden={true} className="size-3.5" />
|
||||||
<span>Search…</span>
|
<span>{t`Search…`}</span>
|
||||||
</button>
|
</button>
|
||||||
{/* Desktop search trigger pill */}
|
{/* Desktop search trigger pill */}
|
||||||
<button
|
<button
|
||||||
@@ -201,7 +199,7 @@ export function NavBar({
|
|||||||
className="hidden items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-[13px] text-muted-foreground transition-colors hover:border-primary/20 hover:bg-card sm:inline-flex"
|
className="hidden items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-[13px] text-muted-foreground transition-colors hover:border-primary/20 hover:bg-card sm:inline-flex"
|
||||||
>
|
>
|
||||||
<IconSearch aria-hidden={true} className="size-3.5" />
|
<IconSearch aria-hidden={true} className="size-3.5" />
|
||||||
<span>Search…</span>
|
<span>{t`Search…`}</span>
|
||||||
<Kbd className="ml-2.5">⌘ K</Kbd>
|
<Kbd className="ml-2.5">⌘ K</Kbd>
|
||||||
</button>
|
</button>
|
||||||
<Separator
|
<Separator
|
||||||
@@ -234,7 +232,7 @@ export function NavBar({
|
|||||||
{userName}
|
{userName}
|
||||||
{userRole === "admin" && (
|
{userRole === "admin" && (
|
||||||
<Badge className="mb-0.5 ml-1.5 rounded-md border-0 bg-primary/10 align-middle text-primary">
|
<Badge className="mb-0.5 ml-1.5 rounded-md border-0 bg-primary/10 align-middle text-primary">
|
||||||
Admin
|
<Trans>Admin</Trans>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
@@ -249,7 +247,7 @@ export function NavBar({
|
|||||||
className="cursor-pointer text-[13px]"
|
className="cursor-pointer text-[13px]"
|
||||||
>
|
>
|
||||||
<IconSettings className="size-3.5" />
|
<IconSettings className="size-3.5" />
|
||||||
Settings
|
<Trans>Settings</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -261,7 +259,7 @@ export function NavBar({
|
|||||||
className="cursor-pointer text-[13px]"
|
className="cursor-pointer text-[13px]"
|
||||||
>
|
>
|
||||||
<IconLogout className="size-3.5" />
|
<IconLogout className="size-3.5" />
|
||||||
Sign out
|
<Trans>Sign out</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -285,8 +283,15 @@ export function NavBar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MobileTabBar() {
|
export function MobileTabBar() {
|
||||||
|
const { t } = useLingui();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
const mobileTabs = [
|
||||||
|
{ href: "/dashboard", label: t`Home`, icon: IconHome },
|
||||||
|
{ href: "/explore", label: t`Explore`, icon: IconCompass },
|
||||||
|
{ href: "/settings", label: t`Settings`, icon: IconSettings },
|
||||||
|
] as const;
|
||||||
|
|
||||||
const activeIndex = mobileTabs.findIndex((tab) =>
|
const activeIndex = mobileTabs.findIndex((tab) =>
|
||||||
isLinkActive(pathname, tab.href),
|
isLinkActive(pathname, tab.href),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { PersonCredit } from "@sofa/api/schemas";
|
import type { PersonCredit } from "@sofa/api/schemas";
|
||||||
import { IconMovie } from "@tabler/icons-react";
|
import { IconMovie } from "@tabler/icons-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
@@ -23,6 +24,7 @@ export function FilmographyGrid({
|
|||||||
credits,
|
credits,
|
||||||
userStatuses,
|
userStatuses,
|
||||||
}: FilmographyGridProps) {
|
}: FilmographyGridProps) {
|
||||||
|
const { t } = useLingui();
|
||||||
const [filter, setFilter] = useState<Filter>("all");
|
const [filter, setFilter] = useState<Filter>("all");
|
||||||
const [sort, setSort] = useState<Sort>("newest");
|
const [sort, setSort] = useState<Sort>("newest");
|
||||||
|
|
||||||
@@ -53,9 +55,9 @@ export function FilmographyGrid({
|
|||||||
if (credits.length === 0) return null;
|
if (credits.length === 0) return null;
|
||||||
|
|
||||||
const filters: { value: Filter; label: string }[] = [
|
const filters: { value: Filter; label: string }[] = [
|
||||||
{ value: "all", label: "All" },
|
{ value: "all", label: t`All` },
|
||||||
{ value: "movie", label: "Movies" },
|
{ value: "movie", label: t`Movies` },
|
||||||
{ value: "tv", label: "TV" },
|
{ value: "tv", label: t`TV` },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -64,7 +66,7 @@ export function FilmographyGrid({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<IconMovie aria-hidden={true} className="size-5 text-primary" />
|
<IconMovie aria-hidden={true} className="size-5 text-primary" />
|
||||||
<h2 className="text-balance font-display text-xl tracking-tight">
|
<h2 className="text-balance font-display text-xl tracking-tight">
|
||||||
Filmography
|
<Trans>Filmography</Trans>
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-muted-foreground text-sm">
|
<span className="text-muted-foreground text-sm">
|
||||||
({filtered.length})
|
({filtered.length})
|
||||||
@@ -81,9 +83,9 @@ export function FilmographyGrid({
|
|||||||
<SelectValue>
|
<SelectValue>
|
||||||
{(value: string | null) =>
|
{(value: string | null) =>
|
||||||
value === "newest"
|
value === "newest"
|
||||||
? "Newest"
|
? t`Newest`
|
||||||
: value === "rating"
|
: value === "rating"
|
||||||
? "Rating"
|
? t`Rating`
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
@@ -93,8 +95,12 @@ export function FilmographyGrid({
|
|||||||
alignItemWithTrigger={false}
|
alignItemWithTrigger={false}
|
||||||
className="p-1"
|
className="p-1"
|
||||||
>
|
>
|
||||||
<SelectItem value="newest">Newest</SelectItem>
|
<SelectItem value="newest">
|
||||||
<SelectItem value="rating">Rating</SelectItem>
|
<Trans>Newest</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="rating">
|
||||||
|
<Trans>Rating</Trans>
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import type { ResolvedPerson } from "@sofa/api/schemas";
|
import type { ResolvedPerson } from "@sofa/api/schemas";
|
||||||
|
import { formatDate } from "@sofa/i18n/format";
|
||||||
import { IconCalendar, IconMapPin } from "@tabler/icons-react";
|
import { IconCalendar, IconMapPin } from "@tabler/icons-react";
|
||||||
import { format, parseISO } from "date-fns";
|
|
||||||
|
|
||||||
import { ExpandableText } from "@/components/expandable-text";
|
import { ExpandableText } from "@/components/expandable-text";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -22,6 +23,7 @@ function calculateAge(birthday: string, deathday?: string | null): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PersonHero({ person }: PersonHeroProps) {
|
export function PersonHero({ person }: PersonHeroProps) {
|
||||||
|
const { t } = useLingui();
|
||||||
const age = person.birthday
|
const age = person.birthday
|
||||||
? calculateAge(person.birthday, person.deathday)
|
? calculateAge(person.birthday, person.deathday)
|
||||||
: null;
|
: null;
|
||||||
@@ -66,15 +68,15 @@ export function PersonHero({ person }: PersonHeroProps) {
|
|||||||
{person.knownForDepartment && (
|
{person.knownForDepartment && (
|
||||||
<Badge className="border-0 bg-primary/10 px-2.5 font-semibold text-primary uppercase tracking-wider">
|
<Badge className="border-0 bg-primary/10 px-2.5 font-semibold text-primary uppercase tracking-wider">
|
||||||
{person.knownForDepartment === "Acting"
|
{person.knownForDepartment === "Acting"
|
||||||
? "Actor"
|
? t`Actor`
|
||||||
: person.knownForDepartment === "Directing"
|
: person.knownForDepartment === "Directing"
|
||||||
? "Director"
|
? t`Director`
|
||||||
: person.knownForDepartment === "Writing"
|
: person.knownForDepartment === "Writing"
|
||||||
? "Writer"
|
? t`Writer`
|
||||||
: person.knownForDepartment === "Production"
|
: person.knownForDepartment === "Production"
|
||||||
? "Producer"
|
? t`Producer`
|
||||||
: person.knownForDepartment === "Editing"
|
: person.knownForDepartment === "Editing"
|
||||||
? "Editor"
|
? t`Editor`
|
||||||
: person.knownForDepartment}
|
: person.knownForDepartment}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -83,10 +85,10 @@ export function PersonHero({ person }: PersonHeroProps) {
|
|||||||
{person.birthday && (
|
{person.birthday && (
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<IconCalendar aria-hidden={true} className="size-3.5" />
|
<IconCalendar aria-hidden={true} className="size-3.5" />
|
||||||
{format(parseISO(person.birthday), "MMMM d, yyyy")}
|
{formatDate(person.birthday)}
|
||||||
{age !== null && (
|
{age !== null && (
|
||||||
<span className="text-muted-foreground/60">
|
<span className="text-muted-foreground/60">
|
||||||
({person.deathday ? `died at ${age}` : `age ${age}`})
|
({person.deathday ? t`died at ${age}` : t`age ${age}`})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import { formatDate } from "@sofa/i18n/format";
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconCamera,
|
IconCamera,
|
||||||
@@ -43,6 +45,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { authClient, signOut } from "@/lib/auth/client";
|
import { authClient, signOut } from "@/lib/auth/client";
|
||||||
|
import { getErrorMessage } from "@/lib/error-messages";
|
||||||
import { orpc } from "@/lib/orpc/client";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
|
|
||||||
export function AccountSection({
|
export function AccountSection({
|
||||||
@@ -56,6 +59,7 @@ export function AccountSection({
|
|||||||
role?: string;
|
role?: string;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [avatarUrl, setAvatarUrl] = useState(user.image);
|
const [avatarUrl, setAvatarUrl] = useState(user.image);
|
||||||
@@ -73,12 +77,11 @@ export function AccountSection({
|
|||||||
const trimmed = editValue.trim();
|
const trimmed = editValue.trim();
|
||||||
setDisplayName(trimmed);
|
setDisplayName(trimmed);
|
||||||
setIsEditingName(false);
|
setIsEditingName(false);
|
||||||
toast.success("Name updated");
|
toast.success(t`Name updated`);
|
||||||
router.invalidate();
|
router.invalidate();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
const message = err instanceof Error ? err.message : "Update failed";
|
toast.error(getErrorMessage(err, t, t`Update failed`));
|
||||||
toast.error(message);
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -91,9 +94,10 @@ export function AccountSection({
|
|||||||
}
|
}
|
||||||
}, [isEditingName]);
|
}, [isEditingName]);
|
||||||
|
|
||||||
const memberSince = new Date(user.createdAt).toLocaleDateString(undefined, {
|
const memberSince = formatDate(user.createdAt, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
|
day: undefined,
|
||||||
});
|
});
|
||||||
const initial = displayName?.charAt(0).toUpperCase() ?? "?";
|
const initial = displayName?.charAt(0).toUpperCase() ?? "?";
|
||||||
|
|
||||||
@@ -101,12 +105,11 @@ export function AccountSection({
|
|||||||
orpc.account.uploadAvatar.mutationOptions({
|
orpc.account.uploadAvatar.mutationOptions({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setAvatarUrl(data.imageUrl);
|
setAvatarUrl(data.imageUrl);
|
||||||
toast.success("Profile picture updated");
|
toast.success(t`Profile picture updated`);
|
||||||
router.invalidate();
|
router.invalidate();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
const message = err instanceof Error ? err.message : "Upload failed";
|
toast.error(getErrorMessage(err, t, t`Upload failed`));
|
||||||
toast.error(message);
|
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
@@ -124,11 +127,11 @@ export function AccountSection({
|
|||||||
orpc.account.removeAvatar.mutationOptions({
|
orpc.account.removeAvatar.mutationOptions({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setAvatarUrl(undefined);
|
setAvatarUrl(undefined);
|
||||||
toast.success("Profile picture removed");
|
toast.success(t`Profile picture removed`);
|
||||||
router.invalidate();
|
router.invalidate();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error("Failed to remove profile picture");
|
toast.error(t`Failed to remove profile picture`);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -169,7 +172,7 @@ export function AccountSection({
|
|||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<IconUser aria-hidden={true} className="size-4 text-muted-foreground" />
|
<IconUser aria-hidden={true} className="size-4 text-muted-foreground" />
|
||||||
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
|
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
|
||||||
Account
|
<Trans>Account</Trans>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -192,7 +195,9 @@ export function AccountSection({
|
|||||||
}
|
}
|
||||||
className="relative shrink-0 cursor-pointer rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
className="relative shrink-0 cursor-pointer rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||||
aria-label={
|
aria-label={
|
||||||
avatarUrl ? "Remove profile picture" : "Upload profile picture"
|
avatarUrl
|
||||||
|
? t`Remove profile picture`
|
||||||
|
: t`Upload profile picture`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Avatar className="size-12 overflow-hidden">
|
<Avatar className="size-12 overflow-hidden">
|
||||||
@@ -230,7 +235,11 @@ export function AccountSection({
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{avatarUrl ? "Remove picture" : "Upload picture"}
|
{avatarUrl ? (
|
||||||
|
<Trans>Remove picture</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Upload picture</Trans>
|
||||||
|
)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -284,7 +293,7 @@ export function AccountSection({
|
|||||||
handleNameSave();
|
handleNameSave();
|
||||||
}}
|
}}
|
||||||
className="shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:text-primary"
|
className="shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:text-primary"
|
||||||
aria-label="Save name"
|
aria-label={t`Save name`}
|
||||||
>
|
>
|
||||||
<IconCheck className="size-3.5" />
|
<IconCheck className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -295,7 +304,7 @@ export function AccountSection({
|
|||||||
handleNameCancel();
|
handleNameCancel();
|
||||||
}}
|
}}
|
||||||
className="shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:text-destructive"
|
className="shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:text-destructive"
|
||||||
aria-label="Cancel editing"
|
aria-label={t`Cancel editing`}
|
||||||
>
|
>
|
||||||
<IconX className="size-3.5" />
|
<IconX className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -323,12 +332,12 @@ export function AccountSection({
|
|||||||
{user.email}
|
{user.email}
|
||||||
{user.role === "admin" && (
|
{user.role === "admin" && (
|
||||||
<Badge className="ml-1.5 rounded-md border-0 bg-primary/10 align-middle text-primary">
|
<Badge className="ml-1.5 rounded-md border-0 bg-primary/10 align-middle text-primary">
|
||||||
Admin
|
<Trans>Admin</Trans>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
<p className="mt-0.5 text-muted-foreground/60 text-xs">
|
<p className="mt-0.5 text-muted-foreground/60 text-xs">
|
||||||
Member since {memberSince}
|
<Trans>Member since {memberSince}</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -342,7 +351,7 @@ export function AccountSection({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconLogout aria-hidden={true} />
|
<IconLogout aria-hidden={true} />
|
||||||
Sign out
|
<Trans>Sign out</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -352,6 +361,7 @@ export function AccountSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ChangePasswordDialog() {
|
function ChangePasswordDialog() {
|
||||||
|
const { t } = useLingui();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
@@ -378,15 +388,15 @@ function ChangePasswordDialog() {
|
|||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
if (!currentPassword) {
|
if (!currentPassword) {
|
||||||
setError("Current password is required");
|
setError(t`Current password is required`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPassword.length < 8) {
|
if (newPassword.length < 8) {
|
||||||
setError("New password must be at least 8 characters");
|
setError(t`New password must be at least 8 characters`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
setError("Passwords do not match");
|
setError(t`Passwords do not match`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,13 +408,13 @@ function ChangePasswordDialog() {
|
|||||||
revokeOtherSessions,
|
revokeOtherSessions,
|
||||||
});
|
});
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
setError(result.error.message ?? "Failed to change password");
|
setError(t`Failed to change password`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success("Password updated");
|
toast.success(t`Password updated`);
|
||||||
handleOpenChange(false);
|
handleOpenChange(false);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Something went wrong");
|
setError(t`Something went wrong`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -414,13 +424,15 @@ function ChangePasswordDialog() {
|
|||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<Button variant="outline" onClick={() => setOpen(true)}>
|
<Button variant="outline" onClick={() => setOpen(true)}>
|
||||||
<IconLockPassword aria-hidden={true} />
|
<IconLockPassword aria-hidden={true} />
|
||||||
Change password
|
<Trans>Change password</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Change password</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Change password</Trans>
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Enter your current password and choose a new one.
|
<Trans>Enter your current password and choose a new one.</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -433,7 +445,9 @@ function ChangePasswordDialog() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="current-password">Current password</Label>
|
<Label htmlFor="current-password">
|
||||||
|
<Trans>Current password</Trans>
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="current-password"
|
id="current-password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -445,7 +459,9 @@ function ChangePasswordDialog() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="new-password">New password</Label>
|
<Label htmlFor="new-password">
|
||||||
|
<Trans>New password</Trans>
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="new-password"
|
id="new-password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -457,7 +473,9 @@ function ChangePasswordDialog() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="confirm-password">Confirm new password</Label>
|
<Label htmlFor="confirm-password">
|
||||||
|
<Trans>Confirm new password</Trans>
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="confirm-password"
|
id="confirm-password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -478,17 +496,17 @@ function ChangePasswordDialog() {
|
|||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="revoke-sessions" className="cursor-pointer">
|
<Label htmlFor="revoke-sessions" className="cursor-pointer">
|
||||||
Sign out of other sessions
|
<Trans>Sign out of other sessions</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="pt-2">
|
<DialogFooter className="pt-2">
|
||||||
<DialogClose render={<Button variant="outline" />}>
|
<DialogClose render={<Button variant="outline" />}>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting && <Spinner className="size-3.5" />}
|
{isSubmitting && <Spinner className="size-3.5" />}
|
||||||
Update password
|
<Trans>Update password</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { IconCloudUpload } from "@tabler/icons-react";
|
import { IconCloudUpload } from "@tabler/icons-react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
@@ -15,9 +16,11 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { getErrorMessage } from "@/lib/error-messages";
|
||||||
import { orpc } from "@/lib/orpc/client";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
|
|
||||||
export function BackupRestoreSection() {
|
export function BackupRestoreSection() {
|
||||||
|
const { t } = useLingui();
|
||||||
const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);
|
const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -25,12 +28,11 @@ export function BackupRestoreSection() {
|
|||||||
const restoreMutation = useMutation(
|
const restoreMutation = useMutation(
|
||||||
orpc.admin.backups.restore.mutationOptions({
|
orpc.admin.backups.restore.mutationOptions({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Database restored. Reloading...");
|
toast.success(t`Database restored. Reloading...`);
|
||||||
setTimeout(() => window.location.reload(), 1500);
|
setTimeout(() => window.location.reload(), 1500);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
const message = err instanceof Error ? err.message : "Restore failed";
|
toast.error(getErrorMessage(err, t, t`Restore failed`));
|
||||||
toast.error(message);
|
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
@@ -53,10 +55,14 @@ export function BackupRestoreSection() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Restore</CardTitle>
|
<CardTitle>
|
||||||
|
<Trans>Restore</Trans>
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Upload a .db file to replace the current database. A safety backup
|
<Trans>
|
||||||
is created first.
|
Upload a .db file to replace the current database. A safety
|
||||||
|
backup is created first.
|
||||||
|
</Trans>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,11 +85,15 @@ export function BackupRestoreSection() {
|
|||||||
>
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Restore database?</AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
|
<Trans>Restore database?</Trans>
|
||||||
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will replace your entire database with the uploaded file. A
|
<Trans>
|
||||||
safety backup of your current data will be created first. Active
|
This will replace your entire database with the uploaded file.
|
||||||
sessions may need to refresh after restore.
|
A safety backup of your current data will be created first.
|
||||||
|
Active sessions may need to refresh after restore.
|
||||||
|
</Trans>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@@ -94,7 +104,7 @@ export function BackupRestoreSection() {
|
|||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -103,7 +113,7 @@ export function BackupRestoreSection() {
|
|||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Restore
|
<Trans>Restore</Trans>
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
@@ -118,7 +128,11 @@ export function BackupRestoreSection() {
|
|||||||
) : (
|
) : (
|
||||||
<IconCloudUpload aria-hidden={true} />
|
<IconCloudUpload aria-hidden={true} />
|
||||||
)}
|
)}
|
||||||
{restoreMutation.isPending ? "Restoring…" : "Upload"}
|
{restoreMutation.isPending ? (
|
||||||
|
<Trans>Restoring…</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Upload</Trans>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { BackupFrequency } from "@sofa/api/schemas";
|
import type { BackupFrequency } from "@sofa/api/schemas";
|
||||||
|
import { formatDate, formatRelativeTime } from "@sofa/i18n/format";
|
||||||
import { IconCalendarWeek } from "@tabler/icons-react";
|
import { IconCalendarWeek } from "@tabler/icons-react";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { format, formatDistanceToNow } from "date-fns";
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -28,16 +29,6 @@ const FREQUENCY_OPTIONS: { value: BackupFrequency; label: string }[] = [
|
|||||||
|
|
||||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||||
|
|
||||||
const DAYS_OF_WEEK = [
|
|
||||||
"Sunday",
|
|
||||||
"Monday",
|
|
||||||
"Tuesday",
|
|
||||||
"Wednesday",
|
|
||||||
"Thursday",
|
|
||||||
"Friday",
|
|
||||||
"Saturday",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
interface BackupScheduleState {
|
interface BackupScheduleState {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
maxRetention: number;
|
maxRetention: number;
|
||||||
@@ -98,16 +89,19 @@ function getNextBackupDate(
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNextBackup(
|
|
||||||
frequency: BackupFrequency,
|
|
||||||
time: string,
|
|
||||||
dayOfWeek: number,
|
|
||||||
): string {
|
|
||||||
const next = getNextBackupDate(frequency, time, dayOfWeek);
|
|
||||||
return `Next backup ${formatDistanceToNow(next, { addSuffix: true })}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BackupScheduleSection() {
|
export function BackupScheduleSection() {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const DAYS_OF_WEEK = [
|
||||||
|
t`Sunday`,
|
||||||
|
t`Monday`,
|
||||||
|
t`Tuesday`,
|
||||||
|
t`Wednesday`,
|
||||||
|
t`Thursday`,
|
||||||
|
t`Friday`,
|
||||||
|
t`Saturday`,
|
||||||
|
];
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: scheduleData,
|
data: scheduleData,
|
||||||
isPending,
|
isPending,
|
||||||
@@ -127,6 +121,16 @@ export function BackupScheduleSection() {
|
|||||||
|
|
||||||
const { enabled, maxRetention, frequency, time, dow } = current;
|
const { enabled, maxRetention, frequency, time, dow } = current;
|
||||||
|
|
||||||
|
function formatNextBackup(
|
||||||
|
frequency: BackupFrequency,
|
||||||
|
time: string,
|
||||||
|
dayOfWeek: number,
|
||||||
|
): string {
|
||||||
|
const next = getNextBackupDate(frequency, time, dayOfWeek);
|
||||||
|
const distance = formatRelativeTime(next);
|
||||||
|
return t`Next backup ${distance}`;
|
||||||
|
}
|
||||||
|
|
||||||
const updateScheduleMutation = useMutation(
|
const updateScheduleMutation = useMutation(
|
||||||
orpc.admin.backups.updateSchedule.mutationOptions({
|
orpc.admin.backups.updateSchedule.mutationOptions({
|
||||||
onMutate: (input) => {
|
onMutate: (input) => {
|
||||||
@@ -145,11 +149,11 @@ export function BackupScheduleSection() {
|
|||||||
onError: (_, input, ctx) => {
|
onError: (_, input, ctx) => {
|
||||||
if (ctx?.previous) setSchedule(ctx.previous);
|
if (ctx?.previous) setSchedule(ctx.previous);
|
||||||
if (input.enabled !== undefined) {
|
if (input.enabled !== undefined) {
|
||||||
toast.error("Failed to update scheduled backup setting");
|
toast.error(t`Failed to update scheduled backup setting`);
|
||||||
} else if (input.maxRetention !== undefined) {
|
} else if (input.maxRetention !== undefined) {
|
||||||
toast.error("Failed to update retention setting");
|
toast.error(t`Failed to update retention setting`);
|
||||||
} else {
|
} else {
|
||||||
toast.error("Failed to update schedule");
|
toast.error(t`Failed to update schedule`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -170,13 +174,13 @@ export function BackupScheduleSection() {
|
|||||||
onSuccess: () =>
|
onSuccess: () =>
|
||||||
toast.success(
|
toast.success(
|
||||||
checked
|
checked
|
||||||
? "Scheduled backups enabled"
|
? t`Scheduled backups enabled`
|
||||||
: "Scheduled backups disabled",
|
: t`Scheduled backups disabled`,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[updateScheduleMutation],
|
[updateScheduleMutation, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const changeMaxRetention = useCallback(
|
const changeMaxRetention = useCallback(
|
||||||
@@ -194,10 +198,10 @@ export function BackupScheduleSection() {
|
|||||||
time: newTime,
|
time: newTime,
|
||||||
dayOfWeek: newDow,
|
dayOfWeek: newDow,
|
||||||
},
|
},
|
||||||
{ onSuccess: () => toast.success("Schedule updated") },
|
{ onSuccess: () => toast.success(t`Schedule updated`) },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[updateScheduleMutation, current.dow],
|
[updateScheduleMutation, current.dow, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
@@ -208,7 +212,7 @@ export function BackupScheduleSection() {
|
|||||||
return (
|
return (
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Failed to load backup schedule settings.
|
<Trans>Failed to load backup schedule settings.</Trans>
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
);
|
);
|
||||||
@@ -226,46 +230,55 @@ export function BackupScheduleSection() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Backup schedule</CardTitle>
|
<CardTitle>
|
||||||
|
<Trans>Backup schedule</Trans>
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{enabled ? (
|
{enabled ? (
|
||||||
<span
|
<span
|
||||||
className="inline-flex flex-wrap items-baseline"
|
className="inline-flex flex-wrap items-baseline"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
{formatNextBackup(frequency, time, dow)}. Keeping{" "}
|
{formatNextBackup(frequency, time, dow)}.{" "}
|
||||||
<Select
|
<Trans>
|
||||||
value={String(maxRetention)}
|
Keeping{" "}
|
||||||
onValueChange={(v) => v && changeMaxRetention(Number(v))}
|
<Select
|
||||||
modal={false}
|
value={String(maxRetention)}
|
||||||
>
|
onValueChange={(v) =>
|
||||||
<SelectTrigger className="!h-auto mr-0.5 ml-1.5 w-auto gap-0.5 rounded-none border-0 bg-transparent p-0 underline decoration-muted-foreground/50 decoration-dotted underline-offset-4 shadow-none hover:bg-transparent hover:text-foreground hover:decoration-foreground/50 focus-visible:decoration-foreground focus-visible:decoration-solid focus-visible:ring-0 dark:bg-transparent dark:hover:bg-transparent">
|
v && changeMaxRetention(Number(v))
|
||||||
<SelectValue>
|
}
|
||||||
{(value: string | null) =>
|
modal={false}
|
||||||
value === "0"
|
|
||||||
? "unlimited"
|
|
||||||
: value
|
|
||||||
? `last ${value}`
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent
|
|
||||||
align="start"
|
|
||||||
alignItemWithTrigger={false}
|
|
||||||
className="p-1"
|
|
||||||
>
|
>
|
||||||
{[3, 5, 7, 14, 30, 0].map((n) => (
|
<SelectTrigger className="!h-auto mr-0.5 ml-1.5 w-auto gap-0.5 rounded-none border-0 bg-transparent p-0 underline decoration-muted-foreground/50 decoration-dotted underline-offset-4 shadow-none hover:bg-transparent hover:text-foreground hover:decoration-foreground/50 focus-visible:decoration-foreground focus-visible:decoration-solid focus-visible:ring-0 dark:bg-transparent dark:hover:bg-transparent">
|
||||||
<SelectItem key={n} value={String(n)}>
|
<SelectValue>
|
||||||
{n === 0 ? "unlimited" : `last ${n}`}
|
{(value: string | null) =>
|
||||||
</SelectItem>
|
value === "0"
|
||||||
))}
|
? t`unlimited`
|
||||||
</SelectContent>
|
: value
|
||||||
</Select>{" "}
|
? t`last ${value}`
|
||||||
backups.
|
: null
|
||||||
|
}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent
|
||||||
|
align="start"
|
||||||
|
alignItemWithTrigger={false}
|
||||||
|
className="p-1"
|
||||||
|
>
|
||||||
|
{[3, 5, 7, 14, 30, 0].map((n) => (
|
||||||
|
<SelectItem key={n} value={String(n)}>
|
||||||
|
{n === 0 ? t`unlimited` : t`last ${n}`}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>{" "}
|
||||||
|
backups.
|
||||||
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
"Automatically back up your database on a schedule"
|
<Trans>
|
||||||
|
Automatically back up your database on a schedule
|
||||||
|
</Trans>
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
@@ -274,7 +287,7 @@ export function BackupScheduleSection() {
|
|||||||
checked={enabled}
|
checked={enabled}
|
||||||
onCheckedChange={toggleScheduled}
|
onCheckedChange={toggleScheduled}
|
||||||
disabled={togglingSchedule}
|
disabled={togglingSchedule}
|
||||||
aria-label="Toggle scheduled backups"
|
aria-label={t`Toggle scheduled backups`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -293,7 +306,7 @@ export function BackupScheduleSection() {
|
|||||||
{/* Frequency selector */}
|
{/* Frequency selector */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<span className="inline-block font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
|
<span className="inline-block font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
|
||||||
Frequency
|
<Trans>Frequency</Trans>
|
||||||
</span>
|
</span>
|
||||||
<ButtonGroup>
|
<ButtonGroup>
|
||||||
{FREQUENCY_OPTIONS.map((opt) => (
|
{FREQUENCY_OPTIONS.map((opt) => (
|
||||||
@@ -326,7 +339,7 @@ export function BackupScheduleSection() {
|
|||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
|
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
|
||||||
Day:{" "}
|
<Trans>Day:</Trans>{" "}
|
||||||
</span>
|
</span>
|
||||||
<Select
|
<Select
|
||||||
value={String(dow)}
|
value={String(dow)}
|
||||||
@@ -371,7 +384,11 @@ export function BackupScheduleSection() {
|
|||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
|
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
|
||||||
{frequency === "12h" ? "Starting at" : "Time:"}{" "}
|
{frequency === "12h" ? (
|
||||||
|
<Trans>Starting at</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Time:</Trans>
|
||||||
|
)}{" "}
|
||||||
</span>
|
</span>
|
||||||
<Select
|
<Select
|
||||||
value={time}
|
value={time}
|
||||||
@@ -382,7 +399,7 @@ export function BackupScheduleSection() {
|
|||||||
<SelectValue>
|
<SelectValue>
|
||||||
{(value: string | null) =>
|
{(value: string | null) =>
|
||||||
value
|
value
|
||||||
? format(
|
? formatDate(
|
||||||
new Date(
|
new Date(
|
||||||
2000,
|
2000,
|
||||||
0,
|
0,
|
||||||
@@ -390,7 +407,13 @@ export function BackupScheduleSection() {
|
|||||||
Number(value.split(":")[0]),
|
Number(value.split(":")[0]),
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
"h:mm a",
|
{
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
year: undefined,
|
||||||
|
month: undefined,
|
||||||
|
day: undefined,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
@@ -405,7 +428,13 @@ export function BackupScheduleSection() {
|
|||||||
const val = `${String(h).padStart(2, "0")}:00`;
|
const val = `${String(h).padStart(2, "0")}:00`;
|
||||||
return (
|
return (
|
||||||
<SelectItem key={h} value={val}>
|
<SelectItem key={h} value={val}>
|
||||||
{format(new Date(2000, 0, 1, h, 0), "h:mm a")}
|
{formatDate(new Date(2000, 0, 1, h, 0), {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
year: undefined,
|
||||||
|
month: undefined,
|
||||||
|
day: undefined,
|
||||||
|
})}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
|
import { plural } from "@lingui/core/macro";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { BackupInfo } from "@sofa/api/schemas";
|
import type { BackupInfo } from "@sofa/api/schemas";
|
||||||
|
import {
|
||||||
|
formatBytes as formatBytesI18n,
|
||||||
|
formatDate,
|
||||||
|
formatRelativeTime,
|
||||||
|
} from "@sofa/i18n/format";
|
||||||
import {
|
import {
|
||||||
IconClock,
|
IconClock,
|
||||||
IconCloudDownload,
|
IconCloudDownload,
|
||||||
@@ -9,7 +16,6 @@ import {
|
|||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { format, formatDistanceToNow } from "date-fns";
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -34,17 +40,17 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { orpc } from "@/lib/orpc/client";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBackupDate(dateStr: string): string {
|
function formatBackupDate(dateStr: string): string {
|
||||||
return format(new Date(dateStr), "MMM d, h:mm a");
|
return formatDate(dateStr, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BackupSection() {
|
export function BackupSection() {
|
||||||
|
const { t } = useLingui();
|
||||||
const { data } = useQuery(orpc.admin.backups.list.queryOptions());
|
const { data } = useQuery(orpc.admin.backups.list.queryOptions());
|
||||||
const [backups, setBackups] = useState<BackupInfo[] | null>(null);
|
const [backups, setBackups] = useState<BackupInfo[] | null>(null);
|
||||||
|
|
||||||
@@ -60,9 +66,9 @@ export function BackupSection() {
|
|||||||
(b: BackupInfo) => b.filename !== backup.filename,
|
(b: BackupInfo) => b.filename !== backup.filename,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
toast.success("Backup created", {
|
toast.success(t`Backup created`, {
|
||||||
action: {
|
action: {
|
||||||
label: "Download",
|
label: t`Download`,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = `/api/backup/${backup.filename}`;
|
a.href = `/api/backup/${backup.filename}`;
|
||||||
@@ -72,7 +78,7 @@ export function BackupSection() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Failed to create backup"),
|
onError: () => toast.error(t`Failed to create backup`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -85,10 +91,10 @@ export function BackupSection() {
|
|||||||
);
|
);
|
||||||
return { previous };
|
return { previous };
|
||||||
},
|
},
|
||||||
onSuccess: () => toast.success("Backup deleted"),
|
onSuccess: () => toast.success(t`Backup deleted`),
|
||||||
onError: (_, __, ctx) => {
|
onError: (_, __, ctx) => {
|
||||||
if (ctx?.previous) setBackups(ctx.previous);
|
if (ctx?.previous) setBackups(ctx.previous);
|
||||||
toast.error("Failed to delete backup");
|
toast.error(t`Failed to delete backup`);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -98,6 +104,11 @@ export function BackupSection() {
|
|||||||
? (deleteMutation.variables?.filename ?? null)
|
? (deleteMutation.variables?.filename ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const backupCountLabel =
|
||||||
|
displayBackups.length > 0
|
||||||
|
? t`${displayBackups.length} ${plural(displayBackups.length, { one: "backup", other: "backups" })} stored`
|
||||||
|
: t`No backups yet`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -111,12 +122,10 @@ export function BackupSection() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Database backups</CardTitle>
|
<CardTitle>
|
||||||
<CardDescription>
|
<Trans>Database backups</Trans>
|
||||||
{displayBackups.length > 0
|
</CardTitle>
|
||||||
? `${displayBackups.length} backup${displayBackups.length !== 1 ? "s" : ""} stored`
|
<CardDescription>{backupCountLabel}</CardDescription>
|
||||||
: "No backups yet"}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => createMutation.mutate()} disabled={creating}>
|
<Button onClick={() => createMutation.mutate()} disabled={creating}>
|
||||||
@@ -125,7 +134,7 @@ export function BackupSection() {
|
|||||||
) : (
|
) : (
|
||||||
<IconPlus aria-hidden={true} />
|
<IconPlus aria-hidden={true} />
|
||||||
)}
|
)}
|
||||||
{creating ? "Creating…" : "New backup"}
|
{creating ? <Trans>Creating…</Trans> : <Trans>New backup</Trans>}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -167,10 +176,10 @@ export function BackupSection() {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{backup.source === "scheduled"
|
{backup.source === "scheduled"
|
||||||
? "Scheduled backup"
|
? t`Scheduled backup`
|
||||||
: backup.source === "pre-restore"
|
: backup.source === "pre-restore"
|
||||||
? "Pre-restore backup"
|
? t`Pre-restore backup`
|
||||||
: "Manual backup"}
|
: t`Manual backup`}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -179,15 +188,13 @@ export function BackupSection() {
|
|||||||
{formatBackupDate(backup.createdAt)}
|
{formatBackupDate(backup.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<span className="text-[11px] text-muted-foreground">
|
||||||
{formatBytes(backup.sizeBytes)}
|
{formatBytesI18n(backup.sizeBytes)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="text-[11px] text-muted-foreground/50"
|
className="text-[11px] text-muted-foreground/50"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
{formatDistanceToNow(new Date(backup.createdAt), {
|
{formatRelativeTime(backup.createdAt)}
|
||||||
addSuffix: true,
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,7 +211,7 @@ export function BackupSection() {
|
|||||||
<a
|
<a
|
||||||
href={`/api/backup/${backup.filename}`}
|
href={`/api/backup/${backup.filename}`}
|
||||||
download
|
download
|
||||||
aria-label="Download backup"
|
aria-label={t`Download backup`}
|
||||||
>
|
>
|
||||||
<IconCloudDownload />
|
<IconCloudDownload />
|
||||||
</a>
|
</a>
|
||||||
@@ -212,7 +219,9 @@ export function BackupSection() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TooltipContent>Download</TooltipContent>
|
<TooltipContent>
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -223,7 +232,7 @@ export function BackupSection() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
aria-label="Delete backup"
|
aria-label={t`Delete backup`}
|
||||||
className="text-muted-foreground hover:text-destructive"
|
className="text-muted-foreground hover:text-destructive"
|
||||||
disabled={deleting === backup.filename}
|
disabled={deleting === backup.filename}
|
||||||
/>
|
/>
|
||||||
@@ -237,21 +246,29 @@ export function BackupSection() {
|
|||||||
<IconTrash />
|
<IconTrash />
|
||||||
)}
|
)}
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<TooltipContent>Delete</TooltipContent>
|
<TooltipContent>
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete backup?</AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
|
<Trans>Delete backup?</Trans>
|
||||||
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will permanently delete the backup from{" "}
|
<Trans>
|
||||||
<strong>
|
This will permanently delete the backup from{" "}
|
||||||
{formatBackupDate(backup.createdAt)}
|
<strong>
|
||||||
</strong>
|
{formatBackupDate(backup.createdAt)}
|
||||||
. This cannot be undone.
|
</strong>
|
||||||
|
. This cannot be undone.
|
||||||
|
</Trans>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -260,7 +277,7 @@ export function BackupSection() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Delete
|
<Trans>Delete</Trans>
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { plural } from "@lingui/core/macro";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { IconDatabase, IconPhoto, IconTrash } from "@tabler/icons-react";
|
import { IconDatabase, IconPhoto, IconTrash } from "@tabler/icons-react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -26,6 +28,7 @@ function formatBytes(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CacheSection() {
|
export function CacheSection() {
|
||||||
|
const { t } = useLingui();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const invalidateHealth = () =>
|
const invalidateHealth = () =>
|
||||||
@@ -35,23 +38,24 @@ export function CacheSection() {
|
|||||||
orpc.admin.purgeMetadataCache.mutationOptions({
|
orpc.admin.purgeMetadataCache.mutationOptions({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast.success(
|
toast.success(
|
||||||
`Purged ${data.deletedTitles} stale title${data.deletedTitles !== 1 ? "s" : ""} and ${data.deletedPersons} orphaned person${data.deletedPersons !== 1 ? "s" : ""}`,
|
t`Purged ${plural(data.deletedTitles, { one: "# stale title", other: "# stale titles" })} and ${plural(data.deletedPersons, { one: "# orphaned person", other: "# orphaned persons" })}`,
|
||||||
);
|
);
|
||||||
invalidateHealth();
|
invalidateHealth();
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Failed to purge metadata cache"),
|
onError: () => toast.error(t`Failed to purge metadata cache`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const purgeImages = useMutation(
|
const purgeImages = useMutation(
|
||||||
orpc.admin.purgeImageCache.mutationOptions({
|
orpc.admin.purgeImageCache.mutationOptions({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
|
const freed = formatBytes(data.freedBytes);
|
||||||
toast.success(
|
toast.success(
|
||||||
`Deleted ${data.deletedFiles.toLocaleString()} file${data.deletedFiles !== 1 ? "s" : ""}, freed ${formatBytes(data.freedBytes)}`,
|
t`Deleted ${plural(data.deletedFiles, { one: "# file", other: "# files" })}, freed ${freed}`,
|
||||||
);
|
);
|
||||||
invalidateHealth();
|
invalidateHealth();
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Failed to purge image cache"),
|
onError: () => toast.error(t`Failed to purge image cache`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -62,12 +66,13 @@ export function CacheSection() {
|
|||||||
client.admin.purgeImageCache(),
|
client.admin.purgeImageCache(),
|
||||||
]),
|
]),
|
||||||
onSuccess: ([metaResult, imageResult]) => {
|
onSuccess: ([metaResult, imageResult]) => {
|
||||||
|
const freed = formatBytes(imageResult.freedBytes);
|
||||||
toast.success(
|
toast.success(
|
||||||
`Purged ${metaResult.deletedTitles} title${metaResult.deletedTitles !== 1 ? "s" : ""}, ${metaResult.deletedPersons} person${metaResult.deletedPersons !== 1 ? "s" : ""}, ${imageResult.deletedFiles.toLocaleString()} file${imageResult.deletedFiles !== 1 ? "s" : ""} (${formatBytes(imageResult.freedBytes)} freed)`,
|
t`Purged ${plural(metaResult.deletedTitles, { one: "# title", other: "# titles" })}, ${plural(metaResult.deletedPersons, { one: "# person", other: "# persons" })}, ${plural(imageResult.deletedFiles, { one: "# file", other: "# files" })} (${freed} freed)`,
|
||||||
);
|
);
|
||||||
invalidateHealth();
|
invalidateHealth();
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Failed to purge caches"),
|
onError: () => toast.error(t`Failed to purge caches`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const disabled =
|
const disabled =
|
||||||
@@ -80,42 +85,54 @@ export function CacheSection() {
|
|||||||
<IconTrash aria-hidden={true} className="size-4 text-primary" />
|
<IconTrash aria-hidden={true} className="size-4 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<CardTitle>Cache management</CardTitle>
|
<CardTitle>
|
||||||
|
<Trans>Cache management</Trans>
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Free up disk space by clearing cached metadata and images
|
<Trans>
|
||||||
|
Free up disk space by clearing cached metadata and images
|
||||||
|
</Trans>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
{/* Purge metadata */}
|
{/* Purge metadata */}
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger
|
<AlertDialogTrigger
|
||||||
render={
|
render={<Button variant="outline" disabled={disabled} />}
|
||||||
<Button variant="outline" size="sm" disabled={disabled} />
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{purgeMetadata.isPending ? (
|
{purgeMetadata.isPending ? (
|
||||||
<Spinner className="size-3" />
|
<Spinner className="size-3" />
|
||||||
) : (
|
) : (
|
||||||
<IconDatabase aria-hidden={true} />
|
<IconDatabase aria-hidden={true} />
|
||||||
)}
|
)}
|
||||||
{purgeMetadata.isPending ? "Purging…" : "Purge metadata"}
|
{purgeMetadata.isPending ? (
|
||||||
|
<Trans>Purging...</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Purge metadata</Trans>
|
||||||
|
)}
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Purge metadata cache?</AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
|
<Trans>Purge metadata cache?</Trans>
|
||||||
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will delete un-enriched stub titles that aren't in any
|
<Trans>
|
||||||
user's library and clean up orphaned person records. Deleted
|
This will delete un-enriched stub titles that aren't in
|
||||||
titles will be re-imported if accessed again.
|
any user's library and clean up orphaned person records.
|
||||||
|
Deleted titles will be re-imported if accessed again.
|
||||||
|
</Trans>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => purgeMetadata.mutate()}
|
onClick={() => purgeMetadata.mutate()}
|
||||||
>
|
>
|
||||||
Purge metadata
|
<Trans>Purge metadata</Trans>
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
@@ -124,32 +141,40 @@ export function CacheSection() {
|
|||||||
{/* Purge images */}
|
{/* Purge images */}
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger
|
<AlertDialogTrigger
|
||||||
render={
|
render={<Button variant="outline" disabled={disabled} />}
|
||||||
<Button variant="outline" size="sm" disabled={disabled} />
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{purgeImages.isPending ? (
|
{purgeImages.isPending ? (
|
||||||
<Spinner className="size-3" />
|
<Spinner className="size-3" />
|
||||||
) : (
|
) : (
|
||||||
<IconPhoto aria-hidden={true} />
|
<IconPhoto aria-hidden={true} />
|
||||||
)}
|
)}
|
||||||
{purgeImages.isPending ? "Purging…" : "Purge images"}
|
{purgeImages.isPending ? (
|
||||||
|
<Trans>Purging...</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Purge images</Trans>
|
||||||
|
)}
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Purge image cache?</AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
|
<Trans>Purge image cache?</Trans>
|
||||||
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will delete all cached TMDB images from disk. Images
|
<Trans>
|
||||||
will be re-downloaded automatically as needed.
|
This will delete all cached TMDB images from disk. Images
|
||||||
|
will be re-downloaded automatically as needed.
|
||||||
|
</Trans>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => purgeImages.mutate()}
|
onClick={() => purgeImages.mutate()}
|
||||||
>
|
>
|
||||||
Purge images
|
<Trans>Purge images</Trans>
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
@@ -158,33 +183,41 @@ export function CacheSection() {
|
|||||||
{/* Purge all */}
|
{/* Purge all */}
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger
|
<AlertDialogTrigger
|
||||||
render={
|
render={<Button variant="destructive" disabled={disabled} />}
|
||||||
<Button variant="destructive" size="sm" disabled={disabled} />
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{purgeAll.isPending ? (
|
{purgeAll.isPending ? (
|
||||||
<Spinner className="size-3" />
|
<Spinner className="size-3" />
|
||||||
) : (
|
) : (
|
||||||
<IconTrash aria-hidden={true} />
|
<IconTrash aria-hidden={true} />
|
||||||
)}
|
)}
|
||||||
{purgeAll.isPending ? "Purging…" : "Purge all"}
|
{purgeAll.isPending ? (
|
||||||
|
<Trans>Purging...</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Purge all</Trans>
|
||||||
|
)}
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Purge all caches?</AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
|
<Trans>Purge all caches?</Trans>
|
||||||
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will delete all un-enriched stub titles and all cached
|
<Trans>
|
||||||
images from disk. Everything will be re-imported and
|
This will delete all un-enriched stub titles and all
|
||||||
re-downloaded as needed.
|
cached images from disk. Everything will be re-imported
|
||||||
|
and re-downloaded as needed.
|
||||||
|
</Trans>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => purgeAll.mutate()}
|
onClick={() => purgeAll.mutate()}
|
||||||
>
|
>
|
||||||
Purge all
|
<Trans>Purge all</Trans>
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { NormalizedImport } from "@sofa/api/schemas";
|
import type { NormalizedImport } from "@sofa/api/schemas";
|
||||||
import { IconCloudUpload, IconFileImport, IconLink } from "@tabler/icons-react";
|
import { IconCloudUpload, IconFileImport, IconLink } from "@tabler/icons-react";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { getErrorMessage } from "@/lib/error-messages";
|
||||||
import { client, orpc } from "@/lib/orpc/client";
|
import { client, orpc } from "@/lib/orpc/client";
|
||||||
|
|
||||||
// ─── Source Configs ──────────────────────────────────────────
|
// ─── Source Configs ──────────────────────────────────────────
|
||||||
@@ -174,7 +176,7 @@ export function ImportsSection() {
|
|||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<IconFileImport aria-hidden className="size-4 text-muted-foreground" />
|
<IconFileImport aria-hidden className="size-4 text-muted-foreground" />
|
||||||
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
|
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
|
||||||
Import
|
<Trans>Import</Trans>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
@@ -189,6 +191,7 @@ export function ImportsSection() {
|
|||||||
// ─── Source Card ─────────────────────────────────────────────
|
// ─── Source Card ─────────────────────────────────────────────
|
||||||
|
|
||||||
function ImportSourceCard({ config }: { config: SourceConfig }) {
|
function ImportSourceCard({ config }: { config: SourceConfig }) {
|
||||||
|
const { t } = useLingui();
|
||||||
const { data: systemStatus } = useQuery(orpc.system.status.queryOptions());
|
const { data: systemStatus } = useQuery(orpc.system.status.queryOptions());
|
||||||
const publicApiUrl =
|
const publicApiUrl =
|
||||||
systemStatus?.publicApiUrl ?? "https://public-api.sofa.watch";
|
systemStatus?.publicApiUrl ?? "https://public-api.sofa.watch";
|
||||||
@@ -219,9 +222,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
|
|||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast.error(
|
toast.error(getErrorMessage(err, t, t`Failed to parse file`));
|
||||||
err instanceof Error ? err.message : "Failed to parse file",
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
@@ -236,9 +237,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
|
|||||||
setStep("preview");
|
setStep("preview");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast.error(
|
toast.error(getErrorMessage(err, t, t`Failed to parse import data`));
|
||||||
err instanceof Error ? err.message : "Failed to parse import data",
|
|
||||||
);
|
|
||||||
setStep("choose");
|
setStep("choose");
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -290,13 +289,13 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
|
|||||||
setStep("done");
|
setStep("done");
|
||||||
if (event.job.importedCount > 0) {
|
if (event.job.importedCount > 0) {
|
||||||
toast.success(
|
toast.success(
|
||||||
`Imported ${event.job.importedCount} items from ${config.label}`,
|
t`Imported ${event.job.importedCount} items from ${config.label}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (event.type === "timeout") {
|
} else if (event.type === "timeout") {
|
||||||
receivedComplete = true;
|
receivedComplete = true;
|
||||||
toast.info(
|
toast.info(
|
||||||
"Import is still running in the background. Check back later.",
|
t`Import is still running in the background. Check back later.`,
|
||||||
);
|
);
|
||||||
setStep("preview");
|
setStep("preview");
|
||||||
} else {
|
} else {
|
||||||
@@ -327,18 +326,18 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
|
|||||||
setStep("done");
|
setStep("done");
|
||||||
} else {
|
} else {
|
||||||
toast.info(
|
toast.info(
|
||||||
"Import is still running in the background. Check back later.",
|
t`Import is still running in the background. Check back later.`,
|
||||||
);
|
);
|
||||||
setStep("preview");
|
setStep("preview");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Lost connection to import. Check status in settings.");
|
toast.error(t`Lost connection to import. Check status in settings.`);
|
||||||
setStep("preview");
|
setStep("preview");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (abort.signal.aborted) return;
|
if (abort.signal.aborted) return;
|
||||||
toast.error(err instanceof Error ? err.message : "Import failed");
|
toast.error(getErrorMessage(err, t, t`Import failed`));
|
||||||
setStep("preview");
|
setStep("preview");
|
||||||
} finally {
|
} finally {
|
||||||
importAbortRef.current = null;
|
importAbortRef.current = null;
|
||||||
@@ -386,7 +385,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
|
|||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw new Error(
|
throw new Error(
|
||||||
(err as { error?: string }).error ??
|
(err as { error?: string }).error ??
|
||||||
`Failed to start ${config.label} connection`,
|
t`Failed to start ${config.label} connection`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const data = (await res.json()) as DeviceCodeInfo;
|
const data = (await res.json()) as DeviceCodeInfo;
|
||||||
@@ -396,7 +395,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
|
|||||||
// Start polling
|
// Start polling
|
||||||
startPolling(data);
|
startPolling(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setOauthError(e instanceof Error ? e.message : "Failed to connect");
|
setOauthError(e instanceof Error ? e.message : t`Failed to connect`);
|
||||||
setStep("choose");
|
setStep("choose");
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}
|
}
|
||||||
@@ -410,7 +409,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
|
|||||||
pollTimerRef.current = setInterval(async () => {
|
pollTimerRef.current = setInterval(async () => {
|
||||||
if (Date.now() > expiresAt) {
|
if (Date.now() > expiresAt) {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
setOauthError("Device code expired. Please try again.");
|
setOauthError(t`Device code expired. Please try again.`);
|
||||||
setStep("choose");
|
setStep("choose");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -438,17 +437,17 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
|
|||||||
parsePayloadMutation.mutate({ data: data.data as NormalizedImport });
|
parsePayloadMutation.mutate({ data: data.data as NormalizedImport });
|
||||||
} else if (data.status === "denied") {
|
} else if (data.status === "denied") {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
setOauthError("Authorization was denied. Please try again.");
|
setOauthError(t`Authorization was denied. Please try again.`);
|
||||||
setStep("choose");
|
setStep("choose");
|
||||||
} else if (data.status === "expired") {
|
} else if (data.status === "expired") {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
setOauthError("Device code expired. Please try again.");
|
setOauthError(t`Device code expired. Please try again.`);
|
||||||
setStep("choose");
|
setStep("choose");
|
||||||
} else if (data.status === "fetch_error") {
|
} else if (data.status === "fetch_error") {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
setOauthError(
|
setOauthError(
|
||||||
data.error ||
|
data.error ||
|
||||||
"Authorization succeeded but failed to fetch your library. Please try again.",
|
t`Authorization succeeded but failed to fetch your library. Please try again.`,
|
||||||
);
|
);
|
||||||
setStep("choose");
|
setStep("choose");
|
||||||
}
|
}
|
||||||
@@ -497,7 +496,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconLink aria-hidden />
|
<IconLink aria-hidden />
|
||||||
Connect
|
<Trans>Connect</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!config.supportsOAuth && (
|
{!config.supportsOAuth && (
|
||||||
@@ -507,7 +506,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
|
|||||||
disabled={isParsing}
|
disabled={isParsing}
|
||||||
>
|
>
|
||||||
{isParsing ? <Spinner /> : <IconCloudUpload aria-hidden />}
|
{isParsing ? <Spinner /> : <IconCloudUpload aria-hidden />}
|
||||||
{isParsing ? "Parsing…" : "Upload"}
|
{isParsing ? <Trans>Parsing...</Trans> : <Trans>Upload</Trans>}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -577,12 +576,15 @@ function ChooseStep({
|
|||||||
isParsing: boolean;
|
isParsing: boolean;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Import from {config.label}</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Import from {config.label}</Trans>
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Choose how to import your {config.label} data.
|
<Trans>Choose how to import your {config.label} data.</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -602,10 +604,14 @@ function ChooseStep({
|
|||||||
<IconLink aria-hidden className="size-5 text-primary" />
|
<IconLink aria-hidden className="size-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">Connect with {config.label}</p>
|
<p className="font-medium text-sm">
|
||||||
|
<Trans>Connect with {config.label}</Trans>
|
||||||
|
</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
Authorize Sofa to read your {config.label} library. No password
|
<Trans>
|
||||||
shared.
|
Authorize Sofa to read your {config.label} library. No password
|
||||||
|
shared.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -624,10 +630,11 @@ function ChooseStep({
|
|||||||
<IconCloudUpload aria-hidden className="size-5 text-primary" />
|
<IconCloudUpload aria-hidden className="size-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">Upload export file</p>
|
<p className="font-medium text-sm">
|
||||||
|
<Trans>Upload export file</Trans>
|
||||||
|
</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
Upload a {config.accept} export from your {config.label} account
|
{t`Upload a ${config.accept} export from your ${config.label} account settings.`}
|
||||||
settings.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -635,7 +642,7 @@ function ChooseStep({
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose render={<Button variant="outline" />} onClick={onCancel}>
|
<DialogClose render={<Button variant="outline" />} onClick={onCancel}>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
@@ -654,16 +661,22 @@ function DeviceCodeStep({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Connect to {source}</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Connect to {source}</Trans>
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Enter the code below on {source}'s website to authorize Sofa.
|
<Trans>
|
||||||
|
Enter the code below on {source}'s website to authorize Sofa.
|
||||||
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{deviceCode ? (
|
{deviceCode ? (
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<p className="text-muted-foreground text-sm">Your code:</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
|
<Trans>Your code:</Trans>
|
||||||
|
</p>
|
||||||
<p className="rounded-lg bg-muted px-6 py-3 font-bold font-mono text-2xl tracking-widest">
|
<p className="rounded-lg bg-muted px-6 py-3 font-bold font-mono text-2xl tracking-widest">
|
||||||
{deviceCode.user_code}
|
{deviceCode.user_code}
|
||||||
</p>
|
</p>
|
||||||
@@ -677,13 +690,13 @@ function DeviceCodeStep({
|
|||||||
className="inline-flex items-center gap-1.5 font-medium text-primary text-sm underline-offset-4 hover:underline"
|
className="inline-flex items-center gap-1.5 font-medium text-primary text-sm underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
<IconLink aria-hidden className="size-4" />
|
<IconLink aria-hidden className="size-4" />
|
||||||
Open {source} to enter code
|
<Trans>Open {source} to enter code</Trans>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-2 text-muted-foreground text-xs">
|
<div className="flex items-center justify-center gap-2 text-muted-foreground text-xs">
|
||||||
<Spinner className="size-3" />
|
<Spinner className="size-3" />
|
||||||
Waiting for authorization...
|
<Trans>Waiting for authorization...</Trans>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -694,7 +707,7 @@ function DeviceCodeStep({
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onCancel}>
|
<Button variant="outline" onClick={onCancel}>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
@@ -705,15 +718,19 @@ function FetchingStep({ source }: { source: string }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Connected to {source}</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Connected to {source}</Trans>
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Fetching your library data from {source}...
|
<Trans>Fetching your library data from {source}...</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col items-center gap-3 py-8">
|
<div className="flex flex-col items-center gap-3 py-8">
|
||||||
<Spinner className="size-8" />
|
<Spinner className="size-8" />
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Retrieving your watch history, watchlist, and ratings...
|
<Trans>
|
||||||
|
Retrieving your watch history, watchlist, and ratings...
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -739,6 +756,7 @@ function PreviewStep({
|
|||||||
onImport: () => void;
|
onImport: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
const { stats, warnings } = preview;
|
const { stats, warnings } = preview;
|
||||||
const totalItems =
|
const totalItems =
|
||||||
(options.importWatches ? stats.movies + stats.episodes : 0) +
|
(options.importWatches ? stats.movies + stats.episodes : 0) +
|
||||||
@@ -748,46 +766,52 @@ function PreviewStep({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Import from {source}</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Import from {source}</Trans>
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Review what was found and choose what to import.
|
<Trans>Review what was found and choose what to import.</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<StatBadge label="Movies" count={stats.movies} />
|
<StatBadge label={t`Movies`} count={stats.movies} />
|
||||||
<StatBadge label="Episodes" count={stats.episodes} />
|
<StatBadge label={t`Episodes`} count={stats.episodes} />
|
||||||
<StatBadge label="Watchlist" count={stats.watchlist} />
|
<StatBadge label={t`Watchlist`} count={stats.watchlist} />
|
||||||
<StatBadge label="Ratings" count={stats.ratings} />
|
<StatBadge label={t`Ratings`} count={stats.ratings} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{preview.diagnostics && preview.diagnostics.unresolved > 0 && (
|
{preview.diagnostics && preview.diagnostics.unresolved > 0 && (
|
||||||
<div className="rounded-lg bg-muted/50 p-3">
|
<div className="rounded-lg bg-muted/50 p-3">
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{preview.diagnostics.unresolved} items have no external IDs and
|
<Trans>
|
||||||
will be resolved by title search, which may be less accurate.
|
{preview.diagnostics.unresolved} items have no external IDs and
|
||||||
|
will be resolved by title search, which may be less accurate.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="font-medium text-sm">Import options</p>
|
<p className="font-medium text-sm">
|
||||||
|
<Trans>Import options</Trans>
|
||||||
|
</p>
|
||||||
<OptionCheckbox
|
<OptionCheckbox
|
||||||
label="Watch history"
|
label={t`Watch history`}
|
||||||
description={`${stats.movies} movies, ${stats.episodes} episodes`}
|
description={t`${stats.movies} movies, ${stats.episodes} episodes`}
|
||||||
checked={options.importWatches}
|
checked={options.importWatches}
|
||||||
onChange={(v) => setOptions({ ...options, importWatches: v })}
|
onChange={(v) => setOptions({ ...options, importWatches: v })}
|
||||||
/>
|
/>
|
||||||
<OptionCheckbox
|
<OptionCheckbox
|
||||||
label="Watchlist"
|
label={t`Watchlist`}
|
||||||
description={`${stats.watchlist} items`}
|
description={t`${stats.watchlist} items`}
|
||||||
checked={options.importWatchlist}
|
checked={options.importWatchlist}
|
||||||
onChange={(v) => setOptions({ ...options, importWatchlist: v })}
|
onChange={(v) => setOptions({ ...options, importWatchlist: v })}
|
||||||
/>
|
/>
|
||||||
<OptionCheckbox
|
<OptionCheckbox
|
||||||
label="Ratings"
|
label={t`Ratings`}
|
||||||
description={`${stats.ratings} ratings`}
|
description={t`${stats.ratings} ratings`}
|
||||||
checked={options.importRatings}
|
checked={options.importRatings}
|
||||||
onChange={(v) => setOptions({ ...options, importRatings: v })}
|
onChange={(v) => setOptions({ ...options, importRatings: v })}
|
||||||
/>
|
/>
|
||||||
@@ -795,7 +819,9 @@ function PreviewStep({
|
|||||||
|
|
||||||
{warnings.length > 0 && (
|
{warnings.length > 0 && (
|
||||||
<div className="rounded-lg bg-yellow-500/10 p-3">
|
<div className="rounded-lg bg-yellow-500/10 p-3">
|
||||||
<p className="mb-1 font-medium text-xs text-yellow-600">Warnings</p>
|
<p className="mb-1 font-medium text-xs text-yellow-600">
|
||||||
|
<Trans>Warnings</Trans>
|
||||||
|
</p>
|
||||||
<ul className="space-y-0.5 text-xs text-yellow-600/80">
|
<ul className="space-y-0.5 text-xs text-yellow-600/80">
|
||||||
{warnings.map((w, i) => (
|
{warnings.map((w, i) => (
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: static display list
|
// biome-ignore lint/suspicious/noArrayIndexKey: static display list
|
||||||
@@ -808,10 +834,10 @@ function PreviewStep({
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose render={<Button variant="outline" />} onClick={onCancel}>
|
<DialogClose render={<Button variant="outline" />} onClick={onCancel}>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<Button onClick={onImport} disabled={totalItems === 0}>
|
<Button onClick={onImport} disabled={totalItems === 0}>
|
||||||
Import {totalItems} items
|
<Trans>Import {totalItems} items</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
@@ -833,10 +859,14 @@ function ImportingStep({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Importing from {source}</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Importing from {source}</Trans>
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This may take a few minutes for large libraries. Please don't close
|
<Trans>
|
||||||
this tab.
|
This may take a few minutes for large libraries. Please don't close
|
||||||
|
this tab.
|
||||||
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col items-center gap-4 py-8">
|
<div className="flex flex-col items-center gap-4 py-8">
|
||||||
@@ -855,7 +885,7 @@ function ImportingStep({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Spinner className="size-3" />
|
<Spinner className="size-3" />
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Starting import...
|
<Trans>Starting import...</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -874,24 +904,29 @@ function DoneStep({
|
|||||||
result: ImportResult;
|
result: ImportResult;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Import complete</DialogTitle>
|
<DialogTitle>
|
||||||
<DialogDescription>Finished importing from {source}.</DialogDescription>
|
<Trans>Import complete</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Finished importing from {source}.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<StatBadge label="Imported" count={result.imported} />
|
<StatBadge label={t`Imported`} count={result.imported} />
|
||||||
<StatBadge label="Skipped" count={result.skipped} />
|
<StatBadge label={t`Skipped`} count={result.skipped} />
|
||||||
<StatBadge label="Failed" count={result.failed} />
|
<StatBadge label={t`Failed`} count={result.failed} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{result.errors.length > 0 && (
|
{result.errors.length > 0 && (
|
||||||
<div className="max-h-40 overflow-y-auto rounded-lg bg-destructive/10 p-3">
|
<div className="max-h-40 overflow-y-auto rounded-lg bg-destructive/10 p-3">
|
||||||
<p className="mb-1 font-medium text-destructive text-xs">
|
<p className="mb-1 font-medium text-destructive text-xs">
|
||||||
Errors ({result.errors.length})
|
<Trans>Errors ({result.errors.length})</Trans>
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-0.5 text-destructive/80 text-xs">
|
<ul className="space-y-0.5 text-destructive/80 text-xs">
|
||||||
{result.errors.slice(0, 50).map((e, i) => (
|
{result.errors.slice(0, 50).map((e, i) => (
|
||||||
@@ -899,7 +934,9 @@ function DoneStep({
|
|||||||
<li key={i}>{e}</li>
|
<li key={i}>{e}</li>
|
||||||
))}
|
))}
|
||||||
{result.errors.length > 50 && (
|
{result.errors.length > 50 && (
|
||||||
<li>...and {result.errors.length - 50} more</li>
|
<li>
|
||||||
|
<Trans>...and {result.errors.length - 50} more</Trans>
|
||||||
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -908,7 +945,7 @@ function DoneStep({
|
|||||||
{result.warnings.length > 0 && (
|
{result.warnings.length > 0 && (
|
||||||
<div className="max-h-32 overflow-y-auto rounded-lg bg-yellow-500/10 p-3">
|
<div className="max-h-32 overflow-y-auto rounded-lg bg-yellow-500/10 p-3">
|
||||||
<p className="mb-1 font-medium text-xs text-yellow-600">
|
<p className="mb-1 font-medium text-xs text-yellow-600">
|
||||||
Warnings ({result.warnings.length})
|
<Trans>Warnings ({result.warnings.length})</Trans>
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-0.5 text-xs text-yellow-600/80">
|
<ul className="space-y-0.5 text-xs text-yellow-600/80">
|
||||||
{result.warnings.slice(0, 20).map((w, i) => (
|
{result.warnings.slice(0, 20).map((w, i) => (
|
||||||
@@ -921,7 +958,9 @@ function DoneStep({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button onClick={onClose}>Done</Button>
|
<Button onClick={onClose}>
|
||||||
|
<Trans>Done</Trans>
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import { i18n } from "@sofa/i18n";
|
||||||
|
import { formatRelativeTime } from "@sofa/i18n/format";
|
||||||
import {
|
import {
|
||||||
IconBook2,
|
IconBook2,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
@@ -8,7 +12,6 @@ import {
|
|||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import type { ComponentType, ReactNode } from "react";
|
import type { ComponentType, ReactNode } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -87,6 +90,7 @@ export function IntegrationCard({
|
|||||||
connection: IntegrationConnection | null;
|
connection: IntegrationConnection | null;
|
||||||
setConnections: React.Dispatch<React.SetStateAction<IntegrationConnection[]>>;
|
setConnections: React.Dispatch<React.SetStateAction<IntegrationConnection[]>>;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
const { provider, label } = config;
|
const { provider, label } = config;
|
||||||
const providerInput = provider as
|
const providerInput = provider as
|
||||||
| "plex"
|
| "plex"
|
||||||
@@ -99,9 +103,9 @@ export function IntegrationCard({
|
|||||||
orpc.integrations.create.mutationOptions({
|
orpc.integrations.create.mutationOptions({
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setConnections((prev) => [...prev, { ...result, recentEvents: [] }]);
|
setConnections((prev) => [...prev, { ...result, recentEvents: [] }]);
|
||||||
toast.success(`${label} connected`);
|
toast.success(t`${label} connected`);
|
||||||
},
|
},
|
||||||
onError: () => toast.error(`Failed to connect ${label}`),
|
onError: () => toast.error(t`Failed to connect ${label}`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -115,10 +119,10 @@ export function IntegrationCard({
|
|||||||
});
|
});
|
||||||
return { previous };
|
return { previous };
|
||||||
},
|
},
|
||||||
onSuccess: () => toast.success(`${label} disconnected`),
|
onSuccess: () => toast.success(t`${label} disconnected`),
|
||||||
onError: (_, __, ctx) => {
|
onError: (_, __, ctx) => {
|
||||||
if (ctx?.previous) setConnections(ctx.previous);
|
if (ctx?.previous) setConnections(ctx.previous);
|
||||||
toast.error(`Failed to disconnect ${label}`);
|
toast.error(t`Failed to disconnect ${label}`);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -131,9 +135,9 @@ export function IntegrationCard({
|
|||||||
c.provider === provider ? { ...c, token: result.token } : c,
|
c.provider === provider ? { ...c, token: result.token } : c,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
toast.success(`${label} URL regenerated`);
|
toast.success(t`${label} URL regenerated`);
|
||||||
},
|
},
|
||||||
onError: () => toast.error(`Failed to regenerate ${label} URL`),
|
onError: () => toast.error(t`Failed to regenerate ${label} URL`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -169,7 +173,7 @@ export function IntegrationCard({
|
|||||||
<CardDescription>
|
<CardDescription>
|
||||||
{connection
|
{connection
|
||||||
? config.connectedStatus(connection.lastEventAt)
|
? config.connectedStatus(connection.lastEventAt)
|
||||||
: "Not configured"}
|
: t`Not configured`}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,7 +197,11 @@ export function IntegrationCard({
|
|||||||
size="lg"
|
size="lg"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{connecting ? "Connecting\u2026" : `Connect ${config.label}`}
|
{connecting ? (
|
||||||
|
<Trans>Connecting...</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Connect {config.label}</Trans>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -234,7 +242,9 @@ export function IntegrationCard({
|
|||||||
<IconCopy />
|
<IconCopy />
|
||||||
)}
|
)}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Copy URL</TooltipContent>
|
<TooltipContent>
|
||||||
|
<Trans>Copy URL</Trans>
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
@@ -251,7 +261,7 @@ export function IntegrationCard({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<IconRefresh />
|
<IconRefresh />
|
||||||
Regenerate URL
|
<Trans>Regenerate URL</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -261,7 +271,7 @@ export function IntegrationCard({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<IconTrash />
|
<IconTrash />
|
||||||
Disconnect
|
<Trans>Disconnect</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -275,7 +285,7 @@ export function IntegrationCard({
|
|||||||
aria-hidden={true}
|
aria-hidden={true}
|
||||||
className={`size-3 transition-transform ${setupOpen ? "rotate-0" : "-rotate-90"}`}
|
className={`size-3 transition-transform ${setupOpen ? "rotate-0" : "-rotate-90"}`}
|
||||||
/>
|
/>
|
||||||
Setup instructions
|
<Trans>Setup instructions</Trans>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
|
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
|
||||||
<div className="mt-2 rounded-lg border border-border/50 bg-muted/30 p-3 text-muted-foreground text-xs leading-relaxed">
|
<div className="mt-2 rounded-lg border border-border/50 bg-muted/30 p-3 text-muted-foreground text-xs leading-relaxed">
|
||||||
@@ -288,14 +298,14 @@ export function IntegrationCard({
|
|||||||
aria-hidden={true}
|
aria-hidden={true}
|
||||||
className="mr-1 inline-block size-3 translate-y-[-1px]"
|
className="mr-1 inline-block size-3 translate-y-[-1px]"
|
||||||
/>
|
/>
|
||||||
Need more help?{" "}
|
<Trans>Need more help?</Trans>{" "}
|
||||||
<a
|
<a
|
||||||
href={config.docsUrl}
|
href={config.docsUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
||||||
>
|
>
|
||||||
Open docs{" "}
|
<Trans>Open docs</Trans>{" "}
|
||||||
<IconExternalLink
|
<IconExternalLink
|
||||||
aria-hidden={true}
|
aria-hidden={true}
|
||||||
className="inline-block size-3 translate-y-[-1px]"
|
className="inline-block size-3 translate-y-[-1px]"
|
||||||
@@ -318,13 +328,13 @@ export function IntegrationCard({
|
|||||||
/** Status line for webhook integrations (shows last event time). */
|
/** Status line for webhook integrations (shows last event time). */
|
||||||
export function webhookStatus(lastEventAt: string | null): string {
|
export function webhookStatus(lastEventAt: string | null): string {
|
||||||
return lastEventAt
|
return lastEventAt
|
||||||
? `Last event ${formatDistanceToNow(new Date(lastEventAt), { addSuffix: true })}`
|
? i18n._(msg`Last event ${formatRelativeTime(lastEventAt)}`)
|
||||||
: "Ready \u2014 nothing received yet";
|
: i18n._(msg`Ready — nothing received yet`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Status line for list integrations (shows last event time). */
|
/** Status line for list integrations (shows last event time). */
|
||||||
export function listStatus(lastEventAt: string | null): string {
|
export function listStatus(lastEventAt: string | null): string {
|
||||||
return lastEventAt
|
return lastEventAt
|
||||||
? `Last polled ${formatDistanceToNow(new Date(lastEventAt), { addSuffix: true })}`
|
? i18n._(msg`Last polled ${formatRelativeTime(lastEventAt)}`)
|
||||||
: "Ready \u2014 not polled yet";
|
: i18n._(msg`Ready — not polled yet`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { IconExternalLink, IconInfoCircle } from "@tabler/icons-react";
|
import { IconExternalLink, IconInfoCircle } from "@tabler/icons-react";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import {
|
import {
|
||||||
@@ -37,46 +38,55 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
|
|||||||
connectedStatus: webhookStatus,
|
connectedStatus: webhookStatus,
|
||||||
alert: (
|
alert: (
|
||||||
<RequirementAlert>
|
<RequirementAlert>
|
||||||
Requires an active{" "}
|
<Trans>
|
||||||
<a
|
Requires an active{" "}
|
||||||
href="https://www.plex.tv/plex-pass/"
|
<a
|
||||||
target="_blank"
|
href="https://www.plex.tv/plex-pass/"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
rel="noopener noreferrer"
|
||||||
>
|
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
||||||
Plex Pass
|
>
|
||||||
<IconExternalLink
|
Plex Pass
|
||||||
aria-hidden={true}
|
<IconExternalLink
|
||||||
className="inline-block size-3 translate-y-[-1px]"
|
aria-hidden={true}
|
||||||
/>
|
className="inline-block size-3 translate-y-[-1px]"
|
||||||
</a>{" "}
|
/>
|
||||||
subscription.
|
</a>{" "}
|
||||||
|
subscription.
|
||||||
|
</Trans>
|
||||||
</RequirementAlert>
|
</RequirementAlert>
|
||||||
),
|
),
|
||||||
setupSteps: (
|
setupSteps: (
|
||||||
<>
|
<>
|
||||||
<li>
|
<li>
|
||||||
Open Plex, go to{" "}
|
<Trans>
|
||||||
<a
|
Open Plex, go to{" "}
|
||||||
href="https://app.plex.tv/desktop/#!/settings/webhooks"
|
<a
|
||||||
target="_blank"
|
href="https://app.plex.tv/desktop/#!/settings/webhooks"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
rel="noopener noreferrer"
|
||||||
>
|
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
||||||
Settings > Webhooks
|
>
|
||||||
<IconExternalLink
|
Settings > Webhooks
|
||||||
aria-hidden={true}
|
<IconExternalLink
|
||||||
className="inline-block size-3 translate-y-[-1px]"
|
aria-hidden={true}
|
||||||
/>
|
className="inline-block size-3 translate-y-[-1px]"
|
||||||
</a>
|
/>
|
||||||
|
</a>
|
||||||
|
</Trans>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Click <span className="font-medium text-foreground">Add Webhook</span>{" "}
|
<Trans>
|
||||||
and paste the URL above
|
Click{" "}
|
||||||
|
<span className="font-medium text-foreground">Add Webhook</span> and
|
||||||
|
paste the URL above
|
||||||
|
</Trans>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Sofa will automatically log movies and episodes when you finish
|
<Trans>
|
||||||
watching them
|
Sofa will automatically log movies and episodes when you finish
|
||||||
|
watching them
|
||||||
|
</Trans>
|
||||||
</li>
|
</li>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -92,42 +102,52 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
|
|||||||
setupSteps: (
|
setupSteps: (
|
||||||
<>
|
<>
|
||||||
<li>
|
<li>
|
||||||
Install the{" "}
|
<Trans>
|
||||||
<a
|
Install the{" "}
|
||||||
href="https://github.com/jellyfin/jellyfin-plugin-webhook/tree/master"
|
<a
|
||||||
target="_blank"
|
href="https://github.com/jellyfin/jellyfin-plugin-webhook/tree/master"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
rel="noopener noreferrer"
|
||||||
>
|
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
||||||
Webhook plugin
|
>
|
||||||
<IconExternalLink
|
Webhook plugin
|
||||||
aria-hidden={true}
|
<IconExternalLink
|
||||||
className="inline-block size-3 translate-y-[-1px]"
|
aria-hidden={true}
|
||||||
/>
|
className="inline-block size-3 translate-y-[-1px]"
|
||||||
</a>{" "}
|
/>
|
||||||
from Jellyfin's plugin catalog
|
</a>{" "}
|
||||||
|
from Jellyfin's plugin catalog
|
||||||
|
</Trans>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Go to{" "}
|
<Trans>
|
||||||
<span className="font-medium text-foreground">
|
Go to{" "}
|
||||||
Dashboard > Plugins > Webhook
|
<span className="font-medium text-foreground">
|
||||||
</span>
|
Dashboard > Plugins > Webhook
|
||||||
|
</span>
|
||||||
|
</Trans>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Add a{" "}
|
<Trans>
|
||||||
<span className="font-medium text-foreground">
|
Add a{" "}
|
||||||
Generic Destination
|
<span className="font-medium text-foreground">
|
||||||
</span>{" "}
|
Generic Destination
|
||||||
and paste the URL above
|
</span>{" "}
|
||||||
|
and paste the URL above
|
||||||
|
</Trans>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Enable the{" "}
|
<Trans>
|
||||||
<span className="font-medium text-foreground">Playback Stop</span>{" "}
|
Enable the{" "}
|
||||||
notification type
|
<span className="font-medium text-foreground">Playback Stop</span>{" "}
|
||||||
|
notification type
|
||||||
|
</Trans>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Sofa will automatically log movies and episodes when you finish
|
<Trans>
|
||||||
watching them
|
Sofa will automatically log movies and episodes when you finish
|
||||||
|
watching them
|
||||||
|
</Trans>
|
||||||
</li>
|
</li>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -142,41 +162,53 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
|
|||||||
connectedStatus: webhookStatus,
|
connectedStatus: webhookStatus,
|
||||||
alert: (
|
alert: (
|
||||||
<RequirementAlert>
|
<RequirementAlert>
|
||||||
Requires{" "}
|
<Trans>
|
||||||
<span className="font-medium text-foreground">Emby Server 4.7.9+</span>{" "}
|
Requires{" "}
|
||||||
and an active{" "}
|
<span className="font-medium text-foreground">
|
||||||
<a
|
Emby Server 4.7.9+
|
||||||
href="https://emby.media/premiere.html"
|
</span>{" "}
|
||||||
target="_blank"
|
and an active{" "}
|
||||||
rel="noopener noreferrer"
|
<a
|
||||||
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
href="https://emby.media/premiere.html"
|
||||||
>
|
target="_blank"
|
||||||
Emby Premiere
|
rel="noopener noreferrer"
|
||||||
<IconExternalLink
|
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
||||||
aria-hidden={true}
|
>
|
||||||
className="inline-block size-3 translate-y-[-1px]"
|
Emby Premiere
|
||||||
/>
|
<IconExternalLink
|
||||||
</a>{" "}
|
aria-hidden={true}
|
||||||
license.
|
className="inline-block size-3 translate-y-[-1px]"
|
||||||
|
/>
|
||||||
|
</a>{" "}
|
||||||
|
license.
|
||||||
|
</Trans>
|
||||||
</RequirementAlert>
|
</RequirementAlert>
|
||||||
),
|
),
|
||||||
setupSteps: (
|
setupSteps: (
|
||||||
<>
|
<>
|
||||||
<li>
|
<li>
|
||||||
Open Emby, go to{" "}
|
<Trans>
|
||||||
<span className="font-medium text-foreground">
|
Open Emby, go to{" "}
|
||||||
Settings > Webhooks
|
<span className="font-medium text-foreground">
|
||||||
</span>
|
Settings > Webhooks
|
||||||
</li>
|
</span>
|
||||||
<li>Add a new webhook and paste the URL above</li>
|
</Trans>
|
||||||
<li>
|
|
||||||
Enable the{" "}
|
|
||||||
<span className="font-medium text-foreground">Playback</span> event
|
|
||||||
category
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Sofa will automatically log movies and episodes when you finish
|
<Trans>Add a new webhook and paste the URL above</Trans>
|
||||||
watching them
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>
|
||||||
|
Enable the{" "}
|
||||||
|
<span className="font-medium text-foreground">Playback</span> event
|
||||||
|
category
|
||||||
|
</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>
|
||||||
|
Sofa will automatically log movies and episodes when you finish
|
||||||
|
watching them
|
||||||
|
</Trans>
|
||||||
</li>
|
</li>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -194,21 +226,31 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
|
|||||||
setupSteps: (
|
setupSteps: (
|
||||||
<>
|
<>
|
||||||
<li>
|
<li>
|
||||||
Open Sonarr, go to{" "}
|
<Trans>
|
||||||
<span className="font-medium text-foreground">
|
Open Sonarr, go to{" "}
|
||||||
Settings > Import Lists
|
<span className="font-medium text-foreground">
|
||||||
</span>
|
Settings > Import Lists
|
||||||
|
</span>
|
||||||
|
</Trans>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Click <span className="font-medium text-foreground">+</span> and
|
<Trans>
|
||||||
select{" "}
|
Click <span className="font-medium text-foreground">+</span> and
|
||||||
<span className="font-medium text-foreground">Custom Lists</span>
|
select{" "}
|
||||||
|
<span className="font-medium text-foreground">Custom Lists</span>
|
||||||
|
</Trans>
|
||||||
</li>
|
</li>
|
||||||
<li>Paste the Sonarr URL above into the List URL field</li>
|
|
||||||
<li>Set your preferred quality profile and root folder</li>
|
|
||||||
<li>
|
<li>
|
||||||
Titles on your Sofa watchlist will be automatically added for download
|
<Trans>Paste the Sonarr URL above into the List URL field</Trans>
|
||||||
when Sonarr polls this list (every 6 hours by default)
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Set your preferred quality profile and root folder</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>
|
||||||
|
Titles on your Sofa watchlist will be automatically added for
|
||||||
|
download when Sonarr polls this list (every 6 hours by default)
|
||||||
|
</Trans>
|
||||||
</li>
|
</li>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -224,21 +266,31 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
|
|||||||
setupSteps: (
|
setupSteps: (
|
||||||
<>
|
<>
|
||||||
<li>
|
<li>
|
||||||
Open Radarr, go to{" "}
|
<Trans>
|
||||||
<span className="font-medium text-foreground">
|
Open Radarr, go to{" "}
|
||||||
Settings > Import Lists
|
<span className="font-medium text-foreground">
|
||||||
</span>
|
Settings > Import Lists
|
||||||
|
</span>
|
||||||
|
</Trans>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Click <span className="font-medium text-foreground">+</span> and
|
<Trans>
|
||||||
select{" "}
|
Click <span className="font-medium text-foreground">+</span> and
|
||||||
<span className="font-medium text-foreground">Custom Lists</span>
|
select{" "}
|
||||||
|
<span className="font-medium text-foreground">Custom Lists</span>
|
||||||
|
</Trans>
|
||||||
</li>
|
</li>
|
||||||
<li>Paste the Radarr URL above into the List URL field</li>
|
|
||||||
<li>Set your preferred quality profile and root folder</li>
|
|
||||||
<li>
|
<li>
|
||||||
Titles on your Sofa watchlist will be automatically added for download
|
<Trans>Paste the Radarr URL above into the List URL field</Trans>
|
||||||
when Radarr polls this list (every 12 hours by default)
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Set your preferred quality profile and root folder</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>
|
||||||
|
Titles on your Sofa watchlist will be automatically added for
|
||||||
|
download when Radarr polls this list (every 12 hours by default)
|
||||||
|
</Trans>
|
||||||
</li>
|
</li>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { IconWebhook } from "@tabler/icons-react";
|
import { IconWebhook } from "@tabler/icons-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -37,7 +38,7 @@ export function IntegrationsSection() {
|
|||||||
className="size-4 text-muted-foreground"
|
className="size-4 text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
|
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
|
||||||
Integrations
|
<Trans>Integrations</Trans>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import { activateLocale, type SupportedLocale } from "@sofa/i18n";
|
||||||
|
import { LOCALE_INFO } from "@sofa/i18n/locales";
|
||||||
|
import { IconCheck, IconChevronDown, IconLanguage } from "@tabler/icons-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import { setPersistedLocale } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export function LanguageSection() {
|
||||||
|
const { t, i18n } = useLingui();
|
||||||
|
const currentLocale = i18n.locale;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const currentInfo = LOCALE_INFO.find((l) => l.code === currentLocale);
|
||||||
|
|
||||||
|
async function handleLocaleChange(locale: SupportedLocale) {
|
||||||
|
await activateLocale(locale);
|
||||||
|
setPersistedLocale(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
<CardContent className={open ? "pb-4" : ""}>
|
||||||
|
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<IconLanguage className="size-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<CardTitle>
|
||||||
|
<Trans>Language</Trans>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{currentInfo?.nativeName ?? "English"}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<IconChevronDown
|
||||||
|
aria-hidden={true}
|
||||||
|
className={`size-4 text-muted-foreground transition-transform duration-200 ${open ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
|
||||||
|
<CardContent className="border-border/30 border-t pt-4">
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{LOCALE_INFO.map((info) => (
|
||||||
|
<button
|
||||||
|
key={info.code}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleLocaleChange(info.code)}
|
||||||
|
className={`relative flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
currentLocale === info.code
|
||||||
|
? "border-primary bg-primary/5 text-foreground"
|
||||||
|
: "border-border hover:border-primary/40 hover:bg-primary/5"
|
||||||
|
}`}
|
||||||
|
aria-label={t`Switch to ${info.name}`}
|
||||||
|
aria-pressed={currentLocale === info.code}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium">
|
||||||
|
{info.nativeName}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-muted-foreground text-xs">
|
||||||
|
{info.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{currentLocale === info.code && (
|
||||||
|
<IconCheck className="ml-auto size-4 shrink-0 text-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { IconDoorEnter } from "@tabler/icons-react";
|
import { IconDoorEnter } from "@tabler/icons-react";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { useOptimistic, useState, useTransition } from "react";
|
import { useOptimistic, useState, useTransition } from "react";
|
||||||
@@ -8,6 +9,7 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { orpc } from "@/lib/orpc/client";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
|
|
||||||
export function RegistrationSection() {
|
export function RegistrationSection() {
|
||||||
|
const { t } = useLingui();
|
||||||
const { data, isPending: isLoading } = useQuery(
|
const { data, isPending: isLoading } = useQuery(
|
||||||
orpc.admin.registration.queryOptions(),
|
orpc.admin.registration.queryOptions(),
|
||||||
);
|
);
|
||||||
@@ -35,9 +37,11 @@ export function RegistrationSection() {
|
|||||||
try {
|
try {
|
||||||
await toggleMutation.mutateAsync({ open: checked });
|
await toggleMutation.mutateAsync({ open: checked });
|
||||||
setRegistrationOpen(checked);
|
setRegistrationOpen(checked);
|
||||||
toast.success(checked ? "Registration opened" : "Registration closed");
|
toast.success(
|
||||||
|
checked ? t`Registration opened` : t`Registration closed`,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to update registration setting");
|
toast.error(t`Failed to update registration setting`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -50,9 +54,11 @@ export function RegistrationSection() {
|
|||||||
<IconDoorEnter aria-hidden={true} className="size-4 text-primary" />
|
<IconDoorEnter aria-hidden={true} className="size-4 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Open registration</CardTitle>
|
<CardTitle>
|
||||||
|
<Trans>Open registration</Trans>
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Allow new users to create accounts
|
<Trans>Allow new users to create accounts</Trans>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,7 +66,7 @@ export function RegistrationSection() {
|
|||||||
checked={optimisticOpen}
|
checked={optimisticOpen}
|
||||||
onCheckedChange={handleToggle}
|
onCheckedChange={handleToggle}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
aria-label="Toggle open registration"
|
aria-label={t`Toggle open registration`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { IconSettings } from "@tabler/icons-react";
|
import { IconSettings } from "@tabler/icons-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { Children, type ReactNode } from "react";
|
import { Children, type ReactNode } from "react";
|
||||||
@@ -32,11 +33,11 @@ export function SettingsShell({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<IconSettings aria-hidden={true} className="size-5 text-primary" />
|
<IconSettings aria-hidden={true} className="size-5 text-primary" />
|
||||||
<h1 className="text-balance font-display text-3xl tracking-tight">
|
<h1 className="text-balance font-display text-3xl tracking-tight">
|
||||||
Settings
|
<Trans>Settings</Trans>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-muted-foreground text-sm">
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
Manage your account and preferences
|
<Trans>Manage your account and preferences</Trans>
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { CronJobName, SystemHealthData } from "@sofa/api/schemas";
|
import type { CronJobName, SystemHealthData } from "@sofa/api/schemas";
|
||||||
import {
|
import {
|
||||||
IconActivity,
|
IconActivity,
|
||||||
@@ -36,16 +37,6 @@ import {
|
|||||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||||
import { orpc } from "@/lib/orpc/client";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
|
|
||||||
const JOB_LABELS: Record<string, string> = {
|
|
||||||
nightlyRefreshLibrary: "Library refresh",
|
|
||||||
refreshAvailability: "Availability",
|
|
||||||
refreshRecommendations: "Recommendations",
|
|
||||||
refreshTvChildren: "TV episodes",
|
|
||||||
cacheImages: "Image cache",
|
|
||||||
scheduledBackup: "Backup",
|
|
||||||
updateCheck: "Update check",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Convert a cron pattern to a short human-readable string */
|
/** Convert a cron pattern to a short human-readable string */
|
||||||
function cronToHuman(pattern: string): string {
|
function cronToHuman(pattern: string): string {
|
||||||
const parts = pattern.split(" ");
|
const parts = pattern.split(" ");
|
||||||
@@ -170,6 +161,7 @@ function RefreshButton({
|
|||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
@@ -177,7 +169,7 @@ function RefreshButton({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label="Refresh system health"
|
aria-label={t`Refresh system health`}
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
@@ -186,7 +178,9 @@ function RefreshButton({
|
|||||||
>
|
>
|
||||||
{isRefreshing ? <Spinner /> : <IconRefresh />}
|
{isRefreshing ? <Spinner /> : <IconRefresh />}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Refresh</TooltipContent>
|
<TooltipContent>
|
||||||
|
<Trans>Refresh</Trans>
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -214,9 +208,13 @@ function SystemStatusCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Health status</CardTitle>
|
<CardTitle>
|
||||||
|
<Trans>Health status</Trans>
|
||||||
|
</CardTitle>
|
||||||
<CardDescription suppressHydrationWarning>
|
<CardDescription suppressHydrationWarning>
|
||||||
Checked <LiveTimeAgo date={checkedAt} />
|
<Trans>
|
||||||
|
Checked <LiveTimeAgo date={checkedAt} />
|
||||||
|
</Trans>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,7 +226,7 @@ function SystemStatusCard({
|
|||||||
<CardContent className="border-border/30 border-t pt-4">
|
<CardContent className="border-border/30 border-t pt-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
|
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
|
||||||
Database
|
<Trans>Database</Trans>
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-[11px] text-muted-foreground">
|
<span className="font-mono text-[11px] text-muted-foreground">
|
||||||
{formatBytes(database.dbSizeBytes)}
|
{formatBytes(database.dbSizeBytes)}
|
||||||
@@ -248,13 +246,15 @@ function SystemStatusCard({
|
|||||||
<>
|
<>
|
||||||
<StatusDot status="error" />
|
<StatusDot status="error" />
|
||||||
<span className="text-muted-foreground/50 text-xs">
|
<span className="text-muted-foreground/50 text-xs">
|
||||||
Not configured
|
<Trans>Not configured</Trans>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : tmdb.connected && tmdb.tokenValid ? (
|
) : tmdb.connected && tmdb.tokenValid ? (
|
||||||
<>
|
<>
|
||||||
<StatusDot status="ok" />
|
<StatusDot status="ok" />
|
||||||
<span className="text-muted-foreground text-xs">Connected</span>
|
<span className="text-muted-foreground text-xs">
|
||||||
|
<Trans>Connected</Trans>
|
||||||
|
</span>
|
||||||
<span className="font-mono text-[11px] text-muted-foreground/80">
|
<span className="font-mono text-[11px] text-muted-foreground/80">
|
||||||
{tmdb.responseTimeMs}ms
|
{tmdb.responseTimeMs}ms
|
||||||
</span>
|
</span>
|
||||||
@@ -262,12 +262,16 @@ function SystemStatusCard({
|
|||||||
) : tmdb.connected && !tmdb.tokenValid ? (
|
) : tmdb.connected && !tmdb.tokenValid ? (
|
||||||
<>
|
<>
|
||||||
<StatusDot status="error" />
|
<StatusDot status="error" />
|
||||||
<span className="text-destructive text-xs">Invalid token</span>
|
<span className="text-destructive text-xs">
|
||||||
|
<Trans>Invalid token</Trans>
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<StatusDot status="error" />
|
<StatusDot status="error" />
|
||||||
<span className="text-destructive text-xs">Unreachable</span>
|
<span className="text-destructive text-xs">
|
||||||
|
<Trans>Unreachable</Trans>
|
||||||
|
</span>
|
||||||
{tmdb.error && (
|
{tmdb.error && (
|
||||||
<span className="text-[11px] text-muted-foreground/50">
|
<span className="text-[11px] text-muted-foreground/50">
|
||||||
{tmdb.error}
|
{tmdb.error}
|
||||||
@@ -282,7 +286,7 @@ function SystemStatusCard({
|
|||||||
<CardContent className="border-border/30 border-t pt-4">
|
<CardContent className="border-border/30 border-t pt-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<span className="inline-flex items-center gap-1.5 font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
|
<span className="inline-flex items-center gap-1.5 font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
|
||||||
Environment
|
<Trans>Environment</Trans>
|
||||||
{environment.dataDirWritable ? (
|
{environment.dataDirWritable ? (
|
||||||
<IconCheck aria-hidden={true} className="size-3 text-green-500" />
|
<IconCheck aria-hidden={true} className="size-3 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
@@ -321,15 +325,27 @@ function BackgroundJobsCard({
|
|||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const JOB_LABELS: Record<string, string> = {
|
||||||
|
nightlyRefreshLibrary: t`Library refresh`,
|
||||||
|
refreshAvailability: t`Availability`,
|
||||||
|
refreshRecommendations: t`Recommendations`,
|
||||||
|
refreshTvChildren: t`TV episodes`,
|
||||||
|
cacheImages: t`Image cache`,
|
||||||
|
scheduledBackup: t`Backup`,
|
||||||
|
updateCheck: t`Update check`,
|
||||||
|
};
|
||||||
|
|
||||||
const triggerJobMutation = useMutation(
|
const triggerJobMutation = useMutation(
|
||||||
orpc.admin.triggerJob.mutationOptions({
|
orpc.admin.triggerJob.mutationOptions({
|
||||||
onSuccess: (_, { name }) => {
|
onSuccess: (_, { name }) => {
|
||||||
toast.success(`${JOB_LABELS[name] ?? name} triggered`);
|
toast.success(t`${JOB_LABELS[name] ?? name} triggered`);
|
||||||
setTimeout(onRefresh, 1500);
|
setTimeout(onRefresh, 1500);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast.error(
|
toast.error(
|
||||||
err instanceof Error ? err.message : "Failed to trigger job",
|
err instanceof Error ? err.message : t`Failed to trigger job`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -362,9 +378,13 @@ function BackgroundJobsCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Background jobs</CardTitle>
|
<CardTitle>
|
||||||
|
<Trans>Background jobs</Trans>
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{healthyCount} of {activeJobs.length} jobs healthy
|
<Trans>
|
||||||
|
{healthyCount} of {activeJobs.length} jobs healthy
|
||||||
|
</Trans>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -376,19 +396,21 @@ function BackgroundJobsCard({
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-b-border/30 hover:bg-transparent">
|
<TableRow className="border-b-border/30 hover:bg-transparent">
|
||||||
<TableHead className="h-8 pl-5 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
|
<TableHead className="h-8 pl-5 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||||
Job
|
<Trans>Job</Trans>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="h-8 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
|
<TableHead className="h-8 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||||
Schedule
|
<Trans>Schedule</Trans>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="h-8 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
|
<TableHead className="h-8 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||||
Last run
|
<Trans>Last run</Trans>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="h-8 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
|
<TableHead className="h-8 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||||
Next run
|
<Trans>Next run</Trans>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="h-8 pr-5 text-right font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
|
<TableHead className="h-8 pr-5 text-right font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||||
<span className="sr-only">Actions</span>
|
<span className="sr-only">
|
||||||
|
<Trans>Actions</Trans>
|
||||||
|
</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -406,17 +428,17 @@ function BackgroundJobsCard({
|
|||||||
<TableCell className="pl-5">
|
<TableCell className="pl-5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{job.disabled ? (
|
{job.disabled ? (
|
||||||
<StatusDot status="inactive" label="Disabled" />
|
<StatusDot status="inactive" label={t`Disabled`} />
|
||||||
) : isRunning ? (
|
) : isRunning ? (
|
||||||
<Spinner className="size-2.5" />
|
<Spinner className="size-2.5" />
|
||||||
) : job.lastStatus === null ? (
|
) : job.lastStatus === null ? (
|
||||||
<StatusDot status="warn" label="Never run" />
|
<StatusDot status="warn" label={t`Never run`} />
|
||||||
) : job.lastStatus === "success" ? (
|
) : job.lastStatus === "success" ? (
|
||||||
<StatusDot status="ok" label="Last run succeeded" />
|
<StatusDot status="ok" label={t`Last run succeeded`} />
|
||||||
) : (
|
) : (
|
||||||
<StatusDot
|
<StatusDot
|
||||||
status="error"
|
status="error"
|
||||||
label={job.lastError ?? "Last run failed"}
|
label={job.lastError ?? t`Last run failed`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
@@ -476,7 +498,7 @@ function BackgroundJobsCard({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground/50 text-xs">
|
<span className="text-muted-foreground/50 text-xs">
|
||||||
Never
|
<Trans>Never</Trans>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -512,7 +534,7 @@ function BackgroundJobsCard({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label="Trigger job"
|
aria-label={t`Trigger job`}
|
||||||
className="size-6"
|
className="size-6"
|
||||||
disabled={isRunning || job.disabled}
|
disabled={isRunning || job.disabled}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -532,7 +554,9 @@ function BackgroundJobsCard({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Run now</TooltipContent>
|
<TooltipContent>
|
||||||
|
<Trans>Run now</Trans>
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -554,6 +578,7 @@ function StorageCard({
|
|||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
return (
|
return (
|
||||||
<Card className="border-l-2 border-l-primary/30">
|
<Card className="border-l-2 border-l-primary/30">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -566,9 +591,11 @@ function StorageCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Storage</CardTitle>
|
<CardTitle>
|
||||||
|
<Trans>Storage</Trans>
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Image cache and backup disk usage
|
<Trans>Image cache and backup disk usage</Trans>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -580,7 +607,7 @@ function StorageCard({
|
|||||||
<CardContent className="border-border/30 border-t pt-4">
|
<CardContent className="border-border/30 border-t pt-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
|
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
|
||||||
Image cache
|
<Trans>Image cache</Trans>
|
||||||
</span>
|
</span>
|
||||||
{imageCache.enabled ? (
|
{imageCache.enabled ? (
|
||||||
<span className="font-mono text-[11px] text-muted-foreground/50">
|
<span className="font-mono text-[11px] text-muted-foreground/50">
|
||||||
@@ -591,7 +618,7 @@ function StorageCard({
|
|||||||
{imageCache.enabled ? (
|
{imageCache.enabled ? (
|
||||||
<>
|
<>
|
||||||
<p className="mt-1 text-muted-foreground text-xs">
|
<p className="mt-1 text-muted-foreground text-xs">
|
||||||
{imageCache.imageCount.toLocaleString()} cached images
|
{t`${imageCache.imageCount.toLocaleString()} cached images`}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-[10px] text-muted-foreground/50 leading-relaxed">
|
<p className="mt-0.5 text-[10px] text-muted-foreground/50 leading-relaxed">
|
||||||
{Object.entries(imageCache.categories)
|
{Object.entries(imageCache.categories)
|
||||||
@@ -602,7 +629,7 @@ function StorageCard({
|
|||||||
) : (
|
) : (
|
||||||
<p className="mt-1 flex items-center gap-1.5 text-muted-foreground/50 text-xs">
|
<p className="mt-1 flex items-center gap-1.5 text-muted-foreground/50 text-xs">
|
||||||
<StatusDot status="inactive" />
|
<StatusDot status="inactive" />
|
||||||
Disabled
|
<Trans>Disabled</Trans>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -611,7 +638,7 @@ function StorageCard({
|
|||||||
<CardContent className="border-border/30 border-t pt-4">
|
<CardContent className="border-border/30 border-t pt-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
|
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
|
||||||
Backups
|
<Trans>Backups</Trans>
|
||||||
</span>
|
</span>
|
||||||
{backups.backupCount > 0 && (
|
{backups.backupCount > 0 && (
|
||||||
<span className="font-mono text-[11px] text-muted-foreground/50">
|
<span className="font-mono text-[11px] text-muted-foreground/50">
|
||||||
@@ -624,13 +651,15 @@ function StorageCard({
|
|||||||
className="mt-1 text-muted-foreground text-xs"
|
className="mt-1 text-muted-foreground text-xs"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
{backups.backupCount} backups · last{" "}
|
<Trans>
|
||||||
<LiveTimeAgo date={backups.lastBackupAt} fallback="unknown" />
|
{backups.backupCount} backups · last{" "}
|
||||||
|
<LiveTimeAgo date={backups.lastBackupAt} fallback={t`unknown`} />
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-1 flex items-center gap-1.5 text-muted-foreground/50 text-xs">
|
<p className="mt-1 flex items-center gap-1.5 text-muted-foreground/50 text-xs">
|
||||||
<StatusDot status="inactive" />
|
<StatusDot status="inactive" />
|
||||||
No backups yet
|
<Trans>No backups yet</Trans>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { IconWorldUpload } from "@tabler/icons-react";
|
import { IconWorldUpload } from "@tabler/icons-react";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { useOptimistic, useState, useTransition } from "react";
|
import { useOptimistic, useState, useTransition } from "react";
|
||||||
@@ -8,6 +9,7 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { orpc } from "@/lib/orpc/client";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
|
|
||||||
export function UpdateCheckSection() {
|
export function UpdateCheckSection() {
|
||||||
|
const { t } = useLingui();
|
||||||
const { data, isPending: isLoading } = useQuery(
|
const { data, isPending: isLoading } = useQuery(
|
||||||
orpc.admin.updateCheck.queryOptions(),
|
orpc.admin.updateCheck.queryOptions(),
|
||||||
);
|
);
|
||||||
@@ -35,10 +37,10 @@ export function UpdateCheckSection() {
|
|||||||
await toggleMutation.mutateAsync({ enabled: checked });
|
await toggleMutation.mutateAsync({ enabled: checked });
|
||||||
setLocalEnabled(checked);
|
setLocalEnabled(checked);
|
||||||
toast.success(
|
toast.success(
|
||||||
checked ? "Update checks enabled" : "Update checks disabled",
|
checked ? t`Update checks enabled` : t`Update checks disabled`,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to update setting");
|
toast.error(t`Failed to update setting`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -54,9 +56,11 @@ export function UpdateCheckSection() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Automatic update checks</CardTitle>
|
<CardTitle>
|
||||||
|
<Trans>Automatic update checks</Trans>
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Periodically check GitHub for new Sofa releases
|
<Trans>Periodically check GitHub for new Sofa releases</Trans>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,7 +68,7 @@ export function UpdateCheckSection() {
|
|||||||
checked={optimisticEnabled}
|
checked={optimisticEnabled}
|
||||||
onCheckedChange={handleToggle}
|
onCheckedChange={handleToggle}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
aria-label="Toggle automatic update checks"
|
aria-label={t`Toggle automatic update checks`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -21,12 +22,12 @@ export function CopyButton({ code }: { code: string }) {
|
|||||||
{copied ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
<IconCheck aria-hidden={true} className="size-3 text-green-400" />
|
<IconCheck aria-hidden={true} className="size-3 text-green-400" />
|
||||||
Copied
|
<Trans>Copied</Trans>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<IconCopy aria-hidden={true} className="size-3" />
|
<IconCopy aria-hidden={true} className="size-3" />
|
||||||
Copy
|
<Trans>Copy</Trans>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { IconRefresh } from "@tabler/icons-react";
|
import { IconRefresh } from "@tabler/icons-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -23,7 +24,11 @@ export function RefreshButton() {
|
|||||||
) : (
|
) : (
|
||||||
<IconRefresh aria-hidden={true} className="size-3.5" />
|
<IconRefresh aria-hidden={true} className="size-3.5" />
|
||||||
)}
|
)}
|
||||||
{isRefreshing ? "Checking…" : "Check configuration"}
|
{isRefreshing ? (
|
||||||
|
<Trans>Checking…</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Check configuration</Trans>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import {
|
import {
|
||||||
IconBookmarkFilled,
|
IconBookmarkFilled,
|
||||||
IconCircleCheckFilled,
|
IconCircleCheckFilled,
|
||||||
@@ -58,23 +59,26 @@ export interface TitleCardProps extends CardInnerProps {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusConfig = {
|
function useStatusConfig() {
|
||||||
watchlist: {
|
const { t } = useLingui();
|
||||||
icon: IconBookmarkFilled,
|
return {
|
||||||
label: "On Watchlist",
|
watchlist: {
|
||||||
badgeClass: "bg-status-watching/90 text-white",
|
icon: IconBookmarkFilled,
|
||||||
},
|
label: t`On Watchlist`,
|
||||||
in_progress: {
|
badgeClass: "bg-status-watching/90 text-white",
|
||||||
icon: IconPlayerPlayFilled,
|
},
|
||||||
label: "Watching",
|
in_progress: {
|
||||||
badgeClass: "bg-status-watching/90 text-white",
|
icon: IconPlayerPlayFilled,
|
||||||
},
|
label: t`Watching`,
|
||||||
completed: {
|
badgeClass: "bg-status-watching/90 text-white",
|
||||||
icon: IconCircleCheckFilled,
|
},
|
||||||
label: "Completed",
|
completed: {
|
||||||
badgeClass: "bg-status-completed/90 text-white",
|
icon: IconCircleCheckFilled,
|
||||||
},
|
label: t`Completed`,
|
||||||
} as const;
|
badgeClass: "bg-status-completed/90 text-white",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
function QuickAddButton({
|
function QuickAddButton({
|
||||||
id,
|
id,
|
||||||
@@ -83,6 +87,8 @@ function QuickAddButton({
|
|||||||
id: string;
|
id: string;
|
||||||
userStatus?: TitleStatus | null;
|
userStatus?: TitleStatus | null;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const statusConfig = useStatusConfig();
|
||||||
const [addedStatus, setAddedStatus] = useState<TitleStatus | null>(
|
const [addedStatus, setAddedStatus] = useState<TitleStatus | null>(
|
||||||
userStatus ?? null,
|
userStatus ?? null,
|
||||||
);
|
);
|
||||||
@@ -137,12 +143,13 @@ function QuickAddButton({
|
|||||||
<IconLoader className="size-4 animate-spin" />
|
<IconLoader className="size-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">Add to Watchlist</TooltipContent>
|
<TooltipContent side="bottom">{t`Add to Watchlist`}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProgressBar({ watched, total }: { watched: number; total: number }) {
|
function ProgressBar({ watched, total }: { watched: number; total: number }) {
|
||||||
|
const { t } = useLingui();
|
||||||
const pct = total > 0 ? (watched / total) * 100 : 0;
|
const pct = total > 0 ? (watched / total) * 100 : 0;
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -156,7 +163,7 @@ function ProgressBar({ watched, total }: { watched: number; total: number }) {
|
|||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
{watched}/{total} episodes
|
{t`${watched}/${total} episodes`}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -173,6 +180,7 @@ function CardInner({
|
|||||||
episodeProgress,
|
episodeProgress,
|
||||||
tiltStyles,
|
tiltStyles,
|
||||||
}: CardInnerProps) {
|
}: CardInnerProps) {
|
||||||
|
const statusConfig = useStatusConfig();
|
||||||
const year = releaseDate?.slice(0, 4);
|
const year = releaseDate?.slice(0, 4);
|
||||||
const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv;
|
const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv;
|
||||||
const placeholderUrl = thumbHashToUrl(posterThumbHash);
|
const placeholderUrl = thumbHashToUrl(posterThumbHash);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { CastMember } from "@sofa/api/schemas";
|
import type { CastMember } from "@sofa/api/schemas";
|
||||||
import { IconUser, IconUsers } from "@tabler/icons-react";
|
import { IconUser, IconUsers } from "@tabler/icons-react";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
@@ -11,11 +12,14 @@ interface CastCarouselProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CastCarousel({ actors, titleType }: CastCarouselProps) {
|
export function CastCarousel({ actors, titleType }: CastCarouselProps) {
|
||||||
|
const { t } = useLingui();
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<IconUsers aria-hidden={true} className="size-5 text-primary" />
|
<IconUsers aria-hidden={true} className="size-5 text-primary" />
|
||||||
<h2 className="font-display text-xl tracking-tight">Cast</h2>
|
<h2 className="font-display text-xl tracking-tight">
|
||||||
|
<Trans>Cast</Trans>
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{actors.length > 0 && (
|
{actors.length > 0 && (
|
||||||
@@ -73,8 +77,7 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
|
|||||||
)}
|
)}
|
||||||
{titleType === "tv" && member.episodeCount && (
|
{titleType === "tv" && member.episodeCount && (
|
||||||
<p className="text-[10px] text-muted-foreground/70">
|
<p className="text-[10px] text-muted-foreground/70">
|
||||||
{member.episodeCount} ep
|
{t`${member.episodeCount} ep${member.episodeCount !== 1 ? "s" : ""}`}
|
||||||
{member.episodeCount !== 1 ? "s" : ""}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import {
|
import {
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconPlayerPlayFilled,
|
IconPlayerPlayFilled,
|
||||||
@@ -6,32 +7,34 @@ import {
|
|||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
|
||||||
const watchingStyle = {
|
|
||||||
label: "Watching",
|
|
||||||
icon: IconPlayerPlayFilled,
|
|
||||||
class: "text-status-watching",
|
|
||||||
bgClass: "bg-status-watching/10 hover:bg-status-watching/15",
|
|
||||||
borderClass: "ring-status-watching/20",
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusConfig = {
|
|
||||||
watchlist: watchingStyle,
|
|
||||||
in_progress: watchingStyle,
|
|
||||||
completed: {
|
|
||||||
label: "Completed",
|
|
||||||
icon: IconCheck,
|
|
||||||
class: "text-status-completed",
|
|
||||||
bgClass: "bg-status-completed/10 hover:bg-status-completed/15",
|
|
||||||
borderClass: "ring-status-completed/20",
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
interface StatusButtonProps {
|
interface StatusButtonProps {
|
||||||
currentStatus: string | null;
|
currentStatus: string | null;
|
||||||
onChange: (status: string | null) => void;
|
onChange: (status: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
|
export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const watchingStyle = {
|
||||||
|
label: t`Watching`,
|
||||||
|
icon: IconPlayerPlayFilled,
|
||||||
|
class: "text-status-watching",
|
||||||
|
bgClass: "bg-status-watching/10 hover:bg-status-watching/15",
|
||||||
|
borderClass: "ring-status-watching/20",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
watchlist: watchingStyle,
|
||||||
|
in_progress: watchingStyle,
|
||||||
|
completed: {
|
||||||
|
label: t`Completed`,
|
||||||
|
icon: IconCheck,
|
||||||
|
class: "text-status-completed",
|
||||||
|
bgClass: "bg-status-completed/10 hover:bg-status-completed/15",
|
||||||
|
borderClass: "ring-status-completed/20",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
const config =
|
const config =
|
||||||
statusConfig[currentStatus as keyof typeof statusConfig] ?? null;
|
statusConfig[currentStatus as keyof typeof statusConfig] ?? null;
|
||||||
|
|
||||||
@@ -49,7 +52,7 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
|
|||||||
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary/10 px-4 font-medium text-primary text-sm ring-1 ring-primary/20 transition-all hover:bg-primary/15 hover:ring-primary/30 active:scale-[0.97]"
|
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary/10 px-4 font-medium text-primary text-sm ring-1 ring-primary/20 transition-all hover:bg-primary/15 hover:ring-primary/30 active:scale-[0.97]"
|
||||||
>
|
>
|
||||||
<IconPlus aria-hidden={true} className="size-3.5" strokeWidth={2.5} />
|
<IconPlus aria-hidden={true} className="size-3.5" strokeWidth={2.5} />
|
||||||
Watchlist
|
{t`Watchlist`}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
) : (
|
) : (
|
||||||
<motion.button
|
<motion.button
|
||||||
@@ -60,7 +63,7 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -4 }}
|
exit={{ opacity: 0, y: -4 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
title="Remove from library"
|
title={t`Remove from library`}
|
||||||
className={`group inline-flex h-9 items-center gap-2 rounded-lg px-4 font-medium text-sm ring-1 transition-all active:scale-[0.97] ${config.class} ${config.bgClass} ${config.borderClass} hover:!bg-destructive/10 hover:!text-destructive hover:!ring-destructive/30`}
|
className={`group inline-flex h-9 items-center gap-2 rounded-lg px-4 font-medium text-sm ring-1 transition-all active:scale-[0.97] ${config.class} ${config.bgClass} ${config.borderClass} hover:!bg-destructive/10 hover:!text-destructive hover:!ring-destructive/30`}
|
||||||
>
|
>
|
||||||
<span className="grid [&>svg]:col-start-1 [&>svg]:row-start-1">
|
<span className="grid [&>svg]:col-start-1 [&>svg]:row-start-1">
|
||||||
@@ -78,7 +81,7 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
|
|||||||
{config.label}
|
{config.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="opacity-0 transition-opacity group-hover:opacity-100">
|
<span className="opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
Remove
|
{t`Remove`}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { IconCheck } from "@tabler/icons-react";
|
import { IconCheck } from "@tabler/icons-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -25,7 +26,7 @@ export function TitleActions() {
|
|||||||
className="h-9 rounded-lg px-4 text-sm hover:shadow-md hover:shadow-primary/20 active:scale-[0.97]"
|
className="h-9 rounded-lg px-4 text-sm hover:shadow-md hover:shadow-primary/20 active:scale-[0.97]"
|
||||||
>
|
>
|
||||||
<IconCheck aria-hidden={true} className="size-3.5" />
|
<IconCheck aria-hidden={true} className="size-3.5" />
|
||||||
Mark Watched
|
<Trans>Mark Watched</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Separator
|
<Separator
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { AvailabilityOffer } from "@sofa/api/schemas";
|
import type { AvailabilityOffer } from "@sofa/api/schemas";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -13,13 +14,7 @@ import {
|
|||||||
|
|
||||||
const MAX_VISIBLE = 4;
|
const MAX_VISIBLE = 4;
|
||||||
|
|
||||||
const offerLabels: Record<string, string> = {
|
// offerLabels moved into component for LingUI
|
||||||
flatrate: "Stream",
|
|
||||||
rent: "Rent",
|
|
||||||
buy: "Buy",
|
|
||||||
free: "Free",
|
|
||||||
ads: "With Ads",
|
|
||||||
};
|
|
||||||
|
|
||||||
function ProviderBadge({
|
function ProviderBadge({
|
||||||
name,
|
name,
|
||||||
@@ -30,6 +25,7 @@ function ProviderBadge({
|
|||||||
logoPath: string | null;
|
logoPath: string | null;
|
||||||
watchUrl: string | null;
|
watchUrl: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
@@ -60,7 +56,7 @@ function ProviderBadge({
|
|||||||
)}
|
)}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="bg-popover px-2 py-1 font-medium text-[10px] text-popover-foreground shadow-md [&>:last-child]:hidden">
|
<TooltipContent className="bg-popover px-2 py-1 font-medium text-[10px] text-popover-foreground shadow-md [&>:last-child]:hidden">
|
||||||
{watchUrl ? `Watch on ${name}` : name}
|
{watchUrl ? t`Watch on ${name}` : name}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -136,6 +132,14 @@ export function TitleAvailability({
|
|||||||
}: {
|
}: {
|
||||||
availability: AvailabilityOffer[];
|
availability: AvailabilityOffer[];
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const offerLabels: Record<string, string> = {
|
||||||
|
flatrate: t`Stream`,
|
||||||
|
rent: t`Rent`,
|
||||||
|
buy: t`Buy`,
|
||||||
|
free: t`Free`,
|
||||||
|
ads: t`With Ads`,
|
||||||
|
};
|
||||||
const availByType: Record<string, AvailabilityOffer[]> = {};
|
const availByType: Record<string, AvailabilityOffer[]> = {};
|
||||||
for (const offer of availability) {
|
for (const offer of availability) {
|
||||||
if (!availByType[offer.offerType]) availByType[offer.offerType] = [];
|
if (!availByType[offer.offerType]) availByType[offer.offerType] = [];
|
||||||
@@ -147,7 +151,7 @@ export function TitleAvailability({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-2 pt-1">
|
<div className="space-y-2 pt-1">
|
||||||
<h2 className="font-semibold text-muted-foreground text-xs uppercase tracking-wider">
|
<h2 className="font-semibold text-muted-foreground text-xs uppercase tracking-wider">
|
||||||
Where to Watch
|
<Trans>Where to Watch</Trans>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
{Object.entries(availByType).map(([type, offers]) => {
|
{Object.entries(availByType).map(([type, offers]) => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import type { ColorPalette, ResolvedTitle } from "@sofa/api/schemas";
|
import type { ColorPalette, ResolvedTitle } from "@sofa/api/schemas";
|
||||||
import {
|
import {
|
||||||
IconCalendarEvent,
|
IconCalendarEvent,
|
||||||
@@ -138,7 +139,9 @@ export function TitleHero({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
className="size-3.5 translate-y-[-0.5px]"
|
className="size-3.5 translate-y-[-0.5px]"
|
||||||
/>
|
/>
|
||||||
<span className="hidden md:inline">Movie</span>
|
<span className="hidden md:inline">
|
||||||
|
<Trans>Movie</Trans>
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -146,7 +149,9 @@ export function TitleHero({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
className="size-3.5 translate-y-[-0.5px]"
|
className="size-3.5 translate-y-[-0.5px]"
|
||||||
/>
|
/>
|
||||||
<span className="hidden md:inline">TV</span>
|
<span className="hidden md:inline">
|
||||||
|
<Trans>TV</Trans>
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { IconThumbUp } from "@tabler/icons-react";
|
import { IconThumbUp } from "@tabler/icons-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
|
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
|
||||||
@@ -35,7 +36,9 @@ export function TitleRecommendations({ titleId }: { titleId: string }) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<IconThumbUp aria-hidden={true} className="size-5 text-primary" />
|
<IconThumbUp aria-hidden={true} className="size-5 text-primary" />
|
||||||
<h2 className="font-display text-xl tracking-tight">Recommended</h2>
|
<h2 className="font-display text-xl tracking-tight">
|
||||||
|
<Trans>Recommended</Trans>
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||||
{data.recommendations.slice(0, 12).map((rec, i) => (
|
{data.recommendations.slice(0, 12).map((rec, i) => (
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import type { Season } from "@sofa/api/schemas";
|
import type { Season } from "@sofa/api/schemas";
|
||||||
|
import { formatShortDate } from "@sofa/i18n/format";
|
||||||
import {
|
import {
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconChecks,
|
IconChecks,
|
||||||
@@ -6,7 +8,6 @@ import {
|
|||||||
IconChevronUp,
|
IconChevronUp,
|
||||||
IconDeviceTvOld,
|
IconDeviceTvOld,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { format, parseISO } from "date-fns";
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@@ -97,7 +98,9 @@ export function TitleSeasons({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<IconDeviceTvOld aria-hidden={true} className="size-5 text-primary" />
|
<IconDeviceTvOld aria-hidden={true} className="size-5 text-primary" />
|
||||||
<h2 className="font-display text-2xl tracking-tight">Episodes</h2>
|
<h2 className="font-display text-2xl tracking-tight">
|
||||||
|
<Trans>Episodes</Trans>
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{userStatus !== "completed" && (
|
{userStatus !== "completed" && (
|
||||||
<AlertDialog open={markAllOpen} onOpenChange={setMarkAllOpen}>
|
<AlertDialog open={markAllOpen} onOpenChange={setMarkAllOpen}>
|
||||||
@@ -109,29 +112,33 @@ export function TitleSeasons({
|
|||||||
className="text-muted-foreground uppercase tracking-wider"
|
className="text-muted-foreground uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
<IconChecks aria-hidden={true} className="size-3.5" />
|
<IconChecks aria-hidden={true} className="size-3.5" />
|
||||||
Mark All Watched
|
<Trans>Mark All Watched</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
Mark all episodes as watched?
|
<Trans>Mark all episodes as watched?</Trans>
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will mark every episode of this show as watched. You can
|
<Trans>
|
||||||
undo this later by unmarking individual seasons.
|
This will mark every episode of this show as watched. You
|
||||||
|
can undo this later by unmarking individual seasons.
|
||||||
|
</Trans>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleMarkAllWatched();
|
handleMarkAllWatched();
|
||||||
setMarkAllOpen(false);
|
setMarkAllOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Mark All Watched
|
<Trans>Mark All Watched</Trans>
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
@@ -191,7 +198,9 @@ export function TitleSeasons({
|
|||||||
aria-hidden={true}
|
aria-hidden={true}
|
||||||
className="size-3.5 sm:hidden"
|
className="size-3.5 sm:hidden"
|
||||||
/>
|
/>
|
||||||
<span className="hidden sm:inline">Watch all</span>
|
<span className="hidden sm:inline">
|
||||||
|
<Trans>Watch all</Trans>
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{totalCount > 0 && watchedCount === totalCount && (
|
{totalCount > 0 && watchedCount === totalCount && (
|
||||||
@@ -208,7 +217,9 @@ export function TitleSeasons({
|
|||||||
aria-hidden={true}
|
aria-hidden={true}
|
||||||
className="size-3.5 sm:hidden"
|
className="size-3.5 sm:hidden"
|
||||||
/>
|
/>
|
||||||
<span className="hidden sm:inline">Unwatch all</span>
|
<span className="hidden sm:inline">
|
||||||
|
<Trans>Unwatch all</Trans>
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{totalCount > 0 && (
|
{totalCount > 0 && (
|
||||||
@@ -326,13 +337,11 @@ export function TitleSeasons({
|
|||||||
</span>
|
</span>
|
||||||
<span className="hidden sm:inline"> </span>
|
<span className="hidden sm:inline"> </span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{ep.name ?? "Untitled"}
|
{ep.name ?? <Trans>Untitled</Trans>}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{ep.airDate
|
{ep.airDate ? formatShortDate(ep.airDate) : ""}
|
||||||
? format(parseISO(ep.airDate), "MMM d, yyyy")
|
|
||||||
: ""}
|
|
||||||
{ep.airDate && ep.runtimeMinutes ? " · " : ""}
|
{ep.airDate && ep.runtimeMinutes ? " · " : ""}
|
||||||
{ep.runtimeMinutes
|
{ep.runtimeMinutes
|
||||||
? `${ep.runtimeMinutes}m`
|
? `${ep.runtimeMinutes}m`
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { IconPlayerPlayFilled } from "@tabler/icons-react";
|
import { IconPlayerPlayFilled } from "@tabler/icons-react";
|
||||||
import { lazy, Suspense, useState } from "react";
|
import { lazy, Suspense, useState } from "react";
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
@@ -12,6 +13,7 @@ export function TrailerDialog({
|
|||||||
videoKey: string;
|
videoKey: string;
|
||||||
variant?: "badge" | "backdrop";
|
variant?: "badge" | "backdrop";
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -19,7 +21,7 @@ export function TrailerDialog({
|
|||||||
{variant === "backdrop" ? (
|
{variant === "backdrop" ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Play trailer"
|
aria-label={t`Play trailer`}
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
className="group flex size-12 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/20 backdrop-blur-md transition-[transform,background-color,box-shadow] duration-300 hover:scale-105 hover:bg-white/20 hover:ring-white/30 active:scale-100 sm:size-16"
|
className="group flex size-12 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/20 backdrop-blur-md transition-[transform,background-color,box-shadow] duration-300 hover:scale-105 hover:bg-white/20 hover:ring-white/30 active:scale-100 sm:size-16"
|
||||||
>
|
>
|
||||||
@@ -32,11 +34,11 @@ export function TrailerDialog({
|
|||||||
className="inline-flex h-5 items-center gap-1 rounded border border-border/50 px-2 text-muted-foreground text-xs transition-colors hover:border-border hover:text-foreground"
|
className="inline-flex h-5 items-center gap-1 rounded border border-border/50 px-2 text-muted-foreground text-xs transition-colors hover:border-border hover:text-foreground"
|
||||||
>
|
>
|
||||||
<IconPlayerPlayFilled aria-hidden={true} className="h-2.5 w-2.5" />
|
<IconPlayerPlayFilled aria-hidden={true} className="h-2.5 w-2.5" />
|
||||||
Trailer
|
{t`Trailer`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<DialogContent className="overflow-hidden border-white/10 bg-black p-0 sm:max-w-4xl">
|
<DialogContent className="overflow-hidden border-white/10 bg-black p-0 sm:max-w-4xl">
|
||||||
<DialogTitle className="sr-only">Trailer</DialogTitle>
|
<DialogTitle className="sr-only">{t`Trailer`}</DialogTitle>
|
||||||
<div className="aspect-video w-full">
|
<div className="aspect-video w-full">
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<MediaThemeSutro
|
<MediaThemeSutro
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { plural } from "@lingui/core/macro";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import type { Season } from "@sofa/api/schemas";
|
import type { Season } from "@sofa/api/schemas";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
@@ -12,6 +14,7 @@ type UserInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useTitleActions() {
|
export function useTitleActions() {
|
||||||
|
const { t } = useLingui();
|
||||||
const { titleId, titleName, seasons, setWatchingEp } = useTitleContext();
|
const { titleId, titleName, seasons, setWatchingEp } = useTitleContext();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const userInfoKey = orpc.titles.userInfo.queryKey({ input: { id: titleId } });
|
const userInfoKey = orpc.titles.userInfo.queryKey({ input: { id: titleId } });
|
||||||
@@ -76,7 +79,7 @@ export function useTitleActions() {
|
|||||||
try {
|
try {
|
||||||
await batchWatchMutation.mutateAsync({ episodeIds });
|
await batchWatchMutation.mutateAsync({ episodeIds });
|
||||||
toast.success(
|
toast.success(
|
||||||
`Caught up — marked ${episodeIds.length} episode${episodeIds.length > 1 ? "s" : ""} as watched`,
|
t`Caught up — marked ${episodeIds.length} ${plural(episodeIds.length, { one: "episode", other: "episodes" })} as watched`,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
setUserInfo((old) => ({
|
setUserInfo((old) => ({
|
||||||
@@ -84,10 +87,10 @@ export function useTitleActions() {
|
|||||||
episodeWatches: prev.episodeWatches,
|
episodeWatches: prev.episodeWatches,
|
||||||
status: prev.status,
|
status: prev.status,
|
||||||
}));
|
}));
|
||||||
toast.error("Failed to catch up");
|
toast.error(t`Failed to catch up`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getUserInfo, setUserInfo, seasons, batchWatchMutation],
|
[getUserInfo, setUserInfo, seasons, batchWatchMutation, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStatusChange = useCallback(
|
const handleStatusChange = useCallback(
|
||||||
@@ -105,13 +108,13 @@ export function useTitleActions() {
|
|||||||
id: titleId,
|
id: titleId,
|
||||||
status: status ? "in_progress" : null,
|
status: status ? "in_progress" : null,
|
||||||
});
|
});
|
||||||
toast.success(status ? "Added to watchlist" : "Removed from library");
|
toast.success(status ? t`Added to watchlist` : t`Removed from library`);
|
||||||
} catch {
|
} catch {
|
||||||
setUserInfo((old) => ({ ...old, status: prevStatus }));
|
setUserInfo((old) => ({ ...old, status: prevStatus }));
|
||||||
toast.error("Failed to update status");
|
toast.error(t`Failed to update status`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getUserInfo, setUserInfo, titleId, updateStatusMutation],
|
[getUserInfo, setUserInfo, titleId, updateStatusMutation, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRating = useCallback(
|
const handleRating = useCallback(
|
||||||
@@ -125,15 +128,15 @@ export function useTitleActions() {
|
|||||||
});
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
ratingStars > 0
|
ratingStars > 0
|
||||||
? `Rated ${ratingStars} star${ratingStars > 1 ? "s" : ""}`
|
? t`Rated ${ratingStars} ${plural(ratingStars, { one: "star", other: "stars" })}`
|
||||||
: "Rating removed",
|
: t`Rating removed`,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
setUserInfo((old) => ({ ...old, rating: prevRating }));
|
setUserInfo((old) => ({ ...old, rating: prevRating }));
|
||||||
toast.error("Failed to update rating");
|
toast.error(t`Failed to update rating`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getUserInfo, setUserInfo, titleId, updateRatingMutation],
|
[getUserInfo, setUserInfo, titleId, updateRatingMutation, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleWatchMovie = useCallback(async () => {
|
const handleWatchMovie = useCallback(async () => {
|
||||||
@@ -141,12 +144,12 @@ export function useTitleActions() {
|
|||||||
setUserInfo((old) => ({ ...old, status: "completed" }));
|
setUserInfo((old) => ({ ...old, status: "completed" }));
|
||||||
try {
|
try {
|
||||||
await watchMovieMutation.mutateAsync({ id: titleId });
|
await watchMovieMutation.mutateAsync({ id: titleId });
|
||||||
toast.success(`Marked "${titleName}" as watched`);
|
toast.success(t`Marked "${titleName}" as watched`);
|
||||||
} catch {
|
} catch {
|
||||||
setUserInfo((old) => ({ ...old, status: prevStatus }));
|
setUserInfo((old) => ({ ...old, status: prevStatus }));
|
||||||
toast.error("Failed to mark as watched");
|
toast.error(t`Failed to mark as watched`);
|
||||||
}
|
}
|
||||||
}, [getUserInfo, setUserInfo, titleId, titleName, watchMovieMutation]);
|
}, [getUserInfo, setUserInfo, titleId, titleName, watchMovieMutation, t]);
|
||||||
|
|
||||||
const handleWatchEpisode = useCallback(
|
const handleWatchEpisode = useCallback(
|
||||||
async (
|
async (
|
||||||
@@ -168,14 +171,14 @@ export function useTitleActions() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await unwatchEpMutation.mutateAsync({ id: episodeId });
|
await unwatchEpMutation.mutateAsync({ id: episodeId });
|
||||||
toast.success(`Unwatched S${seasonNum} E${epNum}`);
|
toast.success(t`Unwatched S${seasonNum} E${epNum}`);
|
||||||
} catch {
|
} catch {
|
||||||
setUserInfo((old) => ({
|
setUserInfo((old) => ({
|
||||||
...old,
|
...old,
|
||||||
episodeWatches: prevWatches,
|
episodeWatches: prevWatches,
|
||||||
status: prevStatus,
|
status: prevStatus,
|
||||||
}));
|
}));
|
||||||
toast.error("Failed to unmark episode");
|
toast.error(t`Failed to unmark episode`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const prevWatches = getUserInfo().episodeWatches;
|
const prevWatches = getUserInfo().episodeWatches;
|
||||||
@@ -214,16 +217,16 @@ export function useTitleActions() {
|
|||||||
|
|
||||||
if (previousUnwatched.length > 0) {
|
if (previousUnwatched.length > 0) {
|
||||||
const count = previousUnwatched.length;
|
const count = previousUnwatched.length;
|
||||||
toast.success(`Watched S${seasonNum} E${epNum}`, {
|
toast.success(t`Watched S${seasonNum} E${epNum}`, {
|
||||||
description: `${count} earlier episode${count > 1 ? "s" : ""} unwatched`,
|
description: t`${count} earlier ${plural(count, { one: "episode", other: "episodes" })} unwatched`,
|
||||||
action: {
|
action: {
|
||||||
label: "Catch up",
|
label: t`Catch up`,
|
||||||
onClick: () => catchUp(previousUnwatched),
|
onClick: () => catchUp(previousUnwatched),
|
||||||
},
|
},
|
||||||
duration: 8000,
|
duration: 8000,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.success(`Watched S${seasonNum} E${epNum}`);
|
toast.success(t`Watched S${seasonNum} E${epNum}`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setUserInfo((old) => ({
|
setUserInfo((old) => ({
|
||||||
@@ -231,7 +234,7 @@ export function useTitleActions() {
|
|||||||
episodeWatches: prevWatches,
|
episodeWatches: prevWatches,
|
||||||
status: prevStatus,
|
status: prevStatus,
|
||||||
}));
|
}));
|
||||||
toast.error("Failed to mark episode");
|
toast.error(t`Failed to mark episode`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +248,7 @@ export function useTitleActions() {
|
|||||||
catchUp,
|
catchUp,
|
||||||
unwatchEpMutation,
|
unwatchEpMutation,
|
||||||
watchEpMutation,
|
watchEpMutation,
|
||||||
|
t,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -288,19 +292,19 @@ export function useTitleActions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seasonLabel = season.name ?? `Season ${season.seasonNumber}`;
|
const seasonLabel = season.name ?? t`Season ${season.seasonNumber}`;
|
||||||
if (previousUnwatched.length > 0) {
|
if (previousUnwatched.length > 0) {
|
||||||
const count = previousUnwatched.length;
|
const count = previousUnwatched.length;
|
||||||
toast.success(`Watched all of ${seasonLabel}`, {
|
toast.success(t`Watched all of ${seasonLabel}`, {
|
||||||
description: `${count} earlier episode${count > 1 ? "s" : ""} unwatched`,
|
description: t`${count} earlier ${plural(count, { one: "episode", other: "episodes" })} unwatched`,
|
||||||
action: {
|
action: {
|
||||||
label: "Catch up",
|
label: t`Catch up`,
|
||||||
onClick: () => catchUp(previousUnwatched),
|
onClick: () => catchUp(previousUnwatched),
|
||||||
},
|
},
|
||||||
duration: 8000,
|
duration: 8000,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.success(`Watched all of ${seasonLabel}`);
|
toast.success(t`Watched all of ${seasonLabel}`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setUserInfo((old) => ({
|
setUserInfo((old) => ({
|
||||||
@@ -308,10 +312,10 @@ export function useTitleActions() {
|
|||||||
episodeWatches: prevWatches,
|
episodeWatches: prevWatches,
|
||||||
status: prevStatus,
|
status: prevStatus,
|
||||||
}));
|
}));
|
||||||
toast.error("Failed to mark some episodes");
|
toast.error(t`Failed to mark some episodes`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getUserInfo, setUserInfo, seasons, catchUp, watchSeasonMutation],
|
[getUserInfo, setUserInfo, seasons, catchUp, watchSeasonMutation, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUnmarkSeason = useCallback(
|
const handleUnmarkSeason = useCallback(
|
||||||
@@ -328,7 +332,7 @@ export function useTitleActions() {
|
|||||||
try {
|
try {
|
||||||
await unwatchSeasonMutation.mutateAsync({ id: season.id });
|
await unwatchSeasonMutation.mutateAsync({ id: season.id });
|
||||||
toast.success(
|
toast.success(
|
||||||
`Unwatched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
|
t`Unwatched all of ${season.name ?? t`Season ${season.seasonNumber}`}`,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
setUserInfo((old) => ({
|
setUserInfo((old) => ({
|
||||||
@@ -336,10 +340,10 @@ export function useTitleActions() {
|
|||||||
episodeWatches: prevWatches,
|
episodeWatches: prevWatches,
|
||||||
status: prevStatus,
|
status: prevStatus,
|
||||||
}));
|
}));
|
||||||
toast.error("Failed to unmark some episodes");
|
toast.error(t`Failed to unmark some episodes`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getUserInfo, setUserInfo, unwatchSeasonMutation],
|
[getUserInfo, setUserInfo, unwatchSeasonMutation, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMarkAllWatched = useCallback(async () => {
|
const handleMarkAllWatched = useCallback(async () => {
|
||||||
@@ -353,16 +357,16 @@ export function useTitleActions() {
|
|||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
await watchAllMutation.mutateAsync({ id: titleId });
|
await watchAllMutation.mutateAsync({ id: titleId });
|
||||||
toast.success("Marked all episodes as watched");
|
toast.success(t`Marked all episodes as watched`);
|
||||||
} catch {
|
} catch {
|
||||||
setUserInfo((old) => ({
|
setUserInfo((old) => ({
|
||||||
...old,
|
...old,
|
||||||
episodeWatches: prevWatches,
|
episodeWatches: prevWatches,
|
||||||
status: prevStatus,
|
status: prevStatus,
|
||||||
}));
|
}));
|
||||||
toast.error("Failed to mark all episodes as watched");
|
toast.error(t`Failed to mark all episodes as watched`);
|
||||||
}
|
}
|
||||||
}, [getUserInfo, setUserInfo, seasons, titleId, watchAllMutation]);
|
}, [getUserInfo, setUserInfo, seasons, titleId, watchAllMutation, t]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleStatusChange,
|
handleStatusChange,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user