mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -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:
@@ -3,7 +3,7 @@ import { IconCalendarEvent } from "@tabler/icons-react-native";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Stack } from "expo-router";
|
||||
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 { useCSSVariable, useResolveClassNames } from "uniwind";
|
||||
|
||||
@@ -92,6 +92,41 @@ export default function UpcomingScreen() {
|
||||
}
|
||||
}, [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 (
|
||||
<>
|
||||
<Stack.Header
|
||||
@@ -107,63 +142,31 @@ export default function UpcomingScreen() {
|
||||
{t`Upcoming`}
|
||||
</Stack.Screen.Title>
|
||||
<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 ? (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300)}
|
||||
className="flex-1 items-center justify-center px-4"
|
||||
>
|
||||
<ScaledIcon icon={IconCalendarEvent} size={48} color={`${mutedColor}66`} />
|
||||
<Text className="text-muted-foreground mt-4 text-center text-sm">
|
||||
<Trans>No upcoming episodes or releases in the next 90 days.</Trans>
|
||||
</Text>
|
||||
{filterChips}
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ScaledIcon icon={IconCalendarEvent} size={48} color={`${mutedColor}66`} />
|
||||
<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>
|
||||
) : (
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(item, i) => `${item.titleId}-${item.date}-${i}`}
|
||||
ListHeaderComponent={filterChips}
|
||||
renderItem={({ item }) => (
|
||||
<View className="px-4 py-1">
|
||||
<UpcomingRow item={item} />
|
||||
</View>
|
||||
)}
|
||||
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">
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
@@ -1,8 +1,97 @@
|
||||
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() {
|
||||
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 {
|
||||
IconAdjustmentsHorizontal,
|
||||
IconAlertTriangle,
|
||||
IconBooks,
|
||||
IconCalendarEvent,
|
||||
IconCategory,
|
||||
IconChecklist,
|
||||
IconShieldCheck,
|
||||
IconStarFilled,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Stack } from "expo-router";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Pressable, RefreshControl, View } from "react-native";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Pressable, RefreshControl, ScrollView, View, useWindowDimensions } from "react-native";
|
||||
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 { 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 { Text } from "@/components/ui/text";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { useTitleActions } from "@/hooks/use-title-actions";
|
||||
import {
|
||||
libraryActiveFilterCountAtom,
|
||||
librarySortByAtom,
|
||||
librarySortDirectionAtom,
|
||||
} from "@/lib/library-atoms";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
type SortBy = "added_at" | "title" | "release_date" | "user_rating" | "vote_average" | "popularity";
|
||||
type SortDirection = "asc" | "desc";
|
||||
// ─── Grid constants ─────────────────────────────────────────────────
|
||||
|
||||
const NUM_COLUMNS = 2;
|
||||
const HORIZONTAL_PADDING = 16;
|
||||
const NUM_COLUMNS = 3;
|
||||
const EDGE_PADDING = 16;
|
||||
const GAP = 8;
|
||||
|
||||
const gridContentContainerStyle = {
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 16,
|
||||
};
|
||||
// ─── Inline filter chip ─────────────────────────────────────────────
|
||||
|
||||
function Chip({
|
||||
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() {
|
||||
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("");
|
||||
const debouncedSearch = useDebounce(search.trim(), 300);
|
||||
// Filter state
|
||||
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>({});
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [sortBy, setSortBy] = useState<SortBy>("added_at");
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
||||
// Sort state from atoms (controlled by layout header)
|
||||
const [sortBy] = useAtom(librarySortByAtom);
|
||||
const [sortDirection] = useAtom(librarySortDirectionAtom);
|
||||
|
||||
const handleSortChange = useCallback((newSortBy: SortBy, newDirection: SortDirection) => {
|
||||
setSortBy(newSortBy);
|
||||
setSortDirection(newDirection);
|
||||
}, []);
|
||||
|
||||
const handleApplyFilters = useCallback((newFilters: LibraryFilters) => {
|
||||
setFilters(newFilters);
|
||||
}, []);
|
||||
// Modal state for dropdowns
|
||||
const [genreModalOpen, setGenreModalOpen] = useState(false);
|
||||
const [statusModalOpen, setStatusModalOpen] = useState(false);
|
||||
const [ratingModalOpen, setRatingModalOpen] = useState(false);
|
||||
const [yearModalOpen, setYearModalOpen] = useState(false);
|
||||
const [contentRatingModalOpen, setContentRatingModalOpen] = useState(false);
|
||||
|
||||
// Sync active filter count to atom for header badge
|
||||
const setActiveFilterCount = useSetAtom(libraryActiveFilterCountAtom);
|
||||
const activeFilterCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (filters.statuses && filters.statuses.length > 0) count++;
|
||||
if (filters.type) count++;
|
||||
if (filters.genreId !== undefined) count++;
|
||||
if (filters.ratingMin !== undefined || filters.ratingMax !== undefined) count++;
|
||||
if (filters.yearMin !== undefined || filters.yearMax !== undefined) count++;
|
||||
if (filters.contentRating) count++;
|
||||
if (filters.onMyServices) count++;
|
||||
if (statuses.length > 0) count++;
|
||||
if (type) count++;
|
||||
if (genreId !== undefined) count++;
|
||||
if (ratingMin !== undefined) count++;
|
||||
if (yearMin !== undefined || yearMax !== undefined) count++;
|
||||
if (contentRating) 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({
|
||||
...orpc.library.list.infiniteOptions({
|
||||
input: (pageParam: number) => ({
|
||||
search: debouncedSearch.length > 0 ? debouncedSearch : undefined,
|
||||
statuses:
|
||||
filters.statuses && filters.statuses.length > 0
|
||||
? (filters.statuses as ("in_watchlist" | "watching" | "caught_up" | "completed")[])
|
||||
statuses.length > 0
|
||||
? (statuses as ("in_watchlist" | "watching" | "caught_up" | "completed")[])
|
||||
: undefined,
|
||||
type: filters.type as "movie" | "tv" | undefined,
|
||||
genreId: filters.genreId,
|
||||
ratingMin: filters.ratingMin,
|
||||
ratingMax: filters.ratingMax,
|
||||
yearMin: filters.yearMin,
|
||||
yearMax: filters.yearMax,
|
||||
contentRating: filters.contentRating,
|
||||
onMyServices: filters.onMyServices,
|
||||
type,
|
||||
genreId,
|
||||
ratingMin,
|
||||
yearMin,
|
||||
yearMax,
|
||||
contentRating,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
page: pageParam,
|
||||
limit: 20,
|
||||
limit: 30,
|
||||
}),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
maxPages: 20,
|
||||
maxPages: 10,
|
||||
}),
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const { quickAdd } = useTitleActions();
|
||||
@@ -108,7 +256,6 @@ export default function LibraryScreen() {
|
||||
);
|
||||
|
||||
const isRefreshing = libraryQuery.isRefetching && !libraryQuery.isFetchingNextPage;
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: orpc.library.key() });
|
||||
}, []);
|
||||
@@ -117,7 +264,7 @@ export default function LibraryScreen() {
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: LibraryItem }) => (
|
||||
<View className="flex-1 px-1.5 pb-3">
|
||||
<View style={{ flex: 1, paddingHorizontal: GAP / 2, paddingBottom: GAP }}>
|
||||
<PosterCard
|
||||
id={item.id}
|
||||
title={item.title}
|
||||
@@ -127,56 +274,103 @@ export default function LibraryScreen() {
|
||||
releaseDate={item.releaseDate ?? item.firstAirDate}
|
||||
voteAverage={item.voteAverage}
|
||||
userStatus={item.userStatus}
|
||||
width={itemWidth}
|
||||
onQuickAdd={handleQuickAdd}
|
||||
isAdding={addingId === item.id}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[handleQuickAdd, addingId],
|
||||
[handleQuickAdd, addingId, itemWidth],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: LibraryItem) => item.id, []);
|
||||
|
||||
const filterButton = (
|
||||
<View className="flex-row items-center gap-4">
|
||||
<SortMenu sortBy={sortBy} sortDirection={sortDirection} onSortChange={handleSortChange} />
|
||||
<Pressable
|
||||
onPress={() => setFilterOpen(true)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t`Filters`}
|
||||
hitSlop={8}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<ScaledIcon icon={IconAdjustmentsHorizontal} size={22} color={foregroundColor} />
|
||||
{activeFilterCount > 0 && (
|
||||
<View className="bg-primary -mt-2 -ml-1.5 size-4 items-center justify-center rounded-full">
|
||||
<Text className="text-primary-foreground text-[10px] font-bold">
|
||||
{activeFilterCount}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
function clearAll() {
|
||||
setType(undefined);
|
||||
setStatuses([]);
|
||||
setGenreId(undefined);
|
||||
setRatingMin(undefined);
|
||||
setYearMin(undefined);
|
||||
setYearMax(undefined);
|
||||
setContentRating(undefined);
|
||||
}
|
||||
|
||||
function handleYearSelect(value: string) {
|
||||
if (!value) {
|
||||
setYearMin(undefined);
|
||||
setYearMax(undefined);
|
||||
} else if (value === "older") {
|
||||
setYearMin(undefined);
|
||||
setYearMax(1979);
|
||||
} else {
|
||||
const min = Number(value);
|
||||
setYearMin(min);
|
||||
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 (
|
||||
<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 ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Spinner colorClassName="accent-primary" />
|
||||
@@ -191,19 +385,14 @@ export default function LibraryScreen() {
|
||||
/>
|
||||
) : allItems.length === 0 ? (
|
||||
<Animated.View entering={FadeIn.duration(300)} className="flex-1">
|
||||
{debouncedSearch.length > 0 ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text className="text-muted-foreground text-base">
|
||||
<Trans>No results for "{debouncedSearch}"</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : activeFilterCount > 0 ? (
|
||||
{filterStrip}
|
||||
{activeFilterCount > 0 ? (
|
||||
<EmptyState
|
||||
icon={IconAdjustmentsHorizontal}
|
||||
title={t`No matching titles`}
|
||||
description={t`Try adjusting your filters`}
|
||||
actionLabel={t`Clear filters`}
|
||||
onAction={() => setFilters({})}
|
||||
onAction={clearAll}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
@@ -219,8 +408,12 @@ export default function LibraryScreen() {
|
||||
numColumns={NUM_COLUMNS}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
ListHeaderComponent={filterStrip}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={gridContentContainerStyle}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: EDGE_PADDING - GAP / 2,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />}
|
||||
onEndReached={() => {
|
||||
if (libraryQuery.hasNextPage && !libraryQuery.isFetchingNextPage) {
|
||||
@@ -238,11 +431,69 @@ export default function LibraryScreen() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<FilterSheet
|
||||
open={filterOpen}
|
||||
onOpenChange={setFilterOpen}
|
||||
filters={filters}
|
||||
onApply={handleApplyFilters}
|
||||
{/* SelectModals for dropdown chips */}
|
||||
<SelectModal
|
||||
label={t`Status`}
|
||||
icon={IconChecklist}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { NativeTabBar } from "@/components/navigation/native-tab-bar";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { authClient } from "@/lib/server";
|
||||
|
||||
export const unstable_settings = {
|
||||
initialRouteName: "(home)",
|
||||
};
|
||||
|
||||
export default function TabLayout() {
|
||||
const { data: session } = authClient.useSession();
|
||||
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} />;
|
||||
return <NativeTabBar />;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function UpcomingSection() {
|
||||
onSeeAll={() => push("/upcoming")}
|
||||
/>
|
||||
</View>
|
||||
<View className="gap-2 px-4">
|
||||
<View className="gap-1 px-4">
|
||||
{items.map((item, i) => (
|
||||
<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 { useCSSVariable } from "uniwind";
|
||||
|
||||
export function NativeTabBar({ showSettingsBadge }: { showSettingsBadge: boolean }) {
|
||||
export function NativeTabBar() {
|
||||
const { t } = useLingui();
|
||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") 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.Label>{t`Settings`}</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
|
||||
{showSettingsBadge ? <NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge> : null}
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="(search)" role="search" disableTransparentOnScrollEdge>
|
||||
<NativeTabs.Trigger.Label>{t`Search`}</NativeTabs.Trigger.Label>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { Text } from "@/components/ui/text";
|
||||
@@ -17,7 +18,7 @@ export interface SelectModalProps {
|
||||
/** Icon displayed in the modal title */
|
||||
icon?: Icon;
|
||||
/** Currently selected value */
|
||||
selection: string;
|
||||
selection: string | string[];
|
||||
/** Available options */
|
||||
options: SelectModalOption[];
|
||||
/** Whether the modal is open */
|
||||
@@ -26,6 +27,12 @@ export interface SelectModalProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Called when an option is selected */
|
||||
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({
|
||||
@@ -36,9 +43,18 @@ export function SelectModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
multiSelect,
|
||||
clearLabel,
|
||||
onClear,
|
||||
}: SelectModalProps) {
|
||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") 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 (
|
||||
<Modal
|
||||
@@ -49,36 +65,51 @@ export function SelectModal({
|
||||
>
|
||||
<Pressable
|
||||
className="flex-1 items-center justify-center bg-black/60"
|
||||
style={{ paddingTop: safeTop + 16, paddingBottom: safeBottom + 16 }}
|
||||
onPress={() => onOpenChange(false)}
|
||||
>
|
||||
<Pressable
|
||||
className="bg-card mx-8 w-full max-w-sm overflow-hidden rounded-2xl"
|
||||
style={{ maxHeight: maxCardHeight }}
|
||||
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} />}
|
||||
<Text className="text-foreground ml-1.5 text-base font-medium">{label}</Text>
|
||||
</View>
|
||||
{options.map((option) => {
|
||||
const isSelected = option.value === selection;
|
||||
return (
|
||||
<Text className="text-foreground ml-1.5 flex-1 text-base font-medium">{label}</Text>
|
||||
{clearLabel && onClear && (
|
||||
<Pressable
|
||||
key={option.value}
|
||||
className="active:bg-primary/5 flex-row items-center px-5 py-3.5"
|
||||
onPress={() => {
|
||||
onClear();
|
||||
onOpenChange(false);
|
||||
onSelect(option.value);
|
||||
}}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Text
|
||||
className={`flex-1 text-base ${isSelected ? "text-primary font-medium" : "text-foreground"}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
{isSelected && <IconCheck size={18} color={primaryColor} strokeWidth={2} />}
|
||||
<Text className="text-muted-foreground text-sm">{clearLabel}</Text>
|
||||
</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>
|
||||
</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