mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
feat(native): add context menus to poster cards, hero banners, and continue-watching cards; remove failedKey error state
- Add `Link.Menu` with contextual actions to `PosterCard` (add to watchlist, mark completed, remove), `HeroBanner` ("Add to Watchlist"), and `ContinueWatchingCard` ("Mark as Completed", "Remove from Library") — all invalidate the relevant `titles` and `dashboard` query keys on success
- Remove the `failedKey`/`resetError` error-state pattern from `usePosterActions` and strip the now-unused `failedKey` and `onQuickAddFailed` props from every call site (`HorizontalPosterRow`, `person/[id].tsx`, `PosterCard`)
- Restructure `ContinueWatchingCard` JSX so `GestureDetector` wraps the outer `Animated.View` rather than sitting inside `Link`, fixing gesture/preview layering
- Simplify single-argument plugin entries in `app.json` from `["plugin-name"]` to `"plugin-name"`
This commit is contained in:
+5
-15
@@ -23,18 +23,8 @@
|
|||||||
"package": "com.jakejarvis.sofa"
|
"package": "com.jakejarvis.sofa"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
[
|
"expo-router",
|
||||||
"expo-router",
|
"expo-secure-store",
|
||||||
{
|
|
||||||
"root": "./src/app"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"expo-secure-store",
|
|
||||||
{
|
|
||||||
"configureAndroidBackup": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"expo-font",
|
"expo-font",
|
||||||
{
|
{
|
||||||
@@ -101,15 +91,15 @@
|
|||||||
"imageWidth": 200
|
"imageWidth": 200
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
["expo-system-ui"],
|
"expo-system-ui",
|
||||||
["expo-web-browser"],
|
"expo-web-browser",
|
||||||
[
|
[
|
||||||
"expo-image-picker",
|
"expo-image-picker",
|
||||||
{
|
{
|
||||||
"microphonePermission": false
|
"microphonePermission": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
["expo-localization"],
|
"expo-localization",
|
||||||
[
|
[
|
||||||
"expo-tracking-transparency",
|
"expo-tracking-transparency",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -75,8 +75,7 @@ export default function PersonDetailScreen() {
|
|||||||
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
||||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||||
|
|
||||||
const { handleQuickAdd, addingKey, failedKey, resetError } =
|
const { handleQuickAdd, addingKey } = usePosterActions();
|
||||||
usePosterActions();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -144,19 +143,10 @@ export default function PersonDetailScreen() {
|
|||||||
width={columnWidth}
|
width={columnWidth}
|
||||||
onQuickAdd={handleQuickAdd}
|
onQuickAdd={handleQuickAdd}
|
||||||
isAdding={addingKey === credit.titleId}
|
isAdding={addingKey === credit.titleId}
|
||||||
failedKey={failedKey}
|
|
||||||
onQuickAddFailed={resetError}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
[
|
[columnWidth, userStatuses, handleQuickAdd, addingKey],
|
||||||
columnWidth,
|
|
||||||
userStatuses,
|
|
||||||
handleQuickAdd,
|
|
||||||
addingKey,
|
|
||||||
failedKey,
|
|
||||||
resetError,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from "expo-router";
|
import { Link } from "expo-router";
|
||||||
import { View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
interpolate,
|
interpolate,
|
||||||
@@ -10,6 +10,9 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Image } from "@/components/ui/image";
|
import { Image } from "@/components/ui/image";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { client, orpc } from "@/lib/orpc";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
export interface ContinueWatchingItem {
|
export interface ContinueWatchingItem {
|
||||||
title: {
|
title: {
|
||||||
@@ -57,85 +60,123 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
|||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/title/${item.title.id}` as `/title/${string}`} asChild>
|
<GestureDetector gesture={tapGesture}>
|
||||||
<Link.Trigger withAppleZoom>
|
<Animated.View className="w-[200px]" style={animatedStyle}>
|
||||||
<GestureDetector gesture={tapGesture}>
|
<Link href={`/title/${item.title.id}` as `/title/${string}`} asChild>
|
||||||
<Animated.View
|
<Link.Trigger withAppleZoom>
|
||||||
accessible
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={cardLabel}
|
accessibilityLabel={cardLabel}
|
||||||
className="w-[200px] overflow-hidden rounded-[12px] border bg-card"
|
>
|
||||||
style={[
|
|
||||||
animatedStyle,
|
|
||||||
{
|
|
||||||
borderColor: "rgba(255,255,255,0.06)",
|
|
||||||
borderCurve: "continuous",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View className="h-28 w-[200px]">
|
|
||||||
{(item.nextEpisode?.stillPath || item.title.backdropPath) && (
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: (item.nextEpisode?.stillPath ??
|
|
||||||
item.title.backdropPath) as string,
|
|
||||||
}}
|
|
||||||
thumbHash={
|
|
||||||
item.nextEpisode?.stillThumbHash ??
|
|
||||||
item.title.backdropThumbHash
|
|
||||||
}
|
|
||||||
recyclingKey={item.title.id}
|
|
||||||
className="h-full w-full"
|
|
||||||
contentFit="cover"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<View
|
<View
|
||||||
className="absolute inset-0"
|
className="overflow-hidden rounded-[12px] border bg-card"
|
||||||
style={{ backgroundColor: "rgba(0,0,0,0.4)" }}
|
style={{
|
||||||
/>
|
borderColor: "rgba(255,255,255,0.06)",
|
||||||
{item.nextEpisode && (
|
borderCurve: "continuous",
|
||||||
<View className="absolute right-2.5 bottom-3 left-2.5">
|
}}
|
||||||
|
>
|
||||||
|
<View className="h-28 w-[200px]">
|
||||||
|
{(item.nextEpisode?.stillPath || item.title.backdropPath) && (
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: (item.nextEpisode?.stillPath ??
|
||||||
|
item.title.backdropPath) as string,
|
||||||
|
}}
|
||||||
|
thumbHash={
|
||||||
|
item.nextEpisode?.stillThumbHash ??
|
||||||
|
item.title.backdropThumbHash
|
||||||
|
}
|
||||||
|
recyclingKey={item.title.id}
|
||||||
|
className="h-full w-full"
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.4)" }}
|
||||||
|
/>
|
||||||
|
{item.nextEpisode && (
|
||||||
|
<View className="absolute right-2.5 bottom-3 left-2.5">
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className="font-medium font-sans text-white/80 text-xs"
|
||||||
|
>
|
||||||
|
S{item.nextEpisode.seasonNumber} E
|
||||||
|
{item.nextEpisode.episodeNumber}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
className="absolute right-0 bottom-0 left-0 h-[3px]"
|
||||||
|
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="h-full bg-status-watching"
|
||||||
|
style={{
|
||||||
|
width: `${item.totalEpisodes > 0 ? (item.watchedEpisodes / item.totalEpisodes) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="p-2.5">
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
className="font-medium font-sans text-white/80 text-xs"
|
className="font-medium font-sans text-foreground text-sm"
|
||||||
>
|
>
|
||||||
S{item.nextEpisode.seasonNumber} E
|
{item.title.title}
|
||||||
{item.nextEpisode.episodeNumber}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
{item.nextEpisode && (
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className="mt-0.5 text-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
{item.nextEpisode.name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
<View
|
|
||||||
className="absolute right-0 bottom-0 left-0 h-[3px]"
|
|
||||||
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className="h-full bg-status-watching"
|
|
||||||
style={{
|
|
||||||
width: `${item.totalEpisodes > 0 ? (item.watchedEpisodes / item.totalEpisodes) * 100 : 0}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Pressable>
|
||||||
<View className="p-2.5">
|
</Link.Trigger>
|
||||||
<Text
|
<Link.Preview />
|
||||||
numberOfLines={1}
|
<Link.Menu>
|
||||||
className="font-medium font-sans text-foreground text-sm"
|
<Link.MenuAction
|
||||||
>
|
title="Mark as Completed"
|
||||||
{item.title.title}
|
icon="checkmark.circle"
|
||||||
</Text>
|
onPress={async () => {
|
||||||
{item.nextEpisode && (
|
await client.titles.updateStatus({
|
||||||
<Text
|
id: item.title.id,
|
||||||
numberOfLines={1}
|
status: "completed",
|
||||||
className="mt-0.5 text-muted-foreground text-xs"
|
});
|
||||||
>
|
toast.success(`Marked "${item.title.title}" as completed`);
|
||||||
{item.nextEpisode.name}
|
queryClient.invalidateQueries({
|
||||||
</Text>
|
queryKey: orpc.titles.key(),
|
||||||
)}
|
});
|
||||||
</View>
|
queryClient.invalidateQueries({
|
||||||
</Animated.View>
|
queryKey: orpc.dashboard.key(),
|
||||||
</GestureDetector>
|
});
|
||||||
</Link.Trigger>
|
}}
|
||||||
<Link.Preview />
|
/>
|
||||||
</Link>
|
<Link.MenuAction
|
||||||
|
title="Remove from Library"
|
||||||
|
icon="trash"
|
||||||
|
destructive
|
||||||
|
onPress={async () => {
|
||||||
|
await client.titles.updateStatus({
|
||||||
|
id: item.title.id,
|
||||||
|
status: null,
|
||||||
|
});
|
||||||
|
toast.success("Removed from library");
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: orpc.titles.key(),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: orpc.dashboard.key(),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Link.Menu>
|
||||||
|
</Link>
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ export function HorizontalPosterRow({
|
|||||||
items: PosterRowItem[];
|
items: PosterRowItem[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { handleQuickAdd, addingKey, failedKey, resetError } =
|
const { handleQuickAdd, addingKey } = usePosterActions();
|
||||||
usePosterActions();
|
|
||||||
const keyExtractor = useCallback((item: PosterRowItem) => item.id, []);
|
const keyExtractor = useCallback((item: PosterRowItem) => item.id, []);
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item }: { item: PosterRowItem }) => (
|
({ item }: { item: PosterRowItem }) => (
|
||||||
@@ -46,11 +45,9 @@ export function HorizontalPosterRow({
|
|||||||
episodeProgress={item.episodeProgress}
|
episodeProgress={item.episodeProgress}
|
||||||
onQuickAdd={handleQuickAdd}
|
onQuickAdd={handleQuickAdd}
|
||||||
isAdding={addingKey === item.id}
|
isAdding={addingKey === item.id}
|
||||||
failedKey={failedKey}
|
|
||||||
onQuickAddFailed={resetError}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[addingKey, failedKey, handleQuickAdd, resetError],
|
[addingKey, handleQuickAdd],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import Animated, {
|
|||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
import { Image } from "@/components/ui/image";
|
import { Image } from "@/components/ui/image";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { client, orpc } from "@/lib/orpc";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
export interface HeroBannerItem {
|
export interface HeroBannerItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -161,6 +164,22 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</Link.Trigger>
|
</Link.Trigger>
|
||||||
<Link.Preview />
|
<Link.Preview />
|
||||||
|
<Link.Menu>
|
||||||
|
<Link.MenuAction
|
||||||
|
title="Add to Watchlist"
|
||||||
|
icon="bookmark"
|
||||||
|
onPress={async () => {
|
||||||
|
await client.titles.quickAdd({ id: item.id });
|
||||||
|
toast.success(`Added "${item.title}" to watchlist`);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: orpc.titles.key(),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: orpc.dashboard.key(),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Link.Menu>
|
||||||
</Link>
|
</Link>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import { useCSSVariable } from "uniwind";
|
|||||||
import { Image } from "@/components/ui/image";
|
import { Image } from "@/components/ui/image";
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { client, orpc } from "@/lib/orpc";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
export interface HeroBannerItem {
|
export interface HeroBannerItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -121,6 +124,22 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</Link.Trigger>
|
</Link.Trigger>
|
||||||
<Link.Preview />
|
<Link.Preview />
|
||||||
|
<Link.Menu>
|
||||||
|
<Link.MenuAction
|
||||||
|
title="Add to Watchlist"
|
||||||
|
icon="bookmark"
|
||||||
|
onPress={async () => {
|
||||||
|
await client.titles.quickAdd({ id: item.id });
|
||||||
|
toast.success(`Added "${item.title}" to watchlist`);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: orpc.titles.key(),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: orpc.dashboard.key(),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Link.Menu>
|
||||||
</Link>
|
</Link>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
IconStarFilled,
|
IconStarFilled,
|
||||||
} from "@tabler/icons-react-native";
|
} from "@tabler/icons-react-native";
|
||||||
import { Link } from "expo-router";
|
import { Link } from "expo-router";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback } from "react";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -20,7 +20,6 @@ import Animated, {
|
|||||||
withSpring,
|
withSpring,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
|
||||||
import { Image } from "@/components/ui/image";
|
import { Image } from "@/components/ui/image";
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -44,8 +43,6 @@ interface PosterCardProps {
|
|||||||
width?: number;
|
width?: number;
|
||||||
onQuickAdd: (id: string) => void;
|
onQuickAdd: (id: string) => void;
|
||||||
isAdding?: boolean;
|
isAdding?: boolean;
|
||||||
failedKey?: string | null;
|
|
||||||
onQuickAddFailed?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PosterCard({
|
export function PosterCard({
|
||||||
@@ -61,8 +58,6 @@ export function PosterCard({
|
|||||||
width = 140,
|
width = 140,
|
||||||
onQuickAdd,
|
onQuickAdd,
|
||||||
isAdding,
|
isAdding,
|
||||||
failedKey,
|
|
||||||
onQuickAddFailed,
|
|
||||||
}: PosterCardProps) {
|
}: PosterCardProps) {
|
||||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||||
const watchlistColor = useCSSVariable("--color-status-watchlist") as string;
|
const watchlistColor = useCSSVariable("--color-status-watchlist") as string;
|
||||||
@@ -77,20 +72,6 @@ export function PosterCard({
|
|||||||
|
|
||||||
const reduceMotion = useReducedMotion();
|
const reduceMotion = useReducedMotion();
|
||||||
const pressed = useSharedValue(0);
|
const pressed = useSharedValue(0);
|
||||||
const [localStatus, setLocalStatus] = useState<TitleStatus | null>(
|
|
||||||
userStatus ?? null,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalStatus(userStatus ?? null);
|
|
||||||
}, [userStatus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (failedKey === id) {
|
|
||||||
setLocalStatus(userStatus ?? null);
|
|
||||||
onQuickAddFailed?.();
|
|
||||||
}
|
|
||||||
}, [failedKey, id, userStatus, onQuickAddFailed]);
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
transform: [
|
transform: [
|
||||||
@@ -101,20 +82,19 @@ export function PosterCard({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const handleQuickAddPress = useCallback(() => {
|
const handleQuickAddPress = useCallback(() => {
|
||||||
if (localStatus || isAdding) return;
|
if (userStatus || isAdding) return;
|
||||||
setLocalStatus("watchlist");
|
|
||||||
onQuickAdd(id);
|
onQuickAdd(id);
|
||||||
}, [localStatus, isAdding, onQuickAdd, id]);
|
}, [userStatus, isAdding, onQuickAdd, id]);
|
||||||
|
|
||||||
const year = releaseDate?.slice(0, 4);
|
const year = releaseDate?.slice(0, 4);
|
||||||
const imageHeight = width * 1.5;
|
const imageHeight = width * 1.5;
|
||||||
|
|
||||||
const statusLabel =
|
const statusLabel =
|
||||||
localStatus === "completed"
|
userStatus === "completed"
|
||||||
? "completed"
|
? "completed"
|
||||||
: localStatus === "in_progress"
|
: userStatus === "in_progress"
|
||||||
? "watching"
|
? "watching"
|
||||||
: localStatus === "watchlist"
|
: userStatus === "watchlist"
|
||||||
? "on watchlist"
|
? "on watchlist"
|
||||||
: undefined;
|
: undefined;
|
||||||
const cardAccessibilityLabel = [
|
const cardAccessibilityLabel = [
|
||||||
@@ -129,7 +109,7 @@ export function PosterCard({
|
|||||||
const cardContent = (
|
const cardContent = (
|
||||||
<View
|
<View
|
||||||
className={`overflow-hidden rounded-xl border bg-card ${
|
className={`overflow-hidden rounded-xl border bg-card ${
|
||||||
localStatus ? "border-primary/25" : "border-white/[0.06]"
|
userStatus ? "border-primary/25" : "border-white/[0.06]"
|
||||||
}`}
|
}`}
|
||||||
style={{ borderCurve: "continuous" }}
|
style={{ borderCurve: "continuous" }}
|
||||||
>
|
>
|
||||||
@@ -153,15 +133,15 @@ export function PosterCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status indicator */}
|
{/* Status indicator */}
|
||||||
{localStatus && (
|
{userStatus && (
|
||||||
<View
|
<View
|
||||||
accessible={false}
|
accessible={false}
|
||||||
className="absolute top-2 right-2 size-[30px] items-center justify-center rounded-full"
|
className="absolute top-2 right-2 size-[30px] items-center justify-center rounded-full"
|
||||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||||
>
|
>
|
||||||
{localStatus === "completed" ? (
|
{userStatus === "completed" ? (
|
||||||
<IconCheckbox size={16} color="white" />
|
<IconCheckbox size={16} color="white" />
|
||||||
) : localStatus === "in_progress" ? (
|
) : userStatus === "in_progress" ? (
|
||||||
<IconPlayerPlayFilled size={16} color="white" />
|
<IconPlayerPlayFilled size={16} color="white" />
|
||||||
) : (
|
) : (
|
||||||
<IconBookmarkFilled size={16} color="white" />
|
<IconBookmarkFilled size={16} color="white" />
|
||||||
@@ -190,10 +170,10 @@ export function PosterCard({
|
|||||||
{/* Metadata */}
|
{/* Metadata */}
|
||||||
<View className="px-2.5 pt-2 pb-2.5">
|
<View className="px-2.5 pt-2 pb-2.5">
|
||||||
<View className="flex-row items-center gap-1.5">
|
<View className="flex-row items-center gap-1.5">
|
||||||
{localStatus && (
|
{userStatus && (
|
||||||
<View
|
<View
|
||||||
className="size-1.5 rounded-full"
|
className="size-1.5 rounded-full"
|
||||||
style={{ backgroundColor: statusColors[localStatus] }}
|
style={{ backgroundColor: statusColors[userStatus] }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Text
|
<Text
|
||||||
@@ -228,7 +208,7 @@ export function PosterCard({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
const quickAddButton = !localStatus ? (
|
const quickAddButton = !userStatus ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleQuickAddPress}
|
onPress={handleQuickAddPress}
|
||||||
disabled={!!isAdding}
|
disabled={!!isAdding}
|
||||||
@@ -262,97 +242,89 @@ export function PosterCard({
|
|||||||
const titleHref = `/title/${id}` as `/title/${string}`;
|
const titleHref = `/title/${id}` as `/title/${string}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<GestureDetector gesture={pressGesture}>
|
||||||
<ContextMenu.Trigger>
|
<Animated.View style={[animatedStyle, { width }]}>
|
||||||
<GestureDetector gesture={pressGesture}>
|
<View>
|
||||||
<Animated.View style={[animatedStyle, { width }]}>
|
<Link href={titleHref} asChild>
|
||||||
<View>
|
<Link.Trigger withAppleZoom>
|
||||||
<Link href={titleHref} asChild>
|
<Pressable
|
||||||
<Link.Trigger withAppleZoom>
|
accessibilityRole="button"
|
||||||
<Pressable
|
accessibilityLabel={cardAccessibilityLabel}
|
||||||
accessibilityRole="button"
|
>
|
||||||
accessibilityLabel={cardAccessibilityLabel}
|
{cardContent}
|
||||||
>
|
</Pressable>
|
||||||
{cardContent}
|
</Link.Trigger>
|
||||||
</Pressable>
|
<Link.Preview />
|
||||||
</Link.Trigger>
|
<Link.Menu>
|
||||||
<Link.Preview />
|
{!userStatus && (
|
||||||
</Link>
|
<Link.MenuAction
|
||||||
{quickAddButton}
|
title="Add to Watchlist"
|
||||||
</View>
|
icon="bookmark"
|
||||||
</Animated.View>
|
onPress={handleQuickAddPress}
|
||||||
</GestureDetector>
|
/>
|
||||||
</ContextMenu.Trigger>
|
)}
|
||||||
<ContextMenu.Content>
|
{userStatus !== "in_progress" && (
|
||||||
{!localStatus && (
|
<Link.MenuAction
|
||||||
<ContextMenu.Item key="watchlist" onSelect={handleQuickAddPress}>
|
title="Mark as Watching"
|
||||||
<ContextMenu.ItemIcon ios={{ name: "bookmark" }} />
|
icon="play.fill"
|
||||||
<ContextMenu.ItemTitle>Add to Watchlist</ContextMenu.ItemTitle>
|
onPress={async () => {
|
||||||
</ContextMenu.Item>
|
await client.titles.updateStatus({
|
||||||
)}
|
id,
|
||||||
{localStatus !== "in_progress" && (
|
status: "in_progress",
|
||||||
<ContextMenu.Item
|
});
|
||||||
key="watching"
|
toast.success("Marked as watching");
|
||||||
onSelect={async () => {
|
queryClient.invalidateQueries({
|
||||||
await client.titles.updateStatus({
|
queryKey: orpc.titles.key(),
|
||||||
id,
|
});
|
||||||
status: "in_progress",
|
queryClient.invalidateQueries({
|
||||||
});
|
queryKey: orpc.dashboard.key(),
|
||||||
toast.success("Marked as watching");
|
});
|
||||||
queryClient.invalidateQueries({
|
}}
|
||||||
queryKey: orpc.titles.key(),
|
/>
|
||||||
});
|
)}
|
||||||
queryClient.invalidateQueries({
|
{type === "movie" && (
|
||||||
queryKey: orpc.dashboard.key(),
|
<Link.MenuAction
|
||||||
});
|
title="Mark as Watched"
|
||||||
}}
|
icon="checkmark.circle"
|
||||||
>
|
onPress={async () => {
|
||||||
<ContextMenu.ItemIcon ios={{ name: "play.fill" }} />
|
await client.titles.watchMovie({ id });
|
||||||
<ContextMenu.ItemTitle>Mark as Watching</ContextMenu.ItemTitle>
|
toast.success(
|
||||||
</ContextMenu.Item>
|
title
|
||||||
)}
|
? `Marked "${title}" as watched`
|
||||||
{type === "movie" && (
|
: "Marked as watched",
|
||||||
<ContextMenu.Item
|
);
|
||||||
key="watched"
|
queryClient.invalidateQueries({
|
||||||
onSelect={async () => {
|
queryKey: orpc.titles.key(),
|
||||||
await client.titles.watchMovie({ id });
|
});
|
||||||
toast.success(
|
queryClient.invalidateQueries({
|
||||||
title ? `Marked "${title}" as watched` : "Marked as watched",
|
queryKey: orpc.dashboard.key(),
|
||||||
);
|
});
|
||||||
queryClient.invalidateQueries({
|
}}
|
||||||
queryKey: orpc.titles.key(),
|
/>
|
||||||
});
|
)}
|
||||||
queryClient.invalidateQueries({
|
{userStatus && (
|
||||||
queryKey: orpc.dashboard.key(),
|
<Link.MenuAction
|
||||||
});
|
title="Remove from Library"
|
||||||
}}
|
icon="trash"
|
||||||
>
|
destructive
|
||||||
<ContextMenu.ItemIcon ios={{ name: "checkmark.circle" }} />
|
onPress={async () => {
|
||||||
<ContextMenu.ItemTitle>Mark as Watched</ContextMenu.ItemTitle>
|
await client.titles.updateStatus({ id, status: null });
|
||||||
</ContextMenu.Item>
|
toast.success("Removed from library");
|
||||||
)}
|
queryClient.invalidateQueries({
|
||||||
{localStatus && (
|
queryKey: orpc.titles.key(),
|
||||||
<ContextMenu.Item
|
});
|
||||||
key="remove"
|
queryClient.invalidateQueries({
|
||||||
destructive
|
queryKey: orpc.dashboard.key(),
|
||||||
onSelect={async () => {
|
});
|
||||||
await client.titles.updateStatus({ id, status: null });
|
}}
|
||||||
setLocalStatus(null);
|
/>
|
||||||
toast.success("Removed from library");
|
)}
|
||||||
queryClient.invalidateQueries({
|
</Link.Menu>
|
||||||
queryKey: orpc.titles.key(),
|
</Link>
|
||||||
});
|
{quickAddButton}
|
||||||
queryClient.invalidateQueries({
|
</View>
|
||||||
queryKey: orpc.dashboard.key(),
|
</Animated.View>
|
||||||
});
|
</GestureDetector>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemIcon ios={{ name: "trash" }} />
|
|
||||||
<ContextMenu.ItemTitle>Remove from Library</ContextMenu.ItemTitle>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
)}
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Root>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useCallback } from "react";
|
|
||||||
import { orpc } from "@/lib/orpc";
|
import { orpc } from "@/lib/orpc";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
@@ -25,26 +24,14 @@ export function usePosterActions() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleQuickAdd = useCallback(
|
const handleQuickAdd = (id: string) => {
|
||||||
(id: string) => {
|
quickAddMutation.mutate({ id });
|
||||||
quickAddMutation.mutate({ id });
|
};
|
||||||
},
|
|
||||||
[quickAddMutation.mutate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const addingKey =
|
const addingKey =
|
||||||
quickAddMutation.isPending && quickAddMutation.variables
|
quickAddMutation.isPending && quickAddMutation.variables
|
||||||
? quickAddMutation.variables.id
|
? quickAddMutation.variables.id
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const failedKey =
|
return { handleQuickAdd, addingKey };
|
||||||
quickAddMutation.isError && quickAddMutation.variables
|
|
||||||
? quickAddMutation.variables.id
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const resetError = useCallback(() => {
|
|
||||||
quickAddMutation.reset();
|
|
||||||
}, [quickAddMutation.reset]);
|
|
||||||
|
|
||||||
return { handleQuickAdd, addingKey, failedKey, resetError };
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user