refactor: return pre-resolved internal IDs from all listing endpoints and remove client-side resolve mutations

All explore, discover, search, recommendation, and person-credit listing procedures now include the internal database `id` on every item so clients can navigate and act without a separate resolve round-trip.

- Remove `titles.resolve` and `people.resolve` mutation calls from the native search screen, hero banners, poster rows, and cast cards; replace with direct `Link` navigation using the pre-returned `id`.
- Change `titles.quickAdd` to accept `{ id }` instead of `{ tmdbId, type }` and update every call site on native and web.
- Key all user-status and episode-progress lookups by `id` instead of `tmdbId-type` composite strings across `PosterCard`, `HorizontalPosterRow`, `FilterableTitleRow`, and `usePosterActions`; drop the `tmdbId` prop from `PosterCard` entirely.
- Remove the `titles.hydrateSeasons` auto-trigger from the title detail screen; season hydration now happens server-side on resolve.
- Delete the `browse-thumbhashes` and `browse-title-ids` server procedures and remove them from the router.
- Extend `@sofa/api` schemas with an `id` field on all listing-item types; update `packages/core` services and add a DB migration accordingly.
This commit is contained in:
2026-03-16 16:35:50 -04:00
parent ccc7922737
commit 25736f684a
48 changed files with 6965 additions and 1777 deletions
+10 -55
View File
@@ -4,7 +4,7 @@ import {
useInfiniteQuery,
useMutation,
} from "@tanstack/react-query";
import { Stack, useRouter } from "expo-router";
import { Stack } from "expo-router";
import { useCallback, useMemo, useRef, useState } from "react";
import { ActivityIndicator, View } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated";
@@ -19,10 +19,8 @@ import { useDebounce } from "@/hooks/use-debounce";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
import * as Haptics from "@/utils/haptics";
export default function SearchScreen() {
const { navigate } = useRouter();
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query.trim(), 300);
@@ -38,36 +36,9 @@ export default function SearchScreen() {
}),
});
// Track which item is currently being resolved/added
const [resolvingId, setResolvingId] = useState<string | null>(null);
// Track which item is currently being added
const [addingId, setAddingId] = useState<string | null>(null);
const resolveTitleMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
setResolvingId(null);
if (id) navigate(`/title/${id}`);
},
onError: () => {
setResolvingId(null);
toast.error("Failed to load title");
},
}),
);
const resolvePersonMutation = useMutation(
orpc.people.resolve.mutationOptions({
onSuccess: ({ id }) => {
setResolvingId(null);
if (id) navigate(`/person/${id}`);
},
onError: () => {
setResolvingId(null);
toast.error("Failed to load person");
},
}),
);
const quickAddMutation = useMutation(
orpc.titles.quickAdd.mutationOptions({
onSuccess: () => {
@@ -83,27 +54,13 @@ export default function SearchScreen() {
}),
);
// Use refs for mutation.mutate to keep callbacks stable across renders
const resolveTitleMutateRef = useRef(resolveTitleMutation.mutate);
resolveTitleMutateRef.current = resolveTitleMutation.mutate;
const resolvePersonMutateRef = useRef(resolvePersonMutation.mutate);
resolvePersonMutateRef.current = resolvePersonMutation.mutate;
// Use ref for mutation.mutate to keep callback stable across renders
const quickAddMutateRef = useRef(quickAddMutation.mutate);
quickAddMutateRef.current = quickAddMutation.mutate;
const handleResolve = useCallback((item: SearchResultItem) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setResolvingId(`${item.type}-${item.tmdbId}`);
if (item.type === "person") {
resolvePersonMutateRef.current({ tmdbId: item.tmdbId });
} else {
resolveTitleMutateRef.current({ tmdbId: item.tmdbId, type: item.type });
}
}, []);
const handleQuickAdd = useCallback((tmdbId: number, type: "movie" | "tv") => {
setAddingId(`${type}-${tmdbId}`);
quickAddMutateRef.current({ tmdbId, type });
const handleQuickAdd = useCallback((id: string) => {
setAddingId(id);
quickAddMutateRef.current({ id });
}, []);
// Memoize mapped results to maintain stable references
@@ -111,7 +68,7 @@ export default function SearchScreen() {
() =>
searchResults.data?.pages.flatMap((page) =>
page.results.map((r) => ({
tmdbId: r.tmdbId,
id: r.id,
title: r.title,
type: r.type,
posterPath: r.posterPath,
@@ -126,17 +83,15 @@ export default function SearchScreen() {
({ item }: { item: SearchResultItem }) => (
<SearchResultRow
item={item}
onResolve={handleResolve}
onQuickAdd={handleQuickAdd}
isResolving={resolvingId === `${item.type}-${item.tmdbId}`}
isAdding={addingId === `${item.type}-${item.tmdbId}`}
isAdding={addingId === item.id}
/>
),
[handleResolve, handleQuickAdd, resolvingId, addingId],
[handleQuickAdd, addingId],
);
const keyExtractor = useCallback(
(item: SearchResultItem) => `${item.type}-${item.tmdbId}`,
(item: SearchResultItem) => `${item.type}-${item.id}`,
[],
);
+2 -5
View File
@@ -75,7 +75,7 @@ export default function PersonDetailScreen() {
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string;
const { handlePress, handleQuickAdd, addingKey, failedKey, resetError } =
const { handleQuickAdd, addingKey, failedKey, resetError } =
usePosterActions();
const {
@@ -134,7 +134,6 @@ export default function PersonDetailScreen() {
>
<PosterCard
id={credit.titleId}
tmdbId={credit.tmdbId}
title={credit.title}
type={credit.type}
posterPath={credit.posterPath}
@@ -143,9 +142,8 @@ export default function PersonDetailScreen() {
voteAverage={credit.voteAverage}
userStatus={userStatuses[credit.titleId] ?? null}
width={columnWidth}
onPress={handlePress}
onQuickAdd={handleQuickAdd}
isAdding={addingKey === `${credit.tmdbId}-${credit.type}`}
isAdding={addingKey === credit.titleId}
failedKey={failedKey}
onQuickAddFailed={resetError}
/>
@@ -154,7 +152,6 @@ export default function PersonDetailScreen() {
[
columnWidth,
userStatuses,
handlePress,
handleQuickAdd,
addingKey,
failedKey,
+2 -40
View File
@@ -16,7 +16,7 @@ import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { LinearGradient } from "expo-linear-gradient";
import { Link, useLocalSearchParams, useRouter } from "expo-router";
import * as WebBrowser from "expo-web-browser";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { Pressable, ScrollView, StyleSheet, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -168,14 +168,6 @@ export default function TitleDetailScreen() {
}),
);
const hydrateMutation = useMutation(
orpc.titles.hydrateSeasons.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
},
}),
);
const title = detail.data?.title;
const palette = title?.colorPalette ?? null;
useTitleTheme(palette);
@@ -212,7 +204,6 @@ export default function TitleDetailScreen() {
() =>
(recommendations.data?.recommendations ?? []).map((item) => ({
id: item.id,
tmdbId: item.tmdbId,
title: item.title,
type: item.type,
posterPath: item.posterPath,
@@ -231,12 +222,6 @@ export default function TitleDetailScreen() {
[],
);
const hydratedTitleId = useRef<string | null>(null);
const hydrateSeasonsRef = useRef(hydrateMutation.mutate);
useEffect(() => {
hydrateSeasonsRef.current = hydrateMutation.mutate;
}, [hydrateMutation.mutate]);
const titleScrollContentStyle = useMemo(
() => ({
paddingBottom: useAutomaticInsets ? 32 : insets.bottom + 32,
@@ -271,17 +256,6 @@ export default function TitleDetailScreen() {
[palette?.darkVibrant],
);
useEffect(() => {
if (
detail.data?.needsHydration &&
title?.type === "tv" &&
hydratedTitleId.current !== id
) {
hydratedTitleId.current = id;
hydrateSeasonsRef.current({ id, tmdbId: title.tmdbId });
}
}, [detail.data?.needsHydration, title?.type, title?.tmdbId, id]);
if (detail.isPending) {
return (
<>
@@ -487,10 +461,7 @@ export default function TitleDetailScreen() {
currentStatus={userInfo.data?.status ?? null}
onStatusChange={(status) => {
if (status === "watchlist") {
quickAddMutation.mutate({
tmdbId: title.tmdbId,
type: title.type,
});
quickAddMutation.mutate({ id });
} else {
updateStatus.mutate({ id, status: null });
}
@@ -620,15 +591,6 @@ export default function TitleDetailScreen() {
</Animated.View>
)}
{hydrateMutation.isPending && (
<View className="items-center py-6">
<Spinner colorClassName="accent-title-accent" />
<Text className="mt-2 text-muted-foreground text-sm">
Loading season data...
</Text>
</View>
)}
{/* Cast */}
{cast.length > 0 && (
<Animated.View
@@ -10,8 +10,7 @@ import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
import { usePosterActions } from "@/hooks/use-poster-actions";
export interface PosterRowItem {
id?: string;
tmdbId: number;
id: string;
title: string;
type: string;
posterPath: string | null;
@@ -30,17 +29,13 @@ export function HorizontalPosterRow({
items: PosterRowItem[];
isLoading?: boolean;
}) {
const { handlePress, handleQuickAdd, addingKey, failedKey, resetError } =
const { handleQuickAdd, addingKey, failedKey, resetError } =
usePosterActions();
const keyExtractor = useCallback(
(item: PosterRowItem) => item.id ?? `${item.tmdbId}-${item.type}`,
[],
);
const keyExtractor = useCallback((item: PosterRowItem) => item.id, []);
const renderItem = useCallback(
({ item }: { item: PosterRowItem }) => (
<PosterCard
id={item.id}
tmdbId={item.tmdbId}
title={item.title}
type={item.type as "movie" | "tv"}
posterPath={item.posterPath}
@@ -49,14 +44,13 @@ export function HorizontalPosterRow({
voteAverage={item.voteAverage}
userStatus={item.userStatus}
episodeProgress={item.episodeProgress}
onPress={handlePress}
onQuickAdd={handleQuickAdd}
isAdding={addingKey === `${item.tmdbId}-${item.type}`}
isAdding={addingKey === item.id}
failedKey={failedKey}
onQuickAddFailed={resetError}
/>
),
[addingKey, failedKey, handlePress, handleQuickAdd, resetError],
[addingKey, failedKey, handleQuickAdd, resetError],
);
if (isLoading) {
@@ -28,8 +28,7 @@ export function FilterableTitleRow({
icon: Icon;
mediaType: "movie" | "tv";
defaultItems: Array<{
id?: string;
tmdbId: number;
id: string;
title: string;
type: string;
posterPath: string | null;
@@ -93,14 +92,11 @@ export function FilterableTitleRow({
// Map items into PosterRowItem shape with status/progress resolved
const items = useMemo<PosterRowItem[]>(
() =>
rawItems.map((item) => {
const key = `${item.tmdbId}-${item.type}`;
return {
...item,
userStatus: userStatuses[key] ?? null,
episodeProgress: episodeProgress[key] ?? null,
};
}),
rawItems.map((item) => ({
...item,
userStatus: userStatuses[item.id] ?? null,
episodeProgress: episodeProgress[item.id] ?? null,
})),
[rawItems, userStatuses, episodeProgress],
);
@@ -1,9 +1,7 @@
import { IconStarFilled } from "@tabler/icons-react-native";
import { useMutation } from "@tanstack/react-query";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { Link, useRouter } from "expo-router";
import { useCallback } from "react";
import { Pressable, View } from "react-native";
import { Link } from "expo-router";
import { View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
@@ -15,12 +13,9 @@ import Animated, {
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc";
import { toast } from "@/lib/toast";
export interface HeroBannerItem {
id?: string;
tmdbId: number;
id: string;
title: string;
type: string;
backdropPath?: string | null;
@@ -30,7 +25,6 @@ export interface HeroBannerItem {
}
export function HeroBanner({ item }: { item: HeroBannerItem }) {
const { navigate } = useRouter();
const primary = useCSSVariable("--color-primary") as string;
const reduceMotion = useReducedMotion();
const pressed = useSharedValue(0);
@@ -42,22 +36,6 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
],
}));
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
navigate(`/title/${id}`);
},
onError: () => toast.error("Failed to load title"),
}),
);
const handlePress = useCallback(() => {
resolveMutation.mutate({
tmdbId: item.tmdbId,
type: item.type as "movie" | "tv",
});
}, [item.tmdbId, item.type, resolveMutation]);
const tapGesture = Gesture.Tap()
.onBegin(() => {
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
@@ -74,124 +52,117 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
]
.filter(Boolean)
.join(", ");
const titleHref = item.id
? (`/title/${item.id}` as `/title/${string}`)
: null;
const titleHref = `/title/${item.id}` as `/title/${string}`;
const bannerContent = (
<GestureDetector gesture={tapGesture}>
<Animated.View
className="mx-4 overflow-hidden rounded-2xl"
style={[
animatedStyle,
{
height: 220,
opacity: resolveMutation.isPending ? 0.7 : 1,
borderCurve: "continuous",
},
]}
>
<Pressable
onPress={titleHref ? undefined : handlePress}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Opens title details"
style={{ flex: 1 }}
>
{item.backdropPath && (
<Image
source={{ uri: item.backdropPath }}
className="absolute h-full w-full"
contentFit="cover"
/>
)}
{useGlass ? (
<GlassView
glassEffectStyle="regular"
colorScheme="dark"
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
padding: 16,
}}
return (
<Link href={titleHref}>
<Link.Trigger>
<GestureDetector gesture={tapGesture}>
<Animated.View
className="mx-4 overflow-hidden rounded-2xl"
style={[
animatedStyle,
{
height: 220,
borderCurve: "continuous",
},
]}
>
<View
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Opens title details"
style={{ flex: 1 }}
>
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
>
{item.title}
</Text>
{item.overview ? (
<Text className="mt-1 text-white/70 text-xs" numberOfLines={2}>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
</GlassView>
) : (
<>
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
/>
<View className="flex-1 justify-end p-4">
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
{item.backdropPath && (
<Image
source={{ uri: item.backdropPath }}
className="absolute h-full w-full"
contentFit="cover"
/>
)}
{useGlass ? (
<GlassView
glassEffectStyle="regular"
colorScheme="dark"
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
padding: 16,
}}
>
{item.title}
</Text>
{item.overview ? (
<Text
className="mt-1 text-white/70 text-xs"
className="font-display text-2xl text-white"
numberOfLines={2}
>
{item.overview}
{item.title}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
{item.overview ? (
<Text
className="mt-1 text-white/70 text-xs"
numberOfLines={2}
>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
</GlassView>
) : (
<>
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
/>
<View className="flex-1 justify-end p-4">
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
>
{item.title}
</Text>
{item.overview ? (
<Text
className="mt-1 text-white/70 text-xs"
numberOfLines={2}
>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
</View>
</>
)}
</Pressable>
</Animated.View>
</GestureDetector>
</View>
</>
)}
</View>
</Animated.View>
</GestureDetector>
</Link.Trigger>
<Link.Preview />
</Link>
);
if (titleHref) {
return (
<Link href={titleHref}>
<Link.Trigger>{bannerContent}</Link.Trigger>
<Link.Preview />
</Link>
);
}
return bannerContent;
}
@@ -1,8 +1,6 @@
import { IconStarFilled } from "@tabler/icons-react-native";
import { useMutation } from "@tanstack/react-query";
import { Link, useRouter } from "expo-router";
import { useCallback } from "react";
import { Pressable, View } from "react-native";
import { Link } from "expo-router";
import { View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
@@ -15,12 +13,9 @@ import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc";
import { toast } from "@/lib/toast";
export interface HeroBannerItem {
id?: string;
tmdbId: number;
id: string;
title: string;
type: string;
backdropPath?: string | null;
@@ -30,7 +25,6 @@ export interface HeroBannerItem {
}
export function HeroBanner({ item }: { item: HeroBannerItem }) {
const { navigate } = useRouter();
const primary = useCSSVariable("--color-primary") as string;
const reduceMotion = useReducedMotion();
const pressed = useSharedValue(0);
@@ -42,22 +36,6 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
],
}));
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
navigate(`/title/${id}`);
},
onError: () => toast.error("Failed to load title"),
}),
);
const handlePress = useCallback(() => {
resolveMutation.mutate({
tmdbId: item.tmdbId,
type: item.type as "movie" | "tv",
});
}, [item.tmdbId, item.type, resolveMutation]);
const tapGesture = Gesture.Tap()
.onBegin(() => {
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
@@ -73,81 +51,78 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
]
.filter(Boolean)
.join(", ");
const titleHref = item.id
? (`/title/${item.id}` as `/title/${string}`)
: null;
const titleHref = `/title/${item.id}` as `/title/${string}`;
const bannerContent = (
<GestureDetector gesture={tapGesture}>
<Animated.View
className="mx-4 overflow-hidden rounded-2xl"
style={[
animatedStyle,
{
height: 220,
opacity: resolveMutation.isPending ? 0.7 : 1,
borderCurve: "continuous",
},
]}
>
<Pressable
onPress={titleHref ? undefined : handlePress}
accessible
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Opens title details"
style={{ flex: 1 }}
>
{item.backdropPath && (
<Image
source={{ uri: item.backdropPath }}
className="absolute h-full w-full"
contentFit="cover"
/>
)}
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
/>
<View className="flex-1 justify-end p-4">
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
return (
<Link href={titleHref}>
<Link.Trigger>
<GestureDetector gesture={tapGesture}>
<Animated.View
className="mx-4 overflow-hidden rounded-2xl"
style={[
animatedStyle,
{
height: 220,
borderCurve: "continuous",
},
]}
>
<View
accessible
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Opens title details"
style={{ flex: 1 }}
>
{item.title}
</Text>
{item.overview ? (
<Text className="mt-1 text-white/70 text-xs" numberOfLines={2}>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<ScaledIcon icon={IconStarFilled} size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
{item.backdropPath && (
<Image
source={{ uri: item.backdropPath }}
className="absolute h-full w-full"
contentFit="cover"
/>
)}
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
/>
<View className="flex-1 justify-end p-4">
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
>
{item.title}
</Text>
{item.overview ? (
<Text
className="mt-1 text-white/70 text-xs"
numberOfLines={2}
>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<ScaledIcon
icon={IconStarFilled}
size={12}
color={primary}
/>
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
</View>
</View>
</Pressable>
</Animated.View>
</GestureDetector>
</Animated.View>
</GestureDetector>
</Link.Trigger>
<Link.Preview />
</Link>
);
if (titleHref) {
return (
<Link href={titleHref}>
<Link.Trigger>{bannerContent}</Link.Trigger>
<Link.Preview />
</Link>
);
}
return bannerContent;
}
@@ -1,6 +1,5 @@
import { FlashList } from "@shopify/flash-list";
import { IconHistory, IconSearch } from "@tabler/icons-react-native";
import { useRouter } from "expo-router";
import { useCallback } from "react";
import { Alert, Pressable, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
@@ -14,25 +13,12 @@ import {
import * as Haptics from "@/utils/haptics";
export function RecentlyViewedList() {
const { navigate } = useRouter();
const { items, removeItem, clearAll } = useRecentlyViewed();
const [mutedForeground, primaryColor] = useCSSVariable([
"--color-muted-foreground",
"--color-primary",
]) as [string, string];
const handlePress = useCallback(
(item: RecentlyViewedItem) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (item.type === "person") {
navigate(`/person/${item.id}`);
} else {
navigate(`/title/${item.id}`);
}
},
[navigate],
);
const handleDelete = useCallback(
(id: string) => {
removeItem(id);
@@ -58,13 +44,9 @@ export function RecentlyViewedList() {
const renderItem = useCallback(
({ item }: { item: RecentlyViewedItem }) => (
<RecentlyViewedRow
item={item}
onPress={handlePress}
onDelete={handleDelete}
/>
<RecentlyViewedRow item={item} onDelete={handleDelete} />
),
[handlePress, handleDelete],
[handleDelete],
);
const keyExtractor = useCallback((item: RecentlyViewedItem) => item.id, []);
@@ -1,4 +1,5 @@
import { memo } from "react";
import { Link } from "expo-router";
import { memo, useMemo } from "react";
import { Pressable } from "react-native";
import { RecentlyViewedRowContent } from "@/components/search/recently-viewed-row-content";
import { SwipeableRow } from "@/components/ui/swipeable-row";
@@ -6,11 +7,9 @@ import type { RecentlyViewedItem } from "@/lib/recently-viewed";
export const RecentlyViewedRow = memo(function RecentlyViewedRow({
item,
onPress,
onDelete,
}: {
item: RecentlyViewedItem;
onPress: (item: RecentlyViewedItem) => void;
onDelete: (id: string) => void;
}) {
const accessibilityLabel = [
@@ -21,21 +20,30 @@ export const RecentlyViewedRow = memo(function RecentlyViewedRow({
.filter(Boolean)
.join(", ");
const href = useMemo(
() =>
item.type === "person"
? (`/person/${item.id}` as `/person/${string}`)
: (`/title/${item.id}` as `/title/${string}`),
[item.id, item.type],
);
return (
<SwipeableRow onDelete={() => onDelete(item.id)}>
<Pressable
onPress={() => onPress(item)}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
className="bg-background px-4 py-3"
style={({ pressed }) => ({
borderBottomWidth: 0.5,
borderBottomColor: "rgba(255,255,255,0.08)",
opacity: pressed ? 0.7 : 1,
})}
>
<RecentlyViewedRowContent item={item} />
</Pressable>
<Link href={href} asChild>
<Pressable
accessibilityRole="link"
accessibilityLabel={accessibilityLabel}
className="bg-background px-4 py-3"
style={({ pressed }) => ({
borderBottomWidth: 0.5,
borderBottomColor: "rgba(255,255,255,0.08)",
opacity: pressed ? 0.7 : 1,
})}
>
<RecentlyViewedRowContent item={item} />
</Pressable>
</Link>
</SwipeableRow>
);
});
@@ -1,14 +1,14 @@
import { IconLoader, IconPlus } from "@tabler/icons-react-native";
import { memo } from "react";
import { Link } from "expo-router";
import { memo, useMemo } from "react";
import { Pressable, View } from "react-native";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Spinner } from "@/components/ui/spinner";
import { Text } from "@/components/ui/text";
export interface SearchResultItem {
tmdbId: number;
id?: string;
title: string;
type: "movie" | "tv" | "person";
posterPath?: string | null;
@@ -18,15 +18,11 @@ export interface SearchResultItem {
export const SearchResultRow = memo(function SearchResultRow({
item,
onResolve,
onQuickAdd,
isResolving,
isAdding,
}: {
item: SearchResultItem;
onResolve: (item: SearchResultItem) => void;
onQuickAdd: (tmdbId: number, type: "movie" | "tv") => void;
isResolving: boolean;
onQuickAdd: (id: string) => void;
isAdding: boolean;
}) {
const primary = useCSSVariable("--color-primary") as string;
@@ -42,6 +38,71 @@ export const SearchResultRow = memo(function SearchResultRow({
.filter(Boolean)
.join(", ");
const href = useMemo(() => {
if (!item.id) return undefined;
return item.type === "person"
? (`/person/${item.id}` as `/person/${string}`)
: (`/title/${item.id}` as `/title/${string}`);
}, [item.id, item.type]);
const rowContent = (
<Pressable
accessibilityRole="link"
accessibilityLabel={accessibilityLabel}
className="flex-1 flex-row items-center"
style={({ pressed }) => ({
opacity: pressed ? 0.6 : 1,
})}
>
<View
className="mr-3 overflow-hidden bg-secondary"
style={{
width: 44,
height: item.type === "person" ? 44 : 66,
borderRadius: item.type === "person" ? 22 : 8,
borderCurve: item.type === "person" ? undefined : "continuous",
}}
>
{imageSrc ? (
<Image
source={{ uri: imageSrc }}
recyclingKey={imageSrc}
className="h-full w-full"
contentFit="cover"
/>
) : null}
</View>
<View className="flex-1">
<Text
numberOfLines={1}
className="font-medium font-sans text-base text-foreground"
>
{item.title}
</Text>
<View className="mt-1 flex-row items-center gap-2">
<View className="rounded-full bg-secondary px-2 py-0.5">
<Text
maxFontSizeMultiplier={1.0}
className="text-muted-foreground text-xs"
>
{item.type === "movie"
? "Movie"
: item.type === "tv"
? "TV"
: "Person"}
</Text>
</View>
{item.releaseDate ? (
<Text className="text-muted-foreground text-xs">
{item.releaseDate.slice(0, 4)}
</Text>
) : null}
</View>
</View>
</Pressable>
);
return (
<View
className="flex-row items-center border-border border-b px-4 py-3"
@@ -49,68 +110,18 @@ export const SearchResultRow = memo(function SearchResultRow({
borderBottomWidth: 0.5,
}}
>
<Pressable
onPress={() => onResolve(item)}
disabled={isResolving}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
className="flex-1 flex-row items-center"
style={({ pressed }) => ({
opacity: pressed || isResolving ? 0.6 : 1,
})}
>
<View
className="mr-3 overflow-hidden bg-secondary"
style={{
width: 44,
height: item.type === "person" ? 44 : 66,
borderRadius: item.type === "person" ? 22 : 8,
borderCurve: item.type === "person" ? undefined : "continuous",
}}
>
{imageSrc ? (
<Image
source={{ uri: imageSrc }}
recyclingKey={imageSrc}
className="h-full w-full"
contentFit="cover"
/>
) : null}
</View>
{href ? (
<Link href={href} asChild>
{rowContent}
</Link>
) : (
rowContent
)}
<View className="flex-1">
<Text
numberOfLines={1}
className="font-medium font-sans text-base text-foreground"
>
{item.title}
</Text>
<View className="mt-1 flex-row items-center gap-2">
<View className="rounded-full bg-secondary px-2 py-0.5">
<Text
maxFontSizeMultiplier={1.0}
className="text-muted-foreground text-xs"
>
{item.type === "movie"
? "Movie"
: item.type === "tv"
? "TV"
: "Person"}
</Text>
</View>
{item.releaseDate ? (
<Text className="text-muted-foreground text-xs">
{item.releaseDate.slice(0, 4)}
</Text>
) : null}
</View>
</View>
</Pressable>
{item.type !== "person" && (
{item.type !== "person" && item.id && (
<Pressable
onPress={() => onQuickAdd(item.tmdbId, item.type as "movie" | "tv")}
disabled={isAdding || isResolving}
onPress={() => onQuickAdd(item.id as string)}
disabled={isAdding}
accessibilityRole="button"
accessibilityLabel={`Add ${item.title} to watchlist`}
hitSlop={12}
@@ -123,10 +134,6 @@ export const SearchResultRow = memo(function SearchResultRow({
)}
</Pressable>
)}
{isResolving && (
<Spinner size="sm" colorClassName="accent-primary" className="ml-2" />
)}
</View>
);
});
+37 -34
View File
@@ -1,4 +1,4 @@
import { useRouter } from "expo-router";
import { Link } from "expo-router";
import { Pressable, View } from "react-native";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
@@ -18,46 +18,49 @@ export function CastCard({
const accessibilityLabel = person.character
? `${person.name} as ${person.character}`
: person.name;
const { navigate } = useRouter();
return (
<Pressable
accessibilityRole="link"
<Link
href={`/person/${person.personId}` as `/person/${string}`}
accessibilityLabel={accessibilityLabel}
hitSlop={8}
onPress={() => navigate(`/person/${person.personId}`)}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
asChild
>
<View className="w-20 items-center">
<View className="mb-2 h-16 w-16 overflow-hidden rounded-full bg-secondary">
{person.profilePath && (
<Image
source={{ uri: person.profilePath }}
thumbHash={person.profileThumbHash}
recyclingKey={person.personId}
className="h-full w-full"
contentFit="cover"
accessible={false}
/>
)}
</View>
<Text
numberOfLines={1}
maxFontSizeMultiplier={1.2}
className="text-center font-medium font-sans text-foreground text-xs"
>
{person.name}
</Text>
{person.character ? (
<Pressable
accessibilityRole="link"
hitSlop={8}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
<View className="w-20 items-center">
<View className="mb-2 h-16 w-16 overflow-hidden rounded-full bg-secondary">
{person.profilePath && (
<Image
source={{ uri: person.profilePath }}
thumbHash={person.profileThumbHash}
recyclingKey={person.personId}
className="h-full w-full"
contentFit="cover"
accessible={false}
/>
)}
</View>
<Text
numberOfLines={1}
maxFontSizeMultiplier={1.0}
className="text-center text-muted-foreground text-xs"
maxFontSizeMultiplier={1.2}
className="text-center font-medium font-sans text-foreground text-xs"
>
{person.character}
{person.name}
</Text>
) : null}
</View>
</Pressable>
{person.character ? (
<Text
numberOfLines={1}
maxFontSizeMultiplier={1.0}
className="text-center text-muted-foreground text-xs"
>
{person.character}
</Text>
) : null}
</View>
</Pressable>
</Link>
);
}
+98 -130
View File
@@ -32,8 +32,7 @@ import { toast } from "@/lib/toast";
type TitleStatus = "watchlist" | "in_progress" | "completed";
interface PosterCardProps {
id?: string;
tmdbId: number;
id: string;
title: string;
type: "movie" | "tv";
posterPath: string | null;
@@ -43,12 +42,7 @@ interface PosterCardProps {
userStatus?: TitleStatus | null;
episodeProgress?: { watched: number; total: number } | null;
width?: number;
onPress: (
id: string | undefined,
tmdbId: number,
type: "movie" | "tv",
) => void;
onQuickAdd: (tmdbId: number, type: "movie" | "tv") => void;
onQuickAdd: (id: string) => void;
isAdding?: boolean;
failedKey?: string | null;
onQuickAddFailed?: () => void;
@@ -56,7 +50,6 @@ interface PosterCardProps {
export function PosterCard({
id,
tmdbId,
title,
type,
posterPath,
@@ -66,7 +59,6 @@ export function PosterCard({
userStatus,
episodeProgress,
width = 140,
onPress,
onQuickAdd,
isAdding,
failedKey,
@@ -94,11 +86,11 @@ export function PosterCard({
}, [userStatus]);
useEffect(() => {
if (failedKey === `${tmdbId}-${type}`) {
if (failedKey === id) {
setLocalStatus(userStatus ?? null);
onQuickAddFailed?.();
}
}, [failedKey, tmdbId, type, userStatus, onQuickAddFailed]);
}, [failedKey, id, userStatus, onQuickAddFailed]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
@@ -108,15 +100,11 @@ export function PosterCard({
],
}));
const handlePressAction = useCallback(() => {
onPress(id, tmdbId, type);
}, [onPress, id, tmdbId, type]);
const handleQuickAddPress = useCallback(() => {
if (localStatus || isAdding) return;
setLocalStatus("watchlist");
onQuickAdd(tmdbId, type);
}, [localStatus, isAdding, onQuickAdd, tmdbId, type]);
onQuickAdd(id);
}, [localStatus, isAdding, onQuickAdd, id]);
const year = releaseDate?.slice(0, 4);
const imageHeight = width * 1.5;
@@ -153,7 +141,7 @@ export function PosterCard({
thumbHash={posterThumbHash}
style={{ width: "100%", height: "100%" }}
contentFit="cover"
recyclingKey={`poster-${tmdbId}`}
recyclingKey={`poster-${id}`}
transition={200}
/>
) : (
@@ -271,120 +259,100 @@ export function PosterCard({
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
});
// Cards with id: use context menu with navigation
if (id) {
const titleHref = `/title/${id}` as `/title/${string}`;
return (
<ContextMenu.Root>
<ContextMenu.Trigger>
<GestureDetector gesture={pressGesture}>
<Animated.View style={[animatedStyle, { width }]}>
<View>
<Link href={titleHref}>
<Link.Trigger withAppleZoom>
<Pressable
accessibilityRole="button"
accessibilityLabel={cardAccessibilityLabel}
>
{cardContent}
</Pressable>
</Link.Trigger>
<Link.Preview />
</Link>
{quickAddButton}
</View>
</Animated.View>
</GestureDetector>
</ContextMenu.Trigger>
<ContextMenu.Content>
{!localStatus && (
<ContextMenu.Item key="watchlist" onSelect={handleQuickAddPress}>
<ContextMenu.ItemIcon ios={{ name: "bookmark" }} />
<ContextMenu.ItemTitle>Add to Watchlist</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{localStatus !== "in_progress" && (
<ContextMenu.Item
key="watching"
onSelect={async () => {
await client.titles.updateStatus({
id,
status: "in_progress",
});
toast.success("Marked as watching");
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
>
<ContextMenu.ItemIcon ios={{ name: "play.fill" }} />
<ContextMenu.ItemTitle>Mark as Watching</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{type === "movie" && (
<ContextMenu.Item
key="watched"
onSelect={async () => {
await client.titles.watchMovie({ id });
toast.success(
title ? `Marked "${title}" as watched` : "Marked as watched",
);
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
>
<ContextMenu.ItemIcon ios={{ name: "checkmark.circle" }} />
<ContextMenu.ItemTitle>Mark as Watched</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{localStatus && (
<ContextMenu.Item
key="remove"
destructive
onSelect={async () => {
await client.titles.updateStatus({ id, status: null });
setLocalStatus(null);
toast.success("Removed from library");
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
>
<ContextMenu.ItemIcon ios={{ name: "trash" }} />
<ContextMenu.ItemTitle>Remove from Library</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
</ContextMenu.Content>
</ContextMenu.Root>
);
}
const titleHref = `/title/${id}` as `/title/${string}`;
return (
<GestureDetector gesture={pressGesture}>
<Animated.View style={[animatedStyle, { width }]}>
<View>
<Pressable
onPress={handlePressAction}
accessibilityRole="button"
accessibilityLabel={cardAccessibilityLabel}
<ContextMenu.Root>
<ContextMenu.Trigger>
<GestureDetector gesture={pressGesture}>
<Animated.View style={[animatedStyle, { width }]}>
<View>
<Link href={titleHref}>
<Link.Trigger withAppleZoom>
<Pressable
accessibilityRole="button"
accessibilityLabel={cardAccessibilityLabel}
>
{cardContent}
</Pressable>
</Link.Trigger>
<Link.Preview />
</Link>
{quickAddButton}
</View>
</Animated.View>
</GestureDetector>
</ContextMenu.Trigger>
<ContextMenu.Content>
{!localStatus && (
<ContextMenu.Item key="watchlist" onSelect={handleQuickAddPress}>
<ContextMenu.ItemIcon ios={{ name: "bookmark" }} />
<ContextMenu.ItemTitle>Add to Watchlist</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{localStatus !== "in_progress" && (
<ContextMenu.Item
key="watching"
onSelect={async () => {
await client.titles.updateStatus({
id,
status: "in_progress",
});
toast.success("Marked as watching");
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
>
{cardContent}
</Pressable>
{quickAddButton}
</View>
</Animated.View>
</GestureDetector>
<ContextMenu.ItemIcon ios={{ name: "play.fill" }} />
<ContextMenu.ItemTitle>Mark as Watching</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{type === "movie" && (
<ContextMenu.Item
key="watched"
onSelect={async () => {
await client.titles.watchMovie({ id });
toast.success(
title ? `Marked "${title}" as watched` : "Marked as watched",
);
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
>
<ContextMenu.ItemIcon ios={{ name: "checkmark.circle" }} />
<ContextMenu.ItemTitle>Mark as Watched</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{localStatus && (
<ContextMenu.Item
key="remove"
destructive
onSelect={async () => {
await client.titles.updateStatus({ id, status: null });
setLocalStatus(null);
toast.success("Removed from library");
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
>
<ContextMenu.ItemIcon ios={{ name: "trash" }} />
<ContextMenu.ItemTitle>Remove from Library</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
</ContextMenu.Content>
</ContextMenu.Root>
);
}
+6 -29
View File
@@ -1,27 +1,15 @@
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useCallback } from "react";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
/**
* Provides shared press/quickAdd handlers for PosterCard lists.
* Provides shared quickAdd handlers for PosterCard lists.
* Use once per list parent instead of per-card to avoid creating
* a mutation observer for every mounted PosterCard.
*/
export function usePosterActions() {
const { navigate } = useRouter();
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
if (id) navigate(`/title/${id}`);
},
onError: () => toast.error("Failed to load title"),
}),
);
const quickAddMutation = useMutation(
orpc.titles.quickAdd.mutationOptions({
onSuccess: () => {
@@ -37,37 +25,26 @@ export function usePosterActions() {
}),
);
const handlePress = useCallback(
(id: string | undefined, tmdbId: number, type: "movie" | "tv") => {
if (id) {
navigate(`/title/${id}`);
} else {
resolveMutation.mutate({ tmdbId, type });
}
},
[navigate, resolveMutation.mutate],
);
const handleQuickAdd = useCallback(
(tmdbId: number, type: "movie" | "tv") => {
quickAddMutation.mutate({ tmdbId, type });
(id: string) => {
quickAddMutation.mutate({ id });
},
[quickAddMutation.mutate],
);
const addingKey =
quickAddMutation.isPending && quickAddMutation.variables
? `${quickAddMutation.variables.tmdbId}-${quickAddMutation.variables.type}`
? quickAddMutation.variables.id
: null;
const failedKey =
quickAddMutation.isError && quickAddMutation.variables
? `${quickAddMutation.variables.tmdbId}-${quickAddMutation.variables.type}`
? quickAddMutation.variables.id
: null;
const resetError = useCallback(() => {
quickAddMutation.reset();
}, [quickAddMutation.reset]);
return { handlePress, handleQuickAdd, addingKey, failedKey, resetError };
return { handleQuickAdd, addingKey, failedKey, resetError };
}
@@ -1,43 +0,0 @@
import { db } from "@sofa/db/client";
import { and, inArray } from "@sofa/db/helpers";
import { titles } from "@sofa/db/schema";
type BrowseLookup = {
tmdbId: number;
type: "movie" | "tv";
};
export function browseLookupKey({ tmdbId, type }: BrowseLookup): string {
return `${tmdbId}-${type}`;
}
export function getBrowsePosterThumbHashes(lookups: BrowseLookup[]) {
if (lookups.length === 0) {
return new Map<string, string | null>();
}
const tmdbIds = [...new Set(lookups.map((lookup) => lookup.tmdbId))];
const mediaTypes = [...new Set(lookups.map((lookup) => lookup.type))];
const rows = db
.select({
tmdbId: titles.tmdbId,
type: titles.type,
posterThumbHash: titles.posterThumbHash,
})
.from(titles)
.where(
and(inArray(titles.tmdbId, tmdbIds), inArray(titles.type, mediaTypes)),
)
.all();
return new Map(
rows.map((row) => [
browseLookupKey({
tmdbId: row.tmdbId,
type: row.type as "movie" | "tv",
}),
row.posterThumbHash,
]),
);
}
@@ -1,35 +0,0 @@
import { db } from "@sofa/db/client";
import { inArray } from "@sofa/db/helpers";
import { titles } from "@sofa/db/schema";
interface BrowseTitleLookup {
tmdbId: number;
type: "movie" | "tv";
}
export function getBrowseTitleIds(
lookups: BrowseTitleLookup[],
): Record<string, string> {
if (lookups.length === 0) return {};
const rows = db
.select({
id: titles.id,
tmdbId: titles.tmdbId,
type: titles.type,
})
.from(titles)
.where(
inArray(
titles.tmdbId,
lookups.map((lookup) => lookup.tmdbId),
),
)
.all();
const idsByLookup: Record<string, string> = {};
for (const row of rows) {
idsByLookup[`${row.tmdbId}-${row.type}`] = row.id;
}
return idsByLookup;
}
+17 -20
View File
@@ -1,18 +1,14 @@
import { ORPCError } from "@orpc/server";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import {
getEpisodeProgressByTmdbIds,
getUserStatusesByTmdbIds,
getEpisodeProgressByTitleIds,
getUserStatusesByTitleIds,
} from "@sofa/core/tracking";
import { discover as discoverTmdb } from "@sofa/tmdb/client";
import { isTmdbConfigured } from "@sofa/tmdb/config";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
import { authed } from "../middleware";
import {
browseLookupKey,
getBrowsePosterThumbHashes,
} from "./browse-thumbhashes";
import { getBrowseTitleIds } from "./browse-title-ids";
export const discover = os.discover
.use(authed)
@@ -51,22 +47,23 @@ export const discover = os.discover
firstAirDate: (r.first_air_date as string | undefined) ?? null,
voteAverage: r.vote_average ?? null,
}));
const titleIdsByLookup = getBrowseTitleIds(
baseItems.map((item) => ({ tmdbId: item.tmdbId, type: item.type })),
);
const posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
const items = baseItems.map((item) => ({
...item,
id: titleIdsByLookup[browseLookupKey(item)],
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
}));
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
const titleMap = ensureBrowseTitlesExist(baseItems);
const items = baseItems.map((item) => {
const entry = titleMap.get(`${item.tmdbId}-${item.type}`);
return {
...item,
id: entry?.id ?? "",
posterThumbHash: entry?.posterThumbHash ?? null,
};
});
const titleIds = items.map((r) => r.id);
const [userStatuses, episodeProgress] =
lookups.length > 0
titleIds.length > 0
? [
getUserStatusesByTmdbIds(context.user.id, lookups),
getEpisodeProgressByTmdbIds(context.user.id, lookups),
getUserStatusesByTitleIds(context.user.id, titleIds),
getEpisodeProgressByTitleIds(context.user.id, titleIds),
]
: [{}, {}];
+55 -36
View File
@@ -1,18 +1,14 @@
import { ORPCError } from "@orpc/server";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import {
getEpisodeProgressByTmdbIds,
getUserStatusesByTmdbIds,
getEpisodeProgressByTitleIds,
getUserStatusesByTitleIds,
} from "@sofa/core/tracking";
import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client";
import { isTmdbConfigured } from "@sofa/tmdb/config";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
import { authed } from "../middleware";
import {
browseLookupKey,
getBrowsePosterThumbHashes,
} from "./browse-thumbhashes";
import { getBrowseTitleIds } from "./browse-title-ids";
function requireTmdb() {
if (!isTmdbConfigured()) {
@@ -54,29 +50,51 @@ export const trending = os.explore.trending
(r) =>
r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"),
);
const titleIdsByLookup = getBrowseTitleIds([
...baseItems.map((item) => ({ tmdbId: item.tmdbId, type: item.type })),
// Batch-upsert all browse items (+ hero) into the titles table
const allBrowseItems = [
...baseItems,
...(heroResult
? [
{
tmdbId: heroResult.id as number,
type: heroResult.media_type as "movie" | "tv",
title:
((heroResult.title ?? heroResult.name) as string | undefined) ??
"",
posterPath: tmdbImageUrl(
(heroResult.poster_path as string) ?? null,
"posters",
),
releaseDate:
(heroResult.release_date as string | undefined) ?? null,
firstAirDate:
(heroResult.first_air_date as string | undefined) ?? null,
voteAverage:
(heroResult.vote_average as number | undefined) ?? null,
},
]
: []),
]);
const posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
const items = baseItems.map((item) => ({
...item,
id: titleIdsByLookup[browseLookupKey(item)],
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
}));
];
const titleMap = ensureBrowseTitlesExist(allBrowseItems);
const items = baseItems.map((item) => {
const entry = titleMap.get(`${item.tmdbId}-${item.type}`);
return {
...item,
id: entry?.id ?? "",
posterThumbHash: entry?.posterThumbHash ?? null,
};
});
const heroEntry = heroResult
? titleMap.get(
`${heroResult.id as number}-${heroResult.media_type as string}`,
)
: undefined;
const hero = heroResult
? {
id: titleIdsByLookup[
`${heroResult.id as number}-${heroResult.media_type as "movie" | "tv"}`
],
id: heroEntry?.id ?? "",
tmdbId: heroResult.id as number,
type: heroResult.media_type as "movie" | "tv",
title:
@@ -90,12 +108,12 @@ export const trending = os.explore.trending
}
: null;
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
const titleIds = items.map((r) => r.id);
const [userStatuses, episodeProgress] =
lookups.length > 0
titleIds.length > 0
? [
getUserStatusesByTmdbIds(context.user.id, lookups),
getEpisodeProgressByTmdbIds(context.user.id, lookups),
getUserStatusesByTitleIds(context.user.id, titleIds),
getEpisodeProgressByTitleIds(context.user.id, titleIds),
]
: [{}, {}];
@@ -127,22 +145,23 @@ export const popular = os.explore.popular
firstAirDate: (r.first_air_date as string | undefined) ?? null,
voteAverage: (r.vote_average as number | undefined) ?? null,
}));
const titleIdsByLookup = getBrowseTitleIds(
baseItems.map((item) => ({ tmdbId: item.tmdbId, type: item.type })),
);
const posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
const items = baseItems.map((item) => ({
...item,
id: titleIdsByLookup[browseLookupKey(item)],
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
}));
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
const titleMap = ensureBrowseTitlesExist(baseItems);
const items = baseItems.map((item) => {
const entry = titleMap.get(`${item.tmdbId}-${item.type}`);
return {
...item,
id: entry?.id ?? "",
posterThumbHash: entry?.posterThumbHash ?? null,
};
});
const titleIds = items.map((r) => r.id);
const [userStatuses, episodeProgress] =
lookups.length > 0
titleIds.length > 0
? [
getUserStatusesByTmdbIds(context.user.id, lookups),
getEpisodeProgressByTmdbIds(context.user.id, lookups),
getUserStatusesByTitleIds(context.user.id, titleIds),
getEpisodeProgressByTitleIds(context.user.id, titleIds),
]
: [{}, {}];
+1 -14
View File
@@ -1,9 +1,5 @@
import { ORPCError } from "@orpc/server";
import {
fetchFullFilmography,
getOrFetchPerson,
getOrFetchPersonByTmdbId,
} from "@sofa/core/person";
import { fetchFullFilmography, getOrFetchPerson } from "@sofa/core/person";
import { getUserStatusesByTitleIds } from "@sofa/core/tracking";
import { os } from "../context";
import { authed } from "../middleware";
@@ -34,12 +30,3 @@ export const detail = os.people.detail
totalResults: allCredits.length,
};
});
export const resolve = os.people.resolve
.use(authed)
.handler(async ({ input }) => {
const person = await getOrFetchPersonByTmdbId(input.tmdbId);
if (!person)
throw new ORPCError("NOT_FOUND", { message: "Person not found" });
return { id: person.id };
});
+56 -17
View File
@@ -1,4 +1,6 @@
import { ORPCError } from "@orpc/server";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import { ensureBrowsePersonsExist } from "@sofa/core/person";
import {
searchMovies,
searchMulti,
@@ -25,23 +27,36 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
if (type === "person") {
const personResults = await searchPerson(query, input.page);
const personItems = (personResults.results ?? []).map((r) => ({
tmdbId: r.id,
type: "person" as const,
title: r.name ?? "",
posterPath: null,
profilePath: tmdbImageUrl(r.profile_path ?? null, "profiles"),
overview: null,
releaseDate: null,
popularity: r.popularity ?? null,
voteAverage: null,
knownForDepartment: r.known_for_department ?? null,
knownFor:
(r.known_for
?.slice(0, 3)
.map((k) => k.title ?? (k as { name?: string }).name)
.filter((s): s is string => !!s) as string[]) ?? null,
}));
const personMap = ensureBrowsePersonsExist(
personItems.map((r) => ({
tmdbId: r.tmdbId,
name: r.title,
profilePath: r.profilePath,
knownForDepartment: r.knownForDepartment,
popularity: r.popularity,
})),
);
return {
results: (personResults.results ?? []).map((r) => ({
tmdbId: r.id,
type: "person" as const,
title: r.name ?? "",
posterPath: null,
profilePath: tmdbImageUrl(r.profile_path ?? null, "profiles"),
overview: null,
releaseDate: null,
popularity: r.popularity ?? null,
voteAverage: null,
knownForDepartment: r.known_for_department ?? null,
knownFor:
(r.known_for
?.slice(0, 3)
.map((k) => k.title ?? (k as { name?: string }).name)
.filter((s): s is string => !!s) as string[]) ?? null,
results: personItems.map((r) => ({
...r,
id: personMap.get(r.tmdbId),
})),
page: personResults.page ?? input.page,
totalPages: personResults.total_pages ?? 1,
@@ -108,8 +123,32 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
})
.filter((r): r is NonNullable<typeof r> => r !== null);
// Batch-import movie/TV results so they have internal IDs
const titleResults = mapped.filter(
(r): r is typeof r & { type: "movie" | "tv" } => r.type !== "person",
);
const titleMap = ensureBrowseTitlesExist(titleResults);
// Batch-import person results so they have internal IDs
const personResults = mapped.filter((r) => r.type === "person");
const personMap = ensureBrowsePersonsExist(
personResults.map((r) => ({
tmdbId: r.tmdbId,
name: r.title,
profilePath: r.profilePath,
knownForDepartment: r.knownForDepartment,
popularity: r.popularity,
})),
);
const results = mapped.map((r) => {
if (r.type === "person") return { ...r, id: personMap.get(r.tmdbId) };
const entry = titleMap.get(`${r.tmdbId}-${r.type}`);
return { ...r, id: entry?.id };
});
return {
results: mapped,
results,
page: raw.page ?? input.page,
totalPages: raw.total_pages ?? 1,
totalResults: raw.total_results ?? 0,
+14 -26
View File
@@ -1,10 +1,6 @@
import { ORPCError } from "@orpc/server";
import { getRecommendationsForTitle } from "@sofa/core/discovery";
import {
ensureTvHydrated,
getOrFetchTitle,
getOrFetchTitleByTmdbId,
} from "@sofa/core/metadata";
import { getOrFetchTitle, getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
import {
getUserStatusesByTitleIds,
getUserTitleInfo,
@@ -16,7 +12,7 @@ import {
} from "@sofa/core/tracking";
import { db } from "@sofa/db/client";
import { and, eq } from "@sofa/db/helpers";
import { userTitleStatus } from "@sofa/db/schema";
import { titles, userTitleStatus } from "@sofa/db/schema";
import { os } from "../context";
import { authed } from "../middleware";
@@ -29,15 +25,6 @@ export const detail = os.titles.detail
return result;
});
export const resolve = os.titles.resolve
.use(authed)
.handler(async ({ input }) => {
const title = await getOrFetchTitleByTmdbId(input.tmdbId, input.type);
if (!title)
throw new ORPCError("NOT_FOUND", { message: "Title not found" });
return { id: title.id };
});
export const updateStatus = os.titles.updateStatus
.use(authed)
.handler(({ input, context }) => {
@@ -83,23 +70,24 @@ export const recommendations = os.titles.recommendations
return { recommendations: recs, userStatuses };
});
export const hydrateSeasons = os.titles.hydrateSeasons
.use(authed)
.handler(async ({ input }) => {
const seasons = await ensureTvHydrated(input.id, input.tmdbId);
return { seasons };
});
export const quickAdd = os.titles.quickAdd
.use(authed)
.handler(async ({ input, context }) => {
const title = await getOrFetchTitleByTmdbId(input.tmdbId, input.type);
// Look up the title (it exists as a shell from browse/search import)
const title = db
.select({ id: titles.id, tmdbId: titles.tmdbId, type: titles.type })
.from(titles)
.where(eq(titles.id, input.id))
.get();
if (!title) {
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Failed to import title",
});
throw new ORPCError("NOT_FOUND", { message: "Title not found" });
}
// Trigger full TMDB import if still a shell
getOrFetchTitleByTmdbId(title.tmdbId, title.type as "movie" | "tv").catch(
() => {},
);
const existing = db
.select()
.from(userTitleStatus)
-3
View File
@@ -16,14 +16,12 @@ import * as titles from "./procedures/titles";
export const implementedRouter = {
titles: {
detail: titles.detail,
resolve: titles.resolve,
updateStatus: titles.updateStatus,
updateRating: titles.updateRating,
watchMovie: titles.watchMovie,
watchAll: titles.watchAll,
userInfo: titles.userInfo,
recommendations: titles.recommendations,
hydrateSeasons: titles.hydrateSeasons,
quickAdd: titles.quickAdd,
},
episodes: {
@@ -37,7 +35,6 @@ export const implementedRouter = {
},
people: {
detail: people.detail,
resolve: people.resolve,
},
dashboard: {
stats: dashboard.stats,
+7 -40
View File
@@ -8,11 +8,10 @@ import {
IconX,
} from "@tabler/icons-react";
import { useHotkey, useHotkeySequence } from "@tanstack/react-hotkeys";
import { skipToken, useMutation, useQuery } from "@tanstack/react-query";
import { skipToken, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress";
import {
Command,
@@ -69,6 +68,7 @@ for (const entry of SHORTCUT_DESCRIPTIONS) {
}
interface SearchResult {
id?: string;
tmdbId: number;
type: "movie" | "tv" | "person";
title: string;
@@ -144,51 +144,18 @@ export function CommandPalette() {
};
}, [debouncedQuery, results.length, setRecentSearches]);
const resolvePersonMutation = useMutation(
orpc.people.resolve.mutationOptions({
onSuccess: ({ id }) => {
if (id) void navigate({ to: "/people/$id", params: { id } });
else progress.done();
},
onError: () => {
progress.done();
toast.error("Failed to load person");
},
}),
);
const resolveTitleMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
if (id) void navigate({ to: "/titles/$id", params: { id } });
else progress.done();
},
onError: () => {
progress.done();
toast.error("Failed to load title");
},
}),
);
const handleSelect = useCallback(
(result: SearchResult) => {
if (!result.id) return;
setCommandPaletteOpen(false);
progress.start();
if (result.type === "person") {
resolvePersonMutation.mutate({ tmdbId: result.tmdbId });
void navigate({ to: "/people/$id", params: { id: result.id } });
} else {
resolveTitleMutation.mutate({
tmdbId: result.tmdbId,
type: result.type,
});
void navigate({ to: "/titles/$id", params: { id: result.id } });
}
},
[
setCommandPaletteOpen,
progress,
resolvePersonMutation,
resolveTitleMutation,
],
[setCommandPaletteOpen, progress, navigate],
);
const handleRecentSearch = useCallback((q: string) => {
@@ -251,7 +218,7 @@ export function CommandPalette() {
<CommandGroup heading="Results">
{results.map((r) => (
<CommandItem
key={`${r.type}-${r.tmdbId}`}
key={r.id ?? `${r.type}-${r.tmdbId}`}
onSelect={() => handleSelect(r)}
className="flex items-center gap-3 py-2"
>
@@ -3,7 +3,6 @@ import { Skeleton } from "@/components/ui/skeleton";
interface TitleGridItem {
id: string;
tmdbId: number;
type: string;
title: string;
posterPath: string | null;
@@ -43,7 +42,6 @@ export function TitleGrid({ items }: { items: TitleGridItem[] }) {
>
<TitleCard
id={t.id}
tmdbId={t.tmdbId}
type={t.type}
title={t.title}
posterPath={t.posterPath}
@@ -96,7 +96,7 @@ export function ExploreClient() {
<div className="space-y-10">
{hero && (
<HeroBanner
tmdbId={hero.tmdbId}
id={hero.id}
type={hero.type}
title={hero.title}
overview={hero.overview}
@@ -12,7 +12,7 @@ interface Genre {
}
interface TitleRowItem {
tmdbId: number;
id: string;
type: "movie" | "tv";
title: string;
posterPath: string | null;
@@ -177,26 +177,21 @@ export function FilterableTitleRow({
>
<div className="flex gap-4 px-6 py-2 sm:px-2">
{items.map((item: TitleRowItem, i: number) => (
<div
key={`${item.type}-${item.tmdbId}`}
className="w-[140px] shrink-0 sm:w-[160px]"
>
<div key={item.id} className="w-[140px] shrink-0 sm:w-[160px]">
<div
className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties}
>
<TitleCard
tmdbId={item.tmdbId}
id={item.id}
type={item.type}
title={item.title}
posterPath={item.posterPath}
posterThumbHash={item.posterThumbHash}
releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage}
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]}
episodeProgress={
episodeProgress[`${item.tmdbId}-${item.type}`]
}
userStatus={userStatuses[item.id]}
episodeProgress={episodeProgress[item.id]}
/>
</div>
</div>
+13 -41
View File
@@ -4,15 +4,10 @@ import {
IconPlus,
IconStar,
} from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress";
import { orpc } from "@/lib/orpc/client";
import { Link } from "@tanstack/react-router";
interface HeroBannerProps {
tmdbId: number;
id: string;
type: "movie" | "tv";
title: string;
overview: string;
@@ -21,34 +16,13 @@ interface HeroBannerProps {
}
export function HeroBanner({
tmdbId,
id,
type,
title,
overview,
backdropPath,
voteAverage,
}: HeroBannerProps) {
const navigate = useNavigate();
const progress = useProgress();
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
if (id) void navigate({ to: "/titles/$id", params: { id } });
else progress.done();
},
onError: () => {
progress.done();
toast.error("Failed to load title");
},
}),
);
function handleNavigate() {
if (resolveMutation.isPending) return;
progress.start();
resolveMutation.mutate({ tmdbId, type });
}
return (
<div className="relative -mt-6 mr-[calc(-50vw+50%)] mb-4 ml-[calc(-50vw+50%)] animate-stagger-item overflow-hidden">
<div className="relative aspect-[21/9] max-h-[420px] min-h-[280px] w-full">
@@ -103,28 +77,26 @@ export function HeroBanner({
Trending today
</span>
</div>
<button
type="button"
className="group/title cursor-pointer text-left"
onClick={handleNavigate}
disabled={resolveMutation.isPending}
<Link
to="/titles/$id"
params={{ id }}
className="group/title text-left"
>
<h2 className="text-balance font-display text-3xl tracking-tight transition-colors group-hover/title:text-primary sm:text-4xl">
{title}
</h2>
</button>
</Link>
<p className="mt-2 line-clamp-2 max-w-2xl text-muted-foreground text-sm">
{overview}
</p>
<button
type="button"
onClick={handleNavigate}
disabled={resolveMutation.isPending}
className="mt-4 inline-flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20 disabled:opacity-70"
<Link
to="/titles/$id"
params={{ id }}
className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20"
>
<IconPlus aria-hidden={true} className="size-4" />
Add to Library
</button>
</Link>
</div>
</div>
</div>
+5 -10
View File
@@ -4,7 +4,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { hasReachedHorizontalEnd } from "@/hooks/use-infinite-scroll";
interface TitleRowItem {
tmdbId: number;
id: string;
type: "movie" | "tv";
title: string;
posterPath: string | null;
@@ -70,26 +70,21 @@ export function TitleRow({
>
<div className="flex gap-4 px-6 py-2 sm:px-2">
{items.map((item, i) => (
<div
key={`${item.type}-${item.tmdbId}`}
className="w-[140px] shrink-0 sm:w-[160px]"
>
<div key={item.id} className="w-[140px] shrink-0 sm:w-[160px]">
<div
className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties}
>
<TitleCard
tmdbId={item.tmdbId}
id={item.id}
type={item.type}
title={item.title}
posterPath={item.posterPath}
posterThumbHash={item.posterThumbHash}
releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage}
userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]}
episodeProgress={
episodeProgress?.[`${item.tmdbId}-${item.type}`]
}
userStatus={userStatuses?.[item.id]}
episodeProgress={episodeProgress?.[item.id]}
/>
</div>
</div>
@@ -125,7 +125,6 @@ export function FilmographyGrid({
>
<TitleCard
id={credit.titleId}
tmdbId={credit.tmdbId}
type={credit.type}
title={credit.title}
posterPath={credit.posterPath}
+9 -48
View File
@@ -9,11 +9,9 @@ import {
IconStarFilled,
} from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router";
import { type MotionStyle, type MotionValue, motion } from "motion/react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
@@ -57,8 +55,7 @@ interface CardInnerProps {
}
export interface TitleCardProps extends CardInnerProps {
id?: string;
tmdbId: number;
id: string;
}
const statusConfig = {
@@ -80,12 +77,10 @@ const statusConfig = {
} as const;
function QuickAddButton({
tmdbId,
type,
id,
userStatus,
}: {
tmdbId: number;
type: "movie" | "tv";
id: string;
userStatus?: TitleStatus | null;
}) {
const [addedStatus, setAddedStatus] = useState<TitleStatus | null>(
@@ -112,7 +107,7 @@ function QuickAddButton({
e.preventDefault();
e.stopPropagation();
if (quickAddMutation.isPending || isAdded) return;
quickAddMutation.mutate({ tmdbId, type });
quickAddMutation.mutate({ id });
}
if (isAdded && config) {
@@ -292,7 +287,6 @@ function CardInner({
export function TitleCard({
id,
tmdbId,
type,
title,
posterPath,
@@ -303,21 +297,6 @@ export function TitleCard({
episodeProgress,
}: TitleCardProps) {
const tilt = useTiltEffect();
const navigate = useNavigate();
const progress = useProgress();
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id: resolvedId }) => {
if (resolvedId)
void navigate({ to: "/titles/$id", params: { id: resolvedId } });
else progress.done();
},
onError: () => {
progress.done();
toast.error("Failed to load title");
},
}),
);
const cardContent = (
<motion.div ref={tilt.ref} style={tilt.containerStyle} {...tilt.handlers}>
@@ -341,28 +320,10 @@ export function TitleCard({
return (
<div className="group relative">
<QuickAddButton
tmdbId={tmdbId}
type={type as "movie" | "tv"}
userStatus={userStatus}
/>
{id ? (
<Link to="/titles/$id" params={{ id }}>
{cardContent}
</Link>
) : (
<button
type="button"
disabled={resolveMutation.isPending}
className={`w-full text-left ${resolveMutation.isPending ? "pointer-events-none opacity-70" : "cursor-pointer"}`}
onClick={() => {
progress.start();
resolveMutation.mutate({ tmdbId, type: type as "movie" | "tv" });
}}
>
{cardContent}
</button>
)}
<QuickAddButton id={id} userStatus={userStatus} />
<Link to="/titles/$id" params={{ id }}>
{cardContent}
</Link>
</div>
);
}
@@ -1,21 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc/client";
import { SeasonsSkeleton, TitleSeasons } from "./title-seasons";
export function AsyncTitleSeasons({
titleId,
tmdbId,
}: {
titleId: string;
tmdbId: number;
}) {
const { data, isPending } = useQuery(
orpc.titles.hydrateSeasons.queryOptions({
input: { id: titleId, tmdbId },
}),
);
if (isPending) return <SeasonsSkeleton />;
if (!data?.seasons || data.seasons.length === 0) return null;
return <TitleSeasons seasons={data.seasons} />;
}
@@ -46,7 +46,6 @@ export function TitleRecommendations({ titleId }: { titleId: string }) {
>
<TitleCard
id={rec.id}
tmdbId={rec.tmdbId}
type={rec.type}
title={rec.title}
posterPath={rec.posterPath}
+3 -16
View File
@@ -1,6 +1,4 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { Suspense } from "react";
import { AsyncTitleSeasons } from "@/components/titles/async-title-seasons";
import { TitleActions } from "@/components/titles/title-actions";
import { TitleAvailability } from "@/components/titles/title-availability";
import { TitleCast } from "@/components/titles/title-cast";
@@ -8,10 +6,7 @@ import { TitleHero } from "@/components/titles/title-hero";
import { TitleKeyboardShortcuts } from "@/components/titles/title-keyboard-shortcuts";
import { TitleProvider } from "@/components/titles/title-provider";
import { TitleRecommendations } from "@/components/titles/title-recommendations";
import {
SeasonsSkeleton,
TitleSeasons,
} from "@/components/titles/title-seasons";
import { TitleSeasons } from "@/components/titles/title-seasons";
import { TitleTheme } from "@/components/titles/title-theme";
import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
@@ -46,8 +41,7 @@ export const Route = createFileRoute("/_app/titles/$id")({
});
function TitleDetailPage() {
const { title, seasons, needsHydration, availability, cast } =
Route.useLoaderData();
const { title, seasons, availability, cast } = Route.useLoaderData();
const themeStyle = getThemeCssProperties(title.colorPalette);
@@ -69,14 +63,7 @@ function TitleDetailPage() {
<TitleAvailability availability={availability} />
</TitleHero>
{title.type === "tv" && needsHydration && (
<Suspense fallback={<SeasonsSkeleton />}>
<AsyncTitleSeasons titleId={title.id} tmdbId={title.tmdbId} />
</Suspense>
)}
{title.type === "tv" && !needsHydration && seasons.length > 0 && (
<TitleSeasons />
)}
{title.type === "tv" && seasons.length > 0 && <TitleSeasons />}
<TitleCast cast={cast} titleType={title.type} />