mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
refactor(native): list optimizations and cleanup
This commit is contained in:
@@ -20,7 +20,6 @@
|
||||
"@expo-google-fonts/geist-mono": "0.4.1",
|
||||
"@expo/metro-runtime": "55.0.6",
|
||||
"@expo/ui": "55.0.2",
|
||||
"@gorhom/bottom-sheet": "5.2.8",
|
||||
"@orpc/client": "catalog:",
|
||||
"@orpc/contract": "catalog:",
|
||||
"@orpc/tanstack-query": "catalog:",
|
||||
|
||||
@@ -19,6 +19,7 @@ import { PosterCard } from "@/components/ui/poster-card";
|
||||
import { SectionHeader } from "@/components/ui/section-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { usePosterActions } from "@/hooks/use-poster-actions";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { addRecentlyViewed } from "@/lib/recently-viewed";
|
||||
|
||||
@@ -55,6 +56,8 @@ export default function PersonDetailScreen() {
|
||||
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||
|
||||
const { handlePress, handleQuickAdd, addingKey } = usePosterActions();
|
||||
|
||||
const { data, isPending, isError } = useQuery(
|
||||
orpc.people.detail.queryOptions({ input: { id } }),
|
||||
);
|
||||
@@ -91,10 +94,13 @@ export default function PersonDetailScreen() {
|
||||
voteAverage={credit.voteAverage}
|
||||
userStatus={data?.userStatuses?.[credit.titleId] ?? null}
|
||||
width={undefined}
|
||||
onPress={handlePress}
|
||||
onQuickAdd={handleQuickAdd}
|
||||
isAdding={addingKey === `${credit.tmdbId}-${credit.type}`}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[columnWidth, data?.userStatuses],
|
||||
[columnWidth, data?.userStatuses, handlePress, handleQuickAdd, addingKey],
|
||||
);
|
||||
|
||||
if (isPending) {
|
||||
|
||||
@@ -14,18 +14,21 @@ import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { FlatList, Platform, Pressable, ScrollView, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import {
|
||||
HorizontalPosterRow,
|
||||
type PosterRowItem,
|
||||
} from "@/components/dashboard/horizontal-poster-row";
|
||||
import { CastCard } from "@/components/titles/cast-card";
|
||||
import { ContinueWatchingBanner } from "@/components/titles/continue-watching-banner";
|
||||
import { SeasonAccordion } from "@/components/titles/season-accordion";
|
||||
import { StatusActionButton } from "@/components/titles/status-action-button";
|
||||
import { ExpandableText } from "@/components/ui/expandable-text";
|
||||
import { Image } from "@/components/ui/image";
|
||||
import { PosterCard } from "@/components/ui/poster-card";
|
||||
import { SectionHeader } from "@/components/ui/section-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -124,13 +127,6 @@ export default function TitleDetailScreen() {
|
||||
}),
|
||||
);
|
||||
|
||||
const [_refreshing, setRefreshing] = useState(false);
|
||||
const _onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
setRefreshing(false);
|
||||
}, []);
|
||||
|
||||
const title = detail.data?.title;
|
||||
const palette = title?.colorPalette ?? null;
|
||||
useTitleTheme(palette);
|
||||
@@ -161,6 +157,24 @@ export default function TitleDetailScreen() {
|
||||
[userInfo.data?.episodeWatches],
|
||||
);
|
||||
|
||||
const recItems = useMemo<PosterRowItem[]>(
|
||||
() =>
|
||||
(recommendations.data?.recommendations ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
tmdbId: item.tmdbId,
|
||||
title: item.title,
|
||||
type: item.type,
|
||||
posterPath: item.posterPath,
|
||||
releaseDate: item.releaseDate,
|
||||
firstAirDate: item.firstAirDate,
|
||||
voteAverage: item.voteAverage,
|
||||
userStatus: item.id
|
||||
? (recommendations.data?.userStatuses?.[item.id] ?? null)
|
||||
: null,
|
||||
})),
|
||||
[recommendations.data],
|
||||
);
|
||||
|
||||
const hydratedTitleId = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -592,45 +606,21 @@ export default function TitleDetailScreen() {
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{recommendations.data &&
|
||||
recommendations.data.recommendations?.length > 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300).delay(600)}
|
||||
className="mt-6"
|
||||
>
|
||||
<View className="px-4">
|
||||
<SectionHeader
|
||||
title="More Like This"
|
||||
icon={IconThumbUp}
|
||||
iconColor={titleAccent}
|
||||
/>
|
||||
</View>
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={recommendations.data.recommendations}
|
||||
keyExtractor={(item) => item.id ?? String(item.tmdbId)}
|
||||
renderItem={({ item }) => (
|
||||
<PosterCard
|
||||
id={item.id}
|
||||
tmdbId={item.tmdbId}
|
||||
title={item.title}
|
||||
type={item.type}
|
||||
posterPath={item.posterPath}
|
||||
releaseDate={item.releaseDate ?? item.firstAirDate}
|
||||
voteAverage={item.voteAverage}
|
||||
userStatus={
|
||||
item.id
|
||||
? (recommendations.data?.userStatuses?.[item.id] ?? null)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||
style={{ overflow: "visible" }}
|
||||
{recItems.length > 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300).delay(600)}
|
||||
className="mt-6"
|
||||
>
|
||||
<View className="px-4">
|
||||
<SectionHeader
|
||||
title="More Like This"
|
||||
icon={IconThumbUp}
|
||||
iconColor={titleAccent}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
<HorizontalPosterRow items={recItems} />
|
||||
</Animated.View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { FlatList } from "react-native";
|
||||
|
||||
import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
|
||||
import { usePosterActions } from "@/hooks/use-poster-actions";
|
||||
|
||||
export interface PosterRowItem {
|
||||
id: string;
|
||||
id?: string;
|
||||
tmdbId: number;
|
||||
title: string;
|
||||
type: string;
|
||||
@@ -15,6 +16,9 @@ export interface PosterRowItem {
|
||||
episodeProgress?: { watched: number; total: number } | null;
|
||||
}
|
||||
|
||||
const listContentStyle = { gap: 12, paddingHorizontal: 16 };
|
||||
const listStyle = { overflow: "visible" as const };
|
||||
|
||||
export function HorizontalPosterRow({
|
||||
items,
|
||||
isLoading,
|
||||
@@ -22,6 +26,8 @@ export function HorizontalPosterRow({
|
||||
items: PosterRowItem[];
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const { handlePress, handleQuickAdd, addingKey } = usePosterActions();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<FlatList
|
||||
@@ -30,8 +36,8 @@ export function HorizontalPosterRow({
|
||||
data={[1, 2, 3, 4]}
|
||||
keyExtractor={(item) => String(item)}
|
||||
renderItem={() => <PosterCardSkeleton />}
|
||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={listContentStyle}
|
||||
style={listStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +47,7 @@ export function HorizontalPosterRow({
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={items}
|
||||
keyExtractor={(item) => item.id}
|
||||
keyExtractor={(item) => item.id ?? `${item.tmdbId}-${item.type}`}
|
||||
renderItem={({ item }) => (
|
||||
<PosterCard
|
||||
id={item.id}
|
||||
@@ -53,10 +59,13 @@ export function HorizontalPosterRow({
|
||||
voteAverage={item.voteAverage}
|
||||
userStatus={item.userStatus}
|
||||
episodeProgress={item.episodeProgress}
|
||||
onPress={handlePress}
|
||||
onQuickAdd={handleQuickAdd}
|
||||
isAdding={addingKey === `${item.tmdbId}-${item.type}`}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={listContentStyle}
|
||||
style={listStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Icon } from "@tabler/icons-react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { FlatList, ScrollView, View } from "react-native";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import {
|
||||
HorizontalPosterRow,
|
||||
type PosterRowItem,
|
||||
} from "@/components/dashboard/horizontal-poster-row";
|
||||
import { GenreChip } from "@/components/explore/genre-chip";
|
||||
import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
|
||||
import { SectionHeader } from "@/components/ui/section-header";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
@@ -46,7 +49,7 @@ export function FilterableTitleRow({
|
||||
enabled: selectedGenre !== null,
|
||||
});
|
||||
|
||||
const items =
|
||||
const rawItems =
|
||||
selectedGenre === null ? defaultItems : (discover.data?.items ?? []);
|
||||
const userStatuses =
|
||||
selectedGenre === null
|
||||
@@ -59,6 +62,20 @@ export function FilterableTitleRow({
|
||||
const showLoading =
|
||||
isLoading || (selectedGenre !== null && discover.isPending);
|
||||
|
||||
// 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, userStatuses, episodeProgress],
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View className="px-4">
|
||||
@@ -90,45 +107,14 @@ export function FilterableTitleRow({
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{showLoading ? (
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={[1, 2, 3, 4]}
|
||||
keyExtractor={(item) => String(item)}
|
||||
renderItem={() => <PosterCardSkeleton />}
|
||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||
style={{ overflow: "visible" }}
|
||||
/>
|
||||
) : items.length === 0 && selectedGenre !== null ? (
|
||||
{!showLoading && items.length === 0 && selectedGenre !== null ? (
|
||||
<View className="items-center py-6">
|
||||
<Text className="text-[13px] text-muted-foreground">
|
||||
No titles found for this genre.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={items}
|
||||
keyExtractor={(item) => `${item.tmdbId}-${item.type}`}
|
||||
renderItem={({ item }) => (
|
||||
<PosterCard
|
||||
tmdbId={item.tmdbId}
|
||||
title={item.title}
|
||||
type={item.type as "movie" | "tv"}
|
||||
posterPath={item.posterPath}
|
||||
releaseDate={item.releaseDate ?? item.firstAirDate}
|
||||
voteAverage={item.voteAverage}
|
||||
userStatus={userStatuses[`${item.tmdbId}-${item.type}`] ?? null}
|
||||
episodeProgress={
|
||||
episodeProgress[`${item.tmdbId}-${item.type}`] ?? null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||
style={{ overflow: "visible" }}
|
||||
/>
|
||||
<HorizontalPosterRow items={items} isLoading={showLoading} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { Link } from "expo-router";
|
||||
import { View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Image } from "@/components/ui/image";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
@@ -21,24 +14,11 @@ export function CastCard({
|
||||
profilePath: string | null;
|
||||
};
|
||||
}) {
|
||||
const pressed = useSharedValue(0);
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.95]) }],
|
||||
}));
|
||||
|
||||
const tapGesture = Gesture.Tap()
|
||||
.onBegin(() => {
|
||||
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
|
||||
})
|
||||
.onFinalize(() => {
|
||||
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
|
||||
});
|
||||
|
||||
return (
|
||||
<Link href={`/person/${person.personId}` as `/person/${string}`}>
|
||||
<Link.Trigger>
|
||||
<GestureDetector gesture={tapGesture}>
|
||||
<Animated.View className="w-20 items-center" style={animatedStyle}>
|
||||
<Pressable 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
|
||||
@@ -62,8 +42,8 @@ export function CastCard({
|
||||
{person.character}
|
||||
</Text>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
</Link>
|
||||
|
||||
@@ -3,13 +3,14 @@ import {
|
||||
IconCircleDashed,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
export function EpisodeRow({
|
||||
episode,
|
||||
isWatched,
|
||||
onToggle,
|
||||
accentColor,
|
||||
mutedColor,
|
||||
}: {
|
||||
episode: {
|
||||
id: string;
|
||||
@@ -19,10 +20,9 @@ export function EpisodeRow({
|
||||
};
|
||||
isWatched: boolean;
|
||||
onToggle: () => void;
|
||||
accentColor: string;
|
||||
mutedColor: string;
|
||||
}) {
|
||||
const titleAccentColor = useCSSVariable("--color-title-accent") as string;
|
||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onToggle}
|
||||
@@ -30,9 +30,9 @@ export function EpisodeRow({
|
||||
style={{ borderBottomWidth: 0.5 }}
|
||||
>
|
||||
{isWatched ? (
|
||||
<IconCircleCheckFilled size={22} color={titleAccentColor} />
|
||||
<IconCircleCheckFilled size={22} color={accentColor} />
|
||||
) : (
|
||||
<IconCircleDashed size={22} color={mutedFgColor} />
|
||||
<IconCircleDashed size={22} color={mutedColor} />
|
||||
)}
|
||||
<View className="ml-3 flex-1">
|
||||
<Text
|
||||
|
||||
@@ -115,6 +115,17 @@ export function SeasonAccordion({
|
||||
}),
|
||||
);
|
||||
|
||||
const handleEpisodeToggle = useCallback(
|
||||
(episodeId: string) => {
|
||||
if (watchedEpisodeIds.has(episodeId)) {
|
||||
unwatchEpisode.mutate({ id: episodeId });
|
||||
} else {
|
||||
watchEpisode.mutate({ id: episodeId });
|
||||
}
|
||||
},
|
||||
[watchedEpisodeIds, unwatchEpisode, watchEpisode],
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="mb-2 overflow-hidden rounded-xl border bg-card"
|
||||
@@ -175,13 +186,9 @@ export function SeasonAccordion({
|
||||
key={episode.id}
|
||||
episode={episode}
|
||||
isWatched={watchedEpisodeIds.has(episode.id)}
|
||||
onToggle={() => {
|
||||
if (watchedEpisodeIds.has(episode.id)) {
|
||||
unwatchEpisode.mutate({ id: episode.id });
|
||||
} else {
|
||||
watchEpisode.mutate({ id: episode.id });
|
||||
}
|
||||
}}
|
||||
onToggle={() => handleEpisodeToggle(episode.id)}
|
||||
accentColor={titleAccentColor}
|
||||
mutedColor={mutedFgColor}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
IconPlus,
|
||||
IconStarFilled,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
@@ -42,6 +40,13 @@ 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;
|
||||
isAdding?: boolean;
|
||||
}
|
||||
|
||||
export function PosterCard({
|
||||
@@ -55,8 +60,10 @@ export function PosterCard({
|
||||
userStatus,
|
||||
episodeProgress,
|
||||
width = 140,
|
||||
onPress,
|
||||
onQuickAdd,
|
||||
isAdding,
|
||||
}: PosterCardProps) {
|
||||
const { navigate } = useRouter();
|
||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||
const watchlistColor = useCSSVariable("--color-status-watchlist") as string;
|
||||
const watchingColor = useCSSVariable("--color-status-watching") as string;
|
||||
@@ -77,29 +84,6 @@ export function PosterCard({
|
||||
setLocalStatus(userStatus ?? null);
|
||||
}, [userStatus]);
|
||||
|
||||
const resolveMutation = useMutation(
|
||||
orpc.titles.resolve.mutationOptions({
|
||||
onSuccess: ({ id: resolvedId }) => {
|
||||
if (resolvedId) {
|
||||
navigate(`/title/${resolvedId}`);
|
||||
}
|
||||
},
|
||||
onError: () => toast.error("Failed to load title"),
|
||||
}),
|
||||
);
|
||||
|
||||
const quickAddMutation = useMutation(
|
||||
orpc.titles.quickAdd.mutationOptions({
|
||||
onSuccess: () => {
|
||||
setLocalStatus("watchlist");
|
||||
toast.success("Added to watchlist");
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||
},
|
||||
onError: () => toast.error("Failed to add to watchlist"),
|
||||
}),
|
||||
);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
@@ -108,14 +92,15 @@ export function PosterCard({
|
||||
],
|
||||
}));
|
||||
|
||||
const handleResolve = useCallback(() => {
|
||||
resolveMutation.mutate({ tmdbId, type });
|
||||
}, [tmdbId, type, resolveMutation]);
|
||||
const handlePressAction = useCallback(() => {
|
||||
onPress(id, tmdbId, type);
|
||||
}, [onPress, id, tmdbId, type]);
|
||||
|
||||
const handleQuickAdd = useCallback(() => {
|
||||
if (localStatus || quickAddMutation.isPending) return;
|
||||
quickAddMutation.mutate({ tmdbId, type });
|
||||
}, [localStatus, quickAddMutation, tmdbId, type]);
|
||||
const handleQuickAddPress = useCallback(() => {
|
||||
if (localStatus || isAdding) return;
|
||||
setLocalStatus("watchlist");
|
||||
onQuickAdd(tmdbId, type);
|
||||
}, [localStatus, isAdding, onQuickAdd, tmdbId, type]);
|
||||
|
||||
const year = releaseDate?.slice(0, 4);
|
||||
const imageHeight = width * 1.5;
|
||||
@@ -148,11 +133,11 @@ export function PosterCard({
|
||||
{/* Quick-add button */}
|
||||
{!localStatus && (
|
||||
<Pressable
|
||||
onPress={handleQuickAdd}
|
||||
onPress={handleQuickAddPress}
|
||||
className="absolute top-2 right-2 size-[30px] items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
>
|
||||
{quickAddMutation.isPending ? (
|
||||
{isAdding ? (
|
||||
<IconLoader size={16} color="white" />
|
||||
) : (
|
||||
<IconPlus size={16} color="white" />
|
||||
@@ -248,15 +233,13 @@ export function PosterCard({
|
||||
<ContextMenu.Trigger>
|
||||
<GestureDetector gesture={pressGesture}>
|
||||
<Animated.View style={[animatedStyle, { width }]}>
|
||||
<Pressable onPress={() => navigate(`/title/${id}`)}>
|
||||
{cardContent}
|
||||
</Pressable>
|
||||
<Pressable onPress={handlePressAction}>{cardContent}</Pressable>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content>
|
||||
{!localStatus && (
|
||||
<ContextMenu.Item key="watchlist" onSelect={handleQuickAdd}>
|
||||
<ContextMenu.Item key="watchlist" onSelect={handleQuickAddPress}>
|
||||
<ContextMenu.ItemIcon ios={{ name: "bookmark" }} />
|
||||
<ContextMenu.ItemTitle>Add to Watchlist</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
@@ -336,7 +319,7 @@ export function PosterCard({
|
||||
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
|
||||
})
|
||||
.onEnd(() => {
|
||||
scheduleOnRN(handleResolve);
|
||||
scheduleOnRN(handlePressAction);
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,7 +13,7 @@ const ACTION_WIDTH = 80;
|
||||
|
||||
function RightAction({ drag }: { drag: SharedValue<number> }) {
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const scale = interpolate(drag.value, [0, -ACTION_WIDTH], [0.6, 1], {
|
||||
const scale = interpolate(drag.get(), [0, -ACTION_WIDTH], [0.6, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ export function Switch({
|
||||
|
||||
const trackStyle = useAnimatedStyle(() => ({
|
||||
backgroundColor: interpolateColor(
|
||||
progress.value,
|
||||
progress.get(),
|
||||
[0, 1],
|
||||
[secondaryColor, primaryColor],
|
||||
),
|
||||
@@ -42,11 +42,11 @@ export function Switch({
|
||||
{
|
||||
translateX:
|
||||
THUMB_OFFSET +
|
||||
progress.value * (TRACK_WIDTH - THUMB_SIZE - THUMB_OFFSET * 2),
|
||||
progress.get() * (TRACK_WIDTH - THUMB_SIZE - THUMB_OFFSET * 2),
|
||||
},
|
||||
],
|
||||
backgroundColor: interpolateColor(
|
||||
progress.value,
|
||||
progress.get(),
|
||||
[0, 1],
|
||||
[mutedFgColor, "#fff"],
|
||||
),
|
||||
@@ -77,11 +77,7 @@ export function Switch({
|
||||
borderRadius: THUMB_SIZE / 2,
|
||||
position: "absolute",
|
||||
top: (TRACK_HEIGHT - THUMB_SIZE) / 2,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 2,
|
||||
elevation: 3,
|
||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.3)",
|
||||
},
|
||||
thumbStyle,
|
||||
]}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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.
|
||||
* 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: () => {
|
||||
toast.success("Added to watchlist");
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to add to watchlist");
|
||||
// Refetch so optimistic local status reverts on failure
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
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 });
|
||||
},
|
||||
[quickAddMutation.mutate],
|
||||
);
|
||||
|
||||
const addingKey =
|
||||
quickAddMutation.isPending && quickAddMutation.variables
|
||||
? `${quickAddMutation.variables.tmdbId}-${quickAddMutation.variables.type}`
|
||||
: null;
|
||||
|
||||
return { handlePress, handleQuickAdd, addingKey };
|
||||
}
|
||||
@@ -20,7 +20,6 @@
|
||||
"@expo-google-fonts/geist-mono": "0.4.1",
|
||||
"@expo/metro-runtime": "55.0.6",
|
||||
"@expo/ui": "55.0.2",
|
||||
"@gorhom/bottom-sheet": "5.2.8",
|
||||
"@orpc/client": "catalog:",
|
||||
"@orpc/contract": "catalog:",
|
||||
"@orpc/tanstack-query": "catalog:",
|
||||
@@ -702,10 +701,6 @@
|
||||
|
||||
"@fontsource/dm-serif-display": ["@fontsource/dm-serif-display@5.2.8", "", {}, "sha512-GYSDSlGU6vyhv9a5MwaiVNf9HCuSVpK8hEFRyG4NNDHCDeHiX7YHDAcWsaoLKKcfXLgWG9YkBkk9T3SxM4rAjQ=="],
|
||||
|
||||
"@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.8", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA=="],
|
||||
|
||||
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
|
||||
|
||||
"@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="],
|
||||
|
||||
Reference in New Issue
Block a user