mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 23:55:43 -04:00
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:
35 files changed
+489
-161
No files matched your search
@@ -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
|
||||
@@ -50,6 +50,7 @@ coverage
|
||||
tmp
|
||||
temp
|
||||
crowdin-context.jsonl
|
||||
messages.pot
|
||||
|
||||
# local data
|
||||
*.db
|
||||
|
||||
@@ -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<TextInput>(null);
|
||||
const [errorFields, setErrorFields] = useState<Set<string>>(new Set());
|
||||
const [isSignedIn, setIsSignedIn] = useState(false);
|
||||
@@ -108,7 +116,7 @@ export default function LoginScreen() {
|
||||
<View className="my-4 flex-row items-center">
|
||||
<View className="bg-border h-px flex-1" />
|
||||
<Text className="text-muted-foreground px-3 text-xs">
|
||||
<Trans>OR</Trans>
|
||||
<Trans>or</Trans>
|
||||
</Text>
|
||||
<View className="bg-border h-px flex-1" />
|
||||
</View>
|
||||
@@ -128,7 +136,7 @@ export default function LoginScreen() {
|
||||
</Label>
|
||||
<Input
|
||||
value={field.state.value}
|
||||
accessibilityLabel="Email"
|
||||
accessibilityLabel={t`Email`}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={(text) => {
|
||||
field.handleChange(text);
|
||||
@@ -159,7 +167,7 @@ export default function LoginScreen() {
|
||||
<Input
|
||||
ref={passwordRef}
|
||||
value={field.state.value}
|
||||
accessibilityLabel="Password"
|
||||
accessibilityLabel={t`Password`}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={(text) => {
|
||||
field.handleChange(text);
|
||||
@@ -184,7 +192,7 @@ export default function LoginScreen() {
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<ButtonLabel>
|
||||
<Trans>Sign In</Trans>
|
||||
<Trans>Sign in</Trans>
|
||||
</ButtonLabel>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -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<TextInput>(null);
|
||||
const passwordRef = useRef<TextInput>(null);
|
||||
const [errorFields, setErrorFields] = useState<Set<string>>(new Set());
|
||||
@@ -101,7 +116,7 @@ export default function RegisterScreen() {
|
||||
}
|
||||
|
||||
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">
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||
<form.Field name="name">
|
||||
@@ -112,7 +127,7 @@ export default function RegisterScreen() {
|
||||
</Label>
|
||||
<Input
|
||||
value={field.state.value}
|
||||
accessibilityLabel="Name"
|
||||
accessibilityLabel={t`Name`}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={(text) => {
|
||||
field.handleChange(text);
|
||||
@@ -141,13 +156,13 @@ export default function RegisterScreen() {
|
||||
<Input
|
||||
ref={emailRef}
|
||||
value={field.state.value}
|
||||
accessibilityLabel="Email"
|
||||
accessibilityLabel={t`Email`}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={(text) => {
|
||||
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() {
|
||||
<Input
|
||||
ref={passwordRef}
|
||||
value={field.state.value}
|
||||
accessibilityLabel="Password"
|
||||
accessibilityLabel={t`Password`}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={(text) => {
|
||||
field.handleChange(text);
|
||||
@@ -197,7 +212,7 @@ export default function RegisterScreen() {
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<ButtonLabel>
|
||||
<Trans>Create Account</Trans>
|
||||
<Trans>Create account</Trans>
|
||||
</ButtonLabel>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -136,8 +136,8 @@ export default function ServerUrlScreen() {
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={url}
|
||||
accessibilityLabel="Server URL"
|
||||
accessibilityHint="Enter the full URL for your Sofa server"
|
||||
accessibilityLabel={t`Server URL`}
|
||||
accessibilityHint={t`Enter the full URL for your Sofa server`}
|
||||
onChangeText={handleChangeText}
|
||||
placeholder="https://sofa.example.com"
|
||||
placeholderTextColorClassName="accent-muted-foreground/50"
|
||||
|
||||
@@ -142,7 +142,7 @@ export default function DashboardScreen() {
|
||||
</View>
|
||||
<View className="flex-row gap-3">
|
||||
<StatsCard
|
||||
label={t`In library`}
|
||||
label={t`In Library`}
|
||||
value={stats.data?.librarySize}
|
||||
icon={IconBooks}
|
||||
color="text-status-watchlist"
|
||||
|
||||
@@ -204,10 +204,10 @@ export default function SettingsScreen() {
|
||||
}, []);
|
||||
|
||||
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`Sign Out`,
|
||||
text: t`Sign out`,
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
authClient.signOut();
|
||||
@@ -234,8 +234,8 @@ export default function SettingsScreen() {
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Edit profile photo"
|
||||
accessibilityHint="Opens options to change or remove your photo"
|
||||
accessibilityLabel={t`Edit profile photo`}
|
||||
accessibilityHint={t`Opens options to change or remove your photo`}
|
||||
className="mr-3"
|
||||
hitSlop={8}
|
||||
>
|
||||
@@ -280,7 +280,7 @@ export default function SettingsScreen() {
|
||||
<Pressable
|
||||
onPress={pickAvatar}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Add profile photo"
|
||||
accessibilityLabel={t`Add profile photo`}
|
||||
className="mr-3"
|
||||
hitSlop={8}
|
||||
>
|
||||
@@ -307,7 +307,7 @@ export default function SettingsScreen() {
|
||||
<View className="flex-row items-center gap-2">
|
||||
<TextInput
|
||||
value={nameInput}
|
||||
accessibilityLabel="Display name"
|
||||
accessibilityLabel={t`Display name`}
|
||||
onChangeText={setNameInput}
|
||||
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={
|
||||
<Switch
|
||||
value={analyticsEnabled}
|
||||
accessibilityLabel="Anonymous usage reporting"
|
||||
accessibilityLabel={t`Anonymous usage reporting`}
|
||||
onValueChange={(enabled) => {
|
||||
setAnalyticsToggle(enabled);
|
||||
setAnalyticsEnabled(enabled);
|
||||
@@ -440,7 +440,7 @@ export default function SettingsScreen() {
|
||||
icon={IconCloud}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t`Image Cache`}
|
||||
label={t`Image cache`}
|
||||
value={
|
||||
systemHealth.data?.imageCache
|
||||
? plural(systemHealth.data.imageCache.imageCount, {
|
||||
@@ -467,7 +467,7 @@ export default function SettingsScreen() {
|
||||
right={
|
||||
<Switch
|
||||
value={registration.data?.open ?? false}
|
||||
accessibilityLabel="Open registration"
|
||||
accessibilityLabel={t`Open registration`}
|
||||
onValueChange={(open) => toggleRegistration.mutate({ open })}
|
||||
/>
|
||||
}
|
||||
@@ -478,7 +478,7 @@ export default function SettingsScreen() {
|
||||
right={
|
||||
<Switch
|
||||
value={updateCheck.data?.enabled ?? false}
|
||||
accessibilityLabel="Check for updates"
|
||||
accessibilityLabel={t`Check for updates`}
|
||||
onValueChange={(enabled) => toggleUpdateCheck.mutate({ enabled })}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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<TextInput>(null);
|
||||
const confirmPasswordRef = useRef<TextInput>(null);
|
||||
@@ -91,7 +95,7 @@ export default function ChangePasswordScreen() {
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Stack.Screen.Title style={headerTitleStyle as Record<string, unknown>}>
|
||||
<Trans>Change Password</Trans>
|
||||
<Trans>Change password</Trans>
|
||||
</Stack.Screen.Title>
|
||||
|
||||
<form.Subscribe
|
||||
@@ -117,7 +121,7 @@ export default function ChangePasswordScreen() {
|
||||
</Label>
|
||||
<Input
|
||||
value={field.state.value}
|
||||
accessibilityLabel="Current password"
|
||||
accessibilityLabel={t`Current password`}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="••••••••"
|
||||
@@ -143,7 +147,7 @@ export default function ChangePasswordScreen() {
|
||||
<Input
|
||||
ref={newPasswordRef}
|
||||
value={field.state.value}
|
||||
accessibilityLabel="New password"
|
||||
accessibilityLabel={t`New password`}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="••••••••"
|
||||
@@ -169,7 +173,7 @@ export default function ChangePasswordScreen() {
|
||||
<Input
|
||||
ref={confirmPasswordRef}
|
||||
value={field.state.value}
|
||||
accessibilityLabel="Confirm new password"
|
||||
accessibilityLabel={t`Confirm new password`}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="••••••••"
|
||||
@@ -195,7 +199,7 @@ export default function ChangePasswordScreen() {
|
||||
<Switch
|
||||
value={revokeOtherSessions}
|
||||
onValueChange={setRevokeOtherSessions}
|
||||
accessibilityLabel="Sign out of other sessions"
|
||||
accessibilityLabel={t`Sign out of other sessions`}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
@@ -209,7 +213,7 @@ export default function ChangePasswordScreen() {
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<ButtonLabel>
|
||||
<Trans>Update Password</Trans>
|
||||
<Trans>Update password</Trans>
|
||||
</ButtonLabel>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -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() ? (
|
||||
|
||||
@@ -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({
|
||||
<Link.Preview />
|
||||
<Link.Menu>
|
||||
<Link.MenuAction
|
||||
title={t`Remove from Library`}
|
||||
title={t`Remove from library`}
|
||||
icon="trash"
|
||||
destructive
|
||||
onPress={() => titleActions.removeFromLibrary(item.title.id)}
|
||||
|
||||
@@ -128,7 +128,7 @@ export function UpcomingRow({ item }: { item: UpcomingItem }) {
|
||||
<Link.Menu>
|
||||
{item.titleType === "movie" && (
|
||||
<Link.MenuAction
|
||||
title={t`Mark as Watched`}
|
||||
title={t`Mark Watched`}
|
||||
icon="checkmark.circle"
|
||||
onPress={() => titleActions.markMovieWatched(item.titleId, item.titleName)}
|
||||
/>
|
||||
@@ -141,7 +141,7 @@ export function UpcomingRow({ item }: { item: UpcomingItem }) {
|
||||
/>
|
||||
)}
|
||||
<Link.MenuAction
|
||||
title={t`Remove from Library`}
|
||||
title={t`Remove from library`}
|
||||
icon="trash"
|
||||
destructive
|
||||
onPress={() => titleActions.removeFromLibrary(item.titleId)}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityHint="Opens title details"
|
||||
accessibilityHint={t`Opens title details`}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{item.backdropPath && (
|
||||
|
||||
@@ -24,7 +24,7 @@ export function HeaderAvatar() {
|
||||
<Pressable
|
||||
onPress={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="User menu"
|
||||
accessibilityLabel={t`User menu`}
|
||||
hitSlop={8}
|
||||
>
|
||||
<View className="size-8 overflow-hidden rounded-full" accessible={false}>
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
<Pressable
|
||||
onPress={toggleExpanded}
|
||||
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 }}
|
||||
className="flex-row items-center justify-between p-4"
|
||||
>
|
||||
|
||||
@@ -43,7 +43,7 @@ const STATUS_STYLES = {
|
||||
function StatusLabel({ status }: { status: TitleStatus }) {
|
||||
switch (status) {
|
||||
case "in_watchlist":
|
||||
return <Trans>In Watchlist</Trans>;
|
||||
return <Trans>On Watchlist</Trans>;
|
||||
case "watching":
|
||||
return <Trans>Watching</Trans>;
|
||||
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"
|
||||
>
|
||||
<ScaledIcon icon={IconPlus} size={14} color={titleAccent} strokeWidth={2.5} />
|
||||
|
||||
@@ -219,7 +219,7 @@ export const PosterCard = memo(function PosterCard({
|
||||
)}
|
||||
{type === "movie" && (
|
||||
<Link.MenuAction
|
||||
title={t`Mark as Watched`}
|
||||
title={t`Mark Watched`}
|
||||
icon="checkmark.circle"
|
||||
onPress={() => titleActions.markMovieWatched(id, title)}
|
||||
/>
|
||||
@@ -233,7 +233,7 @@ export const PosterCard = memo(function PosterCard({
|
||||
)}
|
||||
{userStatus && (
|
||||
<Link.MenuAction
|
||||
title={t`Remove from Library`}
|
||||
title={t`Remove from library`}
|
||||
icon="trash"
|
||||
destructive
|
||||
onPress={() => titleActions.removeFromLibrary(id)}
|
||||
|
||||
@@ -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 (
|
||||
<Animated.View
|
||||
accessible
|
||||
accessibilityLabel="Loading"
|
||||
accessibilityLabel={t`Loading`}
|
||||
style={[
|
||||
{
|
||||
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 { Pressable, View } from "react-native";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
@@ -19,6 +21,7 @@ export function StarRating({
|
||||
interactive = true,
|
||||
accentColor,
|
||||
}: StarRatingProps) {
|
||||
const { t } = useLingui();
|
||||
const [defaultPrimary, mutedForeground] = useCSSVariable([
|
||||
"--color-primary",
|
||||
"--color-muted-foreground",
|
||||
@@ -29,7 +32,7 @@ export function StarRating({
|
||||
return (
|
||||
<View
|
||||
className="flex-row items-center gap-1"
|
||||
accessibilityLabel={`Rating: ${rating} out of 5 stars`}
|
||||
accessibilityLabel={t`Rating: ${rating} out of 5 stars`}
|
||||
accessibilityRole="adjustable"
|
||||
>
|
||||
{[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={() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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…`}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -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…`}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -333,7 +333,7 @@ export function CommandPalette() {
|
||||
<span data-slot="command-shortcut" className="ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove from recent searches"
|
||||
aria-label={t`Remove from recent searches`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveRecent(q);
|
||||
|
||||
@@ -67,7 +67,7 @@ export function HeroBanner({
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<Trans>Trending today</Trans>
|
||||
<Trans>Trending Today</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<Link to="/titles/$id" params={{ id }} className="group/title text-start">
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<span className="relative z-10">
|
||||
<Trans>Sign In</Trans>
|
||||
<Trans>Sign in</Trans>
|
||||
</span>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</Link>
|
||||
|
||||
@@ -147,7 +147,7 @@ export function NavBar({
|
||||
</Link>
|
||||
<nav
|
||||
ref={navRef}
|
||||
aria-label="Primary"
|
||||
aria-label={t`Primary`}
|
||||
className="relative hidden items-center gap-1 sm:flex"
|
||||
>
|
||||
{navLinks.map((link, i) => {
|
||||
@@ -205,7 +205,7 @@ export function NavBar({
|
||||
<DropdownMenu modal={false}>
|
||||
<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"
|
||||
aria-label="Account menu"
|
||||
aria-label={t`Account menu`}
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarImage src={userImage} alt={userName} />
|
||||
@@ -260,7 +260,7 @@ export function NavBar({
|
||||
<Link
|
||||
to="/settings"
|
||||
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">
|
||||
<AvatarImage src={userImage} alt={userName} />
|
||||
@@ -298,7 +298,7 @@ export function MobileTabBar() {
|
||||
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
<div ref={containerRef} className="relative flex h-14 items-stretch">
|
||||
|
||||
@@ -73,7 +73,7 @@ export function FilmographyGrid({ credits, userStatuses }: FilmographyGridProps)
|
||||
value={sort}
|
||||
onValueChange={(v) => v && setSort(v as Sort)}
|
||||
modal={false}
|
||||
aria-label="Sort filmography"
|
||||
aria-label={t`Sort filmography`}
|
||||
>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue>
|
||||
|
||||
@@ -199,7 +199,7 @@ export function BackupScheduleSection() {
|
||||
return (
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Failed to load backup schedule settings.</Trans>
|
||||
<Trans>Failed to load backup schedule settings</Trans>
|
||||
</p>
|
||||
</CardContent>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { msg, plural } from "@lingui/core/macro";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import {
|
||||
IconActivity,
|
||||
@@ -28,6 +29,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import type { CronJobName, SystemHealthData } from "@sofa/api/schemas";
|
||||
import { i18n } from "@sofa/i18n";
|
||||
|
||||
const DIGITS_ONLY_RE = /^\d+$/;
|
||||
|
||||
@@ -40,26 +42,29 @@ function cronToHuman(pattern: string): string {
|
||||
// Every N hours: "0 */6 * * *"
|
||||
if (hour.startsWith("*/")) {
|
||||
const n = Number.parseInt(hour.slice(2), 10);
|
||||
return `Every ${n}h`;
|
||||
return i18n._(msg`Every ${n}h`);
|
||||
}
|
||||
|
||||
// Twice daily: "0 1,13 * * *"
|
||||
if (hour.includes(",") && !hour.includes("/") && !hour.includes("-")) {
|
||||
const hours = hour.split(",");
|
||||
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 * * *"
|
||||
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)) {
|
||||
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
return `Weekly on ${days[Number(dow)] ?? dow}`;
|
||||
const dayDate = new Date(2024, 0, 7 + Number(dow)); // 2024-01-07 is a Sunday
|
||||
const dayName = new Intl.DateTimeFormat(i18n.locale, { weekday: "short" }).format(dayDate);
|
||||
return i18n._(msg`Weekly on ${dayName}`);
|
||||
}
|
||||
|
||||
return pattern;
|
||||
@@ -528,7 +533,7 @@ function StorageCard({
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const cachedImageCount = imageCache.imageCount.toLocaleString();
|
||||
const cachedImageCount = imageCache.imageCount;
|
||||
const backupCount = backups.backupCount;
|
||||
const lastBackupAt = backups.lastBackupAt;
|
||||
return (
|
||||
@@ -567,7 +572,7 @@ function StorageCard({
|
||||
{imageCache.enabled ? (
|
||||
<>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t`${cachedImageCount} cached images`}
|
||||
{plural(cachedImageCount, { one: "# cached image", other: "# cached images" })}
|
||||
</p>
|
||||
<p className="text-muted-foreground/50 mt-0.5 text-[10px] leading-relaxed">
|
||||
{Object.entries(imageCache.categories)
|
||||
@@ -598,7 +603,8 @@ function StorageCard({
|
||||
{backupCount > 0 ? (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
<Trans>
|
||||
{backupCount} backups · last <LiveTimeAgo date={lastBackupAt} fallback={t`unknown`} />
|
||||
{plural(backupCount, { one: "# backup", other: "# backups" })} · last{" "}
|
||||
<LiveTimeAgo date={lastBackupAt} fallback={t`unknown`} />
|
||||
</Trans>
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { plural } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import {
|
||||
IconBookmarkFilled,
|
||||
@@ -148,7 +149,9 @@ function ProgressBar({ watched, total }: { watched: number; total: number }) {
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t`${watched}/${total} episodes`}</TooltipContent>
|
||||
<TooltipContent side="top">
|
||||
{t`${watched}/${plural(total, { one: "# episode", other: "# episodes" })}`}
|
||||
</TooltipContent>
|
||||
</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 { Link } from "@tanstack/react-router";
|
||||
|
||||
@@ -7,9 +8,11 @@ import { thumbHashToUrl } from "@/lib/thumbhash";
|
||||
import type { CastMember } from "@sofa/api/schemas";
|
||||
|
||||
function EpisodeCountLabel({ count }: { count: number }) {
|
||||
const { t } = useLingui();
|
||||
const suffix = count !== 1 ? "s" : "";
|
||||
return <p className="text-muted-foreground/70 text-[10px]">{t`${count} ep${suffix}`}</p>;
|
||||
return (
|
||||
<p className="text-muted-foreground/70 text-[10px]">
|
||||
{plural(count, { one: "# ep", other: "# eps" })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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 { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
@@ -14,13 +16,14 @@ interface StarRatingProps {
|
||||
}
|
||||
|
||||
export function StarRating({ value, onChange }: StarRatingProps) {
|
||||
const { t } = useLingui();
|
||||
const [hover, setHover] = useState(0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-0.5"
|
||||
role="radiogroup"
|
||||
aria-label="Rating"
|
||||
aria-label={t`Rating`}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
@@ -31,7 +34,7 @@ export function StarRating({ value, onChange }: StarRatingProps) {
|
||||
type="button"
|
||||
role="radio"
|
||||
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)}
|
||||
onMouseEnter={() => setHover(star)}
|
||||
className="p-0.5"
|
||||
|
||||
@@ -37,7 +37,7 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
|
||||
|
||||
const statusConfig = {
|
||||
in_watchlist: {
|
||||
label: t`In Watchlist`,
|
||||
label: t`On Watchlist`,
|
||||
icon: IconBookmarkFilled,
|
||||
class: "text-primary",
|
||||
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 {
|
||||
IconCalendarEvent,
|
||||
IconCircleCheck,
|
||||
@@ -30,6 +30,7 @@ export function TitleHero({
|
||||
actions: ReactNode;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const dateStr = title.releaseDate ?? title.firstAirDate;
|
||||
const year = dateStr?.slice(0, 4);
|
||||
const palette = title.colorPalette;
|
||||
@@ -175,7 +176,7 @@ export function TitleHero({
|
||||
href={`https://www.themoviedb.org/${title.type === "movie" ? "movie" : "tv"}/${title.tmdbId}`}
|
||||
target="_blank"
|
||||
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"
|
||||
>
|
||||
<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 {
|
||||
IconCheck,
|
||||
IconChecks,
|
||||
@@ -59,6 +59,7 @@ export function TitleSeasons({
|
||||
}: {
|
||||
seasons?: Season[];
|
||||
} = {}) {
|
||||
const { t } = useLingui();
|
||||
const { seasons, setSeasons, watchingEp } = useTitleContext();
|
||||
const { episodeWatches, userStatus } = useTitleUserInfo();
|
||||
|
||||
@@ -228,6 +229,7 @@ export function TitleSeasons({
|
||||
{season.episodes.map((ep) => {
|
||||
const isWatched = watchedSet.has(ep.id);
|
||||
const { stillPath } = ep;
|
||||
const epNum = ep.episodeNumber;
|
||||
return (
|
||||
<div
|
||||
key={ep.id}
|
||||
@@ -254,7 +256,11 @@ export function TitleSeasons({
|
||||
<div className="flex gap-3 px-4 py-3">
|
||||
<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={() =>
|
||||
handleWatchEpisode(
|
||||
ep.id,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { IconLoader } from "@tabler/icons-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
const { t } = useLingui();
|
||||
return (
|
||||
<IconLoader
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
aria-label={t`Loading`}
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
+293
-40
@@ -37,6 +37,11 @@ msgstr ""
|
||||
msgid "{0, plural, one {# title} other {# titles}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: import { msg, plural } from "@lingui/core/macro"; import { Trans, useLingui } from "@lingui/react/macro"; import { IconActivity, IconAlertTriangle, IconCalendarCheck, IconCheck, IconDatabase, IconPlayerPlay, IconRefresh, } from "@tabler/icons-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { StatusDot } from "@/components/status-dot"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useTimeAgo } from "@/hooks/use-time-ago"; import { orpc } from "@/lib/orpc/client"; import type { CronJobName, SystemHealthData } from "@sofa/api/schemas"; import { i18n } from "@sofa/i18n"; const DIGITS_ONLY_RE = /^\d+$/; /** Convert a cron pattern to a short human-readable string */ function cronToHuman(pattern: string): string { const parts = pattern.split(" "); if (parts.length !== 5) return pattern; const [min, hour, _dom, _mon, dow] = parts; // Every N hours: "0 */6 * * *" if (hour.startsWith("*/")) { const n = Number.parseInt(hour.slice(2), 10); return i18n._(msg`Every ${n}h`); } // Twice daily: "0 1,13 * * *" if (hour.includes(",") && !hour.includes("/") && !hour.includes("-")) { const hours = hour.split(","); if (hours.length === 2) { 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 * * *" if (DIGITS_ONLY_RE.test(hour) && DIGITS_ONLY_RE.test(min) && dow === "*") { const time = `${hour.padStart(2, "0")}:${min.padStart(2, "0")}`; return i18n._(msg`Daily at ${time}`); } // Weekly — use Intl for locale-aware day name if (DIGITS_ONLY_RE.test(dow)) { const dayDate = new Date(2024, 0, 7 + Number(dow)); // 2024-01-07 is a Sunday const dayName = new Intl.DateTimeFormat(i18n.locale, { weekday: "short" }).format(dayDate); return i18n._(msg`Weekly on ${dayName}`); } return pattern; } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; } function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; return `${Math.round(ms / 60000)}m`; } export function SkeletonCards() { return ( <div className="space-y-3"> {["status", "jobs", "storage"].map((s) => ( <Card key={s} className="border-s-primary/30 border-s-2"> <CardContent> <div className="flex items-start gap-3"> <Skeleton className="mt-0.5 h-8 w-8 rounded-lg" /> <div className="flex-1 space-y-2"> <Skeleton className="h-4 w-28" /> <Skeleton className="h-3 w-48" /> </div> </div> </CardContent> </Card> ))} </div> ); } /** Inline component that live-updates a relative timestamp */ function LiveTimeAgo({ date, fallback = "", }: { date: string | Date | null | undefined; fallback?: string; }) { const text = useTimeAgo(date, { fallback }); return <span suppressHydrationWarning>{text}</span>; } /** Hydrates system health state and renders the 3 cards */ export function SystemHealthCards() { const queryClient = useQueryClient(); const { data, isPending, isFetching } = useQuery(orpc.admin.systemHealth.queryOptions()); const isRefreshing = isFetching; const refresh = () => queryClient.invalidateQueries({ queryKey: orpc.admin.systemHealth.key() }); if (isPending || !data) return <SkeletonCards />; return ( <div className="space-y-3"> <SystemStatusCard checkedAt={data.checkedAt} database={data.database} tmdb={data.tmdb} environment={data.environment} isRefreshing={isRefreshing} onRefresh={refresh} /> <BackgroundJobsCard jobs={data.jobs} isRefreshing={isRefreshing} onRefresh={refresh} /> <StorageCard imageCache={data.imageCache} backups={data.backups} isRefreshing={isRefreshing} onRefresh={refresh} /> </div> ); } function RefreshButton({ isRefreshing, onRefresh, }: { isRefreshing: boolean; onRefresh: () => void; }) { const { t } = useLingui(); return ( <Tooltip> <TooltipTrigger render={ <Button variant="ghost" size="icon" aria-label={t`Refresh system health`} onClick={onRefresh} disabled={isRefreshing} className="text-muted-foreground" /> } > {isRefreshing ? <Spinner /> : <IconRefresh />} </TooltipTrigger> <TooltipContent> <Trans>Refresh</Trans> </TooltipContent> </Tooltip> ); } function SystemStatusCard({ checkedAt, database, tmdb, environment, isRefreshing, onRefresh, }: Pick<SystemHealthData, "checkedAt" | "database" | "tmdb" | "environment"> & { isRefreshing: boolean; onRefresh: () => void; }) { rLine truncated
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "{0} · last <0/>"
|
||||
msgstr "{0} · last <0/>"
|
||||
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
#~ msgid "{0} {1, plural, one {backup} other {backups}} stored"
|
||||
#~ msgstr ""
|
||||
@@ -93,31 +98,43 @@ msgstr ""
|
||||
#~ msgid "{0}/{1} episodes"
|
||||
#~ msgstr "{0}/{1} episodes"
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "{backupCount, plural, one {# backup} other {# backups}}"
|
||||
msgstr "{backupCount, plural, one {# backup} other {# backups}}"
|
||||
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
msgid "{backupCount, plural, one {# backup} other {# backups}} stored"
|
||||
msgstr "{backupCount, plural, one {# backup} other {# backups}} stored"
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "{backupCount} backups · last <0/>"
|
||||
msgstr "{backupCount} backups · last <0/>"
|
||||
#~ msgid "{backupCount} backups · last <0/>"
|
||||
#~ msgstr "{backupCount} backups · last <0/>"
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "{cachedImageCount} cached images"
|
||||
msgstr "{cachedImageCount} cached images"
|
||||
msgid "{cachedImageCount, plural, one {# cached image} other {# cached images}}"
|
||||
msgstr "{cachedImageCount, plural, one {# cached image} other {# cached images}}"
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
#~ msgid "{cachedImageCount} cached images"
|
||||
#~ msgstr "{cachedImageCount} cached images"
|
||||
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
msgid "{count, plural, one {# earlier episode} other {# earlier episodes}} unwatched"
|
||||
msgstr "{count, plural, one {# earlier episode} other {# earlier episodes}} unwatched"
|
||||
|
||||
#: apps/web/src/components/titles/cast-carousel.tsx
|
||||
msgid "{count, plural, one {# ep} other {# eps}}"
|
||||
msgstr "{count, plural, one {# ep} other {# eps}}"
|
||||
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
#~ msgid "{count} earlier {count, plural, one {episode} other {episodes}} unwatched"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/cast-carousel.tsx
|
||||
msgid "{count} ep{suffix}"
|
||||
msgstr "{count} ep{suffix}"
|
||||
#~ msgid "{count} ep{suffix}"
|
||||
#~ msgstr "{count} ep{suffix}"
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
#~ msgid "{healthyCount} of {0} jobs healthy"
|
||||
@@ -156,21 +173,29 @@ msgstr "{movieCount} movies, {episodeCount} episodes"
|
||||
msgid "{ratingCount} ratings"
|
||||
msgstr "{ratingCount} ratings"
|
||||
|
||||
#: apps/native/src/components/ui/star-rating.tsx
|
||||
msgid "{star, plural, one {# star} other {# stars}}"
|
||||
msgstr "{star, plural, one {# star} other {# stars}}"
|
||||
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "{unresolvedCount} items have no external IDs and will be resolved by title search, which may be less accurate."
|
||||
msgstr "{unresolvedCount} items have no external IDs and will be resolved by title search, which may be less accurate."
|
||||
|
||||
#: apps/web/src/components/title-card.tsx
|
||||
msgid "{watched}/{total} episodes"
|
||||
msgstr ""
|
||||
msgid "{watched}/{total, plural, one {# episode} other {# episodes}}"
|
||||
msgstr "{watched}/{total, plural, one {# episode} other {# episodes}}"
|
||||
|
||||
#: apps/web/src/components/title-card.tsx
|
||||
#~ msgid "{watched}/{total} episodes"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/native/src/components/titles/season-accordion.tsx
|
||||
#~ msgid "{watchedCount} of {0} episodes watched"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/native/src/components/titles/season-accordion.tsx
|
||||
msgid "{watchedCount} of {episodeCount} episodes watched"
|
||||
msgstr "{watchedCount} of {episodeCount} episodes watched"
|
||||
#~ msgid "{watchedCount} of {episodeCount} episodes watched"
|
||||
#~ msgstr "{watchedCount} of {episodeCount} episodes watched"
|
||||
|
||||
#: apps/native/src/components/titles/season-accordion.tsx
|
||||
#~ msgid "{watchedCount}/{0} {1, plural, one {episode} other {episodes}}"
|
||||
@@ -180,10 +205,12 @@ msgstr "{watchedCount} of {episodeCount} episodes watched"
|
||||
#~ msgid "{watchedCount}/{0} episodes"
|
||||
#~ msgstr "{watchedCount}/{0} episodes"
|
||||
|
||||
#: apps/native/src/components/titles/season-accordion.tsx
|
||||
#: apps/native/src/components/titles/season-accordion.tsx
|
||||
msgid "{watchedCount}/{episodeCount, plural, one {# episode} other {# episodes}}"
|
||||
msgstr "{watchedCount}/{episodeCount, plural, one {# episode} other {# episodes}}"
|
||||
|
||||
#: apps/native/src/components/dashboard/continue-watching-card.tsx
|
||||
#: apps/web/src/components/dashboard/continue-watching-card.tsx
|
||||
msgid "{watchedEpisodes}/{totalEpisodes, plural, one {# episode} other {# episodes}}"
|
||||
msgstr "{watchedEpisodes}/{totalEpisodes, plural, one {# episode} other {# episodes}}"
|
||||
@@ -198,6 +225,10 @@ msgstr "{watchlistCount} items"
|
||||
msgid "Account"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
msgid "Account menu"
|
||||
msgstr "Account menu"
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
@@ -221,15 +252,20 @@ msgstr ""
|
||||
msgid "Add a new webhook and paste the URL above"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Add profile photo"
|
||||
msgstr "Add profile photo"
|
||||
|
||||
#: apps/web/src/components/explore/hero-banner.tsx
|
||||
msgid "Add to Library"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/titles/status-action-button.tsx
|
||||
msgid "Add to watchlist"
|
||||
msgstr ""
|
||||
#~ msgid "Add to watchlist"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/native/src/components/explore/hero-banner.tsx
|
||||
#: apps/native/src/components/titles/status-action-button.tsx
|
||||
#: apps/native/src/components/ui/poster-card.tsx
|
||||
#: apps/web/src/components/title-card.tsx
|
||||
msgid "Add to Watchlist"
|
||||
@@ -288,6 +324,7 @@ msgstr ""
|
||||
msgid "An unexpected error occurred while loading this page. You can try again or head back to the dashboard."
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Anonymous usage reporting"
|
||||
msgstr ""
|
||||
@@ -437,14 +474,15 @@ msgid "Caught up — marked {count, plural, one {# episode} other {# episodes}}
|
||||
msgstr "Caught up — marked {count, plural, one {# episode} other {# episodes}} as watched"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
#: apps/native/src/app/change-password.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
msgid "Change password"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/change-password.tsx
|
||||
msgid "Change Password"
|
||||
msgstr ""
|
||||
#~ msgid "Change Password"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Change Photo"
|
||||
@@ -458,6 +496,7 @@ msgstr ""
|
||||
msgid "Check configuration"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Check for updates"
|
||||
msgstr ""
|
||||
@@ -530,6 +569,7 @@ msgstr ""
|
||||
msgid "Completed"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/change-password.tsx
|
||||
#: apps/native/src/app/change-password.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
msgid "Confirm new password"
|
||||
@@ -624,14 +664,16 @@ msgstr ""
|
||||
msgid "Could not load title details"
|
||||
msgstr "Could not load title details"
|
||||
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
#: apps/web/src/components/auth-form.tsx
|
||||
msgid "Create account"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
msgid "Create Account"
|
||||
msgstr ""
|
||||
#~ msgid "Create Account"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/native/src/app/(auth)/login.tsx
|
||||
msgid "Create an account"
|
||||
@@ -645,15 +687,25 @@ msgstr ""
|
||||
msgid "Creating…"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/change-password.tsx
|
||||
#: apps/native/src/app/change-password.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
msgid "Current password"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/change-password.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
msgid "Current password is required"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Daily at {time}"
|
||||
msgstr "Daily at {time}"
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Daily at {times}"
|
||||
msgstr "Daily at {times}"
|
||||
|
||||
#: apps/web/src/routes/_app/settings.tsx
|
||||
msgid "Danger Zone"
|
||||
msgstr ""
|
||||
@@ -732,6 +784,10 @@ msgstr ""
|
||||
msgid "Disconnect {label}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Display name"
|
||||
msgstr "Display name"
|
||||
|
||||
#: apps/native/src/app/(auth)/server-url.tsx
|
||||
msgid "Don't have a server?"
|
||||
msgstr ""
|
||||
@@ -745,6 +801,10 @@ msgstr ""
|
||||
msgid "Done"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/ui/star-rating.tsx
|
||||
msgid "Double tap to clear rating"
|
||||
msgstr "Double tap to clear rating"
|
||||
|
||||
#: apps/native/src/components/explore/genre-chip.tsx
|
||||
msgid "Double tap to clear this filter"
|
||||
msgstr ""
|
||||
@@ -753,6 +813,10 @@ msgstr ""
|
||||
msgid "Double tap to filter by {label}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/ui/star-rating.tsx
|
||||
msgid "Double tap to rate {star, plural, one {# star} other {# stars}}"
|
||||
msgstr "Double tap to rate {star, plural, one {# star} other {# stars}}"
|
||||
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
msgid "Download"
|
||||
@@ -766,6 +830,10 @@ msgstr ""
|
||||
msgid "Download your library, watch history, and ratings as JSON"
|
||||
msgstr "Download your library, watch history, and ratings as JSON"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Edit profile photo"
|
||||
msgstr "Edit profile photo"
|
||||
|
||||
#: apps/native/src/app/person/[id].tsx
|
||||
#: apps/native/src/components/search/recently-viewed-row-content.tsx
|
||||
#: apps/web/src/components/people/person-hero.tsx
|
||||
@@ -773,11 +841,18 @@ msgid "Editor"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(auth)/login.tsx
|
||||
#: apps/native/src/app/(auth)/login.tsx
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
#: apps/web/src/components/auth-form.tsx
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(auth)/login.tsx
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
msgid "Email is required"
|
||||
msgstr "Email is required"
|
||||
|
||||
#: apps/native/src/components/settings/integration-configs.ts
|
||||
msgid "Enable the \"Playback Stop\" notification type"
|
||||
msgstr ""
|
||||
@@ -794,10 +869,19 @@ msgstr ""
|
||||
msgid "Enable the <0>Playback</0> event category"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(auth)/login.tsx
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
msgid "Enter a valid email address"
|
||||
msgstr "Enter a valid email address"
|
||||
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Enter the code below on {source}'s website to authorize Sofa."
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(auth)/server-url.tsx
|
||||
msgid "Enter the full URL for your Sofa server"
|
||||
msgstr "Enter the full URL for your Sofa server"
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
msgid "Enter your current password and choose a new one."
|
||||
msgstr ""
|
||||
@@ -828,11 +912,13 @@ msgstr "Episode {episodeNumber}"
|
||||
msgid "Episode {episodeNumber}, {episodeLabel}"
|
||||
msgstr "Episode {episodeNumber}, {episodeLabel}"
|
||||
|
||||
#: apps/native/src/components/titles/season-accordion.tsx
|
||||
#: apps/native/src/hooks/use-title-actions.ts
|
||||
#: apps/native/src/lib/title-actions.ts
|
||||
msgid "Episode unwatched"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/titles/season-accordion.tsx
|
||||
#: apps/native/src/hooks/use-title-actions.ts
|
||||
#: apps/native/src/lib/title-actions.ts
|
||||
msgid "Episode watched"
|
||||
@@ -882,6 +968,10 @@ msgstr ""
|
||||
msgid "Errors ({errorCount})"
|
||||
msgstr "Errors ({errorCount})"
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Every {n}h"
|
||||
msgstr "Every {n}h"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/_layout.tsx
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -945,8 +1035,12 @@ msgid "Failed to disconnect {label}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/backup-schedule-section.tsx
|
||||
msgid "Failed to load backup schedule settings."
|
||||
msgstr ""
|
||||
msgid "Failed to load backup schedule settings"
|
||||
msgstr "Failed to load backup schedule settings"
|
||||
|
||||
#: apps/web/src/components/settings/backup-schedule-section.tsx
|
||||
#~ msgid "Failed to load backup schedule settings."
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/web/src/routes/_app/titles.$id.tsx
|
||||
msgid "Failed to load title"
|
||||
@@ -1022,6 +1116,10 @@ msgstr ""
|
||||
msgid "Failed to start {sourceLabel} connection"
|
||||
msgstr "Failed to start {sourceLabel} connection"
|
||||
|
||||
#: apps/web/src/components/auth-form.tsx
|
||||
msgid "Failed to start SSO login"
|
||||
msgstr "Failed to start SSO login"
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Failed to trigger job"
|
||||
msgstr ""
|
||||
@@ -1156,14 +1254,15 @@ msgstr ""
|
||||
msgid "Home"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Image cache"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Image Cache"
|
||||
msgstr ""
|
||||
#~ msgid "Image Cache"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Image cache and backup disk usage"
|
||||
@@ -1243,17 +1342,18 @@ msgid "Importing from {source}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
msgid "In library"
|
||||
msgstr ""
|
||||
#~ msgid "In library"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
#: apps/web/src/components/dashboard/stats-display.tsx
|
||||
msgid "In Library"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/titles/status-action-button.tsx
|
||||
#: apps/web/src/components/titles/status-button.tsx
|
||||
msgid "In Watchlist"
|
||||
msgstr "In Watchlist"
|
||||
#~ msgid "In Watchlist"
|
||||
#~ msgstr "In Watchlist"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
#: apps/web/src/components/dashboard/library-section.tsx
|
||||
@@ -1356,10 +1456,19 @@ msgstr ""
|
||||
msgid "Library statuses"
|
||||
msgstr "Library statuses"
|
||||
|
||||
#: apps/native/src/components/ui/skeleton.tsx
|
||||
#: apps/web/src/components/ui/spinner.tsx
|
||||
msgid "Loading"
|
||||
msgstr "Loading"
|
||||
|
||||
#: apps/web/src/components/auth-form.tsx
|
||||
msgid "Loading…"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/auth-form.tsx
|
||||
msgid "Login failed"
|
||||
msgstr "Login failed"
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Lost connection to import. Check status in settings."
|
||||
@@ -1390,22 +1499,32 @@ msgstr ""
|
||||
|
||||
#: apps/native/src/components/dashboard/upcoming-row.tsx
|
||||
#: apps/native/src/components/ui/poster-card.tsx
|
||||
msgid "Mark as Watched"
|
||||
msgstr ""
|
||||
#~ msgid "Mark as Watched"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/native/src/components/ui/poster-card.tsx
|
||||
#~ msgid "Mark as Watching"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/title-seasons.tsx
|
||||
msgid "Mark episode {epNum} watched"
|
||||
msgstr "Mark episode {epNum} watched"
|
||||
|
||||
#: apps/native/src/components/dashboard/upcoming-row.tsx
|
||||
msgid "Mark Episode Watched"
|
||||
msgstr "Mark Episode Watched"
|
||||
|
||||
#: apps/native/src/app/title/[id].tsx
|
||||
#: apps/native/src/components/dashboard/upcoming-row.tsx
|
||||
#: apps/native/src/components/ui/poster-card.tsx
|
||||
#: apps/web/src/components/titles/title-actions.tsx
|
||||
msgid "Mark Watched"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/title/[id].tsx
|
||||
msgid "Marked \"{name}\" as watched"
|
||||
msgstr "Marked \"{name}\" as watched"
|
||||
|
||||
#: apps/native/src/lib/title-actions.ts
|
||||
#~ msgid "Marked \"{titleName}\" as completed"
|
||||
#~ msgstr ""
|
||||
@@ -1429,6 +1548,7 @@ msgstr "Marked all episodes of \"{titleName}\" as watched"
|
||||
#~ msgid "Marked as completed"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/native/src/app/title/[id].tsx
|
||||
#: apps/native/src/hooks/use-title-actions.ts
|
||||
#: apps/native/src/lib/title-actions.ts
|
||||
msgid "Marked as watched"
|
||||
@@ -1443,6 +1563,10 @@ msgstr ""
|
||||
msgid "Member since {memberSince}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/auth-form.tsx
|
||||
msgid "Min 8 characters…"
|
||||
msgstr "Min 8 characters…"
|
||||
|
||||
#: apps/web/src/components/settings/backup-schedule-section.tsx
|
||||
msgid "Monday"
|
||||
msgstr ""
|
||||
@@ -1490,11 +1614,20 @@ msgstr ""
|
||||
#~ msgid "Movies this month"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
#: apps/web/src/components/auth-form.tsx
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
msgid "Name is required"
|
||||
msgstr "Name is required"
|
||||
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
msgid "Name must be at least 2 characters"
|
||||
msgstr "Name must be at least 2 characters"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
msgid "Name updated"
|
||||
@@ -1528,6 +1661,7 @@ msgstr ""
|
||||
msgid "New backup"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/change-password.tsx
|
||||
#: apps/native/src/app/change-password.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
msgid "New password"
|
||||
@@ -1559,6 +1693,10 @@ msgstr ""
|
||||
msgid "Next run"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/dashboard/continue-watching-card.tsx
|
||||
msgid "Next: S{seasonNum} E{epNum}"
|
||||
msgstr "Next: S{seasonNum} E{epNum}"
|
||||
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "No backups yet"
|
||||
@@ -1598,8 +1736,10 @@ msgstr ""
|
||||
msgid "Not Found"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/titles/status-action-button.tsx
|
||||
#: apps/web/src/components/dashboard/upcoming-item.tsx
|
||||
#: apps/web/src/components/title-card.tsx
|
||||
#: apps/web/src/components/titles/status-button.tsx
|
||||
msgid "On Watchlist"
|
||||
msgstr ""
|
||||
|
||||
@@ -1639,6 +1779,7 @@ msgstr ""
|
||||
msgid "Open Radarr, go to Settings > Import Lists"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
#: apps/web/src/components/settings/registration-section.tsx
|
||||
msgid "Open registration"
|
||||
@@ -1652,13 +1793,26 @@ msgstr ""
|
||||
msgid "Open Sonarr, go to Settings > Import Lists"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Opens options to change or remove your photo"
|
||||
msgstr "Opens options to change or remove your photo"
|
||||
|
||||
#: apps/native/src/app/title/[id].tsx
|
||||
msgid "Opens the trailer in YouTube"
|
||||
msgstr "Opens the trailer in YouTube"
|
||||
|
||||
#: apps/native/src/components/explore/hero-banner.tsx
|
||||
msgid "Opens title details"
|
||||
msgstr "Opens title details"
|
||||
|
||||
#: apps/native/src/app/(auth)/login.tsx
|
||||
#: apps/web/src/components/auth-form.tsx
|
||||
msgid "or"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(auth)/login.tsx
|
||||
msgid "OR"
|
||||
msgstr ""
|
||||
#~ msgid "OR"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/native/src/app/+not-found.tsx
|
||||
msgid "Page Not Found"
|
||||
@@ -1673,16 +1827,28 @@ msgid "Parsing..."
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(auth)/login.tsx
|
||||
#: apps/native/src/app/(auth)/login.tsx
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
#: apps/web/src/components/auth-form.tsx
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(auth)/login.tsx
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
msgid "Password is required"
|
||||
msgstr "Password is required"
|
||||
|
||||
#: apps/native/src/app/change-password.tsx
|
||||
msgid "Password must be at least 8 characters"
|
||||
msgstr "Password must be at least 8 characters"
|
||||
|
||||
#: apps/native/src/app/change-password.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
msgid "Password updated"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/change-password.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
msgid "Passwords do not match"
|
||||
msgstr ""
|
||||
@@ -1716,6 +1882,14 @@ msgstr ""
|
||||
msgid "Play trailer"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/title/[id].tsx
|
||||
msgid "Play trailer for {titleName}"
|
||||
msgstr "Play trailer for {titleName}"
|
||||
|
||||
#: apps/native/src/app/change-password.tsx
|
||||
msgid "Please confirm your password"
|
||||
msgstr "Please confirm your password"
|
||||
|
||||
#: apps/native/src/hooks/use-server-connection.ts
|
||||
msgid "Please sign in again."
|
||||
msgstr "Please sign in again."
|
||||
@@ -1734,6 +1908,11 @@ msgstr ""
|
||||
msgid "Pre-restore backup"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
msgid "Primary"
|
||||
msgstr "Primary"
|
||||
|
||||
#: apps/native/src/app/person/[id].tsx
|
||||
#: apps/native/src/components/search/recently-viewed-row-content.tsx
|
||||
#: apps/web/src/components/people/person-hero.tsx
|
||||
@@ -1804,6 +1983,10 @@ msgstr ""
|
||||
msgid "Radarr List URL"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/star-rating.tsx
|
||||
msgid "Rate {star, plural, one {# star} other {# stars}}"
|
||||
msgstr "Rate {star, plural, one {# star} other {# stars}}"
|
||||
|
||||
#: apps/native/src/hooks/use-title-actions.ts
|
||||
#~ msgid "Rated {0} {1, plural, one {star} other {stars}}"
|
||||
#~ msgstr ""
|
||||
@@ -1827,6 +2010,7 @@ msgstr ""
|
||||
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/titles/star-rating.tsx
|
||||
msgid "Rating"
|
||||
msgstr ""
|
||||
|
||||
@@ -1836,6 +2020,10 @@ msgstr ""
|
||||
msgid "Rating removed"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/ui/star-rating.tsx
|
||||
msgid "Rating: {rating} out of 5 stars"
|
||||
msgstr "Rating: {rating} out of 5 stars"
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
@@ -1920,6 +2108,10 @@ msgstr ""
|
||||
msgid "Registration Closed"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/auth-form.tsx
|
||||
msgid "Registration failed"
|
||||
msgstr "Registration failed"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
#: apps/web/src/components/settings/registration-section.tsx
|
||||
msgid "Registration opened"
|
||||
@@ -1931,7 +2123,10 @@ msgstr ""
|
||||
msgid "Remove"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/dashboard/continue-watching-card.tsx
|
||||
#: apps/native/src/components/dashboard/upcoming-row.tsx
|
||||
#: apps/native/src/components/titles/status-action-button.tsx
|
||||
#: apps/native/src/components/ui/poster-card.tsx
|
||||
#: apps/web/src/components/titles/status-button.tsx
|
||||
msgid "Remove from library"
|
||||
msgstr ""
|
||||
@@ -1939,14 +2134,18 @@ msgstr ""
|
||||
#: apps/native/src/components/dashboard/continue-watching-card.tsx
|
||||
#: apps/native/src/components/dashboard/upcoming-row.tsx
|
||||
#: apps/native/src/components/ui/poster-card.tsx
|
||||
msgid "Remove from Library"
|
||||
msgstr ""
|
||||
#~ msgid "Remove from Library"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/native/src/components/titles/status-action-button.tsx
|
||||
#: apps/web/src/components/titles/status-button.tsx
|
||||
msgid "Remove from library?"
|
||||
msgstr "Remove from library?"
|
||||
|
||||
#: apps/web/src/components/command-palette.tsx
|
||||
msgid "Remove from recent searches"
|
||||
msgstr "Remove from recent searches"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Remove Photo"
|
||||
msgstr ""
|
||||
@@ -2133,6 +2332,10 @@ msgstr ""
|
||||
msgid "Season {seasonNumber}"
|
||||
msgstr "Season {seasonNumber}"
|
||||
|
||||
#: apps/native/src/components/titles/season-accordion.tsx
|
||||
msgid "Season {sn}"
|
||||
msgstr "Season {sn}"
|
||||
|
||||
#: apps/native/src/hooks/use-title-actions.ts
|
||||
#: apps/native/src/lib/title-actions.ts
|
||||
msgid "Season watched"
|
||||
@@ -2164,6 +2367,7 @@ msgstr ""
|
||||
msgid "Server Health"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(auth)/server-url.tsx
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Server URL"
|
||||
msgstr ""
|
||||
@@ -2188,6 +2392,7 @@ msgstr ""
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/components/settings/settings-shell.tsx
|
||||
msgid "Settings"
|
||||
msgstr ""
|
||||
@@ -2212,15 +2417,17 @@ msgstr ""
|
||||
msgid "Show more"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(auth)/login.tsx
|
||||
#: apps/web/src/components/auth-form.tsx
|
||||
#: apps/web/src/components/auth-form.tsx
|
||||
#: apps/web/src/components/landing-page.tsx
|
||||
msgid "Sign in"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(auth)/login.tsx
|
||||
#: apps/web/src/components/landing-page.tsx
|
||||
msgid "Sign In"
|
||||
msgstr ""
|
||||
#~ msgid "Sign In"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/web/src/routes/_auth/register.tsx
|
||||
msgid "Sign in instead"
|
||||
@@ -2245,6 +2452,10 @@ msgid "Sign in with {providerName}"
|
||||
msgstr "Sign in with {providerName}"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
#: apps/native/src/components/header-avatar.tsx
|
||||
#: apps/native/src/components/header-avatar.tsx
|
||||
#: apps/native/src/components/header-avatar.tsx
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
@@ -2255,9 +2466,10 @@ msgstr ""
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
#: apps/native/src/components/header-avatar.tsx
|
||||
#: apps/native/src/components/header-avatar.tsx
|
||||
msgid "Sign Out"
|
||||
msgstr ""
|
||||
#~ msgid "Sign Out"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/native/src/app/change-password.tsx
|
||||
#: apps/native/src/app/change-password.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
msgid "Sign out of other sessions"
|
||||
@@ -2292,6 +2504,7 @@ msgstr ""
|
||||
#: apps/native/src/app/change-password.tsx
|
||||
#: apps/native/src/app/person/[id].tsx
|
||||
#: apps/native/src/app/title/[id].tsx
|
||||
#: apps/web/src/components/auth-form.tsx
|
||||
#: apps/web/src/components/route-error.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/routes/__root.tsx
|
||||
@@ -2311,6 +2524,10 @@ msgstr ""
|
||||
msgid "Sonarr List URL"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
msgid "Sort filmography"
|
||||
msgstr "Sort filmography"
|
||||
|
||||
#: apps/web/src/components/dashboard/stats-section.tsx
|
||||
msgid "Start exploring"
|
||||
msgstr ""
|
||||
@@ -2506,10 +2723,11 @@ msgid "Trailer"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/explore/hero-banner.tsx
|
||||
msgid "Trending today"
|
||||
msgstr ""
|
||||
#~ msgid "Trending today"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/index.tsx
|
||||
#: apps/web/src/components/explore/hero-banner.tsx
|
||||
#: apps/web/src/routes/_app/explore.tsx
|
||||
msgid "Trending Today"
|
||||
msgstr ""
|
||||
@@ -2554,6 +2772,10 @@ msgstr ""
|
||||
msgid "unlimited"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/title-seasons.tsx
|
||||
msgid "Unmark episode {epNum}"
|
||||
msgstr "Unmark episode {epNum}"
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Unreachable"
|
||||
msgstr ""
|
||||
@@ -2578,6 +2800,10 @@ msgstr "Unwatched all of {seasonLabel}"
|
||||
msgid "Unwatched S{seasonNum} E{epNum}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/titles/season-accordion.tsx
|
||||
msgid "Unwatched S{sNum} E{eNum}"
|
||||
msgstr "Unwatched S{sNum} E{eNum}"
|
||||
|
||||
#: apps/native/src/components/titles/continue-watching-banner.tsx
|
||||
#: apps/web/src/components/dashboard/continue-watching-card.tsx
|
||||
msgid "Up next"
|
||||
@@ -2618,13 +2844,14 @@ msgstr ""
|
||||
msgid "Update failed"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/change-password.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
msgid "Update password"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/change-password.tsx
|
||||
msgid "Update Password"
|
||||
msgstr ""
|
||||
#~ msgid "Update Password"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/backup-restore-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
@@ -2663,6 +2890,18 @@ msgstr ""
|
||||
msgid "URL copied to clipboard"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
msgid "Use at least 8 characters"
|
||||
msgstr "Use at least 8 characters"
|
||||
|
||||
#: apps/native/src/components/header-avatar.tsx
|
||||
msgid "User menu"
|
||||
msgstr "User menu"
|
||||
|
||||
#: apps/web/src/components/titles/title-hero.tsx
|
||||
msgid "View on TMDB"
|
||||
msgstr "View on TMDB"
|
||||
|
||||
#: apps/web/src/components/update-toast.tsx
|
||||
msgid "View release"
|
||||
msgstr ""
|
||||
@@ -2699,20 +2938,26 @@ msgstr ""
|
||||
msgid "Watch on {name}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/titles/season-accordion.tsx
|
||||
#: apps/native/src/lib/title-actions.ts
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
msgid "Watched all of {seasonLabel}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/lib/title-actions.ts
|
||||
msgid "Watched all of {seasonName}"
|
||||
msgstr ""
|
||||
#~ msgid "Watched all of {seasonName}"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
msgid "Watched S{seasonNum} E{epNum}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/titles/season-accordion.tsx
|
||||
msgid "Watched S{sNum} E{eNum}"
|
||||
msgstr "Watched S{sNum} E{eNum}"
|
||||
|
||||
#: apps/native/src/components/titles/status-action-button.tsx
|
||||
#: apps/web/src/components/dashboard/upcoming-item.tsx
|
||||
#: apps/web/src/components/title-card.tsx
|
||||
@@ -2741,6 +2986,10 @@ msgstr ""
|
||||
msgid "Wednesday"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Weekly on {dayName}"
|
||||
msgstr "Weekly on {dayName}"
|
||||
|
||||
#: apps/web/src/components/auth-form.tsx
|
||||
#: apps/web/src/components/dashboard/welcome-header.tsx
|
||||
msgid "Welcome back"
|
||||
@@ -2789,3 +3038,7 @@ msgstr ""
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
msgid "Your name"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/auth-form.tsx
|
||||
msgid "Your name…"
|
||||
msgstr "Your name…"
|
||||
Reference in new issue
Block a user