diff --git a/.oxlintrc.json b/.oxlintrc.json index cd8b8b2..27fc875 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,6 +1,7 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", "plugins": ["oxc", "eslint", "typescript", "react", "import", "unicorn"], + "jsPlugins": ["eslint-plugin-lingui"], "env": { "builtin": true }, @@ -20,7 +21,21 @@ "no-await-in-loop": "off", "react/no-array-index-key": "off", "react/jsx-no-useless-fragment": "error", - "react/rules-of-hooks": "error" + "react/rules-of-hooks": "error", + "lingui/no-unlocalized-strings": "off", + "lingui/t-call-in-function": "error", + "lingui/no-single-variables-to-translate": "error", + "lingui/no-expression-in-message": "error", + "lingui/no-single-tag-to-translate": "error", + "lingui/no-trans-inside-trans": "error" }, + "overrides": [ + { + "files": ["apps/web/src/**/*", "apps/native/src/**/*"], + "rules": { + "lingui/no-unlocalized-strings": "warn" + } + } + ], "ignorePatterns": ["node_modules", "dist", "build", "docs", "**/routeTree.gen.ts"] } diff --git a/apps/native/package.json b/apps/native/package.json index 015f003..ff46cdb 100644 --- a/apps/native/package.json +++ b/apps/native/package.json @@ -64,7 +64,7 @@ "expo-system-ui": "55.0.10", "expo-tracking-transparency": "55.0.9", "expo-web-browser": "55.0.10", - "posthog-react-native": "4.37.4", + "posthog-react-native": "4.37.5", "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.2", diff --git a/apps/native/src/app/(auth)/login.tsx b/apps/native/src/app/(auth)/login.tsx index 037d152..99dd496 100644 --- a/apps/native/src/app/(auth)/login.tsx +++ b/apps/native/src/app/(auth)/login.tsx @@ -84,34 +84,38 @@ export default function LoginScreen() { return ( - {showOidc && ( - - + {showOidc && + (() => { + const providerName = authConfig.data?.oidcProviderName ?? "SSO"; + return ( + + - {showPasswordLogin && ( - - - - OR - - - - )} - - )} + {showPasswordLogin && ( + + + + OR + + + + )} + + ); + })()} {showPasswordLogin && ( diff --git a/apps/native/src/app/(tabs)/(home)/index.tsx b/apps/native/src/app/(tabs)/(home)/index.tsx index 2f4a4c7..999c68d 100644 --- a/apps/native/src/app/(tabs)/(home)/index.tsx +++ b/apps/native/src/app/(tabs)/(home)/index.tsx @@ -111,7 +111,10 @@ export default function DashboardScreen() { { + const period = periodLabels[moviePeriod]; + return t`Movies ${period}`; + })()} value={movieCount} icon={IconMovie} color="text-primary" @@ -122,7 +125,10 @@ export default function DashboardScreen() { onPeriodChange={setMoviePeriod} /> { + const period = periodLabels[episodePeriod]; + return t`Episodes ${period}`; + })()} value={episodeCount} icon={IconDeviceTvOld} color="text-status-watching" diff --git a/apps/native/src/app/(tabs)/(settings)/index.tsx b/apps/native/src/app/(tabs)/(settings)/index.tsx index a0712be..d8e941d 100644 --- a/apps/native/src/app/(tabs)/(settings)/index.tsx +++ b/apps/native/src/app/(tabs)/(settings)/index.tsx @@ -21,6 +21,7 @@ import { IconWorld, } from "@tabler/icons-react-native"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { reloadAppAsync } from "expo"; import * as Application from "expo-application"; import * as ImagePicker from "expo-image-picker"; import { useRouter } from "expo-router"; @@ -54,7 +55,7 @@ import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog"; import { queryClient } from "@/lib/query-client"; import { authClient, getServerUrl } from "@/lib/server"; import { toast } from "@/lib/toast"; -import { activateLocale, type SupportedLocale } from "@sofa/i18n"; +import { activateLocale, isLocaleRTL, type SupportedLocale } from "@sofa/i18n"; import { LOCALE_INFO } from "@sofa/i18n/locales"; const settingsContentContainerStyle = { @@ -421,7 +422,10 @@ export default function SettingsScreen() { label={t`Database`} value={ systemHealth.data?.database - ? t`${plural(systemHealth.data.database.titleCount, { one: "# title", other: "# titles" })}` + ? plural(systemHealth.data.database.titleCount, { + one: "# title", + other: "# titles", + }) : "—" } icon={IconDatabase} @@ -435,7 +439,10 @@ export default function SettingsScreen() { label={t`Image Cache`} value={ systemHealth.data?.imageCache - ? t`${plural(systemHealth.data.imageCache.imageCount, { one: "# image", other: "# images" })}` + ? plural(systemHealth.data.imageCache.imageCount, { + one: "# image", + other: "# images", + }) : "—" } icon={IconPhoto} @@ -472,13 +479,16 @@ export default function SettingsScreen() { /> } /> - {updateCheck.data?.updateCheck?.updateAvailable && ( - - - Update available: {updateCheck.data.updateCheck.latestVersion} - - - )} + {(() => { + const latestVersion = updateCheck.data?.updateCheck?.latestVersion; + return updateCheck.data?.updateCheck?.updateAvailable ? ( + + + Update available: {latestVersion} + + + ) : null; + })()} )} @@ -512,8 +522,18 @@ export default function SettingsScreen() { }))} onSelect={(locale) => { setLanguageModalOpen(false); + const previousLocale = i18n.locale; activateLocale(locale as SupportedLocale).then( - () => setPersistedLocale(locale as SupportedLocale), + () => { + setPersistedLocale(locale as SupportedLocale); + if (isLocaleRTL(locale) !== isLocaleRTL(previousLocale)) { + Alert.alert( + t`Restart Required`, + t`Sofa needs to restart to apply the new layout direction.`, + [{ text: t`Restart`, onPress: () => reloadAppAsync() }], + ); + } + }, () => {}, ); }} diff --git a/apps/native/src/components/settings/settings-row.tsx b/apps/native/src/components/settings/settings-row.tsx index b835603..c048b23 100644 --- a/apps/native/src/components/settings/settings-row.tsx +++ b/apps/native/src/components/settings/settings-row.tsx @@ -1,7 +1,7 @@ import type { Icon } from "@tabler/icons-react-native"; -import { IconChevronRight } from "@tabler/icons-react-native"; +import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react-native"; import type { ReactNode } from "react"; -import { Pressable, View } from "react-native"; +import { I18nManager, Pressable, View } from "react-native"; import { useCSSVariable } from "uniwind"; import { ScaledIcon } from "@/components/ui/scaled-icon"; @@ -53,7 +53,12 @@ export function SettingsRow({ ) : null} {!right && onPress && ( - + )} ); diff --git a/apps/native/src/components/titles/season-accordion.tsx b/apps/native/src/components/titles/season-accordion.tsx index 1337ecc..1400a30 100644 --- a/apps/native/src/components/titles/season-accordion.tsx +++ b/apps/native/src/components/titles/season-accordion.tsx @@ -99,6 +99,9 @@ export function SeasonAccordion({ [watchedEpisodeIds, unwatchEpisode, watchEpisode], ); + const seasonNumber = season.seasonNumber; + const episodeCount = episodes.length; + return ( - {season.name ?? t`Season ${season.seasonNumber}`} + {season.name ?? t`Season ${seasonNumber}`} - {t`${watchedCount}/${episodes.length} ${plural(episodes.length, { one: "episode", other: "episodes" })}`} + {t`${watchedCount}/${plural(episodeCount, { one: "# episode", other: "# episodes" })}`} diff --git a/apps/native/src/hooks/use-title-actions.ts b/apps/native/src/hooks/use-title-actions.ts index 18b31ed..892f516 100644 --- a/apps/native/src/hooks/use-title-actions.ts +++ b/apps/native/src/hooks/use-title-actions.ts @@ -81,9 +81,10 @@ export function useTitleActions(options?: UseTitleActionsOptions) { const updateRating = useMutation( orpc.titles.updateRating.mutationOptions({ onSuccess: (_data, input) => { + const stars = input.stars; const defaultMsg = - input.stars > 0 - ? t`Rated ${input.stars} ${plural(input.stars, { one: "star", other: "stars" })}` + stars > 0 + ? t`Rated ${plural(stars, { one: "# star", other: "# stars" })}` : t`Rating removed`; toast.success(resolveToast(toastOverrides?.updateRating, defaultMsg, input)); // Rating only invalidates title queries, not dashboard diff --git a/apps/native/src/lib/i18n.ts b/apps/native/src/lib/i18n.ts index 64a53ef..c75f358 100644 --- a/apps/native/src/lib/i18n.ts +++ b/apps/native/src/lib/i18n.ts @@ -1,6 +1,8 @@ +import { reloadAppAsync } from "expo"; import * as Localization from "expo-localization"; +import { I18nManager } from "react-native"; -import { activateLocale, SUPPORTED_LOCALES, type SupportedLocale } from "@sofa/i18n"; +import { activateLocale, isLocaleRTL, SUPPORTED_LOCALES, type SupportedLocale } from "@sofa/i18n"; import { globalStorage } from "./mmkv"; @@ -23,10 +25,21 @@ export function getPersistedLocale(): SupportedLocale { export function setPersistedLocale(locale: SupportedLocale): void { globalStorage.set(LOCALE_STORAGE_KEY, locale); + const rtl = isLocaleRTL(locale); + I18nManager.allowRTL(rtl); + I18nManager.forceRTL(rtl); } export function initLocale(): Promise { const locale = getPersistedLocale(); + const rtl = isLocaleRTL(locale); + I18nManager.allowRTL(rtl); + I18nManager.forceRTL(rtl); + // forceRTL only takes effect on the next launch — if the current layout + // doesn't match (e.g. fresh install on an RTL device), reload immediately. + if (I18nManager.isRTL !== rtl) { + reloadAppAsync(); + } if (locale !== "en") { return activateLocale(locale); } diff --git a/apps/native/src/lib/intl-polyfills.ts b/apps/native/src/lib/intl-polyfills.ts index 4915dcc..489f0cf 100644 --- a/apps/native/src/lib/intl-polyfills.ts +++ b/apps/native/src/lib/intl-polyfills.ts @@ -20,6 +20,12 @@ import "@formatjs/intl-pluralrules/locale-data/de.js"; import "@formatjs/intl-pluralrules/locale-data/es.js"; import "@formatjs/intl-pluralrules/locale-data/it.js"; import "@formatjs/intl-pluralrules/locale-data/pt.js"; +import "@formatjs/intl-pluralrules/locale-data/ar.js"; +import "@formatjs/intl-pluralrules/locale-data/nl.js"; +import "@formatjs/intl-pluralrules/locale-data/he.js"; +import "@formatjs/intl-pluralrules/locale-data/zh.js"; +import "@formatjs/intl-pluralrules/locale-data/ja.js"; +import "@formatjs/intl-pluralrules/locale-data/ko.js"; // 4. NumberFormat (depends on PluralRules, Locale) import "@formatjs/intl-numberformat/polyfill.js"; import "@formatjs/intl-numberformat/locale-data/en.js"; @@ -28,6 +34,12 @@ import "@formatjs/intl-numberformat/locale-data/de.js"; import "@formatjs/intl-numberformat/locale-data/es.js"; import "@formatjs/intl-numberformat/locale-data/it.js"; import "@formatjs/intl-numberformat/locale-data/pt.js"; +import "@formatjs/intl-numberformat/locale-data/ar.js"; +import "@formatjs/intl-numberformat/locale-data/nl.js"; +import "@formatjs/intl-numberformat/locale-data/he.js"; +import "@formatjs/intl-numberformat/locale-data/zh.js"; +import "@formatjs/intl-numberformat/locale-data/ja.js"; +import "@formatjs/intl-numberformat/locale-data/ko.js"; // 5. DateTimeFormat (depends on Locale) import "@formatjs/intl-datetimeformat/polyfill.js"; import "@formatjs/intl-datetimeformat/locale-data/en.js"; @@ -36,6 +48,12 @@ import "@formatjs/intl-datetimeformat/locale-data/de.js"; import "@formatjs/intl-datetimeformat/locale-data/es.js"; import "@formatjs/intl-datetimeformat/locale-data/it.js"; import "@formatjs/intl-datetimeformat/locale-data/pt.js"; +import "@formatjs/intl-datetimeformat/locale-data/ar.js"; +import "@formatjs/intl-datetimeformat/locale-data/nl.js"; +import "@formatjs/intl-datetimeformat/locale-data/he.js"; +import "@formatjs/intl-datetimeformat/locale-data/zh.js"; +import "@formatjs/intl-datetimeformat/locale-data/ja.js"; +import "@formatjs/intl-datetimeformat/locale-data/ko.js"; // 6. RelativeTimeFormat (depends on PluralRules, Locale) import "@formatjs/intl-relativetimeformat/polyfill.js"; import "@formatjs/intl-relativetimeformat/locale-data/en.js"; @@ -44,3 +62,9 @@ import "@formatjs/intl-relativetimeformat/locale-data/de.js"; import "@formatjs/intl-relativetimeformat/locale-data/es.js"; import "@formatjs/intl-relativetimeformat/locale-data/it.js"; import "@formatjs/intl-relativetimeformat/locale-data/pt.js"; +import "@formatjs/intl-relativetimeformat/locale-data/ar.js"; +import "@formatjs/intl-relativetimeformat/locale-data/nl.js"; +import "@formatjs/intl-relativetimeformat/locale-data/he.js"; +import "@formatjs/intl-relativetimeformat/locale-data/zh.js"; +import "@formatjs/intl-relativetimeformat/locale-data/ja.js"; +import "@formatjs/intl-relativetimeformat/locale-data/ko.js"; diff --git a/apps/native/tsconfig.json b/apps/native/tsconfig.json index cab70d5..ff8ba58 100644 --- a/apps/native/tsconfig.json +++ b/apps/native/tsconfig.json @@ -7,5 +7,12 @@ "@/*": ["./src/*"] } }, - "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "uniwind-types.d.ts"] + "include": [ + "**/*.ts", + "**/*.tsx", + ".expo/types/**/*.ts", + "expo-env.d.ts", + "uniwind-types.d.ts", + "../../packages/i18n/src/po.d.ts" + ] } diff --git a/apps/web/components.json b/apps/web/components.json index 106612a..3fea3e4 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -11,7 +11,7 @@ "prefix": "" }, "iconLibrary": "tabler", - "rtl": false, + "rtl": true, "aliases": { "components": "@/components", "utils": "@/lib/utils", diff --git a/apps/web/package.json b/apps/web/package.json index 09da2b3..ce51a7d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,7 +28,7 @@ "@tabler/icons-react": "3.40.0", "@tanstack/react-hotkeys": "0.4.2", "@tanstack/react-query": "catalog:", - "@tanstack/react-router": "1.167.5", + "@tanstack/react-router": "1.168.1", "better-auth": "catalog:", "class-variance-authority": "0.7.1", "clsx": "2.1.1", @@ -37,7 +37,7 @@ "motion": "12.38.0", "react": "catalog:", "react-dom": "catalog:", - "shadcn": "4.0.8", + "shadcn": "4.1.0", "sonner": "2.0.7", "tailwind-merge": "catalog:", "thumbhash": "catalog:", @@ -51,8 +51,8 @@ "@tanstack/devtools-vite": "0.6.0", "@tanstack/react-devtools": "0.10.0", "@tanstack/react-query-devtools": "5.91.3", - "@tanstack/react-router-devtools": "1.166.9", - "@tanstack/router-plugin": "1.166.14", + "@tanstack/react-router-devtools": "1.166.10", + "@tanstack/router-plugin": "1.167.1", "@types/bun": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", diff --git a/apps/web/src/components/auth-form.tsx b/apps/web/src/components/auth-form.tsx index 1e80e13..48b3f05 100644 --- a/apps/web/src/components/auth-form.tsx +++ b/apps/web/src/components/auth-form.tsx @@ -48,6 +48,7 @@ export function AuthForm({ const isRegister = mode === "register"; const showOidc = authConfig?.oidcEnabled ?? false; const showPasswordForm = !(authConfig?.passwordLoginDisabled ?? false); + const oidcProviderName = authConfig?.oidcProviderName || "SSO"; async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -139,7 +140,7 @@ export function AuthForm({ {oidcLoading ? ( Redirecting… ) : ( - Sign in with {authConfig?.oidcProviderName || "SSO"} + Sign in with {oidcProviderName} )} diff --git a/apps/web/src/components/dashboard/continue-watching-card.tsx b/apps/web/src/components/dashboard/continue-watching-card.tsx index ea61806..9af2cfb 100644 --- a/apps/web/src/components/dashboard/continue-watching-card.tsx +++ b/apps/web/src/components/dashboard/continue-watching-card.tsx @@ -27,6 +27,8 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItemProps const { t } = useLingui(); const stillUrl = item.nextEpisode?.stillPath ?? item.title.backdropPath ?? null; const progress = item.totalEpisodes > 0 ? (item.watchedEpisodes / item.totalEpisodes) * 100 : 0; + const watchedEpisodes = item.watchedEpisodes; + const totalEpisodes = item.totalEpisodes; return (

- + S{item.nextEpisode.seasonNumber} E{item.nextEpisode.episodeNumber} {" "} {item.nextEpisode.name} @@ -75,7 +77,7 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItemProps

{item.title.title}

- {t`${item.watchedEpisodes}/${item.totalEpisodes} ${plural(item.totalEpisodes, { one: "episode", other: "episodes" })}`} + {t`${watchedEpisodes}/${plural(totalEpisodes, { one: "# episode", other: "# episodes" })}`}

diff --git a/apps/web/src/components/explore/hero-banner.tsx b/apps/web/src/components/explore/hero-banner.tsx index 205d476..b1ce5e9 100644 --- a/apps/web/src/components/explore/hero-banner.tsx +++ b/apps/web/src/components/explore/hero-banner.tsx @@ -70,7 +70,7 @@ export function HeroBanner({ Trending today
- +

{title}

diff --git a/apps/web/src/components/nav-bar.tsx b/apps/web/src/components/nav-bar.tsx index 8766f97..21dd631 100644 --- a/apps/web/src/components/nav-bar.tsx +++ b/apps/web/src/components/nav-bar.tsx @@ -188,7 +188,7 @@ export function NavBar({ > {t`Search…`} - ⌘ K + ⌘ K {userName} {userRole === "admin" && ( - + Admin )} @@ -291,7 +291,7 @@ export function MobileTabBar() { return (