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:
2026-03-17 19:32:00 -04:00
parent c6ae58f41d
commit 1257a485e9
8 changed files with 262 additions and 247 deletions
+5 -15
View File
@@ -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",
{ {
+2 -12
View File
@@ -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>
+96 -124
View File
@@ -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>
); );
} }
+4 -17
View File
@@ -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 };
} }