refactor(native): list optimizations and cleanup

This commit is contained in:
2026-03-14 13:46:12 -04:00
parent 3148574d5b
commit c6cd061175
13 changed files with 199 additions and 184 deletions
-1
View File
@@ -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:",
+7 -1
View File
@@ -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) {
+37 -47
View File
@@ -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>
+23 -40
View File
@@ -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",
});
+4 -8
View File
@@ -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 };
}
-5
View File
@@ -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=="],