Files
sofa/apps/native/src/components/ui/poster-card.tsx
T
jakeandClaude Opus 4.6 eb9c7ae6df 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>
2026-03-23 16:21:54 -04:00

269 lines
8.7 KiB
TypeScript

import { useLingui } from "@lingui/react/macro";
import {
IconBookmarkFilled,
IconCheckbox,
IconDeviceTv,
IconLoader,
IconMovie,
IconPlayerPlayFilled,
IconPlus,
IconStarFilled,
} from "@tabler/icons-react-native";
import { Link } from "expo-router";
import { memo, useCallback } from "react";
import { Pressable, View } from "react-native";
import { GestureDetector } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Skeleton } from "@/components/ui/skeleton";
import { Text } from "@/components/ui/text";
import { usePressAnimation } from "@/hooks/use-press-animation";
import { titleActions } from "@/lib/title-actions";
type TitleStatus = "in_watchlist" | "watching" | "caught_up" | "completed";
interface PosterCardProps {
id: string;
title: string;
type: "movie" | "tv";
posterPath: string | null;
posterThumbHash?: string | null;
releaseDate?: string | null;
voteAverage?: number | null;
userStatus?: TitleStatus | null;
episodeProgress?: { watched: number; total: number } | null;
width?: number;
onQuickAdd: (id: string) => void;
isAdding?: boolean;
}
export const PosterCard = memo(function PosterCard({
id,
title,
type,
posterPath,
posterThumbHash,
releaseDate,
voteAverage,
userStatus,
episodeProgress,
width = 140,
onQuickAdd,
isAdding,
}: PosterCardProps) {
const { t } = useLingui();
const primaryColor = useCSSVariable("--color-primary") as string;
const watchlistColor = useCSSVariable("--color-status-watchlist") as string;
const watchingColor = useCSSVariable("--color-status-watching") as string;
const completedColor = useCSSVariable("--color-status-completed") as string;
const statusColors: Record<TitleStatus, string> = {
in_watchlist: watchlistColor,
watching: watchingColor,
caught_up: completedColor,
completed: completedColor,
};
const { animatedStyle, gesture: pressGesture } = usePressAnimation();
const handleQuickAddPress = useCallback(() => {
if (userStatus || isAdding) return;
onQuickAdd(id);
}, [userStatus, isAdding, onQuickAdd, id]);
const year = releaseDate?.slice(0, 4);
const imageHeight = width * 1.5;
const statusLabel =
userStatus === "completed"
? "completed"
: userStatus === "caught_up"
? "caught up"
: userStatus === "watching"
? "watching"
: userStatus === "in_watchlist"
? "on watchlist"
: undefined;
const cardAccessibilityLabel = [title, type === "movie" ? "movie" : "TV show", year, statusLabel]
.filter(Boolean)
.join(", ");
const cardContent = (
<View
className={`bg-card overflow-hidden rounded-xl border ${
userStatus ? "border-primary/25" : "border-white/[0.06]"
}`}
style={{ borderCurve: "continuous" }}
>
{/* Poster image */}
<View style={{ width, height: imageHeight }}>
{posterPath ? (
<Image
source={{ uri: posterPath }}
thumbHash={posterThumbHash}
style={{ width: "100%", height: "100%" }}
contentFit="cover"
recyclingKey={`poster-${id}`}
transition={200}
/>
) : (
<View className="bg-secondary flex-1 items-center justify-center p-3">
<Text className="font-display text-foreground/[0.44] text-center text-sm">{title}</Text>
</View>
)}
{/* Status indicator */}
{userStatus && (
<View
accessible={false}
className="absolute top-2 right-2 size-[30px] items-center justify-center rounded-full"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
>
{userStatus === "completed" || userStatus === "caught_up" ? (
<IconCheckbox size={16} color="white" />
) : userStatus === "watching" ? (
<IconPlayerPlayFilled size={16} color="white" />
) : (
<IconBookmarkFilled size={16} color="white" />
)}
</View>
)}
{/* Episode progress bar */}
{episodeProgress && episodeProgress.total > 0 && episodeProgress.watched > 0 && (
<View
className="absolute right-0 bottom-0 left-0 h-[3px]"
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
>
<View
className="bg-status-watching h-full"
style={{
width: `${episodeProgress.total > 0 ? (episodeProgress.watched / episodeProgress.total) * 100 : 0}%`,
}}
/>
</View>
)}
</View>
{/* Metadata */}
<View className="px-2.5 pt-2 pb-2.5">
<View className="flex-row items-center gap-1.5">
{userStatus && (
<View
className="size-1.5 rounded-full"
style={{ backgroundColor: statusColors[userStatus] }}
/>
)}
<Text className="text-foreground flex-1 font-sans text-sm font-medium" numberOfLines={1}>
{title}
</Text>
</View>
<View className="mt-1 flex-row items-center gap-1" accessible={false}>
{type === "movie" ? (
<ScaledIcon icon={IconMovie} size={12} color={primaryColor} />
) : (
<ScaledIcon icon={IconDeviceTv} size={12} color={primaryColor} />
)}
{year ? <Text className="text-muted-foreground text-xs">{year}</Text> : null}
{voteAverage != null && voteAverage > 0 && (
<View className="ml-auto flex-row items-center gap-1">
<ScaledIcon icon={IconStarFilled} size={10} color={primaryColor} />
<Text className="text-primary text-xs">{voteAverage.toFixed(1)}</Text>
</View>
)}
</View>
</View>
</View>
);
const quickAddButton = !userStatus ? (
<Pressable
onPress={handleQuickAddPress}
disabled={!!isAdding}
accessibilityRole="button"
accessibilityLabel={`Add ${title} to watchlist`}
accessibilityState={{ disabled: !!isAdding }}
className="absolute top-1 right-1 size-[44px] items-center justify-center rounded-full"
>
<View
className="size-[30px] items-center justify-center rounded-full"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
>
{isAdding ? <IconLoader size={16} color="white" /> : <IconPlus size={16} color="white" />}
</View>
</Pressable>
) : null;
const titleHref = `/title/${id}` as `/title/${string}`;
return (
<GestureDetector gesture={pressGesture}>
<Animated.View style={[animatedStyle, { width }]}>
<View>
<Link href={titleHref} asChild>
<Link.Trigger withAppleZoom>
<Pressable accessibilityRole="button" accessibilityLabel={cardAccessibilityLabel}>
{cardContent}
</Pressable>
</Link.Trigger>
<Link.Preview />
<Link.Menu>
{!userStatus && (
<Link.MenuAction
title={t`Add to Watchlist`}
icon="bookmark"
onPress={handleQuickAddPress}
/>
)}
{type === "movie" && (
<Link.MenuAction
title={t`Mark Watched`}
icon="checkmark.circle"
onPress={() => titleActions.markMovieWatched(id, title)}
/>
)}
{type === "tv" && userStatus && (
<Link.MenuAction
title={t`Mark All Watched`}
icon="checkmark.circle"
onPress={() => titleActions.markAllWatched(id, title)}
/>
)}
{userStatus && (
<Link.MenuAction
title={t`Remove from library`}
icon="trash"
destructive
onPress={() => titleActions.removeFromLibrary(id)}
/>
)}
</Link.Menu>
</Link>
{quickAddButton}
</View>
</Animated.View>
</GestureDetector>
);
});
export function PosterCardSkeleton({ width = 140 }: { width?: number }) {
const imageHeight = width * 1.5;
return (
<View
className="bg-card overflow-hidden rounded-xl border border-white/[0.06]"
style={{
width,
borderCurve: "continuous",
}}
>
<Skeleton width={width} height={imageHeight} borderRadius={0} />
<View className="px-2.5 pt-2 pb-2.5">
<Skeleton width="75%" height={14} />
<Skeleton width="50%" height={10} style={{ marginTop: 6 }} />
</View>
</View>
);
}