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) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 16:21:54 -04:00
co-authored by Claude Opus 4.6
parent b289b7abed
commit eb9c7ae6df
35 changed files with 489 additions and 161 deletions
+3
View File
@@ -47,6 +47,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Extract i18n template
run: bunx lingui extract-template
- name: Build - name: Build
working-directory: apps/native working-directory: apps/native
run: eas build --platform ${{ inputs.platform || 'all' }} --profile ${{ inputs.profile || 'production' }} --non-interactive --no-wait run: eas build --platform ${{ inputs.platform || 'all' }} --profile ${{ inputs.profile || 'production' }} --non-interactive --no-wait
+1
View File
@@ -50,6 +50,7 @@ coverage
tmp tmp
temp temp
crowdin-context.jsonl crowdin-context.jsonl
messages.pot
# local data # local data
*.db *.db
+18 -10
View File
@@ -3,7 +3,7 @@ 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";
import { Link } from "expo-router"; 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 { Pressable, type TextInput, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated"; import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { useCSSVariable } from "uniwind"; import { useCSSVariable } from "uniwind";
@@ -22,13 +22,21 @@ import { toast } from "@/lib/toast";
import { getFormErrors } from "@/utils/form-errors"; import { getFormErrors } from "@/utils/form-errors";
import * as Haptics from "@/utils/haptics"; 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() { export default function LoginScreen() {
const { t } = useLingui(); 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<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);
@@ -108,7 +116,7 @@ export default function LoginScreen() {
<View className="my-4 flex-row items-center"> <View className="my-4 flex-row items-center">
<View className="bg-border h-px flex-1" /> <View className="bg-border h-px flex-1" />
<Text className="text-muted-foreground px-3 text-xs"> <Text className="text-muted-foreground px-3 text-xs">
<Trans>OR</Trans> <Trans>or</Trans>
</Text> </Text>
<View className="bg-border h-px flex-1" /> <View className="bg-border h-px flex-1" />
</View> </View>
@@ -128,7 +136,7 @@ export default function LoginScreen() {
</Label> </Label>
<Input <Input
value={field.state.value} value={field.state.value}
accessibilityLabel="Email" accessibilityLabel={t`Email`}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChangeText={(text) => { onChangeText={(text) => {
field.handleChange(text); field.handleChange(text);
@@ -159,7 +167,7 @@ export default function LoginScreen() {
<Input <Input
ref={passwordRef} ref={passwordRef}
value={field.state.value} value={field.state.value}
accessibilityLabel="Password" accessibilityLabel={t`Password`}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChangeText={(text) => { onChangeText={(text) => {
field.handleChange(text); field.handleChange(text);
@@ -184,7 +192,7 @@ export default function LoginScreen() {
<Spinner size="sm" /> <Spinner size="sm" />
) : ( ) : (
<ButtonLabel> <ButtonLabel>
<Trans>Sign In</Trans> <Trans>Sign in</Trans>
</ButtonLabel> </ButtonLabel>
)} )}
</Button> </Button>
+28 -13
View File
@@ -2,7 +2,7 @@ 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";
import { useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { Pressable, type TextInput, View } from "react-native"; import { Pressable, type TextInput, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated"; import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { z } from "zod"; import { z } from "zod";
@@ -19,14 +19,29 @@ import { toast } from "@/lib/toast";
import { getFormErrors } from "@/utils/form-errors"; import { getFormErrors } from "@/utils/form-errors";
import * as Haptics from "@/utils/haptics"; 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() { export default function RegisterScreen() {
const { t } = useLingui(); 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<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());
@@ -101,7 +116,7 @@ export default function RegisterScreen() {
} }
return ( return (
<AuthScreen title={t`Create Account`} subtitle={t`Registering on ${serverHost}`}> <AuthScreen title={t`Create account`} 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">
@@ -112,7 +127,7 @@ export default function RegisterScreen() {
</Label> </Label>
<Input <Input
value={field.state.value} value={field.state.value}
accessibilityLabel="Name" accessibilityLabel={t`Name`}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChangeText={(text) => { onChangeText={(text) => {
field.handleChange(text); field.handleChange(text);
@@ -141,13 +156,13 @@ export default function RegisterScreen() {
<Input <Input
ref={emailRef} ref={emailRef}
value={field.state.value} value={field.state.value}
accessibilityLabel="Email" accessibilityLabel={t`Email`}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChangeText={(text) => { onChangeText={(text) => {
field.handleChange(text); field.handleChange(text);
clearFieldError("email"); clearFieldError("email");
}} }}
placeholder="email@example.com" placeholder="wwhite@graymatter.biz"
keyboardType="email-address" keyboardType="email-address"
autoCapitalize="none" autoCapitalize="none"
autoComplete="email" autoComplete="email"
@@ -172,7 +187,7 @@ export default function RegisterScreen() {
<Input <Input
ref={passwordRef} ref={passwordRef}
value={field.state.value} value={field.state.value}
accessibilityLabel="Password" accessibilityLabel={t`Password`}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChangeText={(text) => { onChangeText={(text) => {
field.handleChange(text); field.handleChange(text);
@@ -197,7 +212,7 @@ export default function RegisterScreen() {
<Spinner size="sm" /> <Spinner size="sm" />
) : ( ) : (
<ButtonLabel> <ButtonLabel>
<Trans>Create Account</Trans> <Trans>Create account</Trans>
</ButtonLabel> </ButtonLabel>
)} )}
</Button> </Button>
+2 -2
View File
@@ -136,8 +136,8 @@ export default function ServerUrlScreen() {
<Input <Input
ref={inputRef} ref={inputRef}
value={url} value={url}
accessibilityLabel="Server URL" accessibilityLabel={t`Server URL`}
accessibilityHint="Enter the full URL for your Sofa server" accessibilityHint={t`Enter the full URL for your Sofa server`}
onChangeText={handleChangeText} onChangeText={handleChangeText}
placeholder="https://sofa.example.com" placeholder="https://sofa.example.com"
placeholderTextColorClassName="accent-muted-foreground/50" placeholderTextColorClassName="accent-muted-foreground/50"
+1 -1
View File
@@ -142,7 +142,7 @@ export default function DashboardScreen() {
</View> </View>
<View className="flex-row gap-3"> <View className="flex-row gap-3">
<StatsCard <StatsCard
label={t`In library`} label={t`In Library`}
value={stats.data?.librarySize} value={stats.data?.librarySize}
icon={IconBooks} icon={IconBooks}
color="text-status-watchlist" color="text-status-watchlist"
+10 -10
View File
@@ -204,10 +204,10 @@ export default function SettingsScreen() {
}, []); }, []);
const handleSignOut = () => { const handleSignOut = () => {
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`Cancel`, style: "cancel" },
{ {
text: t`Sign Out`, text: t`Sign out`,
style: "destructive", style: "destructive",
onPress: () => { onPress: () => {
authClient.signOut(); authClient.signOut();
@@ -234,8 +234,8 @@ export default function SettingsScreen() {
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Edit profile photo" accessibilityLabel={t`Edit profile photo`}
accessibilityHint="Opens options to change or remove your photo" accessibilityHint={t`Opens options to change or remove your photo`}
className="mr-3" className="mr-3"
hitSlop={8} hitSlop={8}
> >
@@ -280,7 +280,7 @@ export default function SettingsScreen() {
<Pressable <Pressable
onPress={pickAvatar} onPress={pickAvatar}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Add profile photo" accessibilityLabel={t`Add profile photo`}
className="mr-3" className="mr-3"
hitSlop={8} hitSlop={8}
> >
@@ -307,7 +307,7 @@ export default function SettingsScreen() {
<View className="flex-row items-center gap-2"> <View className="flex-row items-center gap-2">
<TextInput <TextInput
value={nameInput} value={nameInput}
accessibilityLabel="Display name" accessibilityLabel={t`Display name`}
onChangeText={setNameInput} onChangeText={setNameInput}
className="border-primary text-foreground min-h-10 flex-1 border-b py-2 font-sans text-base" className="border-primary text-foreground min-h-10 flex-1 border-b py-2 font-sans text-base"
/> />
@@ -396,7 +396,7 @@ export default function SettingsScreen() {
right={ right={
<Switch <Switch
value={analyticsEnabled} value={analyticsEnabled}
accessibilityLabel="Anonymous usage reporting" accessibilityLabel={t`Anonymous usage reporting`}
onValueChange={(enabled) => { onValueChange={(enabled) => {
setAnalyticsToggle(enabled); setAnalyticsToggle(enabled);
setAnalyticsEnabled(enabled); setAnalyticsEnabled(enabled);
@@ -440,7 +440,7 @@ export default function SettingsScreen() {
icon={IconCloud} icon={IconCloud}
/> />
<SettingsRow <SettingsRow
label={t`Image Cache`} label={t`Image cache`}
value={ value={
systemHealth.data?.imageCache systemHealth.data?.imageCache
? plural(systemHealth.data.imageCache.imageCount, { ? plural(systemHealth.data.imageCache.imageCount, {
@@ -467,7 +467,7 @@ export default function SettingsScreen() {
right={ right={
<Switch <Switch
value={registration.data?.open ?? false} value={registration.data?.open ?? false}
accessibilityLabel="Open registration" accessibilityLabel={t`Open registration`}
onValueChange={(open) => toggleRegistration.mutate({ open })} onValueChange={(open) => toggleRegistration.mutate({ open })}
/> />
} }
@@ -478,7 +478,7 @@ export default function SettingsScreen() {
right={ right={
<Switch <Switch
value={updateCheck.data?.enabled ?? false} value={updateCheck.data?.enabled ?? false}
accessibilityLabel="Check for updates" accessibilityLabel={t`Check for updates`}
onValueChange={(enabled) => toggleUpdateCheck.mutate({ enabled })} onValueChange={(enabled) => toggleUpdateCheck.mutate({ enabled })}
/> />
} }
+22 -18
View File
@@ -1,7 +1,7 @@
import { Trans, useLingui } from "@lingui/react/macro"; 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 { useMemo, useRef, useState } from "react";
import { Alert, ScrollView, type TextInput, View } from "react-native"; import { Alert, ScrollView, type TextInput, View } from "react-native";
import Animated, { FadeInDown } from "react-native-reanimated"; import Animated, { FadeInDown } from "react-native-reanimated";
import { useResolveClassNames } from "uniwind"; import { useResolveClassNames } from "uniwind";
@@ -16,17 +16,6 @@ import { authClient } from "@/lib/server";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import * as Haptics from "@/utils/haptics"; 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 { function formatFormErrors(errors: unknown): string | null {
if (!errors) return null; if (!errors) return null;
if (typeof errors === "string") return errors; if (typeof errors === "string") return errors;
@@ -41,6 +30,21 @@ function formatFormErrors(errors: unknown): string | null {
export default function ChangePasswordScreen() { export default function ChangePasswordScreen() {
const { t } = useLingui(); 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 { back } = useRouter();
const newPasswordRef = useRef<TextInput>(null); const newPasswordRef = useRef<TextInput>(null);
const confirmPasswordRef = useRef<TextInput>(null); const confirmPasswordRef = useRef<TextInput>(null);
@@ -91,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>}>
<Trans>Change Password</Trans> <Trans>Change password</Trans>
</Stack.Screen.Title> </Stack.Screen.Title>
<form.Subscribe <form.Subscribe
@@ -117,7 +121,7 @@ export default function ChangePasswordScreen() {
</Label> </Label>
<Input <Input
value={field.state.value} value={field.state.value}
accessibilityLabel="Current password" accessibilityLabel={t`Current password`}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChangeText={field.handleChange} onChangeText={field.handleChange}
placeholder="••••••••" placeholder="••••••••"
@@ -143,7 +147,7 @@ export default function ChangePasswordScreen() {
<Input <Input
ref={newPasswordRef} ref={newPasswordRef}
value={field.state.value} value={field.state.value}
accessibilityLabel="New password" accessibilityLabel={t`New password`}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChangeText={field.handleChange} onChangeText={field.handleChange}
placeholder="••••••••" placeholder="••••••••"
@@ -169,7 +173,7 @@ export default function ChangePasswordScreen() {
<Input <Input
ref={confirmPasswordRef} ref={confirmPasswordRef}
value={field.state.value} value={field.state.value}
accessibilityLabel="Confirm new password" accessibilityLabel={t`Confirm new password`}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChangeText={field.handleChange} onChangeText={field.handleChange}
placeholder="••••••••" placeholder="••••••••"
@@ -195,7 +199,7 @@ export default function ChangePasswordScreen() {
<Switch <Switch
value={revokeOtherSessions} value={revokeOtherSessions}
onValueChange={setRevokeOtherSessions} onValueChange={setRevokeOtherSessions}
accessibilityLabel="Sign out of other sessions" accessibilityLabel={t`Sign out of other sessions`}
/> />
</Animated.View> </Animated.View>
@@ -209,7 +213,7 @@ export default function ChangePasswordScreen() {
<Spinner size="sm" /> <Spinner size="sm" />
) : ( ) : (
<ButtonLabel> <ButtonLabel>
<Trans>Update Password</Trans> <Trans>Update password</Trans>
</ButtonLabel> </ButtonLabel>
)} )}
</Button> </Button>
+6 -3
View File
@@ -115,7 +115,10 @@ export default function TitleDetailScreen() {
quickAdd: quickAddMutation, quickAdd: quickAddMutation,
} = useTitleActions({ } = useTitleActions({
toasts: { 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" accessibilityRole="button"
accessibilityLabel={`Play trailer for ${title.title}`} accessibilityLabel={t`Play trailer for ${titleName}`}
accessibilityHint="Opens the trailer in YouTube" accessibilityHint={t`Opens the trailer in YouTube`}
className="absolute inset-0 items-center justify-center" className="absolute inset-0 items-center justify-center"
> >
{isLiquidGlassAvailable() ? ( {isLiquidGlassAvailable() ? (
@@ -1,3 +1,4 @@
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro"; import { useLingui } from "@lingui/react/macro";
import { Link } from "expo-router"; import { Link } from "expo-router";
import { memo } from "react"; import { memo } from "react";
@@ -36,10 +37,12 @@ export const ContinueWatchingCard = memo(function ContinueWatchingCard({
const { t } = useLingui(); const { t } = useLingui();
const { animatedStyle, gesture: tapGesture } = usePressAnimation(); const { animatedStyle, gesture: tapGesture } = usePressAnimation();
const progressLabel = `${item.watchedEpisodes} of ${item.totalEpisodes} episodes`; const watchedEpisodes = item.watchedEpisodes;
const nextEpLabel = item.nextEpisode const totalEpisodes = item.totalEpisodes;
? `Next: Season ${item.nextEpisode.seasonNumber} Episode ${item.nextEpisode.episodeNumber}` const progressLabel = t`${watchedEpisodes}/${plural(totalEpisodes, { one: "# episode", other: "# episodes" })}`;
: undefined; 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(", "); const cardLabel = [item.title.title, progressLabel, nextEpLabel].filter(Boolean).join(", ");
return ( return (
@@ -105,7 +108,7 @@ export const ContinueWatchingCard = memo(function ContinueWatchingCard({
<Link.Preview /> <Link.Preview />
<Link.Menu> <Link.Menu>
<Link.MenuAction <Link.MenuAction
title={t`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)}
@@ -128,7 +128,7 @@ export function UpcomingRow({ item }: { item: UpcomingItem }) {
<Link.Menu> <Link.Menu>
{item.titleType === "movie" && ( {item.titleType === "movie" && (
<Link.MenuAction <Link.MenuAction
title={t`Mark as Watched`} title={t`Mark Watched`}
icon="checkmark.circle" icon="checkmark.circle"
onPress={() => titleActions.markMovieWatched(item.titleId, item.titleName)} onPress={() => titleActions.markMovieWatched(item.titleId, item.titleName)}
/> />
@@ -141,7 +141,7 @@ export function UpcomingRow({ item }: { item: UpcomingItem }) {
/> />
)} )}
<Link.MenuAction <Link.MenuAction
title={t`Remove from Library`} title={t`Remove from library`}
icon="trash" icon="trash"
destructive destructive
onPress={() => titleActions.removeFromLibrary(item.titleId)} onPress={() => titleActions.removeFromLibrary(item.titleId)}
@@ -55,7 +55,7 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={accessibilityLabel} accessibilityLabel={accessibilityLabel}
accessibilityHint="Opens title details" accessibilityHint={t`Opens title details`}
style={{ flex: 1 }} style={{ flex: 1 }}
> >
{item.backdropPath && ( {item.backdropPath && (
+3 -3
View File
@@ -24,7 +24,7 @@ export function HeaderAvatar() {
<Pressable <Pressable
onPress={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)} onPress={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="User menu" accessibilityLabel={t`User menu`}
hitSlop={8} hitSlop={8}
> >
<View className="size-8 overflow-hidden rounded-full" accessible={false}> <View className="size-8 overflow-hidden rounded-full" accessible={false}>
@@ -59,10 +59,10 @@ export function HeaderAvatar() {
key="sign-out" key="sign-out"
destructive destructive
onSelect={() => { 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`Cancel`, style: "cancel" },
{ {
text: t`Sign Out`, text: t`Sign out`,
style: "destructive", style: "destructive",
onPress: () => { onPress: () => {
authClient.signOut(); authClient.signOut();
@@ -78,13 +78,21 @@ export function SeasonAccordion({
toasts: { toasts: {
watchEpisode: ({ id: epId }) => { watchEpisode: ({ id: epId }) => {
const ep = episodes.find((e) => e.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 }) => { unwatchEpisode: ({ id: epId }) => {
const ep = episodes.find((e) => e.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({
<Pressable <Pressable
onPress={toggleExpanded} onPress={toggleExpanded}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`${season.name ?? t`Season ${seasonNumber}`}, ${t`${watchedCount} of ${episodeCount} episodes watched`}`} accessibilityLabel={`${season.name ?? t`Season ${seasonNumber}`}, ${t`${watchedCount}/${plural(episodeCount, { one: "# episode", other: "# episodes" })}`}`}
accessibilityState={{ expanded }} accessibilityState={{ expanded }}
className="flex-row items-center justify-between p-4" className="flex-row items-center justify-between p-4"
> >
@@ -43,7 +43,7 @@ const STATUS_STYLES = {
function StatusLabel({ status }: { status: TitleStatus }) { function StatusLabel({ status }: { status: TitleStatus }) {
switch (status) { switch (status) {
case "in_watchlist": case "in_watchlist":
return <Trans>In Watchlist</Trans>; return <Trans>On Watchlist</Trans>;
case "watching": case "watching":
return <Trans>Watching</Trans>; return <Trans>Watching</Trans>;
case "caught_up": case "caught_up":
@@ -77,7 +77,7 @@ export function StatusActionButton({
}} }}
disabled={isPending} disabled={isPending}
accessibilityRole="button" 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" className="border-title-accent/20 bg-title-accent/10 flex-row items-center gap-1.5 rounded-lg border px-4 py-2"
> >
<ScaledIcon icon={IconPlus} size={14} color={titleAccent} strokeWidth={2.5} /> <ScaledIcon icon={IconPlus} size={14} color={titleAccent} strokeWidth={2.5} />
@@ -219,7 +219,7 @@ export const PosterCard = memo(function PosterCard({
)} )}
{type === "movie" && ( {type === "movie" && (
<Link.MenuAction <Link.MenuAction
title={t`Mark as Watched`} title={t`Mark Watched`}
icon="checkmark.circle" icon="checkmark.circle"
onPress={() => titleActions.markMovieWatched(id, title)} onPress={() => titleActions.markMovieWatched(id, title)}
/> />
@@ -233,7 +233,7 @@ export const PosterCard = memo(function PosterCard({
)} )}
{userStatus && ( {userStatus && (
<Link.MenuAction <Link.MenuAction
title={t`Remove from Library`} title={t`Remove from library`}
icon="trash" icon="trash"
destructive destructive
onPress={() => titleActions.removeFromLibrary(id)} onPress={() => titleActions.removeFromLibrary(id)}
+3 -1
View File
@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { useEffect } from "react"; import { useEffect } from "react";
import type { ViewStyle } from "react-native"; import type { ViewStyle } from "react-native";
import Animated, { import Animated, {
@@ -17,6 +18,7 @@ interface SkeletonProps {
} }
export function Skeleton({ width, height = 14, borderRadius = 4, style }: SkeletonProps) { export function Skeleton({ width, height = 14, borderRadius = 4, style }: SkeletonProps) {
const { t } = useLingui();
const secondaryColor = useCSSVariable("--color-secondary") as string; const secondaryColor = useCSSVariable("--color-secondary") as string;
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const opacity = useSharedValue(reduceMotion ? 0.7 : 0.4); const opacity = useSharedValue(reduceMotion ? 0.7 : 0.4);
@@ -34,7 +36,7 @@ export function Skeleton({ width, height = 14, borderRadius = 4, style }: Skelet
return ( return (
<Animated.View <Animated.View
accessible accessible
accessibilityLabel="Loading" accessibilityLabel={t`Loading`}
style={[ style={[
{ {
width, width,
@@ -1,3 +1,5 @@
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { IconStar, IconStarFilled } from "@tabler/icons-react-native"; import { IconStar, IconStarFilled } from "@tabler/icons-react-native";
import { Pressable, View } from "react-native"; import { Pressable, View } from "react-native";
import { useCSSVariable } from "uniwind"; import { useCSSVariable } from "uniwind";
@@ -19,6 +21,7 @@ export function StarRating({
interactive = true, interactive = true,
accentColor, accentColor,
}: StarRatingProps) { }: StarRatingProps) {
const { t } = useLingui();
const [defaultPrimary, mutedForeground] = useCSSVariable([ const [defaultPrimary, mutedForeground] = useCSSVariable([
"--color-primary", "--color-primary",
"--color-muted-foreground", "--color-muted-foreground",
@@ -29,7 +32,7 @@ export function StarRating({
return ( return (
<View <View
className="flex-row items-center gap-1" className="flex-row items-center gap-1"
accessibilityLabel={`Rating: ${rating} out of 5 stars`} accessibilityLabel={t`Rating: ${rating} out of 5 stars`}
accessibilityRole="adjustable" accessibilityRole="adjustable"
> >
{[1, 2, 3, 4, 5].map((star) => ( {[1, 2, 3, 4, 5].map((star) => (
@@ -37,12 +40,12 @@ export function StarRating({
key={star} key={star}
disabled={!interactive} disabled={!interactive}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`${star} star${star !== 1 ? "s" : ""}`} accessibilityLabel={plural(star, { one: "# star", other: "# stars" })}
accessibilityHint={ accessibilityHint={
interactive interactive
? star === rating ? star === rating
? "Double tap to clear rating" ? t`Double tap to clear rating`
: `Double tap to rate ${star} star${star !== 1 ? "s" : ""}` : t`Double tap to rate ${plural(star, { one: "# star", other: "# stars" })}`
: undefined : undefined
} }
onPress={() => { onPress={() => {
+2 -2
View File
@@ -112,11 +112,11 @@ export const titleActions = {
} }
}, },
async watchSeason(id: string, seasonName?: string) { async watchSeason(id: string, seasonLabel?: string) {
try { try {
await client.seasons.watch({ id }); await client.seasons.watch({ id });
toast.success( 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(); invalidateTitleQueries();
} catch { } catch {
+8 -7
View File
@@ -1,4 +1,4 @@
import { Trans } from "@lingui/react/macro"; import { Trans, useLingui } 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";
@@ -37,6 +37,7 @@ export function AuthForm({
mode: "login" | "register"; mode: "login" | "register";
authConfig?: AuthConfig; authConfig?: AuthConfig;
}) { }) {
const { t } = useLingui();
const navigate = useNavigate(); const navigate = useNavigate();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@@ -59,19 +60,19 @@ export function AuthForm({
if (isRegister) { if (isRegister) {
const result = await signUp.email({ name, email, password }); const result = await signUp.email({ name, email, password });
if (result.error) { if (result.error) {
setError(result.error.message ?? "Registration failed"); setError(result.error.message ?? t`Registration failed`);
return; return;
} }
} else { } else {
const result = await signIn.email({ email, password }); const result = await signIn.email({ email, password });
if (result.error) { if (result.error) {
setError(result.error.message ?? "Login failed"); setError(result.error.message ?? t`Login failed`);
return; return;
} }
} }
void navigate({ to: "/dashboard" }); void navigate({ to: "/dashboard" });
} catch { } catch {
setError("Something went wrong"); setError(t`Something went wrong`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -86,7 +87,7 @@ export function AuthForm({
callbackURL: "/dashboard", callbackURL: "/dashboard",
}); });
} catch { } catch {
setError("Failed to start SSO login"); setError(t`Failed to start SSO login`);
} finally { } finally {
setOidcLoading(false); setOidcLoading(false);
} }
@@ -181,7 +182,7 @@ export function AuthForm({
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className={authInputClass} className={authInputClass}
placeholder="Your name…" placeholder={t`Your name…`}
/> />
</motion.div> </motion.div>
)} )}
@@ -216,7 +217,7 @@ export function AuthForm({
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className={authInputClass} className={authInputClass}
placeholder="Min 8 characters…" placeholder={t`Min 8 characters…`}
/> />
</motion.div> </motion.div>
+1 -1
View File
@@ -333,7 +333,7 @@ export function CommandPalette() {
<span data-slot="command-shortcut" className="ml-auto"> <span data-slot="command-shortcut" className="ml-auto">
<button <button
type="button" type="button"
aria-label="Remove from recent searches" aria-label={t`Remove from recent searches`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleRemoveRecent(q); handleRemoveRecent(q);
@@ -67,7 +67,7 @@ export function HeroBanner({
</span> </span>
)} )}
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
<Trans>Trending today</Trans> <Trans>Trending Today</Trans>
</span> </span>
</div> </div>
<Link to="/titles/$id" params={{ id }} className="group/title text-start"> <Link to="/titles/$id" params={{ id }} className="group/title text-start">
+1 -1
View File
@@ -165,7 +165,7 @@ export function LandingPage({
className="group bg-primary text-primary-foreground hover:shadow-primary/20 relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg px-8 font-medium transition-shadow hover:shadow-lg" className="group bg-primary text-primary-foreground hover:shadow-primary/20 relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg px-8 font-medium transition-shadow hover:shadow-lg"
> >
<span className="relative z-10"> <span className="relative z-10">
<Trans>Sign In</Trans> <Trans>Sign in</Trans>
</span> </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>
+4 -4
View File
@@ -147,7 +147,7 @@ export function NavBar({
</Link> </Link>
<nav <nav
ref={navRef} ref={navRef}
aria-label="Primary" aria-label={t`Primary`}
className="relative hidden items-center gap-1 sm:flex" className="relative hidden items-center gap-1 sm:flex"
> >
{navLinks.map((link, i) => { {navLinks.map((link, i) => {
@@ -205,7 +205,7 @@ export function NavBar({
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger <DropdownMenuTrigger
className="hover:ring-primary/40 focus-visible:ring-primary/60 hidden cursor-pointer rounded-full ring-2 ring-transparent transition-all outline-none sm:block" className="hover:ring-primary/40 focus-visible:ring-primary/60 hidden cursor-pointer rounded-full ring-2 ring-transparent transition-all outline-none sm:block"
aria-label="Account menu" aria-label={t`Account menu`}
> >
<Avatar> <Avatar>
<AvatarImage src={userImage} alt={userName} /> <AvatarImage src={userImage} alt={userName} />
@@ -260,7 +260,7 @@ export function NavBar({
<Link <Link
to="/settings" to="/settings"
className="hover:ring-primary/40 rounded-full ring-2 ring-transparent transition-all sm:hidden" className="hover:ring-primary/40 rounded-full ring-2 ring-transparent transition-all sm:hidden"
aria-label="Settings" aria-label={t`Settings`}
> >
<Avatar size="sm"> <Avatar size="sm">
<AvatarImage src={userImage} alt={userName} /> <AvatarImage src={userImage} alt={userName} />
@@ -298,7 +298,7 @@ export function MobileTabBar() {
return ( return (
<nav <nav
aria-label="Primary" aria-label={t`Primary`}
className="border-border/50 bg-background/90 fixed right-0 bottom-0 left-0 z-50 border-t ps-[env(safe-area-inset-left)] pe-[env(safe-area-inset-right)] backdrop-blur-xl sm:hidden" className="border-border/50 bg-background/90 fixed right-0 bottom-0 left-0 z-50 border-t ps-[env(safe-area-inset-left)] pe-[env(safe-area-inset-right)] backdrop-blur-xl sm:hidden"
> >
<div ref={containerRef} className="relative flex h-14 items-stretch"> <div ref={containerRef} className="relative flex h-14 items-stretch">
@@ -73,7 +73,7 @@ export function FilmographyGrid({ credits, userStatuses }: FilmographyGridProps)
value={sort} value={sort}
onValueChange={(v) => v && setSort(v as Sort)} onValueChange={(v) => v && setSort(v as Sort)}
modal={false} modal={false}
aria-label="Sort filmography" aria-label={t`Sort filmography`}
> >
<SelectTrigger size="sm"> <SelectTrigger size="sm">
<SelectValue> <SelectValue>
@@ -199,7 +199,7 @@ export function BackupScheduleSection() {
return ( return (
<CardContent> <CardContent>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
<Trans>Failed to load backup schedule settings.</Trans> <Trans>Failed to load backup schedule settings</Trans>
</p> </p>
</CardContent> </CardContent>
); );
@@ -1,3 +1,4 @@
import { msg, plural } from "@lingui/core/macro";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { import {
IconActivity, IconActivity,
@@ -28,6 +29,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
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";
import type { CronJobName, SystemHealthData } from "@sofa/api/schemas"; import type { CronJobName, SystemHealthData } from "@sofa/api/schemas";
import { i18n } from "@sofa/i18n";
const DIGITS_ONLY_RE = /^\d+$/; const DIGITS_ONLY_RE = /^\d+$/;
@@ -40,26 +42,29 @@ function cronToHuman(pattern: string): string {
// Every N hours: "0 */6 * * *" // Every N hours: "0 */6 * * *"
if (hour.startsWith("*/")) { if (hour.startsWith("*/")) {
const n = Number.parseInt(hour.slice(2), 10); const n = Number.parseInt(hour.slice(2), 10);
return `Every ${n}h`; return i18n._(msg`Every ${n}h`);
} }
// Twice daily: "0 1,13 * * *" // Twice daily: "0 1,13 * * *"
if (hour.includes(",") && !hour.includes("/") && !hour.includes("-")) { if (hour.includes(",") && !hour.includes("/") && !hour.includes("-")) {
const hours = hour.split(","); const hours = hour.split(",");
if (hours.length === 2) { if (hours.length === 2) {
return `Daily at ${hours.map((h) => `${h.padStart(2, "0")}:${min.padStart(2, "0")}`).join(", ")}`; const times = hours.map((h) => `${h.padStart(2, "0")}:${min.padStart(2, "0")}`).join(", ");
return i18n._(msg`Daily at ${times}`);
} }
} }
// Daily at specific time: "0 3 * * *" // Daily at specific time: "0 3 * * *"
if (DIGITS_ONLY_RE.test(hour) && DIGITS_ONLY_RE.test(min) && dow === "*") { if (DIGITS_ONLY_RE.test(hour) && DIGITS_ONLY_RE.test(min) && dow === "*") {
return `Daily at ${hour.padStart(2, "0")}:${min.padStart(2, "0")}`; const time = `${hour.padStart(2, "0")}:${min.padStart(2, "0")}`;
return i18n._(msg`Daily at ${time}`);
} }
// Weekly // Weekly — use Intl for locale-aware day name
if (DIGITS_ONLY_RE.test(dow)) { if (DIGITS_ONLY_RE.test(dow)) {
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const dayDate = new Date(2024, 0, 7 + Number(dow)); // 2024-01-07 is a Sunday
return `Weekly on ${days[Number(dow)] ?? dow}`; const dayName = new Intl.DateTimeFormat(i18n.locale, { weekday: "short" }).format(dayDate);
return i18n._(msg`Weekly on ${dayName}`);
} }
return pattern; return pattern;
@@ -528,7 +533,7 @@ function StorageCard({
onRefresh: () => void; onRefresh: () => void;
}) { }) {
const { t } = useLingui(); const { t } = useLingui();
const cachedImageCount = imageCache.imageCount.toLocaleString(); const cachedImageCount = imageCache.imageCount;
const backupCount = backups.backupCount; const backupCount = backups.backupCount;
const lastBackupAt = backups.lastBackupAt; const lastBackupAt = backups.lastBackupAt;
return ( return (
@@ -567,7 +572,7 @@ function StorageCard({
{imageCache.enabled ? ( {imageCache.enabled ? (
<> <>
<p className="text-muted-foreground mt-1 text-xs"> <p className="text-muted-foreground mt-1 text-xs">
{t`${cachedImageCount} cached images`} {plural(cachedImageCount, { one: "# cached image", other: "# cached images" })}
</p> </p>
<p className="text-muted-foreground/50 mt-0.5 text-[10px] leading-relaxed"> <p className="text-muted-foreground/50 mt-0.5 text-[10px] leading-relaxed">
{Object.entries(imageCache.categories) {Object.entries(imageCache.categories)
@@ -598,7 +603,8 @@ function StorageCard({
{backupCount > 0 ? ( {backupCount > 0 ? (
<p className="text-muted-foreground mt-1 text-xs"> <p className="text-muted-foreground mt-1 text-xs">
<Trans> <Trans>
{backupCount} backups · last <LiveTimeAgo date={lastBackupAt} fallback={t`unknown`} /> {plural(backupCount, { one: "# backup", other: "# backups" })} · last{" "}
<LiveTimeAgo date={lastBackupAt} fallback={t`unknown`} />
</Trans> </Trans>
</p> </p>
) : ( ) : (
+4 -1
View File
@@ -1,3 +1,4 @@
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro"; import { useLingui } from "@lingui/react/macro";
import { import {
IconBookmarkFilled, IconBookmarkFilled,
@@ -148,7 +149,9 @@ function ProgressBar({ watched, total }: { watched: number; total: number }) {
style={{ width: `${pct}%` }} style={{ width: `${pct}%` }}
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">{t`${watched}/${total} episodes`}</TooltipContent> <TooltipContent side="top">
{t`${watched}/${plural(total, { one: "# episode", other: "# episodes" })}`}
</TooltipContent>
</Tooltip> </Tooltip>
); );
} }
@@ -1,4 +1,5 @@
import { Trans, useLingui } from "@lingui/react/macro"; import { plural } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
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";
@@ -7,9 +8,11 @@ import { thumbHashToUrl } from "@/lib/thumbhash";
import type { CastMember } from "@sofa/api/schemas"; import type { CastMember } from "@sofa/api/schemas";
function EpisodeCountLabel({ count }: { count: number }) { function EpisodeCountLabel({ count }: { count: number }) {
const { t } = useLingui(); return (
const suffix = count !== 1 ? "s" : ""; <p className="text-muted-foreground/70 text-[10px]">
return <p className="text-muted-foreground/70 text-[10px]">{t`${count} ep${suffix}`}</p>; {plural(count, { one: "# ep", other: "# eps" })}
</p>
);
} }
interface CastCarouselProps { interface CastCarouselProps {
@@ -1,3 +1,5 @@
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { IconStar, IconStarFilled } from "@tabler/icons-react"; import { IconStar, IconStarFilled } from "@tabler/icons-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useState } from "react"; import { useState } from "react";
@@ -14,13 +16,14 @@ interface StarRatingProps {
} }
export function StarRating({ value, onChange }: StarRatingProps) { export function StarRating({ value, onChange }: StarRatingProps) {
const { t } = useLingui();
const [hover, setHover] = useState(0); const [hover, setHover] = useState(0);
return ( return (
<div <div
className="flex items-center gap-0.5" className="flex items-center gap-0.5"
role="radiogroup" role="radiogroup"
aria-label="Rating" aria-label={t`Rating`}
onMouseLeave={() => setHover(0)} onMouseLeave={() => setHover(0)}
> >
{[1, 2, 3, 4, 5].map((star) => { {[1, 2, 3, 4, 5].map((star) => {
@@ -31,7 +34,7 @@ export function StarRating({ value, onChange }: StarRatingProps) {
type="button" type="button"
role="radio" role="radio"
aria-checked={star === value} aria-checked={star === value}
aria-label={`Rate ${star} star${star !== 1 ? "s" : ""}`} aria-label={t`Rate ${plural(star, { one: "# star", other: "# stars" })}`}
onClick={() => onChange(star === value ? 0 : star)} onClick={() => onChange(star === value ? 0 : star)}
onMouseEnter={() => setHover(star)} onMouseEnter={() => setHover(star)}
className="p-0.5" className="p-0.5"
@@ -37,7 +37,7 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
const statusConfig = { const statusConfig = {
in_watchlist: { in_watchlist: {
label: t`In Watchlist`, label: t`On Watchlist`,
icon: IconBookmarkFilled, icon: IconBookmarkFilled,
class: "text-primary", class: "text-primary",
bgClass: "bg-primary/10 hover:bg-primary/15", bgClass: "bg-primary/10 hover:bg-primary/15",
@@ -1,4 +1,4 @@
import { Trans } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { import {
IconCalendarEvent, IconCalendarEvent,
IconCircleCheck, IconCircleCheck,
@@ -30,6 +30,7 @@ export function TitleHero({
actions: ReactNode; actions: ReactNode;
children?: ReactNode; children?: ReactNode;
}) { }) {
const { t } = useLingui();
const dateStr = title.releaseDate ?? title.firstAirDate; const dateStr = title.releaseDate ?? title.firstAirDate;
const year = dateStr?.slice(0, 4); const year = dateStr?.slice(0, 4);
const palette = title.colorPalette; const palette = title.colorPalette;
@@ -175,7 +176,7 @@ export function TitleHero({
href={`https://www.themoviedb.org/${title.type === "movie" ? "movie" : "tv"}/${title.tmdbId}`} href={`https://www.themoviedb.org/${title.type === "movie" ? "movie" : "tv"}/${title.tmdbId}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label="View on TMDB" aria-label={t`View on TMDB`}
className="inline-flex items-center opacity-70 transition-opacity hover:opacity-40" className="inline-flex items-center opacity-70 transition-opacity hover:opacity-40"
> >
<TmdbLogo className="h-2.5 w-auto" /> <TmdbLogo className="h-2.5 w-auto" />
@@ -1,4 +1,4 @@
import { Trans } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { import {
IconCheck, IconCheck,
IconChecks, IconChecks,
@@ -59,6 +59,7 @@ export function TitleSeasons({
}: { }: {
seasons?: Season[]; seasons?: Season[];
} = {}) { } = {}) {
const { t } = useLingui();
const { seasons, setSeasons, watchingEp } = useTitleContext(); const { seasons, setSeasons, watchingEp } = useTitleContext();
const { episodeWatches, userStatus } = useTitleUserInfo(); const { episodeWatches, userStatus } = useTitleUserInfo();
@@ -228,6 +229,7 @@ export function TitleSeasons({
{season.episodes.map((ep) => { {season.episodes.map((ep) => {
const isWatched = watchedSet.has(ep.id); const isWatched = watchedSet.has(ep.id);
const { stillPath } = ep; const { stillPath } = ep;
const epNum = ep.episodeNumber;
return ( return (
<div <div
key={ep.id} key={ep.id}
@@ -254,7 +256,11 @@ export function TitleSeasons({
<div className="flex gap-3 px-4 py-3"> <div className="flex gap-3 px-4 py-3">
<button <button
type="button" type="button"
aria-label={`Mark episode ${ep.episodeNumber} as ${isWatched ? "unwatched" : "watched"}`} aria-label={
isWatched
? t`Unmark episode ${epNum}`
: t`Mark episode ${epNum} watched`
}
onClick={() => onClick={() =>
handleWatchEpisode( handleWatchEpisode(
ep.id, ep.id,
+3 -1
View File
@@ -1,12 +1,14 @@
import { useLingui } from "@lingui/react/macro";
import { IconLoader } from "@tabler/icons-react"; import { IconLoader } from "@tabler/icons-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Spinner({ className, ...props }: React.ComponentProps<"svg">) { function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
const { t } = useLingui();
return ( return (
<IconLoader <IconLoader
role="status" role="status"
aria-label="Loading" aria-label={t`Loading`}
className={cn("size-4 animate-spin", className)} className={cn("size-4 animate-spin", className)}
{...props} {...props}
/> />
File diff suppressed because one or more lines are too long