From eb9c7ae6df4ddf8281040d1981111d20cbffb58e Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Mon, 23 Mar 2026 16:21:54 -0400 Subject: [PATCH] fix(i18n): comprehensive audit of translated strings across web and native - Replace hand-rolled pluralization (ternary suffix hacks) with `plural` macro - Wrap 40+ untranslated user-facing strings with LingUI macros: aria-labels, accessibilityLabels/Hints, placeholders, error fallbacks, toast overrides - Unify episode progress format to `${watched}/${plural(total, ...)}` across all 4 call sites (title-card, continue-watching, season-accordion) - Unify capitalization: "Sign in", "Sign out", "Create account", "Change password", "Update password", "Image cache", "Remove from library", "Mark Watched", "Trending Today", "On Watchlist", "or" - Translate cron schedule strings in system health via `i18n._(msg)` with locale-aware day names via `Intl.DateTimeFormat` - Move Zod validation schemas inside components with `useMemo(() => ..., [t])` so validation messages are translatable at runtime - Unify "Watched all of" variable naming to `seasonLabel` across all call sites to produce a single msgid - Remove trailing period from lone "Failed to..." error message - Add EAS build i18n template extraction step Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/eas-build.yml | 3 + .gitignore | 1 + apps/native/src/app/(auth)/login.tsx | 28 +- apps/native/src/app/(auth)/register.tsx | 41 ++- apps/native/src/app/(auth)/server-url.tsx | 4 +- apps/native/src/app/(tabs)/(home)/index.tsx | 2 +- .../src/app/(tabs)/(settings)/index.tsx | 20 +- apps/native/src/app/change-password.tsx | 40 ++- apps/native/src/app/title/[id].tsx | 9 +- .../dashboard/continue-watching-card.tsx | 13 +- .../src/components/dashboard/upcoming-row.tsx | 4 +- .../src/components/explore/hero-banner.tsx | 2 +- apps/native/src/components/header-avatar.tsx | 6 +- .../components/titles/season-accordion.tsx | 16 +- .../titles/status-action-button.tsx | 4 +- apps/native/src/components/ui/poster-card.tsx | 4 +- apps/native/src/components/ui/skeleton.tsx | 4 +- apps/native/src/components/ui/star-rating.tsx | 11 +- apps/native/src/lib/title-actions.ts | 4 +- apps/web/src/components/auth-form.tsx | 15 +- apps/web/src/components/command-palette.tsx | 2 +- .../src/components/explore/hero-banner.tsx | 2 +- apps/web/src/components/landing-page.tsx | 2 +- apps/web/src/components/nav-bar.tsx | 8 +- .../components/people/filmography-grid.tsx | 2 +- .../settings/backup-schedule-section.tsx | 2 +- .../settings/system-health-section.tsx | 24 +- apps/web/src/components/title-card.tsx | 5 +- .../src/components/titles/cast-carousel.tsx | 11 +- .../web/src/components/titles/star-rating.tsx | 7 +- .../src/components/titles/status-button.tsx | 2 +- apps/web/src/components/titles/title-hero.tsx | 5 +- .../src/components/titles/title-seasons.tsx | 10 +- apps/web/src/components/ui/spinner.tsx | 4 +- packages/i18n/src/po/en.po | 333 +++++++++++++++--- 35 files changed, 489 insertions(+), 161 deletions(-) diff --git a/.github/workflows/eas-build.yml b/.github/workflows/eas-build.yml index 463fd92..e4d170d 100644 --- a/.github/workflows/eas-build.yml +++ b/.github/workflows/eas-build.yml @@ -47,6 +47,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Extract i18n template + run: bunx lingui extract-template + - name: Build working-directory: apps/native run: eas build --platform ${{ inputs.platform || 'all' }} --profile ${{ inputs.profile || 'production' }} --non-interactive --no-wait diff --git a/.gitignore b/.gitignore index 729650e..5341e15 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ coverage tmp temp crowdin-context.jsonl +messages.pot # local data *.db diff --git a/apps/native/src/app/(auth)/login.tsx b/apps/native/src/app/(auth)/login.tsx index 99dd496..ec215cd 100644 --- a/apps/native/src/app/(auth)/login.tsx +++ b/apps/native/src/app/(auth)/login.tsx @@ -3,7 +3,7 @@ import { IconServer2 } from "@tabler/icons-react-native"; import { useForm, useStore } from "@tanstack/react-form"; import { useQuery } from "@tanstack/react-query"; import { Link } from "expo-router"; -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { Pressable, type TextInput, View } from "react-native"; import Animated, { FadeIn, FadeInDown } from "react-native-reanimated"; import { useCSSVariable } from "uniwind"; @@ -22,13 +22,21 @@ import { toast } from "@/lib/toast"; import { getFormErrors } from "@/utils/form-errors"; import * as Haptics from "@/utils/haptics"; -const signInSchema = z.object({ - email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"), - password: z.string().min(1, "Password is required"), -}); - export default function LoginScreen() { const { t } = useLingui(); + + const signInSchema = useMemo( + () => + z.object({ + email: z + .string() + .trim() + .min(1, t`Email is required`) + .email(t`Enter a valid email address`), + password: z.string().min(1, t`Password is required`), + }), + [t], + ); const passwordRef = useRef(null); const [errorFields, setErrorFields] = useState>(new Set()); const [isSignedIn, setIsSignedIn] = useState(false); @@ -108,7 +116,7 @@ export default function LoginScreen() { - OR + or @@ -128,7 +136,7 @@ export default function LoginScreen() { { field.handleChange(text); @@ -159,7 +167,7 @@ export default function LoginScreen() { { field.handleChange(text); @@ -184,7 +192,7 @@ export default function LoginScreen() { ) : ( - Sign In + Sign in )} diff --git a/apps/native/src/app/(auth)/register.tsx b/apps/native/src/app/(auth)/register.tsx index c46f006..3d58622 100644 --- a/apps/native/src/app/(auth)/register.tsx +++ b/apps/native/src/app/(auth)/register.tsx @@ -2,7 +2,7 @@ import { Trans, useLingui } from "@lingui/react/macro"; import { useForm, useStore } from "@tanstack/react-form"; import { useQuery } from "@tanstack/react-query"; import { Link } from "expo-router"; -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { Pressable, type TextInput, View } from "react-native"; import Animated, { FadeIn, FadeInDown } from "react-native-reanimated"; import { z } from "zod"; @@ -19,14 +19,29 @@ import { toast } from "@/lib/toast"; import { getFormErrors } from "@/utils/form-errors"; import * as Haptics from "@/utils/haptics"; -const signUpSchema = z.object({ - name: z.string().trim().min(1, "Name is required").min(2, "Name must be at least 2 characters"), - email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"), - password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"), -}); - export default function RegisterScreen() { const { t } = useLingui(); + + const signUpSchema = useMemo( + () => + z.object({ + name: z + .string() + .trim() + .min(1, t`Name is required`) + .min(2, t`Name must be at least 2 characters`), + email: z + .string() + .trim() + .min(1, t`Email is required`) + .email(t`Enter a valid email address`), + password: z + .string() + .min(1, t`Password is required`) + .min(8, t`Use at least 8 characters`), + }), + [t], + ); const emailRef = useRef(null); const passwordRef = useRef(null); const [errorFields, setErrorFields] = useState>(new Set()); @@ -101,7 +116,7 @@ export default function RegisterScreen() { } return ( - + @@ -112,7 +127,7 @@ export default function RegisterScreen() { { field.handleChange(text); @@ -141,13 +156,13 @@ export default function RegisterScreen() { { field.handleChange(text); clearFieldError("email"); }} - placeholder="email@example.com" + placeholder="wwhite@graymatter.biz" keyboardType="email-address" autoCapitalize="none" autoComplete="email" @@ -172,7 +187,7 @@ export default function RegisterScreen() { { field.handleChange(text); @@ -197,7 +212,7 @@ export default function RegisterScreen() { ) : ( - Create Account + Create account )} diff --git a/apps/native/src/app/(auth)/server-url.tsx b/apps/native/src/app/(auth)/server-url.tsx index 576ff30..1d7a53e 100644 --- a/apps/native/src/app/(auth)/server-url.tsx +++ b/apps/native/src/app/(auth)/server-url.tsx @@ -136,8 +136,8 @@ export default function ServerUrlScreen() { { - Alert.alert(t`Sign Out`, t`Are you sure you want to sign out?`, [ + Alert.alert(t`Sign out`, t`Are you sure you want to sign out?`, [ { text: t`Cancel`, style: "cancel" }, { - text: t`Sign Out`, + text: t`Sign out`, style: "destructive", onPress: () => { authClient.signOut(); @@ -234,8 +234,8 @@ export default function SettingsScreen() { @@ -280,7 +280,7 @@ export default function SettingsScreen() { @@ -307,7 +307,7 @@ export default function SettingsScreen() { @@ -396,7 +396,7 @@ export default function SettingsScreen() { right={ { setAnalyticsToggle(enabled); setAnalyticsEnabled(enabled); @@ -440,7 +440,7 @@ export default function SettingsScreen() { icon={IconCloud} /> toggleRegistration.mutate({ open })} /> } @@ -478,7 +478,7 @@ export default function SettingsScreen() { right={ toggleUpdateCheck.mutate({ enabled })} /> } diff --git a/apps/native/src/app/change-password.tsx b/apps/native/src/app/change-password.tsx index 782f36f..49a5b0d 100644 --- a/apps/native/src/app/change-password.tsx +++ b/apps/native/src/app/change-password.tsx @@ -1,7 +1,7 @@ import { Trans, useLingui } from "@lingui/react/macro"; import { useForm } from "@tanstack/react-form"; import { Stack, useRouter } from "expo-router"; -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { Alert, ScrollView, type TextInput, View } from "react-native"; import Animated, { FadeInDown } from "react-native-reanimated"; import { useResolveClassNames } from "uniwind"; @@ -16,17 +16,6 @@ import { authClient } from "@/lib/server"; import { toast } from "@/lib/toast"; import * as Haptics from "@/utils/haptics"; -const changePasswordSchema = z - .object({ - currentPassword: z.string().min(1, "Current password is required"), - newPassword: z.string().min(8, "Password must be at least 8 characters"), - confirmPassword: z.string().min(1, "Please confirm your password"), - }) - .refine((data) => data.newPassword === data.confirmPassword, { - message: "Passwords do not match", - path: ["confirmPassword"], - }); - function formatFormErrors(errors: unknown): string | null { if (!errors) return null; if (typeof errors === "string") return errors; @@ -41,6 +30,21 @@ function formatFormErrors(errors: unknown): string | null { export default function ChangePasswordScreen() { const { t } = useLingui(); + + const changePasswordSchema = useMemo( + () => + z + .object({ + currentPassword: z.string().min(1, t`Current password is required`), + newPassword: z.string().min(8, t`Password must be at least 8 characters`), + confirmPassword: z.string().min(1, t`Please confirm your password`), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: t`Passwords do not match`, + path: ["confirmPassword"], + }), + [t], + ); const { back } = useRouter(); const newPasswordRef = useRef(null); const confirmPasswordRef = useRef(null); @@ -91,7 +95,7 @@ export default function ChangePasswordScreen() { keyboardShouldPersistTaps="handled" > }> - Change Password + Change password @@ -209,7 +213,7 @@ export default function ChangePasswordScreen() { ) : ( - Update Password + Update password )} diff --git a/apps/native/src/app/title/[id].tsx b/apps/native/src/app/title/[id].tsx index af4bec9..b4739ad 100644 --- a/apps/native/src/app/title/[id].tsx +++ b/apps/native/src/app/title/[id].tsx @@ -115,7 +115,10 @@ export default function TitleDetailScreen() { quickAdd: quickAddMutation, } = useTitleActions({ toasts: { - watchMovie: () => (title?.title ? `Marked "${title.title}" as watched` : "Marked as watched"), + watchMovie: () => { + const name = title?.title; + return name ? t`Marked "${name}" as watched` : t`Marked as watched`; + }, }, }); @@ -315,8 +318,8 @@ export default function TitleDetailScreen() { ) } accessibilityRole="button" - accessibilityLabel={`Play trailer for ${title.title}`} - accessibilityHint="Opens the trailer in YouTube" + accessibilityLabel={t`Play trailer for ${titleName}`} + accessibilityHint={t`Opens the trailer in YouTube`} className="absolute inset-0 items-center justify-center" > {isLiquidGlassAvailable() ? ( diff --git a/apps/native/src/components/dashboard/continue-watching-card.tsx b/apps/native/src/components/dashboard/continue-watching-card.tsx index ee65649..12b4042 100644 --- a/apps/native/src/components/dashboard/continue-watching-card.tsx +++ b/apps/native/src/components/dashboard/continue-watching-card.tsx @@ -1,3 +1,4 @@ +import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { Link } from "expo-router"; import { memo } from "react"; @@ -36,10 +37,12 @@ export const ContinueWatchingCard = memo(function ContinueWatchingCard({ const { t } = useLingui(); const { animatedStyle, gesture: tapGesture } = usePressAnimation(); - const progressLabel = `${item.watchedEpisodes} of ${item.totalEpisodes} episodes`; - const nextEpLabel = item.nextEpisode - ? `Next: Season ${item.nextEpisode.seasonNumber} Episode ${item.nextEpisode.episodeNumber}` - : undefined; + const watchedEpisodes = item.watchedEpisodes; + const totalEpisodes = item.totalEpisodes; + const progressLabel = t`${watchedEpisodes}/${plural(totalEpisodes, { one: "# episode", other: "# episodes" })}`; + const seasonNum = item.nextEpisode?.seasonNumber; + const epNum = item.nextEpisode?.episodeNumber; + const nextEpLabel = item.nextEpisode ? t`Next: S${seasonNum} E${epNum}` : undefined; const cardLabel = [item.title.title, progressLabel, nextEpLabel].filter(Boolean).join(", "); return ( @@ -105,7 +108,7 @@ export const ContinueWatchingCard = memo(function ContinueWatchingCard({ titleActions.removeFromLibrary(item.title.id)} diff --git a/apps/native/src/components/dashboard/upcoming-row.tsx b/apps/native/src/components/dashboard/upcoming-row.tsx index 9758b88..27270a6 100644 --- a/apps/native/src/components/dashboard/upcoming-row.tsx +++ b/apps/native/src/components/dashboard/upcoming-row.tsx @@ -128,7 +128,7 @@ export function UpcomingRow({ item }: { item: UpcomingItem }) { {item.titleType === "movie" && ( titleActions.markMovieWatched(item.titleId, item.titleName)} /> @@ -141,7 +141,7 @@ export function UpcomingRow({ item }: { item: UpcomingItem }) { /> )} titleActions.removeFromLibrary(item.titleId)} diff --git a/apps/native/src/components/explore/hero-banner.tsx b/apps/native/src/components/explore/hero-banner.tsx index 5e82d64..ddad660 100644 --- a/apps/native/src/components/explore/hero-banner.tsx +++ b/apps/native/src/components/explore/hero-banner.tsx @@ -55,7 +55,7 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) { {item.backdropPath && ( diff --git a/apps/native/src/components/header-avatar.tsx b/apps/native/src/components/header-avatar.tsx index 8313e0f..de61467 100644 --- a/apps/native/src/components/header-avatar.tsx +++ b/apps/native/src/components/header-avatar.tsx @@ -24,7 +24,7 @@ export function HeaderAvatar() { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)} accessibilityRole="button" - accessibilityLabel="User menu" + accessibilityLabel={t`User menu`} hitSlop={8} > @@ -59,10 +59,10 @@ export function HeaderAvatar() { key="sign-out" destructive onSelect={() => { - Alert.alert(t`Sign Out`, t`Are you sure you want to sign out?`, [ + Alert.alert(t`Sign out`, t`Are you sure you want to sign out?`, [ { text: t`Cancel`, style: "cancel" }, { - text: t`Sign Out`, + text: t`Sign out`, style: "destructive", onPress: () => { authClient.signOut(); diff --git a/apps/native/src/components/titles/season-accordion.tsx b/apps/native/src/components/titles/season-accordion.tsx index 1400a30..7a4ab61 100644 --- a/apps/native/src/components/titles/season-accordion.tsx +++ b/apps/native/src/components/titles/season-accordion.tsx @@ -78,13 +78,21 @@ export function SeasonAccordion({ toasts: { watchEpisode: ({ id: epId }) => { const ep = episodes.find((e) => e.id === epId); - return ep ? `Watched S${season.seasonNumber} E${ep.episodeNumber}` : "Episode watched"; + const sNum = season.seasonNumber; + const eNum = ep?.episodeNumber; + return ep ? t`Watched S${sNum} E${eNum}` : t`Episode watched`; }, unwatchEpisode: ({ id: epId }) => { const ep = episodes.find((e) => e.id === epId); - return ep ? `Unwatched S${season.seasonNumber} E${ep.episodeNumber}` : "Episode unwatched"; + const sNum = season.seasonNumber; + const eNum = ep?.episodeNumber; + return ep ? t`Unwatched S${sNum} E${eNum}` : t`Episode unwatched`; }, - watchSeason: `Watched all of ${season.name ?? `Season ${season.seasonNumber}`}`, + watchSeason: (() => { + const sn = season.seasonNumber; + const seasonLabel = season.name ?? t`Season ${sn}`; + return t`Watched all of ${seasonLabel}`; + })(), }, }); @@ -113,7 +121,7 @@ export function SeasonAccordion({ diff --git a/apps/native/src/components/titles/status-action-button.tsx b/apps/native/src/components/titles/status-action-button.tsx index ffe10dd..03dd756 100644 --- a/apps/native/src/components/titles/status-action-button.tsx +++ b/apps/native/src/components/titles/status-action-button.tsx @@ -43,7 +43,7 @@ const STATUS_STYLES = { function StatusLabel({ status }: { status: TitleStatus }) { switch (status) { case "in_watchlist": - return In Watchlist; + return On Watchlist; case "watching": return Watching; case "caught_up": @@ -77,7 +77,7 @@ export function StatusActionButton({ }} disabled={isPending} accessibilityRole="button" - accessibilityLabel={t`Add to watchlist`} + accessibilityLabel={t`Add to Watchlist`} className="border-title-accent/20 bg-title-accent/10 flex-row items-center gap-1.5 rounded-lg border px-4 py-2" > diff --git a/apps/native/src/components/ui/poster-card.tsx b/apps/native/src/components/ui/poster-card.tsx index b01cab2..c60b6fa 100644 --- a/apps/native/src/components/ui/poster-card.tsx +++ b/apps/native/src/components/ui/poster-card.tsx @@ -219,7 +219,7 @@ export const PosterCard = memo(function PosterCard({ )} {type === "movie" && ( titleActions.markMovieWatched(id, title)} /> @@ -233,7 +233,7 @@ export const PosterCard = memo(function PosterCard({ )} {userStatus && ( titleActions.removeFromLibrary(id)} diff --git a/apps/native/src/components/ui/skeleton.tsx b/apps/native/src/components/ui/skeleton.tsx index 60e2720..beb661c 100644 --- a/apps/native/src/components/ui/skeleton.tsx +++ b/apps/native/src/components/ui/skeleton.tsx @@ -1,3 +1,4 @@ +import { useLingui } from "@lingui/react/macro"; import { useEffect } from "react"; import type { ViewStyle } from "react-native"; import Animated, { @@ -17,6 +18,7 @@ interface SkeletonProps { } export function Skeleton({ width, height = 14, borderRadius = 4, style }: SkeletonProps) { + const { t } = useLingui(); const secondaryColor = useCSSVariable("--color-secondary") as string; const reduceMotion = useReducedMotion(); const opacity = useSharedValue(reduceMotion ? 0.7 : 0.4); @@ -34,7 +36,7 @@ export function Skeleton({ width, height = 14, borderRadius = 4, style }: Skelet return ( {[1, 2, 3, 4, 5].map((star) => ( @@ -37,12 +40,12 @@ export function StarRating({ key={star} disabled={!interactive} accessibilityRole="button" - accessibilityLabel={`${star} star${star !== 1 ? "s" : ""}`} + accessibilityLabel={plural(star, { one: "# star", other: "# stars" })} accessibilityHint={ interactive ? star === rating - ? "Double tap to clear rating" - : `Double tap to rate ${star} star${star !== 1 ? "s" : ""}` + ? t`Double tap to clear rating` + : t`Double tap to rate ${plural(star, { one: "# star", other: "# stars" })}` : undefined } onPress={() => { diff --git a/apps/native/src/lib/title-actions.ts b/apps/native/src/lib/title-actions.ts index 978e6a8..8906b8b 100644 --- a/apps/native/src/lib/title-actions.ts +++ b/apps/native/src/lib/title-actions.ts @@ -112,11 +112,11 @@ export const titleActions = { } }, - async watchSeason(id: string, seasonName?: string) { + async watchSeason(id: string, seasonLabel?: string) { try { await client.seasons.watch({ id }); toast.success( - seasonName ? i18n._(msg`Watched all of ${seasonName}`) : i18n._(msg`Season watched`), + seasonLabel ? i18n._(msg`Watched all of ${seasonLabel}`) : i18n._(msg`Season watched`), ); invalidateTitleQueries(); } catch { diff --git a/apps/web/src/components/auth-form.tsx b/apps/web/src/components/auth-form.tsx index 48b3f05..66fc580 100644 --- a/apps/web/src/components/auth-form.tsx +++ b/apps/web/src/components/auth-form.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { IconKey } from "@tabler/icons-react"; import { Link, useNavigate } from "@tanstack/react-router"; import { AnimatePresence, motion } from "motion/react"; @@ -37,6 +37,7 @@ export function AuthForm({ mode: "login" | "register"; authConfig?: AuthConfig; }) { + const { t } = useLingui(); const navigate = useNavigate(); const [name, setName] = useState(""); const [email, setEmail] = useState(""); @@ -59,19 +60,19 @@ export function AuthForm({ if (isRegister) { const result = await signUp.email({ name, email, password }); if (result.error) { - setError(result.error.message ?? "Registration failed"); + setError(result.error.message ?? t`Registration failed`); return; } } else { const result = await signIn.email({ email, password }); if (result.error) { - setError(result.error.message ?? "Login failed"); + setError(result.error.message ?? t`Login failed`); return; } } void navigate({ to: "/dashboard" }); } catch { - setError("Something went wrong"); + setError(t`Something went wrong`); } finally { setLoading(false); } @@ -86,7 +87,7 @@ export function AuthForm({ callbackURL: "/dashboard", }); } catch { - setError("Failed to start SSO login"); + setError(t`Failed to start SSO login`); } finally { setOidcLoading(false); } @@ -181,7 +182,7 @@ export function AuthForm({ value={name} onChange={(e) => setName(e.target.value)} className={authInputClass} - placeholder="Your name…" + placeholder={t`Your name…`} /> )} @@ -216,7 +217,7 @@ export function AuthForm({ value={password} onChange={(e) => setPassword(e.target.value)} className={authInputClass} - placeholder="Min 8 characters…" + placeholder={t`Min 8 characters…`} /> diff --git a/apps/web/src/components/command-palette.tsx b/apps/web/src/components/command-palette.tsx index 4c7bcec..55a1479 100644 --- a/apps/web/src/components/command-palette.tsx +++ b/apps/web/src/components/command-palette.tsx @@ -333,7 +333,7 @@ export function CommandPalette() {