mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 06:25:42 -04:00
fix(native): overhaul library screen, fix upcoming filters, improve SelectModal
Library screen: - Replace broken bottom sheet with inline filter strip (horizontal scroll chips matching the web pattern) — type pills, status/genre/rating/year/ content rating dropdown chips with SelectModal pickers - Replace TabStack with custom layout so filter sort controls appear in the native header alongside the avatar - Fix grid: 3-column layout with computed item widths, proper spacing - Remove redundant search bar (Search tab already exists) - Sync sort/filter state via Jotai atoms between layout and screen Upcoming screen: - Move filter chips into SectionList ListHeaderComponent so they scroll with content and don't overlap the native header - Single horizontal scroll row with divider between type and status groups - Status chips toggle on/off instead of redundant dual "All" buttons - Tighten section header spacing SelectModal: - Add ScrollView for long option lists (genres, content ratings) - Respect safe areas with dynamic max height from screen dimensions - Add optional multiSelect mode (stays open, toggles checkmarks) - Add optional clearLabel/onClear for a "Clear" button in the header - Accept string | string[] for selection to support multi-select Other: - Remove Settings tab from tab bar (accessible via avatar dropdown) - Simplify tab layout by removing unused update check query Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
8 files changed
+562
-191
No files matched your search
@@ -3,7 +3,7 @@ import { IconCalendarEvent } from "@tabler/icons-react-native";
|
|||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Pressable, RefreshControl, SectionList, View } from "react-native";
|
import { Pressable, RefreshControl, ScrollView, SectionList, View } from "react-native";
|
||||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||||
import { useCSSVariable, useResolveClassNames } from "uniwind";
|
import { useCSSVariable, useResolveClassNames } from "uniwind";
|
||||||
|
|
||||||
@@ -92,6 +92,41 @@ export default function UpcomingScreen() {
|
|||||||
}
|
}
|
||||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
const filterChips = (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 16, gap: 6, paddingTop: 10, paddingBottom: 6 }}
|
||||||
|
>
|
||||||
|
<FilterChip
|
||||||
|
label={t`All`}
|
||||||
|
isSelected={mediaType === "all"}
|
||||||
|
onPress={() => setMediaType("all")}
|
||||||
|
/>
|
||||||
|
<FilterChip
|
||||||
|
label={t`Movies`}
|
||||||
|
isSelected={mediaType === "movie"}
|
||||||
|
onPress={() => setMediaType("movie")}
|
||||||
|
/>
|
||||||
|
<FilterChip
|
||||||
|
label={t`TV`}
|
||||||
|
isSelected={mediaType === "tv"}
|
||||||
|
onPress={() => setMediaType("tv")}
|
||||||
|
/>
|
||||||
|
<View className="bg-border/30 mx-1 w-px self-stretch" />
|
||||||
|
<FilterChip
|
||||||
|
label={t`Watching`}
|
||||||
|
isSelected={statusFilter === "watching"}
|
||||||
|
onPress={() => setStatusFilter(statusFilter === "watching" ? "all" : "watching")}
|
||||||
|
/>
|
||||||
|
<FilterChip
|
||||||
|
label={t`Watchlist`}
|
||||||
|
isSelected={statusFilter === "watchlist"}
|
||||||
|
onPress={() => setStatusFilter(statusFilter === "watchlist" ? "all" : "watchlist")}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Header
|
<Stack.Header
|
||||||
@@ -107,63 +142,31 @@ export default function UpcomingScreen() {
|
|||||||
{t`Upcoming`}
|
{t`Upcoming`}
|
||||||
</Stack.Screen.Title>
|
</Stack.Screen.Title>
|
||||||
<Stack.Screen.BackButton displayMode="minimal" />
|
<Stack.Screen.BackButton displayMode="minimal" />
|
||||||
<View className="flex-row flex-wrap gap-2 px-4 pt-2 pb-1">
|
|
||||||
<View className="flex-row gap-1.5">
|
|
||||||
<FilterChip
|
|
||||||
label={t`All`}
|
|
||||||
isSelected={mediaType === "all"}
|
|
||||||
onPress={() => setMediaType("all")}
|
|
||||||
/>
|
|
||||||
<FilterChip
|
|
||||||
label={t`Movies`}
|
|
||||||
isSelected={mediaType === "movie"}
|
|
||||||
onPress={() => setMediaType("movie")}
|
|
||||||
/>
|
|
||||||
<FilterChip
|
|
||||||
label={t`TV Shows`}
|
|
||||||
isSelected={mediaType === "tv"}
|
|
||||||
onPress={() => setMediaType("tv")}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row gap-1.5">
|
|
||||||
<FilterChip
|
|
||||||
label={t`All`}
|
|
||||||
isSelected={statusFilter === "all"}
|
|
||||||
onPress={() => setStatusFilter("all")}
|
|
||||||
/>
|
|
||||||
<FilterChip
|
|
||||||
label={t`Watching`}
|
|
||||||
isSelected={statusFilter === "watching"}
|
|
||||||
onPress={() => setStatusFilter("watching")}
|
|
||||||
/>
|
|
||||||
<FilterChip
|
|
||||||
label={t`Watchlist`}
|
|
||||||
isSelected={statusFilter === "watchlist"}
|
|
||||||
onPress={() => setStatusFilter("watchlist")}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{!isPending && allItems.length === 0 ? (
|
{!isPending && allItems.length === 0 ? (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.duration(300)}
|
entering={FadeInDown.duration(300)}
|
||||||
className="flex-1 items-center justify-center px-4"
|
className="flex-1 items-center justify-center px-4"
|
||||||
>
|
>
|
||||||
<ScaledIcon icon={IconCalendarEvent} size={48} color={`${mutedColor}66`} />
|
{filterChips}
|
||||||
<Text className="text-muted-foreground mt-4 text-center text-sm">
|
<View className="flex-1 items-center justify-center">
|
||||||
<Trans>No upcoming episodes or releases in the next 90 days.</Trans>
|
<ScaledIcon icon={IconCalendarEvent} size={48} color={`${mutedColor}66`} />
|
||||||
</Text>
|
<Text className="text-muted-foreground mt-4 text-center text-sm">
|
||||||
|
<Trans>No upcoming episodes or releases in the next 90 days.</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
) : (
|
) : (
|
||||||
<SectionList
|
<SectionList
|
||||||
sections={sections}
|
sections={sections}
|
||||||
keyExtractor={(item, i) => `${item.titleId}-${item.date}-${i}`}
|
keyExtractor={(item, i) => `${item.titleId}-${item.date}-${i}`}
|
||||||
|
ListHeaderComponent={filterChips}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View className="px-4 py-1">
|
<View className="px-4 py-1">
|
||||||
<UpcomingRow item={item} />
|
<UpcomingRow item={item} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
renderSectionHeader={({ section: { title } }) => (
|
renderSectionHeader={({ section: { title } }) => (
|
||||||
<View className="bg-background px-4 pt-4 pb-1">
|
<View className="bg-background px-4 pt-3 pb-1">
|
||||||
<Text className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
<Text className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,8 +1,97 @@
|
|||||||
import { useLingui } from "@lingui/react/macro";
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { useCSSVariable, useResolveClassNames } from "uniwind";
|
||||||
|
|
||||||
import { TabStack } from "@/components/navigation/tab-stack";
|
import { HeaderAvatar } from "@/components/header-avatar";
|
||||||
|
import { SortMenu } from "@/components/library/sort-menu";
|
||||||
|
import { type SortBy, librarySortByAtom, librarySortDirectionAtom } from "@/lib/library-atoms";
|
||||||
|
|
||||||
|
function LibraryHeaderRight() {
|
||||||
|
const [sortBy, setSortBy] = useAtom(librarySortByAtom);
|
||||||
|
const [sortDirection, setSortDirection] = useAtom(librarySortDirectionAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center gap-4">
|
||||||
|
<SortMenu
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSortChange={(newSort, newDir) => {
|
||||||
|
setSortBy(newSort as SortBy);
|
||||||
|
setSortDirection(newDir);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<HeaderAvatar />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LibraryLayout() {
|
export default function LibraryLayout() {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
return <TabStack title={t`Library`} />;
|
const contentStyle = useResolveClassNames("bg-background");
|
||||||
|
const tintColor = useCSSVariable("--color-primary") as string;
|
||||||
|
const backgroundColor = useCSSVariable("--color-background") as string;
|
||||||
|
const headerTitleStyle = useResolveClassNames("font-display text-foreground text-xl");
|
||||||
|
const headerLargeTitleStyle = useResolveClassNames("font-display text-foreground");
|
||||||
|
|
||||||
|
if (process.env.EXPO_OS === "ios") {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
contentStyle,
|
||||||
|
unstable_headerRightItems: () => [
|
||||||
|
{
|
||||||
|
type: "custom" as const,
|
||||||
|
element: <LibraryHeaderRight />,
|
||||||
|
hidesSharedBackground: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="index">
|
||||||
|
<Stack.Header
|
||||||
|
transparent
|
||||||
|
blurEffect="systemChromeMaterialDark"
|
||||||
|
style={{ color: tintColor, shadowColor: "transparent" }}
|
||||||
|
largeStyle={{
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
shadowColor: "transparent",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen.Title
|
||||||
|
large
|
||||||
|
style={headerTitleStyle as Record<string, unknown>}
|
||||||
|
largeStyle={headerLargeTitleStyle as Record<string, unknown>}
|
||||||
|
>
|
||||||
|
{t`Library`}
|
||||||
|
</Stack.Screen.Title>
|
||||||
|
</Stack.Screen>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
contentStyle,
|
||||||
|
headerTitleAlign: "left",
|
||||||
|
headerRight: () => <LibraryHeaderRight />,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="index">
|
||||||
|
<Stack.Header
|
||||||
|
transparent={false}
|
||||||
|
style={{
|
||||||
|
backgroundColor,
|
||||||
|
color: tintColor,
|
||||||
|
shadowColor: "transparent",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen.Title style={headerTitleStyle as Record<string, unknown>}>
|
||||||
|
{t`Library`}
|
||||||
|
</Stack.Screen.Title>
|
||||||
|
</Stack.Screen>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,101 +1,249 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import {
|
import {
|
||||||
IconAdjustmentsHorizontal,
|
IconAdjustmentsHorizontal,
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconBooks,
|
IconBooks,
|
||||||
|
IconCalendarEvent,
|
||||||
|
IconCategory,
|
||||||
|
IconChecklist,
|
||||||
|
IconShieldCheck,
|
||||||
|
IconStarFilled,
|
||||||
} from "@tabler/icons-react-native";
|
} from "@tabler/icons-react-native";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { Stack } from "expo-router";
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Pressable, RefreshControl, View } from "react-native";
|
import { Pressable, RefreshControl, ScrollView, View, useWindowDimensions } from "react-native";
|
||||||
import Animated, { FadeIn } from "react-native-reanimated";
|
import Animated, { FadeIn } from "react-native-reanimated";
|
||||||
import { useCSSVariable } from "uniwind";
|
|
||||||
|
|
||||||
import type { LibraryFilters } from "@/components/library/filter-sheet";
|
|
||||||
import { FilterSheet } from "@/components/library/filter-sheet";
|
|
||||||
import { SortMenu } from "@/components/library/sort-menu";
|
|
||||||
import { EmptyState } from "@/components/ui/empty-state";
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
import { PosterCard } from "@/components/ui/poster-card";
|
import { PosterCard } from "@/components/ui/poster-card";
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { SelectModal } from "@/components/ui/select-modal";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
|
||||||
import { useTitleActions } from "@/hooks/use-title-actions";
|
import { useTitleActions } from "@/hooks/use-title-actions";
|
||||||
|
import {
|
||||||
|
libraryActiveFilterCountAtom,
|
||||||
|
librarySortByAtom,
|
||||||
|
librarySortDirectionAtom,
|
||||||
|
} from "@/lib/library-atoms";
|
||||||
import { orpc } from "@/lib/orpc";
|
import { orpc } from "@/lib/orpc";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
type SortBy = "added_at" | "title" | "release_date" | "user_rating" | "vote_average" | "popularity";
|
// ─── Grid constants ─────────────────────────────────────────────────
|
||||||
type SortDirection = "asc" | "desc";
|
|
||||||
|
|
||||||
const NUM_COLUMNS = 2;
|
const NUM_COLUMNS = 3;
|
||||||
const HORIZONTAL_PADDING = 16;
|
const EDGE_PADDING = 16;
|
||||||
|
const GAP = 8;
|
||||||
|
|
||||||
const gridContentContainerStyle = {
|
// ─── Inline filter chip ─────────────────────────────────────────────
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
paddingTop: 8,
|
function Chip({
|
||||||
paddingBottom: 16,
|
label,
|
||||||
};
|
isActive,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
onPress();
|
||||||
|
}}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ selected: isActive }}
|
||||||
|
className={`rounded-full px-3 py-1.5 ${isActive ? "bg-primary" : "bg-secondary"}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`font-sans text-xs font-medium ${isActive ? "text-primary-foreground" : "text-foreground"}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownChip({
|
||||||
|
label,
|
||||||
|
isActive,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
onPress();
|
||||||
|
}}
|
||||||
|
accessibilityRole="button"
|
||||||
|
className={`flex-row items-center gap-1 rounded-full px-3 py-1.5 ${isActive ? "bg-primary/15 border-primary/40 border" : "bg-secondary"}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`font-sans text-xs font-medium ${isActive ? "text-primary" : "text-foreground"}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text className={`text-[10px] ${isActive ? "text-primary" : "text-muted-foreground"}`}>
|
||||||
|
{"\u25BE"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Screen ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function LibraryScreen() {
|
export default function LibraryScreen() {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const foregroundColor = useCSSVariable("--color-foreground") as string;
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
|
const itemWidth = Math.floor(
|
||||||
|
(screenWidth - 2 * EDGE_PADDING - (NUM_COLUMNS - 1) * GAP) / NUM_COLUMNS,
|
||||||
|
);
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
// Filter state
|
||||||
const debouncedSearch = useDebounce(search.trim(), 300);
|
const [type, setType] = useState<"movie" | "tv" | undefined>(undefined);
|
||||||
|
const [statuses, setStatuses] = useState<string[]>([]);
|
||||||
|
const [genreId, setGenreId] = useState<number | undefined>(undefined);
|
||||||
|
const [ratingMin, setRatingMin] = useState<number | undefined>(undefined);
|
||||||
|
const [yearMin, setYearMin] = useState<number | undefined>(undefined);
|
||||||
|
const [yearMax, setYearMax] = useState<number | undefined>(undefined);
|
||||||
|
const [contentRating, setContentRating] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const [filters, setFilters] = useState<LibraryFilters>({});
|
// Sort state from atoms (controlled by layout header)
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [sortBy] = useAtom(librarySortByAtom);
|
||||||
const [sortBy, setSortBy] = useState<SortBy>("added_at");
|
const [sortDirection] = useAtom(librarySortDirectionAtom);
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
|
||||||
|
|
||||||
const handleSortChange = useCallback((newSortBy: SortBy, newDirection: SortDirection) => {
|
// Modal state for dropdowns
|
||||||
setSortBy(newSortBy);
|
const [genreModalOpen, setGenreModalOpen] = useState(false);
|
||||||
setSortDirection(newDirection);
|
const [statusModalOpen, setStatusModalOpen] = useState(false);
|
||||||
}, []);
|
const [ratingModalOpen, setRatingModalOpen] = useState(false);
|
||||||
|
const [yearModalOpen, setYearModalOpen] = useState(false);
|
||||||
const handleApplyFilters = useCallback((newFilters: LibraryFilters) => {
|
const [contentRatingModalOpen, setContentRatingModalOpen] = useState(false);
|
||||||
setFilters(newFilters);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
// Sync active filter count to atom for header badge
|
||||||
|
const setActiveFilterCount = useSetAtom(libraryActiveFilterCountAtom);
|
||||||
const activeFilterCount = useMemo(() => {
|
const activeFilterCount = useMemo(() => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
if (filters.statuses && filters.statuses.length > 0) count++;
|
if (statuses.length > 0) count++;
|
||||||
if (filters.type) count++;
|
if (type) count++;
|
||||||
if (filters.genreId !== undefined) count++;
|
if (genreId !== undefined) count++;
|
||||||
if (filters.ratingMin !== undefined || filters.ratingMax !== undefined) count++;
|
if (ratingMin !== undefined) count++;
|
||||||
if (filters.yearMin !== undefined || filters.yearMax !== undefined) count++;
|
if (yearMin !== undefined || yearMax !== undefined) count++;
|
||||||
if (filters.contentRating) count++;
|
if (contentRating) count++;
|
||||||
if (filters.onMyServices) count++;
|
|
||||||
return count;
|
return count;
|
||||||
}, [filters]);
|
}, [statuses, type, genreId, ratingMin, yearMin, yearMax, contentRating]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveFilterCount(activeFilterCount);
|
||||||
|
}, [activeFilterCount, setActiveFilterCount]);
|
||||||
|
|
||||||
|
// Genre data for filter
|
||||||
|
const { data: genreData } = useQuery(orpc.library.genres.queryOptions());
|
||||||
|
|
||||||
|
const genreOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: "", label: t`All genres` },
|
||||||
|
...(genreData?.genres.map((g) => ({ value: String(g.id), label: g.name })) ?? []),
|
||||||
|
],
|
||||||
|
[genreData, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: "in_watchlist", label: t`Watchlist` },
|
||||||
|
{ value: "watching", label: t`Watching` },
|
||||||
|
{ value: "caught_up", label: t`Caught Up` },
|
||||||
|
{ value: "completed", label: t`Completed` },
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const ratingOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: "", label: t`Any` },
|
||||||
|
{ value: "1", label: "1\u2605+" },
|
||||||
|
{ value: "2", label: "2\u2605+" },
|
||||||
|
{ value: "3", label: "3\u2605+" },
|
||||||
|
{ value: "4", label: "4\u2605+" },
|
||||||
|
{ value: "5", label: "5\u2605" },
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const yearOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: "", label: t`Any year` },
|
||||||
|
{ value: "2020", label: "2020s" },
|
||||||
|
{ value: "2010", label: "2010s" },
|
||||||
|
{ value: "2000", label: "2000s" },
|
||||||
|
{ value: "1990", label: "1990s" },
|
||||||
|
{ value: "1980", label: "1980s" },
|
||||||
|
{ value: "older", label: t`Pre-1980` },
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentRatingOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: "", label: t`All` },
|
||||||
|
...["G", "PG", "PG-13", "R", "NC-17", "TV-Y", "TV-Y7", "TV-G", "TV-PG", "TV-14", "TV-MA"].map(
|
||||||
|
(r) => ({ value: r, label: r }),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derived labels for dropdown chips
|
||||||
|
const statusLabel = useMemo(() => {
|
||||||
|
if (statuses.length === 0) return t`Status`;
|
||||||
|
if (statuses.length === 1)
|
||||||
|
return statusOptions.find((s) => s.value === statuses[0])?.label ?? t`Status`;
|
||||||
|
return `${statuses.length} statuses`;
|
||||||
|
}, [statuses, statusOptions, t]);
|
||||||
|
|
||||||
|
const genreLabel = genreData?.genres.find((g) => g.id === genreId)?.name ?? t`Genre`;
|
||||||
|
const ratingLabel = ratingMin ? `${ratingMin}\u2605+` : t`Rating`;
|
||||||
|
const yearLabel = useMemo(() => {
|
||||||
|
if (yearMin && yearMax) {
|
||||||
|
const decade = yearOptions.find((y) => y.value === String(yearMin));
|
||||||
|
return decade?.label ?? t`Year`;
|
||||||
|
}
|
||||||
|
if (yearMax === 1979) return t`Pre-1980`;
|
||||||
|
return t`Year`;
|
||||||
|
}, [yearMin, yearMax, yearOptions, t]);
|
||||||
|
const contentRatingLabel = contentRating ?? t`Age`;
|
||||||
|
|
||||||
|
// Query
|
||||||
const libraryQuery = useInfiniteQuery({
|
const libraryQuery = useInfiniteQuery({
|
||||||
...orpc.library.list.infiniteOptions({
|
...orpc.library.list.infiniteOptions({
|
||||||
input: (pageParam: number) => ({
|
input: (pageParam: number) => ({
|
||||||
search: debouncedSearch.length > 0 ? debouncedSearch : undefined,
|
|
||||||
statuses:
|
statuses:
|
||||||
filters.statuses && filters.statuses.length > 0
|
statuses.length > 0
|
||||||
? (filters.statuses as ("in_watchlist" | "watching" | "caught_up" | "completed")[])
|
? (statuses as ("in_watchlist" | "watching" | "caught_up" | "completed")[])
|
||||||
: undefined,
|
: undefined,
|
||||||
type: filters.type as "movie" | "tv" | undefined,
|
type,
|
||||||
genreId: filters.genreId,
|
genreId,
|
||||||
ratingMin: filters.ratingMin,
|
ratingMin,
|
||||||
ratingMax: filters.ratingMax,
|
yearMin,
|
||||||
yearMin: filters.yearMin,
|
yearMax,
|
||||||
yearMax: filters.yearMax,
|
contentRating,
|
||||||
contentRating: filters.contentRating,
|
|
||||||
onMyServices: filters.onMyServices,
|
|
||||||
sortBy,
|
sortBy,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
page: pageParam,
|
page: pageParam,
|
||||||
limit: 20,
|
limit: 30,
|
||||||
}),
|
}),
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||||
maxPages: 20,
|
maxPages: 10,
|
||||||
}),
|
}),
|
||||||
|
enabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { quickAdd } = useTitleActions();
|
const { quickAdd } = useTitleActions();
|
||||||
@@ -108,7 +256,6 @@ export default function LibraryScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isRefreshing = libraryQuery.isRefetching && !libraryQuery.isFetchingNextPage;
|
const isRefreshing = libraryQuery.isRefetching && !libraryQuery.isFetchingNextPage;
|
||||||
|
|
||||||
const onRefresh = useCallback(() => {
|
const onRefresh = useCallback(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: orpc.library.key() });
|
queryClient.invalidateQueries({ queryKey: orpc.library.key() });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -117,7 +264,7 @@ export default function LibraryScreen() {
|
|||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item }: { item: LibraryItem }) => (
|
({ item }: { item: LibraryItem }) => (
|
||||||
<View className="flex-1 px-1.5 pb-3">
|
<View style={{ flex: 1, paddingHorizontal: GAP / 2, paddingBottom: GAP }}>
|
||||||
<PosterCard
|
<PosterCard
|
||||||
id={item.id}
|
id={item.id}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
@@ -127,56 +274,103 @@ export default function LibraryScreen() {
|
|||||||
releaseDate={item.releaseDate ?? item.firstAirDate}
|
releaseDate={item.releaseDate ?? item.firstAirDate}
|
||||||
voteAverage={item.voteAverage}
|
voteAverage={item.voteAverage}
|
||||||
userStatus={item.userStatus}
|
userStatus={item.userStatus}
|
||||||
|
width={itemWidth}
|
||||||
onQuickAdd={handleQuickAdd}
|
onQuickAdd={handleQuickAdd}
|
||||||
isAdding={addingId === item.id}
|
isAdding={addingId === item.id}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
[handleQuickAdd, addingId],
|
[handleQuickAdd, addingId, itemWidth],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: LibraryItem) => item.id, []);
|
const keyExtractor = useCallback((item: LibraryItem) => item.id, []);
|
||||||
|
|
||||||
const filterButton = (
|
function clearAll() {
|
||||||
<View className="flex-row items-center gap-4">
|
setType(undefined);
|
||||||
<SortMenu sortBy={sortBy} sortDirection={sortDirection} onSortChange={handleSortChange} />
|
setStatuses([]);
|
||||||
<Pressable
|
setGenreId(undefined);
|
||||||
onPress={() => setFilterOpen(true)}
|
setRatingMin(undefined);
|
||||||
accessibilityRole="button"
|
setYearMin(undefined);
|
||||||
accessibilityLabel={t`Filters`}
|
setYearMax(undefined);
|
||||||
hitSlop={8}
|
setContentRating(undefined);
|
||||||
>
|
}
|
||||||
<View className="flex-row items-center">
|
|
||||||
<ScaledIcon icon={IconAdjustmentsHorizontal} size={22} color={foregroundColor} />
|
function handleYearSelect(value: string) {
|
||||||
{activeFilterCount > 0 && (
|
if (!value) {
|
||||||
<View className="bg-primary -mt-2 -ml-1.5 size-4 items-center justify-center rounded-full">
|
setYearMin(undefined);
|
||||||
<Text className="text-primary-foreground text-[10px] font-bold">
|
setYearMax(undefined);
|
||||||
{activeFilterCount}
|
} else if (value === "older") {
|
||||||
</Text>
|
setYearMin(undefined);
|
||||||
</View>
|
setYearMax(1979);
|
||||||
)}
|
} else {
|
||||||
</View>
|
const min = Number(value);
|
||||||
</Pressable>
|
setYearMin(min);
|
||||||
</View>
|
setYearMax(min + 9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Filter strip ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const filterStrip = (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ gap: 6, paddingTop: 6, paddingBottom: 16 }}
|
||||||
|
>
|
||||||
|
{/* Type pills */}
|
||||||
|
<Chip label={t`All`} isActive={!type} onPress={() => setType(undefined)} />
|
||||||
|
<Chip
|
||||||
|
label={t`Movies`}
|
||||||
|
isActive={type === "movie"}
|
||||||
|
onPress={() => setType(type === "movie" ? undefined : "movie")}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={t`TV`}
|
||||||
|
isActive={type === "tv"}
|
||||||
|
onPress={() => setType(type === "tv" ? undefined : "tv")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="bg-border/30 mx-1 w-px self-stretch" />
|
||||||
|
|
||||||
|
{/* Dropdown chips */}
|
||||||
|
<DropdownChip
|
||||||
|
label={statusLabel}
|
||||||
|
isActive={statuses.length > 0}
|
||||||
|
onPress={() => setStatusModalOpen(true)}
|
||||||
|
/>
|
||||||
|
<DropdownChip
|
||||||
|
label={genreLabel}
|
||||||
|
isActive={genreId !== undefined}
|
||||||
|
onPress={() => setGenreModalOpen(true)}
|
||||||
|
/>
|
||||||
|
<DropdownChip
|
||||||
|
label={ratingLabel}
|
||||||
|
isActive={ratingMin !== undefined}
|
||||||
|
onPress={() => setRatingModalOpen(true)}
|
||||||
|
/>
|
||||||
|
<DropdownChip
|
||||||
|
label={yearLabel}
|
||||||
|
isActive={yearMin !== undefined || yearMax !== undefined}
|
||||||
|
onPress={() => setYearModalOpen(true)}
|
||||||
|
/>
|
||||||
|
<DropdownChip
|
||||||
|
label={contentRatingLabel}
|
||||||
|
isActive={!!contentRating}
|
||||||
|
onPress={() => setContentRatingModalOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<Pressable onPress={clearAll} className="justify-center px-2">
|
||||||
|
<Text className="text-muted-foreground text-xs">{t`Clear`}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Render ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View collapsable={false} className="bg-background flex-1">
|
<View collapsable={false} className="bg-background flex-1">
|
||||||
<Stack.Screen
|
|
||||||
options={{
|
|
||||||
headerRight: () => filterButton,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.SearchBar
|
|
||||||
placeholder={t`Search your library...`}
|
|
||||||
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
|
||||||
onCancelButtonPress={() => setSearch("")}
|
|
||||||
onClose={() => setSearch("")}
|
|
||||||
hideWhenScrolling={false}
|
|
||||||
placement={process.env.EXPO_OS === "ios" ? "integrated" : undefined}
|
|
||||||
allowToolbarIntegration={process.env.EXPO_OS === "ios" ? true : undefined}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{libraryQuery.isPending ? (
|
{libraryQuery.isPending ? (
|
||||||
<View className="flex-1 items-center justify-center">
|
<View className="flex-1 items-center justify-center">
|
||||||
<Spinner colorClassName="accent-primary" />
|
<Spinner colorClassName="accent-primary" />
|
||||||
@@ -191,19 +385,14 @@ export default function LibraryScreen() {
|
|||||||
/>
|
/>
|
||||||
) : allItems.length === 0 ? (
|
) : allItems.length === 0 ? (
|
||||||
<Animated.View entering={FadeIn.duration(300)} className="flex-1">
|
<Animated.View entering={FadeIn.duration(300)} className="flex-1">
|
||||||
{debouncedSearch.length > 0 ? (
|
{filterStrip}
|
||||||
<View className="flex-1 items-center justify-center">
|
{activeFilterCount > 0 ? (
|
||||||
<Text className="text-muted-foreground text-base">
|
|
||||||
<Trans>No results for "{debouncedSearch}"</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : activeFilterCount > 0 ? (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={IconAdjustmentsHorizontal}
|
icon={IconAdjustmentsHorizontal}
|
||||||
title={t`No matching titles`}
|
title={t`No matching titles`}
|
||||||
description={t`Try adjusting your filters`}
|
description={t`Try adjusting your filters`}
|
||||||
actionLabel={t`Clear filters`}
|
actionLabel={t`Clear filters`}
|
||||||
onAction={() => setFilters({})}
|
onAction={clearAll}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -219,8 +408,12 @@ export default function LibraryScreen() {
|
|||||||
numColumns={NUM_COLUMNS}
|
numColumns={NUM_COLUMNS}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
|
ListHeaderComponent={filterStrip}
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
contentContainerStyle={gridContentContainerStyle}
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: EDGE_PADDING - GAP / 2,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />}
|
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (libraryQuery.hasNextPage && !libraryQuery.isFetchingNextPage) {
|
if (libraryQuery.hasNextPage && !libraryQuery.isFetchingNextPage) {
|
||||||
@@ -238,11 +431,69 @@ export default function LibraryScreen() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FilterSheet
|
{/* SelectModals for dropdown chips */}
|
||||||
open={filterOpen}
|
<SelectModal
|
||||||
onOpenChange={setFilterOpen}
|
label={t`Status`}
|
||||||
filters={filters}
|
icon={IconChecklist}
|
||||||
onApply={handleApplyFilters}
|
selection={statuses}
|
||||||
|
options={statusOptions}
|
||||||
|
open={statusModalOpen}
|
||||||
|
onOpenChange={setStatusModalOpen}
|
||||||
|
multiSelect
|
||||||
|
clearLabel={statuses.length > 0 ? t`Clear` : undefined}
|
||||||
|
onClear={() => setStatuses([])}
|
||||||
|
onSelect={(value) =>
|
||||||
|
setStatuses((prev) =>
|
||||||
|
prev.includes(value) ? prev.filter((s) => s !== value) : [...prev, value],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SelectModal
|
||||||
|
label={t`Genre`}
|
||||||
|
icon={IconCategory}
|
||||||
|
selection={genreId !== undefined ? String(genreId) : ""}
|
||||||
|
options={genreOptions}
|
||||||
|
open={genreModalOpen}
|
||||||
|
onOpenChange={setGenreModalOpen}
|
||||||
|
clearLabel={genreId !== undefined ? t`Clear` : undefined}
|
||||||
|
onClear={() => setGenreId(undefined)}
|
||||||
|
onSelect={(value) => setGenreId(value ? Number(value) : undefined)}
|
||||||
|
/>
|
||||||
|
<SelectModal
|
||||||
|
label={t`Rating`}
|
||||||
|
icon={IconStarFilled}
|
||||||
|
selection={ratingMin !== undefined ? String(ratingMin) : ""}
|
||||||
|
options={ratingOptions}
|
||||||
|
open={ratingModalOpen}
|
||||||
|
onOpenChange={setRatingModalOpen}
|
||||||
|
clearLabel={ratingMin !== undefined ? t`Clear` : undefined}
|
||||||
|
onClear={() => setRatingMin(undefined)}
|
||||||
|
onSelect={(value) => setRatingMin(value ? Number(value) : undefined)}
|
||||||
|
/>
|
||||||
|
<SelectModal
|
||||||
|
label={t`Year`}
|
||||||
|
icon={IconCalendarEvent}
|
||||||
|
selection={yearMin ? String(yearMin) : yearMax === 1979 ? "older" : ""}
|
||||||
|
options={yearOptions}
|
||||||
|
open={yearModalOpen}
|
||||||
|
onOpenChange={setYearModalOpen}
|
||||||
|
clearLabel={yearMin !== undefined || yearMax !== undefined ? t`Clear` : undefined}
|
||||||
|
onClear={() => {
|
||||||
|
setYearMin(undefined);
|
||||||
|
setYearMax(undefined);
|
||||||
|
}}
|
||||||
|
onSelect={handleYearSelect}
|
||||||
|
/>
|
||||||
|
<SelectModal
|
||||||
|
label={t`Content Rating`}
|
||||||
|
icon={IconShieldCheck}
|
||||||
|
selection={contentRating ?? ""}
|
||||||
|
options={contentRatingOptions}
|
||||||
|
open={contentRatingModalOpen}
|
||||||
|
onOpenChange={setContentRatingModalOpen}
|
||||||
|
clearLabel={contentRating ? t`Clear` : undefined}
|
||||||
|
onClear={() => setContentRating(undefined)}
|
||||||
|
onSelect={(value) => setContentRating(value || undefined)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,9 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { NativeTabBar } from "@/components/navigation/native-tab-bar";
|
import { NativeTabBar } from "@/components/navigation/native-tab-bar";
|
||||||
import { orpc } from "@/lib/orpc";
|
|
||||||
import { authClient } from "@/lib/server";
|
|
||||||
|
|
||||||
export const unstable_settings = {
|
export const unstable_settings = {
|
||||||
initialRouteName: "(home)",
|
initialRouteName: "(home)",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const { data: session } = authClient.useSession();
|
return <NativeTabBar />;
|
||||||
const isAdmin = session?.user?.role === "admin";
|
|
||||||
|
|
||||||
const updateCheck = useQuery({
|
|
||||||
...orpc.admin.updateCheck.queryOptions(),
|
|
||||||
enabled: isAdmin,
|
|
||||||
staleTime: 10 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const showSettingsBadge = !!updateCheck.data?.updateCheck?.updateAvailable;
|
|
||||||
|
|
||||||
return <NativeTabBar showSettingsBadge={showSettingsBadge} />;
|
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ export function UpcomingSection() {
|
|||||||
onSeeAll={() => push("/upcoming")}
|
onSeeAll={() => push("/upcoming")}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="gap-2 px-4">
|
<View className="gap-1 px-4">
|
||||||
{items.map((item, i) => (
|
{items.map((item, i) => (
|
||||||
<UpcomingRow key={`${item.titleId}-${item.date}-${i}`} item={item} />
|
<UpcomingRow key={`${item.titleId}-${item.date}-${i}`} item={item} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as Haptics from "expo-haptics";
|
|||||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
export function NativeTabBar({ showSettingsBadge }: { showSettingsBadge: boolean }) {
|
export function NativeTabBar() {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||||
@@ -39,7 +39,6 @@ export function NativeTabBar({ showSettingsBadge }: { showSettingsBadge: boolean
|
|||||||
<NativeTabs.Trigger name="(settings)" disableTransparentOnScrollEdge>
|
<NativeTabs.Trigger name="(settings)" disableTransparentOnScrollEdge>
|
||||||
<NativeTabs.Trigger.Label>{t`Settings`}</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>{t`Settings`}</NativeTabs.Trigger.Label>
|
||||||
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
|
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
|
||||||
{showSettingsBadge ? <NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge> : null}
|
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
<NativeTabs.Trigger name="(search)" role="search" disableTransparentOnScrollEdge>
|
<NativeTabs.Trigger name="(search)" role="search" disableTransparentOnScrollEdge>
|
||||||
<NativeTabs.Trigger.Label>{t`Search`}</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>{t`Search`}</NativeTabs.Trigger.Label>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type Icon, IconCheck } from "@tabler/icons-react-native";
|
import { type Icon, IconCheck } from "@tabler/icons-react-native";
|
||||||
import { Modal, Pressable, View } from "react-native";
|
import { Modal, Pressable, ScrollView, View, useWindowDimensions } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
@@ -17,7 +18,7 @@ export interface SelectModalProps {
|
|||||||
/** Icon displayed in the modal title */
|
/** Icon displayed in the modal title */
|
||||||
icon?: Icon;
|
icon?: Icon;
|
||||||
/** Currently selected value */
|
/** Currently selected value */
|
||||||
selection: string;
|
selection: string | string[];
|
||||||
/** Available options */
|
/** Available options */
|
||||||
options: SelectModalOption[];
|
options: SelectModalOption[];
|
||||||
/** Whether the modal is open */
|
/** Whether the modal is open */
|
||||||
@@ -26,6 +27,12 @@ export interface SelectModalProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
/** Called when an option is selected */
|
/** Called when an option is selected */
|
||||||
onSelect: (value: string) => void;
|
onSelect: (value: string) => void;
|
||||||
|
/** When true, tapping an option toggles it without closing the modal */
|
||||||
|
multiSelect?: boolean;
|
||||||
|
/** Optional clear button label — shown in the header when provided */
|
||||||
|
clearLabel?: string;
|
||||||
|
/** Called when the clear button is pressed */
|
||||||
|
onClear?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectModal({
|
export function SelectModal({
|
||||||
@@ -36,9 +43,18 @@ export function SelectModal({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
multiSelect,
|
||||||
|
clearLabel,
|
||||||
|
onClear,
|
||||||
}: SelectModalProps) {
|
}: SelectModalProps) {
|
||||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||||
|
const { top: safeTop, bottom: safeBottom } = useSafeAreaInsets();
|
||||||
|
const { height: screenHeight } = useWindowDimensions();
|
||||||
|
const maxCardHeight = screenHeight - safeTop - safeBottom - 64;
|
||||||
|
|
||||||
|
const isSelected = (value: string) =>
|
||||||
|
Array.isArray(selection) ? selection.includes(value) : selection === value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -49,36 +65,51 @@ export function SelectModal({
|
|||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
className="flex-1 items-center justify-center bg-black/60"
|
className="flex-1 items-center justify-center bg-black/60"
|
||||||
|
style={{ paddingTop: safeTop + 16, paddingBottom: safeBottom + 16 }}
|
||||||
onPress={() => onOpenChange(false)}
|
onPress={() => onOpenChange(false)}
|
||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
className="bg-card mx-8 w-full max-w-sm overflow-hidden rounded-2xl"
|
className="bg-card mx-8 w-full max-w-sm overflow-hidden rounded-2xl"
|
||||||
|
style={{ maxHeight: maxCardHeight }}
|
||||||
onPress={(e) => e.stopPropagation()}
|
onPress={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<View className="border-border/50 flex-row items-center border-b px-5 py-4">
|
<View className="border-border/50 flex-row items-center border-b p-4">
|
||||||
{Icon && <ScaledIcon icon={Icon} size={20} color={mutedFgColor} />}
|
{Icon && <ScaledIcon icon={Icon} size={20} color={mutedFgColor} />}
|
||||||
<Text className="text-foreground ml-1.5 text-base font-medium">{label}</Text>
|
<Text className="text-foreground ml-1.5 flex-1 text-base font-medium">{label}</Text>
|
||||||
</View>
|
{clearLabel && onClear && (
|
||||||
{options.map((option) => {
|
|
||||||
const isSelected = option.value === selection;
|
|
||||||
return (
|
|
||||||
<Pressable
|
<Pressable
|
||||||
key={option.value}
|
|
||||||
className="active:bg-primary/5 flex-row items-center px-5 py-3.5"
|
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
onClear();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
onSelect(option.value);
|
|
||||||
}}
|
}}
|
||||||
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
<Text
|
<Text className="text-muted-foreground text-sm">{clearLabel}</Text>
|
||||||
className={`flex-1 text-base ${isSelected ? "text-primary font-medium" : "text-foreground"}`}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</Text>
|
|
||||||
{isSelected && <IconCheck size={18} color={primaryColor} strokeWidth={2} />}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
)}
|
||||||
})}
|
</View>
|
||||||
|
<ScrollView bounces={false}>
|
||||||
|
{options.map((option) => {
|
||||||
|
const selected = isSelected(option.value);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.value}
|
||||||
|
className="active:bg-primary/5 flex-row items-center px-5 py-3.5"
|
||||||
|
onPress={() => {
|
||||||
|
onSelect(option.value);
|
||||||
|
if (!multiSelect) onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`flex-1 text-base ${selected ? "text-primary font-medium" : "text-foreground"}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
{selected && <IconCheck size={18} color={primaryColor} strokeWidth={2} />}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
export type SortBy =
|
||||||
|
| "added_at"
|
||||||
|
| "title"
|
||||||
|
| "release_date"
|
||||||
|
| "user_rating"
|
||||||
|
| "vote_average"
|
||||||
|
| "popularity";
|
||||||
|
|
||||||
|
export const libraryActiveFilterCountAtom = atom(0);
|
||||||
|
export const librarySortByAtom = atom<SortBy>("added_at");
|
||||||
|
export const librarySortDirectionAtom = atom<"asc" | "desc">("desc");
|
||||||
Reference in new issue
Block a user