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");