diff --git a/apps/native/src/app/(tabs)/(home)/upcoming.tsx b/apps/native/src/app/(tabs)/(home)/upcoming.tsx index 3152306..c13cea4 100644 --- a/apps/native/src/app/(tabs)/(home)/upcoming.tsx +++ b/apps/native/src/app/(tabs)/(home)/upcoming.tsx @@ -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 = ( + + setMediaType("all")} + /> + setMediaType("movie")} + /> + setMediaType("tv")} + /> + + setStatusFilter(statusFilter === "watching" ? "all" : "watching")} + /> + setStatusFilter(statusFilter === "watchlist" ? "all" : "watchlist")} + /> + + ); + return ( <> - - - setMediaType("all")} - /> - setMediaType("movie")} - /> - setMediaType("tv")} - /> - - - setStatusFilter("all")} - /> - setStatusFilter("watching")} - /> - setStatusFilter("watchlist")} - /> - - {!isPending && allItems.length === 0 ? ( - - - No upcoming episodes or releases in the next 90 days. - + {filterChips} + + + + No upcoming episodes or releases in the next 90 days. + + ) : ( `${item.titleId}-${item.date}-${i}`} + ListHeaderComponent={filterChips} renderItem={({ item }) => ( )} renderSectionHeader={({ section: { title } }) => ( - + {title} diff --git a/apps/native/src/app/(tabs)/(library)/_layout.tsx b/apps/native/src/app/(tabs)/(library)/_layout.tsx index 712fa85..db869f9 100644 --- a/apps/native/src/app/(tabs)/(library)/_layout.tsx +++ b/apps/native/src/app/(tabs)/(library)/_layout.tsx @@ -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 ( + + { + setSortBy(newSort as SortBy); + setSortDirection(newDir); + }} + /> + + + ); +} export default function LibraryLayout() { const { t } = useLingui(); - return ; + 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 ( + [ + { + type: "custom" as const, + element: , + hidesSharedBackground: true, + }, + ], + }} + > + + + } + largeStyle={headerLargeTitleStyle as Record} + > + {t`Library`} + + + + ); + } + + return ( + , + }} + > + + + }> + {t`Library`} + + + + ); } diff --git a/apps/native/src/app/(tabs)/(library)/index.tsx b/apps/native/src/app/(tabs)/(library)/index.tsx index 4b9450f..ffed188 100644 --- a/apps/native/src/app/(tabs)/(library)/index.tsx +++ b/apps/native/src/app/(tabs)/(library)/index.tsx @@ -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 ( + { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + onPress(); + }} + accessibilityRole="button" + accessibilityState={{ selected: isActive }} + className={`rounded-full px-3 py-1.5 ${isActive ? "bg-primary" : "bg-secondary"}`} + > + + {label} + + + ); +} + +function DropdownChip({ + label, + isActive, + onPress, +}: { + label: string; + isActive: boolean; + onPress: () => void; +}) { + return ( + { + 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"}`} + > + + {label} + + + {"\u25BE"} + + + ); +} + +// ─── 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([]); + const [genreId, setGenreId] = useState(undefined); + const [ratingMin, setRatingMin] = useState(undefined); + const [yearMin, setYearMin] = useState(undefined); + const [yearMax, setYearMax] = useState(undefined); + const [contentRating, setContentRating] = useState(undefined); - const [filters, setFilters] = useState({}); - const [filterOpen, setFilterOpen] = useState(false); - const [sortBy, setSortBy] = useState("added_at"); - const [sortDirection, setSortDirection] = useState("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 }) => ( - + ), - [handleQuickAdd, addingId], + [handleQuickAdd, addingId, itemWidth], ); const keyExtractor = useCallback((item: LibraryItem) => item.id, []); - const filterButton = ( - - - setFilterOpen(true)} - accessibilityRole="button" - accessibilityLabel={t`Filters`} - hitSlop={8} - > - - - {activeFilterCount > 0 && ( - - - {activeFilterCount} - - - )} - - - + 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 = ( + + {/* Type pills */} + setType(undefined)} /> + setType(type === "movie" ? undefined : "movie")} + /> + setType(type === "tv" ? undefined : "tv")} + /> + + + + {/* Dropdown chips */} + 0} + onPress={() => setStatusModalOpen(true)} + /> + setGenreModalOpen(true)} + /> + setRatingModalOpen(true)} + /> + setYearModalOpen(true)} + /> + setContentRatingModalOpen(true)} + /> + + {activeFilterCount > 0 && ( + + {t`Clear`} + + )} + ); + // ─── Render ─────────────────────────────────────────────────────── + return ( - filterButton, - }} - /> - 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 ? ( @@ -191,19 +385,14 @@ export default function LibraryScreen() { /> ) : allItems.length === 0 ? ( - {debouncedSearch.length > 0 ? ( - - - No results for "{debouncedSearch}" - - - ) : activeFilterCount > 0 ? ( + {filterStrip} + {activeFilterCount > 0 ? ( setFilters({})} + onAction={clearAll} /> ) : ( } onEndReached={() => { if (libraryQuery.hasNextPage && !libraryQuery.isFetchingNextPage) { @@ -238,11 +431,69 @@ export default function LibraryScreen() { /> )} - 0 ? t`Clear` : undefined} + onClear={() => setStatuses([])} + onSelect={(value) => + setStatuses((prev) => + prev.includes(value) ? prev.filter((s) => s !== value) : [...prev, value], + ) + } + /> + setGenreId(undefined)} + onSelect={(value) => setGenreId(value ? Number(value) : undefined)} + /> + setRatingMin(undefined)} + onSelect={(value) => setRatingMin(value ? Number(value) : undefined)} + /> + { + setYearMin(undefined); + setYearMax(undefined); + }} + onSelect={handleYearSelect} + /> + setContentRating(undefined)} + onSelect={(value) => setContentRating(value || undefined)} /> ); diff --git a/apps/native/src/app/(tabs)/_layout.tsx b/apps/native/src/app/(tabs)/_layout.tsx index ce4bc4d..cae62a8 100644 --- a/apps/native/src/app/(tabs)/_layout.tsx +++ b/apps/native/src/app/(tabs)/_layout.tsx @@ -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 ; + return ; } diff --git a/apps/native/src/components/dashboard/upcoming-section.tsx b/apps/native/src/components/dashboard/upcoming-section.tsx index 801aa39..3ddabcc 100644 --- a/apps/native/src/components/dashboard/upcoming-section.tsx +++ b/apps/native/src/components/dashboard/upcoming-section.tsx @@ -30,7 +30,7 @@ export function UpcomingSection() { onSeeAll={() => push("/upcoming")} /> - + {items.map((item, i) => ( ))} diff --git a/apps/native/src/components/navigation/native-tab-bar.tsx b/apps/native/src/components/navigation/native-tab-bar.tsx index 0f2ec3c..e8012d6 100644 --- a/apps/native/src/components/navigation/native-tab-bar.tsx +++ b/apps/native/src/components/navigation/native-tab-bar.tsx @@ -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 {t`Settings`} - {showSettingsBadge ? ! : null} {t`Search`} diff --git a/apps/native/src/components/ui/select-modal.tsx b/apps/native/src/components/ui/select-modal.tsx index 2884789..6934c08 100644 --- a/apps/native/src/components/ui/select-modal.tsx +++ b/apps/native/src/components/ui/select-modal.tsx @@ -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 ( onOpenChange(false)} > e.stopPropagation()} > - + {Icon && } - {label} - - {options.map((option) => { - const isSelected = option.value === selection; - return ( + {label} + {clearLabel && onClear && ( { + onClear(); onOpenChange(false); - onSelect(option.value); }} + hitSlop={8} > - - {option.label} - - {isSelected && } + {clearLabel} - ); - })} + )} + + + {options.map((option) => { + const selected = isSelected(option.value); + return ( + { + onSelect(option.value); + if (!multiSelect) onOpenChange(false); + }} + > + + {option.label} + + {selected && } + + ); + })} + diff --git a/apps/native/src/lib/library-atoms.ts b/apps/native/src/lib/library-atoms.ts new file mode 100644 index 0000000..f22ad2c --- /dev/null +++ b/apps/native/src/lib/library-atoms.ts @@ -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("added_at"); +export const librarySortDirectionAtom = atom<"asc" | "desc">("desc");