mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 00:25:38 -04:00
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:
@@ -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}`,
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
: [{}, {}];
|
||||
|
||||
|
||||
@@ -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,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 };
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user