feat: add filtering and sorting across library, explore, and upcoming (#21)

* feat: add filtering and sorting across library, explore, and upcoming

Add comprehensive filtering and sorting to all list views on both web and mobile.

Library (new dedicated page):
- New /library route (web) with URL search param persistence
- New Library tab (5th tab, mobile) with FlashList grid
- Filters: status (multi-select), type, genre (library-only), user rating range,
  release year (decade presets + custom), content rating, available to stream
- Sort by: title, date added, release date, popularity, user rating, TMDB rating
- Text search within collection
- Filter popover (web) / bottom sheet with Apply (mobile)
- Remove old dashboard.library endpoint in favor of library.list

Explore/Discover:
- New Discover section on Explore page with inline filter controls
- TMDB filters: genre (now optional), year range, min rating, sort order,
  original language, streaming provider
- Watch provider list endpoint (WATCH_REGION env var, default "US")
- Default to popular content when no filters selected

Upcoming:
- Type filter (All/Movies/TV Shows) and status filter (All/Watching/Watchlist)
- Toggle chips on both web (URL params) and mobile
- Backend: mediaType and statusFilter params on upcoming endpoint

Navigation:
- Library added to web nav bar (desktop + mobile tab bar)
- Library added as 5th tab on mobile (between Home and Explore)
- Dashboard library section simplified to compact preview with "See all" link

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add user streaming platform subscriptions and redesign filter UI

Replace the denormalized availabilityOffers table with a normalized
platforms + titleAvailability + userPlatforms schema. Users can now
declare which streaming services they subscribe to, enabling
personalized "On your services" availability display and filtered
library/explore results.

Backend: new platforms table seeded from TMDB provider data on startup,
new API endpoints (platforms.list, account.platforms, account.updatePlatforms),
title detail annotates availability with isUserSubscribed flag, library
"on my services" filter checks user's subscribed platforms.

Web UI: post-registration onboarding page for platform selection,
streaming services settings section, title detail split into "On your
services" / "Also available on", explore dropdown uses platforms table
with active filter highlighting. Library filters redesigned from
cluttered popover to clean collapsible inline strip with consolidated
dropdowns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address race conditions, stale state, and edge cases in platforms feature

- Fix debounced autosave race in streaming services settings: use ref
  for latest selection + counter guard so only the most recent save
  updates UI. Clean up timers on unmount.
- Fix explore provider dropdown desyncing from query filter when
  switching Movie/TV type: reset selectedPlatformId alongside providerId.
- Fix platformIdsExist rejecting duplicate valid IDs by deduplicating
  input before comparing against DB results.
- Fix stale platform metadata: always upsert name/logo on TMDB refresh
  instead of skipping when platform already exists.
- Fix invisible ratingMax filter: clear ratingMax when user changes
  ratingMin since the UI only exposes a single rating dropdown now.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: clear stale saved-state timer and preserve falsy filter values

- Clear previous savedTimerRef timeout before scheduling a new one in
  streaming-services-section, preventing an older timer from hiding the
  "Saved" indicator too early on rapid successive saves
- Replace `value || undefined` with explicit empty-value check in
  library filter handler so numeric 0 (e.g. ratingMin=0) is not
  incorrectly dropped

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add TanStack Form and migrate auth + change password forms

Set up TanStack Form v1 composition layer with createFormHook, pre-bound
field components (TextField, TextareaField, CheckboxField, SwitchField,
SubmitButton), and migrate the two traditional submit-based forms:

- AuthForm: replace 4 useState calls with useAppForm, use form.Field
  for each input while preserving motion animation layout
- ChangePasswordDialog: replace 6 useState calls with useAppForm + Zod
  schema validation (cross-field password match, min length), per-field
  error display, form.reset() on dialog close

Also adds @tanstack/react-form to the monorepo catalog and updates both
web and native apps to reference it via catalog:.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 09:07:14 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent d1b0d952bd
commit 4fc6b2f8d1
76 changed files with 8918 additions and 942 deletions
+1 -1
View File
@@ -37,7 +37,7 @@
"@sofa/i18n": "workspace:*",
"@tabler/icons-react-native": "3.40.0",
"@tanstack/query-async-storage-persister": "5.95.2",
"@tanstack/react-form": "1.28.5",
"@tanstack/react-form": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-persist-client": "5.95.2",
"better-auth": "catalog:",
+7 -2
View File
@@ -63,7 +63,7 @@ export default function DashboardScreen() {
}),
);
const continueWatching = useQuery(orpc.dashboard.continueWatching.queryOptions());
const library = useQuery(orpc.dashboard.library.queryOptions({ input: {} }));
const library = useQuery(orpc.library.list.queryOptions({ input: { page: 1, limit: 10 } }));
const recommendations = useQuery(orpc.dashboard.recommendations.queryOptions());
const isRefreshing =
@@ -75,6 +75,7 @@ export default function DashboardScreen() {
const onRefresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
queryClient.invalidateQueries({ queryKey: orpc.library.key() });
}, []);
const hasLibrary = (library.data?.items?.length ?? 0) > 0;
@@ -188,7 +189,11 @@ export default function DashboardScreen() {
{/* Library */}
<Animated.View entering={FadeInDown.duration(300).delay(350)}>
<View className="px-4">
<SectionHeader title={t`In Your Library`} icon={IconBooks} />
<SectionHeader
title={t`In Your Library`}
icon={IconBooks}
onSeeAll={() => push("/(tabs)/(library)")}
/>
</View>
{library.isPending ? (
<HorizontalPosterRow items={[]} isLoading />
+72 -2
View File
@@ -2,8 +2,8 @@ import { Trans, useLingui } from "@lingui/react/macro";
import { IconCalendarEvent } from "@tabler/icons-react-native";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Stack } from "expo-router";
import { useCallback, useMemo } from "react";
import { RefreshControl, SectionList, View } from "react-native";
import { useCallback, useMemo, useState } from "react";
import { Pressable, RefreshControl, SectionList, View } from "react-native";
import Animated, { FadeInDown } from "react-native-reanimated";
import { useCSSVariable, useResolveClassNames } from "uniwind";
@@ -12,10 +12,39 @@ import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import * as Haptics from "@/utils/haptics";
import { groupByDateBucket } from "@sofa/i18n/date-buckets";
const contentContainerStyle = { paddingBottom: 24 };
function FilterChip({
label,
isSelected,
onPress,
}: {
label: string;
isSelected: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onPress();
}}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
className={`rounded-full px-3 py-1.5 ${isSelected ? "bg-primary" : "bg-secondary"}`}
>
<Text
className={`font-sans text-xs font-medium ${isSelected ? "text-primary-foreground" : "text-foreground"}`}
>
{label}
</Text>
</Pressable>
);
}
export default function UpcomingScreen() {
const { t } = useLingui();
const headerTitleStyle = useResolveClassNames("font-display text-foreground text-xl");
@@ -23,6 +52,9 @@ export default function UpcomingScreen() {
const backgroundColor = useCSSVariable("--color-background") as string;
const mutedColor = useCSSVariable("--color-muted-foreground") as string;
const [mediaType, setMediaType] = useState<"all" | "movie" | "tv">("all");
const [statusFilter, setStatusFilter] = useState<"all" | "watching" | "watchlist">("all");
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage, isRefetching } =
useInfiniteQuery(
orpc.dashboard.upcoming.infiniteOptions({
@@ -30,6 +62,8 @@ export default function UpcomingScreen() {
days: 90,
limit: 20,
cursor: pageParam,
mediaType: mediaType !== "all" ? mediaType : undefined,
statusFilter: statusFilter !== "all" ? [statusFilter] : undefined,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
@@ -73,6 +107,42 @@ 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)}
@@ -0,0 +1,8 @@
import { useLingui } from "@lingui/react/macro";
import { TabStack } from "@/components/navigation/tab-stack";
export default function LibraryLayout() {
const { t } = useLingui();
return <TabStack title={t`Library`} />;
}
@@ -0,0 +1,249 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { FlashList } from "@shopify/flash-list";
import {
IconAdjustmentsHorizontal,
IconAlertTriangle,
IconBooks,
} 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 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 { 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 { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
type SortBy = "added_at" | "title" | "release_date" | "user_rating" | "vote_average" | "popularity";
type SortDirection = "asc" | "desc";
const NUM_COLUMNS = 2;
const HORIZONTAL_PADDING = 16;
const gridContentContainerStyle = {
paddingHorizontal: HORIZONTAL_PADDING,
paddingTop: 8,
paddingBottom: 16,
};
export default function LibraryScreen() {
const { t } = useLingui();
const foregroundColor = useCSSVariable("--color-foreground") as string;
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search.trim(), 300);
const [filters, setFilters] = useState<LibraryFilters>({});
const [filterOpen, setFilterOpen] = useState(false);
const [sortBy, setSortBy] = useState<SortBy>("added_at");
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
const handleSortChange = useCallback((newSortBy: SortBy, newDirection: SortDirection) => {
setSortBy(newSortBy);
setSortDirection(newDirection);
}, []);
const handleApplyFilters = useCallback((newFilters: LibraryFilters) => {
setFilters(newFilters);
}, []);
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++;
return count;
}, [filters]);
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")[])
: 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,
sortBy,
sortDirection,
page: pageParam,
limit: 20,
}),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 20,
}),
});
const { quickAdd } = useTitleActions();
const handleQuickAdd = useCallback((id: string) => quickAdd.mutate({ id }), [quickAdd]);
const addingId = quickAdd.isPending ? (quickAdd.variables?.id ?? null) : null;
const allItems = useMemo(
() => libraryQuery.data?.pages.flatMap((page) => page.items) ?? [],
[libraryQuery.data?.pages],
);
const isRefreshing = libraryQuery.isRefetching && !libraryQuery.isFetchingNextPage;
const onRefresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: orpc.library.key() });
}, []);
type LibraryItem = (typeof allItems)[number];
const renderItem = useCallback(
({ item }: { item: LibraryItem }) => (
<View className="flex-1 px-1.5 pb-3">
<PosterCard
id={item.id}
title={item.title}
type={item.type as "movie" | "tv"}
posterPath={item.posterPath}
posterThumbHash={item.posterThumbHash}
releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage}
userStatus={item.userStatus}
onQuickAdd={handleQuickAdd}
isAdding={addingId === item.id}
/>
</View>
),
[handleQuickAdd, addingId],
);
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>
);
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" />
</View>
) : libraryQuery.isError ? (
<EmptyState
icon={IconAlertTriangle}
title={t`Something went wrong`}
description={t`Could not load your library`}
actionLabel={t`Retry`}
onAction={() => libraryQuery.refetch()}
/>
) : 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 ? (
<EmptyState
icon={IconAdjustmentsHorizontal}
title={t`No matching titles`}
description={t`Try adjusting your filters`}
actionLabel={t`Clear filters`}
onAction={() => setFilters({})}
/>
) : (
<EmptyState
icon={IconBooks}
title={t`Your library is empty`}
description={t`Start tracking movies and shows`}
/>
)}
</Animated.View>
) : (
<FlashList
data={allItems}
numColumns={NUM_COLUMNS}
keyExtractor={keyExtractor}
renderItem={renderItem}
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={gridContentContainerStyle}
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />}
onEndReached={() => {
if (libraryQuery.hasNextPage && !libraryQuery.isFetchingNextPage) {
libraryQuery.fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListFooterComponent={
libraryQuery.isFetchingNextPage ? (
<View className="items-center py-4">
<Spinner />
</View>
) : null
}
/>
)}
<FilterSheet
open={filterOpen}
onOpenChange={setFilterOpen}
filters={filters}
onApply={handleApplyFilters}
/>
</View>
);
}
+1 -1
View File
@@ -477,7 +477,7 @@ export default function TitleDetailScreen() {
contentContainerStyle={titleAvailabilityContentStyle}
>
{availability.map((offer) => (
<View key={`${offer.providerId}-${offer.offerType}`} className="items-center">
<View key={`${offer.platformId}-${offer.offerType}`} className="items-center">
{offer.logoPath && (
<Image
source={{ uri: offer.logoPath }}
@@ -0,0 +1,400 @@
import { useLingui } from "@lingui/react/macro";
import { IconAdjustmentsHorizontal, IconStarFilled, IconTag } from "@tabler/icons-react-native";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { Modal, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCSSVariable } from "uniwind";
import { Button, ButtonLabel } from "@/components/ui/button";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { SelectModal } from "@/components/ui/select-modal";
import { Switch } from "@/components/ui/switch";
import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc";
import * as Haptics from "@/utils/haptics";
export interface LibraryFilters {
statuses?: string[];
type?: string;
genreId?: number;
ratingMin?: number;
ratingMax?: number;
yearMin?: number;
yearMax?: number;
contentRating?: string;
onMyServices?: boolean;
}
interface FilterSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
filters: LibraryFilters;
onApply: (filters: LibraryFilters) => void;
}
const DECADES = [
{ label: "2020s", yearMin: 2020, yearMax: 2029 },
{ label: "2010s", yearMin: 2010, yearMax: 2019 },
{ label: "2000s", yearMin: 2000, yearMax: 2009 },
{ label: "90s", yearMin: 1990, yearMax: 1999 },
{ label: "80s", yearMin: 1980, yearMax: 1989 },
] as const;
const CONTENT_RATINGS = [
"G",
"PG",
"PG-13",
"R",
"NC-17",
"TV-Y",
"TV-Y7",
"TV-G",
"TV-PG",
"TV-14",
"TV-MA",
];
function Chip({
label,
isSelected,
onPress,
}: {
label: string;
isSelected: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onPress();
}}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
className={`rounded-full px-3 py-1.5 ${isSelected ? "bg-primary" : "bg-secondary"}`}
>
<Text
className={`font-sans text-xs font-medium ${isSelected ? "text-primary-foreground" : "text-foreground"}`}
>
{label}
</Text>
</Pressable>
);
}
function SectionLabel({ children }: { children: string }) {
return (
<Text className="text-muted-foreground mb-2 text-xs font-medium tracking-wider uppercase">
{children}
</Text>
);
}
function RatingStar({ filled, onPress }: { filled: boolean; onPress: () => void }) {
const primaryColor = useCSSVariable("--color-primary") as string;
const mutedColor = useCSSVariable("--color-muted-foreground") as string;
return (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onPress();
}}
hitSlop={4}
accessibilityRole="button"
>
<ScaledIcon icon={IconStarFilled} size={24} color={filled ? primaryColor : mutedColor} />
</Pressable>
);
}
export function FilterSheet({ open, onOpenChange, filters, onApply }: FilterSheetProps) {
const { t } = useLingui();
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const { bottom: safeBottom } = useSafeAreaInsets();
// Local draft state — reset from applied filters when sheet opens.
// Uses React's "adjusting state during render" pattern (react.dev/learn/
// you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)
// to avoid useEffect + setState cascade.
const [local, setLocal] = useState<LibraryFilters>(() => ({ ...filters }));
const [prevOpen, setPrevOpen] = useState(open);
if (open && !prevOpen) {
setLocal({ ...filters });
}
if (open !== prevOpen) {
setPrevOpen(open);
}
const handleClose = () => onOpenChange(false);
// Genre modal
const [genreModalOpen, setGenreModalOpen] = useState(false);
// Content rating modal
const [contentRatingModalOpen, setContentRatingModalOpen] = useState(false);
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 contentRatingOptions = useMemo(
() => [{ value: "", label: t`All` }, ...CONTENT_RATINGS.map((r) => ({ value: r, label: r }))],
[t],
);
const statuses = 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 types = useMemo(
() => [
{ value: undefined as string | undefined, label: t`All` },
{ value: "movie" as string | undefined, label: t`Movie` },
{ value: "tv" as string | undefined, label: t`TV` },
],
[t],
);
function toggleStatus(status: string) {
const current = local.statuses ?? [];
const next = current.includes(status)
? current.filter((s) => s !== status)
: [...current, status];
setLocal((prev) => ({ ...prev, statuses: next.length > 0 ? next : undefined }));
}
const activeDecade = DECADES.find(
(d) => local.yearMin === d.yearMin && local.yearMax === d.yearMax,
);
function selectDecade(decade: (typeof DECADES)[number]) {
if (activeDecade === decade) {
setLocal((prev) => ({ ...prev, yearMin: undefined, yearMax: undefined }));
} else {
setLocal((prev) => ({ ...prev, yearMin: decade.yearMin, yearMax: decade.yearMax }));
}
}
function clearAll() {
setLocal({});
}
function applyFilters() {
onApply(local);
onOpenChange(false);
}
const selectedGenreLabel =
genreData?.genres.find((g) => g.id === local.genreId)?.name ?? t`All genres`;
return (
<>
<Modal visible={open} transparent animationType="slide" onRequestClose={() => handleClose()}>
<Pressable className="flex-1 justify-end bg-black/60" onPress={() => handleClose()}>
<Pressable
className="bg-card max-h-[85%] rounded-t-2xl"
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View className="border-border/50 flex-row items-center border-b px-5 py-4">
<ScaledIcon icon={IconAdjustmentsHorizontal} size={20} color={mutedFgColor} />
<Text className="text-foreground ml-2 text-base font-medium">{t`Filters`}</Text>
</View>
<ScrollView className="px-5 py-4" showsVerticalScrollIndicator={false} bounces={false}>
<View className="gap-5 pb-4">
{/* Status */}
<View>
<SectionLabel>{t`Status`}</SectionLabel>
<View className="flex-row flex-wrap gap-2">
{statuses.map((s) => (
<Chip
key={s.value}
label={s.label}
isSelected={(local.statuses ?? []).includes(s.value)}
onPress={() => toggleStatus(s.value)}
/>
))}
</View>
</View>
{/* Type */}
<View>
<SectionLabel>{t`Type`}</SectionLabel>
<View className="flex-row flex-wrap gap-2">
{types.map((typ) => (
<Chip
key={typ.value ?? "all"}
label={typ.label}
isSelected={local.type === typ.value}
onPress={() => setLocal((prev) => ({ ...prev, type: typ.value }))}
/>
))}
</View>
</View>
{/* Genre */}
<View>
<SectionLabel>{t`Genre`}</SectionLabel>
<Pressable
onPress={() => setGenreModalOpen(true)}
className="bg-secondary flex-row items-center rounded-xl px-4 py-3"
>
<ScaledIcon icon={IconTag} size={16} color={mutedFgColor} />
<Text className="text-foreground ml-2 flex-1 text-sm">
{selectedGenreLabel}
</Text>
<Text className="text-muted-foreground text-xs">{"\u203A"}</Text>
</Pressable>
</View>
{/* Rating */}
<View>
<SectionLabel>{t`Rating`}</SectionLabel>
<View className="gap-2">
<View className="flex-row items-center gap-2">
<Text className="text-muted-foreground w-8 text-xs">{t`Min`}</Text>
<View className="flex-row gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<RatingStar
key={`min-${star}`}
filled={star <= (local.ratingMin ?? 0)}
onPress={() =>
setLocal((prev) => ({
...prev,
ratingMin: prev.ratingMin === star ? undefined : star,
}))
}
/>
))}
</View>
</View>
<View className="flex-row items-center gap-2">
<Text className="text-muted-foreground w-8 text-xs">{t`Max`}</Text>
<View className="flex-row gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<RatingStar
key={`max-${star}`}
filled={star <= (local.ratingMax ?? 0)}
onPress={() =>
setLocal((prev) => ({
...prev,
ratingMax: prev.ratingMax === star ? undefined : star,
}))
}
/>
))}
</View>
</View>
</View>
</View>
{/* Year */}
<View>
<SectionLabel>{t`Year`}</SectionLabel>
<View className="flex-row flex-wrap gap-2">
{DECADES.map((decade) => (
<Chip
key={decade.label}
label={decade.label}
isSelected={activeDecade === decade}
onPress={() => selectDecade(decade)}
/>
))}
</View>
</View>
{/* Content Rating */}
<View>
<SectionLabel>{t`Content Rating`}</SectionLabel>
<Pressable
onPress={() => setContentRatingModalOpen(true)}
className="bg-secondary flex-row items-center rounded-xl px-4 py-3"
>
<Text className="text-foreground flex-1 text-sm">
{local.contentRating ?? t`All`}
</Text>
<Text className="text-muted-foreground text-xs">{"\u203A"}</Text>
</Pressable>
</View>
{/* Streaming */}
<View>
<SectionLabel>{t`Streaming`}</SectionLabel>
<View className="flex-row items-center justify-between">
<Text className="text-foreground text-sm">{t`On my services`}</Text>
<Switch
value={local.onMyServices ?? false}
onValueChange={(checked) =>
setLocal((prev) => ({
...prev,
onMyServices: checked || undefined,
}))
}
accessibilityLabel={t`On my services`}
/>
</View>
</View>
</View>
</ScrollView>
{/* Bottom buttons */}
<View
className="border-border/50 flex-row gap-3 border-t px-5 py-4"
style={{ paddingBottom: Math.max(16, safeBottom) }}
>
<Button variant="secondary" onPress={clearAll} className="flex-1">
<ButtonLabel>{t`Clear`}</ButtonLabel>
</Button>
<Button onPress={applyFilters} className="flex-1">
<ButtonLabel>{t`Apply`}</ButtonLabel>
</Button>
</View>
</Pressable>
</Pressable>
</Modal>
<SelectModal
label={t`Genre`}
icon={IconTag}
selection={local.genreId !== undefined ? String(local.genreId) : ""}
options={genreOptions}
open={genreModalOpen}
onOpenChange={setGenreModalOpen}
onSelect={(value) =>
setLocal((prev) => ({
...prev,
genreId: value === "" ? undefined : Number(value),
}))
}
/>
<SelectModal
label={t`Content Rating`}
selection={local.contentRating ?? ""}
options={contentRatingOptions}
open={contentRatingModalOpen}
onOpenChange={setContentRatingModalOpen}
onSelect={(value) =>
setLocal((prev) => ({
...prev,
contentRating: value === "" ? undefined : value,
}))
}
/>
</>
);
}
@@ -0,0 +1,69 @@
import { useLingui } from "@lingui/react/macro";
import { IconArrowsSort } from "@tabler/icons-react-native";
import { Pressable } from "react-native";
import { useCSSVariable } from "uniwind";
import * as DropdownMenu from "zeego/dropdown-menu";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import * as Haptics from "@/utils/haptics";
type SortBy = "added_at" | "title" | "release_date" | "user_rating" | "vote_average" | "popularity";
type SortDirection = "asc" | "desc";
interface SortOption {
sortBy: SortBy;
sortDirection: SortDirection;
label: string;
}
interface SortMenuProps {
sortBy: SortBy;
sortDirection: SortDirection;
onSortChange: (sortBy: SortBy, sortDirection: SortDirection) => void;
}
export function SortMenu({ sortBy, sortDirection, onSortChange }: SortMenuProps) {
const { t } = useLingui();
const foregroundColor = useCSSVariable("--color-foreground") as string;
const sortOptions: SortOption[] = [
{ sortBy: "added_at", sortDirection: "desc", label: t`Date Added` },
{ sortBy: "title", sortDirection: "asc", label: t`Title A-Z` },
{ sortBy: "title", sortDirection: "desc", label: t`Title Z-A` },
{ sortBy: "release_date", sortDirection: "desc", label: t`Release Date` },
{ sortBy: "user_rating", sortDirection: "desc", label: t`User Rating` },
{ sortBy: "vote_average", sortDirection: "desc", label: t`TMDB Rating` },
{ sortBy: "popularity", sortDirection: "desc", label: t`Popularity` },
];
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Pressable
onPress={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)}
accessibilityRole="button"
accessibilityLabel={t`Sort`}
hitSlop={8}
>
<ScaledIcon icon={IconArrowsSort} size={22} color={foregroundColor} />
</Pressable>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{sortOptions.map((option) => {
const isSelected = option.sortBy === sortBy && option.sortDirection === sortDirection;
return (
<DropdownMenu.CheckboxItem
key={`${option.sortBy}-${option.sortDirection}`}
value={isSelected ? "on" : "off"}
onValueChange={() => onSortChange(option.sortBy, option.sortDirection)}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle>{option.label}</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
);
})}
</DropdownMenu.Content>
</DropdownMenu.Root>
);
}
@@ -28,6 +28,10 @@ export function NativeTabBar({ showSettingsBadge }: { showSettingsBadge: boolean
<NativeTabs.Trigger.Label>{t`Home`}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(library)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>{t`Library`}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="books.vertical" md="local_library" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(explore)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>{t`Explore`}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="safari" md="explore" />
+4
View File
@@ -9,6 +9,7 @@ import { registerJobScheduleProvider } from "@sofa/core/system-health";
import { closeDatabase, isDatabaseAccessBlocked } from "@sofa/db/client";
import { runMigrations } from "@sofa/db/migrate";
import { recoverStaleImportJobs } from "@sofa/db/queries/imports";
import { seedPlatforms } from "@sofa/db/seed-platforms";
import { createLogger } from "@sofa/logger";
import { getJobSchedules, startJobs, stopJobs } from "./cron";
@@ -36,6 +37,9 @@ await ensureBackupDir();
// Run database migrations
runMigrations();
// Seed streaming platforms (idempotent upsert)
seedPlatforms();
// Recover import jobs left in running/pending state from a previous crash
const recoveredJobs = recoverStaleImportJobs();
if (recoveredJobs > 0) {
@@ -3,6 +3,7 @@ import path from "node:path";
import { auth } from "@sofa/auth/server";
import { AVATAR_DIR } from "@sofa/config";
import { getUserPlatformIdList, updateUserPlatforms } from "@sofa/core/platforms";
import { os } from "../context";
import { authed } from "../middleware";
@@ -64,3 +65,13 @@ export const removeAvatar = os.account.removeAvatar.use(authed).handler(async ({
headers: context.headers,
});
});
export const platforms = os.account.platforms.use(authed).handler(async ({ context }) => {
return { platformIds: getUserPlatformIdList(context.user.id) };
});
export const updatePlatformsHandler = os.account.updatePlatforms
.use(authed)
.handler(async ({ input, context }) => {
updateUserPlatforms(context.user.id, input.platformIds);
});
+2 -28
View File
@@ -1,13 +1,11 @@
import {
getContinueWatchingFeed,
getLibraryFeed,
getRecommendationsFeed,
getUpcomingFeed,
getUserStats,
getWatchCount,
getWatchHistory,
} from "@sofa/core/discovery";
import { getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
@@ -43,32 +41,6 @@ export const continueWatching = os.dashboard.continueWatching.use(authed).handle
return { items };
});
export const library = os.dashboard.library.use(authed).handler(({ input, context }) => {
const {
items: feed,
page,
totalPages,
totalResults,
} = getLibraryFeed(context.user.id, input.page, input.limit);
const titleIds = feed.map((t) => t.titleId);
const displayStatuses = getDisplayStatusesByTitleIds(context.user.id, titleIds);
const items = feed.map((t) => ({
id: t.titleId,
tmdbId: t.tmdbId,
type: t.type,
title: t.title,
posterPath: tmdbImageUrl(t.posterPath, "posters"),
posterThumbHash: t.posterThumbHash ?? null,
releaseDate: t.releaseDate ?? null,
firstAirDate: t.firstAirDate ?? null,
voteAverage: t.voteAverage,
userStatus: displayStatuses[t.titleId] ?? null,
}));
return { items, page, totalPages, totalResults };
});
export const recommendations = os.dashboard.recommendations.use(authed).handler(({ context }) => {
const feed = getRecommendationsFeed(context.user.id);
const items = feed
@@ -93,6 +65,8 @@ export const upcoming = os.dashboard.upcoming.use(authed).handler(({ input, cont
days: input.days,
limit: input.limit,
cursor: input.cursor,
mediaType: input.mediaType,
statusFilter: input.statusFilter,
});
return {
items: result.items.map((item) => ({
+22 -9
View File
@@ -1,6 +1,7 @@
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { WATCH_REGION } from "@sofa/config";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
import { discover as discoverTmdb } from "@sofa/tmdb/client";
@@ -18,15 +19,27 @@ export const discover = os.discover.use(authed).handler(async ({ input, context
});
}
const results = await discoverTmdb(
input.type,
{
sort_by: "popularity.desc",
"vote_count.gte": "50",
with_genres: String(input.genreId),
},
input.page,
);
const params: Record<string, string> = {
sort_by: input.sortBy ?? "popularity.desc",
"vote_count.gte": "50",
};
if (input.genreId) params.with_genres = String(input.genreId);
if (input.yearMin) {
const key = input.type === "movie" ? "primary_release_date.gte" : "first_air_date.gte";
params[key] = `${input.yearMin}-01-01`;
}
if (input.yearMax) {
const key = input.type === "movie" ? "primary_release_date.lte" : "first_air_date.lte";
params[key] = `${input.yearMax}-12-31`;
}
if (input.ratingMin != null) params["vote_average.gte"] = String(input.ratingMin);
if (input.language) params.with_original_language = input.language;
if (input.providerId) {
params.with_watch_providers = String(input.providerId);
params.watch_region = WATCH_REGION;
}
const results = await discoverTmdb(input.type, params, input.page);
type DiscoverResult = NonNullable<typeof results.results>[number] & {
title?: string;
@@ -2,6 +2,7 @@ import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import { listPlatforms } from "@sofa/core/platforms";
import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client";
import { isTmdbConfigured } from "@sofa/tmdb/config";
@@ -161,3 +162,15 @@ export const genres = os.explore.genres.use(authed).handler(async ({ input }) =>
})),
};
});
export const watchProviders = os.explore.watchProviders.use(authed).handler(async () => {
const allPlatforms = listPlatforms();
return {
providers: allPlatforms.map((p) => ({
id: p.id,
tmdbProviderId: p.tmdbProviderId,
name: p.name,
logoPath: tmdbImageUrl(p.logoPath, "logos"),
})),
};
});
@@ -0,0 +1,47 @@
import { getFilteredLibraryFeed, getLibraryGenresList } from "@sofa/core/library";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
import { authed } from "../middleware";
export const list = os.library.list.use(authed).handler(({ input, context }) => {
const result = getFilteredLibraryFeed(context.user.id, {
search: input.search,
statuses: input.statuses,
type: input.type,
genreId: input.genreId,
ratingMin: input.ratingMin,
ratingMax: input.ratingMax,
yearMin: input.yearMin,
yearMax: input.yearMax,
contentRating: input.contentRating,
onMyServices: input.onMyServices,
sortBy: input.sortBy,
sortDirection: input.sortDirection,
page: input.page,
limit: input.limit,
});
return {
items: result.items.map((item) => ({
id: item.titleId,
tmdbId: item.tmdbId,
type: item.type,
title: item.title,
posterPath: tmdbImageUrl(item.posterPath, "posters"),
posterThumbHash: item.posterThumbHash ?? null,
releaseDate: item.releaseDate ?? null,
firstAirDate: item.firstAirDate ?? null,
voteAverage: item.voteAverage,
userStatus: item.userStatus,
userRating: item.userRating,
})),
page: result.page,
totalPages: result.totalPages,
totalResults: result.totalResults,
};
});
export const genres = os.library.genres.use(authed).handler(({ context }) => {
return { genres: getLibraryGenresList(context.user.id) };
});
@@ -0,0 +1,18 @@
import { listPlatforms } from "@sofa/core/platforms";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
import { authed } from "../middleware";
export const list = os.platforms.list.use(authed).handler(async () => {
const allPlatforms = listPlatforms();
return {
platforms: allPlatforms.map((p) => ({
id: p.id,
name: p.name,
tmdbProviderId: p.tmdbProviderId,
logoPath: tmdbImageUrl(p.logoPath, "logos"),
displayOrder: p.displayOrder,
})),
};
});
+2 -2
View File
@@ -20,8 +20,8 @@ import { authed } from "../middleware";
const log = createLogger("titles");
export const detail = os.titles.detail.use(authed).handler(async ({ input }) => {
const result = await getOrFetchTitle(input.id);
export const detail = os.titles.detail.use(authed).handler(async ({ input, context }) => {
const result = await getOrFetchTitle(input.id, context.user.id);
if (!result)
throw new ORPCError("NOT_FOUND", {
message: "Title not found",
+12 -1
View File
@@ -7,7 +7,9 @@ import * as episodes from "./procedures/episodes";
import * as explore from "./procedures/explore";
import * as imports from "./procedures/imports";
import * as integrations from "./procedures/integrations";
import * as library from "./procedures/library";
import * as people from "./procedures/people";
import * as platformProcs from "./procedures/platforms";
import { search } from "./procedures/search";
import * as seasons from "./procedures/seasons";
import * as status from "./procedures/status";
@@ -15,6 +17,10 @@ import * as system from "./procedures/system";
import * as titles from "./procedures/titles";
export const implementedRouter = {
library: {
list: library.list,
genres: library.genres,
},
titles: {
detail: titles.detail,
updateStatus: titles.updateStatus,
@@ -41,7 +47,6 @@ export const implementedRouter = {
stats: dashboard.stats,
continueWatching: dashboard.continueWatching,
upcoming: dashboard.upcoming,
library: dashboard.library,
recommendations: dashboard.recommendations,
watchHistory: dashboard.watchHistory,
},
@@ -49,6 +54,7 @@ export const implementedRouter = {
trending: explore.trending,
popular: explore.popular,
genres: explore.genres,
watchProviders: explore.watchProviders,
},
search,
discover,
@@ -87,6 +93,11 @@ export const implementedRouter = {
updateName: account.updateName,
uploadAvatar: account.uploadAvatar,
removeAvatar: account.removeAvatar,
platforms: account.platforms,
updatePlatforms: account.updatePlatformsHandler,
},
platforms: {
list: platformProcs.list,
},
imports: {
parseFile: imports.parseFile,
+2
View File
@@ -28,9 +28,11 @@
"@sofa/api": "workspace:*",
"@sofa/i18n": "workspace:*",
"@tabler/icons-react": "3.40.0",
"@tanstack/react-form": "catalog:",
"@tanstack/react-hotkeys": "0.5.1",
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "1.168.3",
"@tanstack/zod-adapter": "1.166.9",
"better-auth": "catalog:",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
+115 -91
View File
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient, signIn, signUp } from "@/lib/auth/client";
import { useAppForm } from "@/lib/form";
export interface AuthConfig {
oidcEnabled: boolean;
@@ -39,11 +40,7 @@ export function AuthForm({
}) {
const { t } = useLingui();
const navigate = useNavigate();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [oidcLoading, setOidcLoading] = useState(false);
const isRegister = mode === "register";
@@ -51,32 +48,34 @@ export function AuthForm({
const showPasswordForm = !(authConfig?.passwordLoginDisabled ?? false);
const oidcProviderName = authConfig?.oidcProviderName || "SSO";
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
if (isRegister) {
const result = await signUp.email({ name, email, password });
if (result.error) {
setError(result.error.message ?? t`Registration failed`);
return;
}
} else {
const result = await signIn.email({ email, password });
if (result.error) {
setError(result.error.message ?? t`Login failed`);
return;
const form = useAppForm({
defaultValues: { name: "", email: "", password: "" },
onSubmit: async ({ value }) => {
setError("");
try {
if (isRegister) {
const result = await signUp.email({
name: value.name,
email: value.email,
password: value.password,
});
if (result.error) {
setError(result.error.message ?? t`Registration failed`);
return;
}
} else {
const result = await signIn.email({ email: value.email, password: value.password });
if (result.error) {
setError(result.error.message ?? t`Login failed`);
return;
}
}
void navigate({ to: isRegister ? "/onboarding" : "/dashboard" });
} catch {
setError(t`Something went wrong`);
}
void navigate({ to: "/dashboard" });
} catch {
setError(t`Something went wrong`);
} finally {
setLoading(false);
}
}
},
});
async function handleOidcLogin() {
setError("");
@@ -160,7 +159,10 @@ export function AuthForm({
{showPasswordForm && (
<motion.form
onSubmit={handleSubmit}
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="space-y-4"
initial="hidden"
animate="visible"
@@ -170,72 +172,94 @@ export function AuthForm({
}}
>
{isRegister && (
<motion.div variants={fieldVariants} className="space-y-1.5">
<Label htmlFor="name" className="text-muted-foreground tracking-wider uppercase">
<Trans>Name</Trans>
</Label>
<Input
id="name"
type="text"
required
autoComplete="name"
value={name}
onChange={(e) => setName(e.target.value)}
className={authInputClass}
placeholder={t`Your name…`}
/>
</motion.div>
<form.Field name="name">
{(field) => (
<motion.div variants={fieldVariants} className="space-y-1.5">
<Label
htmlFor="name"
className="text-muted-foreground tracking-wider uppercase"
>
<Trans>Name</Trans>
</Label>
<Input
id="name"
type="text"
required
autoComplete="name"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
className={authInputClass}
placeholder={t`Your name…`}
/>
</motion.div>
)}
</form.Field>
)}
<motion.div variants={fieldVariants} className="space-y-1.5">
<Label htmlFor="email" className="text-muted-foreground tracking-wider uppercase">
<Trans>Email</Trans>
</Label>
<Input
id="email"
type="email"
required
autoComplete="email"
spellCheck={false}
value={email}
onChange={(e) => setEmail(e.target.value)}
className={authInputClass}
placeholder="wwhite@graymatter.biz"
/>
</motion.div>
<form.Field name="email">
{(field) => (
<motion.div variants={fieldVariants} className="space-y-1.5">
<Label htmlFor="email" className="text-muted-foreground tracking-wider uppercase">
<Trans>Email</Trans>
</Label>
<Input
id="email"
type="email"
required
autoComplete="email"
spellCheck={false}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
className={authInputClass}
placeholder="wwhite@graymatter.biz"
/>
</motion.div>
)}
</form.Field>
<motion.div variants={fieldVariants} className="space-y-1.5">
<Label htmlFor="password" className="text-muted-foreground tracking-wider uppercase">
<Trans>Password</Trans>
</Label>
<Input
id="password"
type="password"
required
minLength={8}
autoComplete={mode === "login" ? "current-password" : "new-password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
className={authInputClass}
placeholder={t`Min 8 characters…`}
/>
</motion.div>
<form.Field name="password">
{(field) => (
<motion.div variants={fieldVariants} className="space-y-1.5">
<Label
htmlFor="password"
className="text-muted-foreground tracking-wider uppercase"
>
<Trans>Password</Trans>
</Label>
<Input
id="password"
type="password"
required
minLength={8}
autoComplete={mode === "login" ? "current-password" : "new-password"}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
className={authInputClass}
placeholder={t`Min 8 characters…`}
/>
</motion.div>
)}
</form.Field>
<motion.div variants={fieldVariants}>
<Button
type="submit"
disabled={loading}
className="hover:shadow-primary/20 h-11 w-full rounded-lg text-sm hover:shadow-lg"
>
{loading ? (
<Trans>Loading</Trans>
) : isRegister ? (
<Trans>Create account</Trans>
) : (
<Trans>Sign in</Trans>
)}
</Button>
</motion.div>
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => (
<motion.div variants={fieldVariants}>
<Button
type="submit"
disabled={isSubmitting}
className="hover:shadow-primary/20 h-11 w-full rounded-lg text-sm hover:shadow-lg"
>
{isSubmitting ? (
<Trans>Loading</Trans>
) : isRegister ? (
<Trans>Create account</Trans>
) : (
<Trans>Sign in</Trans>
)}
</Button>
</motion.div>
)}
</form.Subscribe>
</motion.form>
)}
@@ -1,42 +1,31 @@
import { useLingui } from "@lingui/react/macro";
import { IconBooks } from "@tabler/icons-react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { orpc } from "@/lib/orpc/client";
import { FeedSection } from "./feed-section";
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
export function LibrarySection() {
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
orpc.dashboard.library.infiniteOptions({
input: (pageParam: number) => ({ page: pageParam }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 10,
}),
const { data, isPending } = useQuery(
orpc.library.list.queryOptions({ input: { page: 1, limit: 10 } }),
);
const { t } = useLingui();
const sentinelRef = useInfiniteScroll({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
});
if (isPending) return <TitleGridSectionSkeleton />;
const items = data?.pages.flatMap((p) => p.items) ?? [];
const items = data?.items ?? [];
if (items.length === 0) return null;
return (
<FeedSection title={t`In Your Library`} icon={<IconBooks className="text-primary size-5" />}>
<FeedSection
title={t`In Your Library`}
icon={<IconBooks className="text-primary size-5" />}
seeAllLink="/library"
>
<TitleGrid items={items} />
<div ref={sentinelRef} />
{isFetchingNextPage && <TitleGridSectionSkeleton />}
</FeedSection>
);
}
@@ -0,0 +1,403 @@
import { useLingui } from "@lingui/react/macro";
import { IconLoader, IconSearch } from "@tabler/icons-react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { FeedSection } from "@/components/dashboard/feed-section";
import { TitleGrid } from "@/components/dashboard/title-grid";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { orpc } from "@/lib/orpc/client";
const DECADE_PRESETS = [
{ label: "2020s", min: 2020, max: 2029 },
{ label: "2010s", min: 2010, max: 2019 },
{ label: "2000s", min: 2000, max: 2009 },
{ label: "1990s", min: 1990, max: 1999 },
{ label: "1980s", min: 1980, max: 1989 },
{ label: "1970s", min: 1970, max: 1979 },
{ label: "Pre-1970", min: 1900, max: 1969 },
] as const;
const RATING_PRESETS = [
{ label: "7+", value: 7 },
{ label: "6+", value: 6 },
{ label: "5+", value: 5 },
] as const;
const SORT_OPTIONS = [
{ value: "popularity.desc", labelKey: "Most popular" },
{ value: "vote_average.desc", labelKey: "Highest rated" },
{ value: "primary_release_date.desc", labelKey: "Newest" },
{ value: "primary_release_date.asc", labelKey: "Oldest" },
] as const;
const LANGUAGE_OPTIONS = [
{ code: "en", name: "English" },
{ code: "es", name: "Spanish" },
{ code: "fr", name: "French" },
{ code: "de", name: "German" },
{ code: "ja", name: "Japanese" },
{ code: "ko", name: "Korean" },
{ code: "zh", name: "Chinese" },
{ code: "hi", name: "Hindi" },
{ code: "it", name: "Italian" },
{ code: "pt", name: "Portuguese" },
] as const;
export function DiscoverSection() {
const { t } = useLingui();
const [type, setType] = useState<"movie" | "tv">("movie");
const [genreId, setGenreId] = useState<number | undefined>(undefined);
const [yearMin, setYearMin] = useState<number | undefined>(undefined);
const [yearMax, setYearMax] = useState<number | undefined>(undefined);
const [ratingMin, setRatingMin] = useState<number | undefined>(undefined);
type DiscoverSortBy =
| "popularity.desc"
| "vote_average.desc"
| "primary_release_date.desc"
| "primary_release_date.asc";
const [sortBy, setSortBy] = useState<DiscoverSortBy | undefined>(undefined);
const [language, setLanguage] = useState<string | undefined>(undefined);
const [providerId, setProviderId] = useState<number | undefined>(undefined);
const [selectedPlatformId, setSelectedPlatformId] = useState("");
const { data: genreData } = useQuery(orpc.explore.genres.queryOptions({ input: { type } }));
const { data: providerData } = useQuery(orpc.platforms.list.queryOptions());
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery(
orpc.discover.infiniteOptions({
input: (pageParam: number) => ({
type,
genreId,
yearMin,
yearMax,
ratingMin,
sortBy,
language,
providerId,
page: pageParam,
}),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 10,
}),
);
const sentinelRef = useInfiniteScroll({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
});
const items = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data?.pages]);
const genres = genreData?.genres ?? [];
const providers = providerData?.platforms ?? [];
const sortLabels: Record<string, string> = {
"popularity.desc": t`Most popular`,
"vote_average.desc": t`Highest rated`,
"primary_release_date.desc": t`Newest`,
"primary_release_date.asc": t`Oldest`,
};
const languageNames: Record<string, string> = {
en: t`English`,
es: t`Spanish`,
fr: t`French`,
de: t`German`,
ja: t`Japanese`,
ko: t`Korean`,
zh: t`Chinese`,
hi: t`Hindi`,
it: t`Italian`,
pt: t`Portuguese`,
};
function handleDecadeChange(value: string | null) {
if (!value) {
setYearMin(undefined);
setYearMax(undefined);
return;
}
const preset = DECADE_PRESETS.find((d) => String(d.min) === value);
if (preset) {
setYearMin(preset.min);
setYearMax(preset.max);
}
}
function handleRatingChange(value: string | null) {
if (!value) {
setRatingMin(undefined);
return;
}
setRatingMin(Number(value));
}
function handleSortChange(value: string | null) {
setSortBy((value || undefined) as DiscoverSortBy | undefined);
}
function handleLanguageChange(value: string | null) {
setLanguage(value || undefined);
}
function handleProviderChange(value: string | null) {
setSelectedPlatformId(value ?? "");
if (!value) {
setProviderId(undefined);
return;
}
// Resolve platform UUID to TMDB provider ID for the discover API
const platform = providers.find((p) => p.id === value);
setProviderId(platform?.tmdbProviderId ?? undefined);
}
function handleGenreChange(value: string | null) {
if (!value) {
setGenreId(undefined);
return;
}
setGenreId(Number(value));
}
return (
<FeedSection title={t`Discover`} icon={<IconSearch className="text-primary size-5" />}>
<div className="flex flex-wrap items-center gap-2">
{/* Type toggle: Movie | TV */}
<ToggleGroup
value={[type]}
onValueChange={(values) => {
const next = values.find((v) => v !== type);
if (next === "movie" || next === "tv") {
setType(next);
setGenreId(undefined);
setProviderId(undefined);
setSelectedPlatformId("");
}
}}
variant="outline"
size="sm"
>
<ToggleGroupItem value="movie">{t`Movie`}</ToggleGroupItem>
<ToggleGroupItem value="tv">{t`TV`}</ToggleGroupItem>
</ToggleGroup>
{/* Divider */}
<div className="bg-border/30 mx-0.5 hidden h-5 w-px sm:block" />
{/* Genre select */}
<Select
value={genreId != null ? String(genreId) : ""}
onValueChange={handleGenreChange}
modal={false}
aria-label={t`Genre`}
>
<SelectTrigger
size="sm"
data-active={genreId != null ? "" : undefined}
className="data-[active]:border-primary/40 data-[active]:text-foreground"
>
<SelectValue>
{(value: string | null) => {
if (!value) return t`Genre`;
const genre = genres.find((g) => String(g.id) === value);
return genre?.name ?? t`Genre`;
}}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false} className="p-1">
<SelectItem value="">{t`All genres`}</SelectItem>
{genres.map((genre) => (
<SelectItem key={genre.id} value={String(genre.id)}>
{genre.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Year select (decade presets) */}
<Select
value={yearMin != null ? String(yearMin) : ""}
onValueChange={handleDecadeChange}
modal={false}
aria-label={t`Decade`}
>
<SelectTrigger
size="sm"
data-active={yearMin != null ? "" : undefined}
className="data-[active]:border-primary/40 data-[active]:text-foreground"
>
<SelectValue>
{(value: string | null) => {
if (!value) return t`Year`;
const preset = DECADE_PRESETS.find((d) => String(d.min) === value);
if (preset?.min === 1900) return t`Pre-1970`;
return preset?.label ?? t`Year`;
}}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false} className="p-1">
<SelectItem value="">{t`Any year`}</SelectItem>
{DECADE_PRESETS.map((d) => (
<SelectItem key={d.min} value={String(d.min)}>
{d.min === 1900 ? t`Pre-1970` : d.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Rating select (minimum TMDB rating) */}
<Select
value={ratingMin != null ? String(ratingMin) : ""}
onValueChange={handleRatingChange}
modal={false}
aria-label={t`Rating`}
>
<SelectTrigger
size="sm"
data-active={ratingMin != null ? "" : undefined}
className="data-[active]:border-primary/40 data-[active]:text-foreground"
>
<SelectValue>
{(value: string | null) => {
if (!value) return t`Rating`;
return `${value}+`;
}}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false} className="p-1">
<SelectItem value="">{t`Any rating`}</SelectItem>
{RATING_PRESETS.map((r) => (
<SelectItem key={r.value} value={String(r.value)}>
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Sort select */}
<Select
value={sortBy ?? ""}
onValueChange={handleSortChange}
modal={false}
aria-label={t`Sort`}
>
<SelectTrigger
size="sm"
data-active={sortBy ? "" : undefined}
className="data-[active]:border-primary/40 data-[active]:text-foreground"
>
<SelectValue>
{(value: string | null) => {
if (!value) return t`Sort`;
return sortLabels[value] ?? t`Sort`;
}}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false} className="p-1">
<SelectItem value="">{t`Default`}</SelectItem>
{SORT_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{sortLabels[s.value]}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Language select */}
<Select
value={language ?? ""}
onValueChange={handleLanguageChange}
modal={false}
aria-label={t`Language`}
>
<SelectTrigger
size="sm"
data-active={language ? "" : undefined}
className="data-[active]:border-primary/40 data-[active]:text-foreground"
>
<SelectValue>
{(value: string | null) => {
if (!value) return t`Language`;
return languageNames[value] ?? t`Language`;
}}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false} className="p-1">
<SelectItem value="">{t`Any language`}</SelectItem>
{LANGUAGE_OPTIONS.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{languageNames[lang.code]}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Provider select */}
<Select
value={selectedPlatformId}
onValueChange={handleProviderChange}
modal={false}
aria-label={t`Provider`}
>
<SelectTrigger
size="sm"
data-active={selectedPlatformId ? "" : undefined}
className="data-[active]:border-primary/40 data-[active]:text-foreground"
>
<SelectValue>
{(value: string | null) => {
if (!value) return t`Provider`;
const platform = providers.find((p) => p.id === value);
return platform?.name ?? t`Provider`;
}}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false} className="p-1">
<SelectItem value="">{t`All providers`}</SelectItem>
{providers
.filter((p) => p.tmdbProviderId != null)
.map((platform) => (
<SelectItem key={platform.id} value={platform.id}>
{platform.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Results */}
{isPending ? (
<div className="flex items-center justify-center py-12">
<IconLoader className="text-muted-foreground size-6 animate-spin" />
</div>
) : items.length === 0 ? (
<p className="text-muted-foreground py-12 text-center text-sm">
{t`No titles found. Try adjusting your filters.`}
</p>
) : (
<>
<TitleGrid items={items} />
<div ref={sentinelRef} />
{isFetchingNextPage && (
<div className="flex items-center justify-center py-4">
<IconLoader className="text-muted-foreground size-5 animate-spin" />
</div>
)}
</>
)}
</FeedSection>
);
}
@@ -0,0 +1,327 @@
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { orpc } from "@/lib/orpc/client";
export interface LibraryFiltersProps {
filters: {
statuses?: string[];
type?: string;
genreId?: number;
ratingMin?: number;
ratingMax?: number;
yearMin?: number;
yearMax?: number;
contentRating?: string;
onMyServices?: boolean;
};
onFilterChange: (key: string, value: unknown) => void;
onClearAll: () => void;
activeFilterCount: number;
}
const DECADES = [
{ label: "2020s", yearMin: 2020, yearMax: 2029 },
{ label: "2010s", yearMin: 2010, yearMax: 2019 },
{ label: "2000s", yearMin: 2000, yearMax: 2009 },
{ label: "90s", yearMin: 1990, yearMax: 1999 },
{ label: "80s", yearMin: 1980, yearMax: 1989 },
] as const;
const CONTENT_RATINGS = [
"G",
"PG",
"PG-13",
"R",
"NC-17",
"TV-Y",
"TV-Y7",
"TV-G",
"TV-PG",
"TV-14",
"TV-MA",
];
function Divider() {
return <div className="bg-border/30 mx-1 hidden h-5 w-px sm:block" />;
}
export function LibraryFilters({
filters,
onFilterChange,
onClearAll,
activeFilterCount,
}: LibraryFiltersProps) {
const { t } = useLingui();
const { data: genreData } = useQuery(orpc.library.genres.queryOptions());
const statuses = 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 types = useMemo(
() => [
{ value: undefined, label: t`All` },
{ value: "movie", label: t`Movie` },
{ value: "tv", label: t`TV` },
],
[t],
);
function toggleStatus(status: string) {
const current = filters.statuses ?? [];
const next = current.includes(status)
? current.filter((s) => s !== status)
: [...current, status];
onFilterChange("statuses", next.length > 0 ? next : undefined);
}
// Status dropdown label
const statusLabel = useMemo(() => {
const selected = filters.statuses ?? [];
if (selected.length === 0) return t`Status`;
if (selected.length === 1) {
return statuses.find((s) => s.value === selected[0])?.label ?? t`Status`;
}
return plural(selected.length, { one: "# status", other: "# statuses" });
}, [filters.statuses, statuses, t]);
// Year dropdown value
const activeDecade = DECADES.find(
(d) => filters.yearMin === d.yearMin && filters.yearMax === d.yearMax,
);
const isOlderActive = filters.yearMax === 1979 && filters.yearMin === undefined;
const yearValue = activeDecade ? String(activeDecade.yearMin) : isOlderActive ? "older" : "";
function handleYearChange(value: string | null) {
if (!value) {
onFilterChange("yearMin", undefined);
onFilterChange("yearMax", undefined);
return;
}
if (value === "older") {
onFilterChange("yearMin", undefined);
onFilterChange("yearMax", 1979);
return;
}
const decade = DECADES.find((d) => String(d.yearMin) === value);
if (decade) {
onFilterChange("yearMin", decade.yearMin);
onFilterChange("yearMax", decade.yearMax);
}
}
return (
<div className="flex flex-wrap items-center gap-2 py-1">
{/* Type pills */}
{types.map((typ) => (
<Button
key={typ.value ?? "all"}
variant={filters.type === typ.value ? "default" : "outline"}
size="sm"
className="rounded-full"
onClick={() => onFilterChange("type", typ.value)}
>
{typ.label}
</Button>
))}
<Divider />
{/* Status — multi-select dropdown */}
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
size="sm"
data-active={(filters.statuses ?? []).length > 0 ? "" : undefined}
className="data-[active]:border-primary/40 data-[active]:text-foreground"
>
{statusLabel}
<IconChevronDown aria-hidden={true} className="text-muted-foreground size-3" />
</Button>
}
/>
<PopoverContent className="w-44 p-1">
{statuses.map((s) => {
const isActive = (filters.statuses ?? []).includes(s.value);
return (
<button
key={s.value}
type="button"
onClick={() => toggleStatus(s.value)}
className="hover:bg-muted/50 flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm"
>
<div
className={`flex size-3.5 items-center justify-center rounded border transition-colors ${
isActive ? "border-primary bg-primary text-primary-foreground" : "border-border"
}`}
>
{isActive && <IconCheck className="size-2.5" strokeWidth={3} />}
</div>
{s.label}
</button>
);
})}
</PopoverContent>
</Popover>
{/* Genre */}
<Select
value={filters.genreId !== undefined ? String(filters.genreId) : ""}
onValueChange={(v) => onFilterChange("genreId", v === "" ? undefined : Number(v))}
modal={false}
aria-label={t`Genre`}
>
<SelectTrigger
size="sm"
data-active={filters.genreId != null ? "" : undefined}
className="data-[active]:border-primary/40 data-[active]:text-foreground"
>
<SelectValue>
{(value: string | null) => {
if (!value) return t`Genre`;
const genre = genreData?.genres.find((g) => String(g.id) === value);
return genre?.name ?? t`Genre`;
}}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false} className="p-1">
<SelectItem value="">{t`All genres`}</SelectItem>
{genreData?.genres.map((genre) => (
<SelectItem key={genre.id} value={String(genre.id)}>
{genre.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Rating */}
<Select
value={filters.ratingMin !== undefined ? String(filters.ratingMin) : ""}
onValueChange={(v) => onFilterChange("ratingMin", v === "" ? undefined : Number(v))}
modal={false}
aria-label={t`Rating`}
>
<SelectTrigger
size="sm"
data-active={filters.ratingMin != null ? "" : undefined}
className="data-[active]:border-primary/40 data-[active]:text-foreground"
>
<SelectValue>
{(value: string | null) => {
if (!value) return t`Rating`;
return `${value}\u2605+`;
}}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false} className="p-1">
<SelectItem value="">{t`Any`}</SelectItem>
<SelectItem value="1">1+</SelectItem>
<SelectItem value="2">2+</SelectItem>
<SelectItem value="3">3+</SelectItem>
<SelectItem value="4">4+</SelectItem>
<SelectItem value="5">5</SelectItem>
</SelectContent>
</Select>
{/* Year */}
<Select value={yearValue} onValueChange={handleYearChange} modal={false} aria-label={t`Year`}>
<SelectTrigger
size="sm"
data-active={yearValue ? "" : undefined}
className="data-[active]:border-primary/40 data-[active]:text-foreground"
>
<SelectValue>
{(value: string | null) => {
if (!value) return t`Year`;
if (value === "older") return t`Pre-1980`;
const decade = DECADES.find((d) => String(d.yearMin) === value);
return decade?.label ?? t`Year`;
}}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false} className="p-1">
<SelectItem value="">{t`Any year`}</SelectItem>
{DECADES.map((d) => (
<SelectItem key={d.yearMin} value={String(d.yearMin)}>
{d.label}
</SelectItem>
))}
<SelectItem value="older">{t`Pre-1980`}</SelectItem>
</SelectContent>
</Select>
{/* Content Rating */}
<Select
value={filters.contentRating ?? ""}
onValueChange={(v) => onFilterChange("contentRating", v === "" ? undefined : v)}
modal={false}
aria-label={t`Content rating`}
>
<SelectTrigger
size="sm"
data-active={filters.contentRating ? "" : undefined}
className="data-[active]:border-primary/40 data-[active]:text-foreground"
>
<SelectValue>{(value: string | null) => (value ? value : t`Age`)}</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false} className="p-1">
<SelectItem value="">{t`All`}</SelectItem>
{CONTENT_RATINGS.map((rating) => (
<SelectItem key={rating} value={rating}>
{rating}
</SelectItem>
))}
</SelectContent>
</Select>
<Divider />
{/* On my services */}
<label className="flex cursor-pointer items-center gap-1.5">
<Switch
size="sm"
checked={filters.onMyServices ?? false}
onCheckedChange={(checked) => onFilterChange("onMyServices", checked || undefined)}
aria-label={t`On my services`}
/>
<span className="text-muted-foreground text-[11px]">{t`On my services`}</span>
</label>
{/* Clear */}
{activeFilterCount > 0 && (
<>
<div className="flex-1" />
<button
type="button"
onClick={onClearAll}
className="text-muted-foreground hover:text-foreground text-xs transition-colors"
>
{t`Clear`}
</button>
</>
)}
</div>
);
}
@@ -0,0 +1,143 @@
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { IconChevronDown, IconFilter, IconSearch, IconSortDescending } from "@tabler/icons-react";
import { useMemo, useState } from "react";
import { LibraryFilters, type LibraryFiltersProps } from "@/components/library/library-filters";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
export interface LibraryToolbarProps {
search: string;
onSearchChange: (value: string) => void;
filters: LibraryFiltersProps["filters"];
onFilterChange: (key: string, value: unknown) => void;
onClearAll: () => void;
sortBy: string;
sortDirection: string;
onSortChange: (sortBy: string, sortDirection: string) => void;
totalResults: number;
activeFilterCount: number;
}
export function LibraryToolbar({
search,
onSearchChange,
filters,
onFilterChange,
onClearAll,
sortBy,
sortDirection,
onSortChange,
totalResults,
activeFilterCount,
}: LibraryToolbarProps) {
const { t } = useLingui();
const [filtersOpen, setFiltersOpen] = useState(false);
const sortOptions = useMemo(
() => [
{ label: t`Date Added`, sortBy: "added_at", direction: "desc" },
{ label: t`Title A-Z`, sortBy: "title", direction: "asc" },
{ label: t`Title Z-A`, sortBy: "title", direction: "desc" },
{ label: t`Release Date`, sortBy: "release_date", direction: "desc" },
{ label: t`User Rating`, sortBy: "user_rating", direction: "desc" },
{ label: t`TMDB Rating`, sortBy: "vote_average", direction: "desc" },
{ label: t`Popularity`, sortBy: "popularity", direction: "desc" },
],
[t],
);
return (
<Collapsible open={filtersOpen} onOpenChange={setFiltersOpen}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1 sm:max-w-xs">
<IconSearch
aria-hidden={true}
className="text-muted-foreground pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2"
/>
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t`Search library...`}
className="pl-7"
aria-label={t`Search library`}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">
{plural(totalResults, { one: "# result", other: "# results" })}
</span>
{/* Filter toggle */}
<CollapsibleTrigger
render={
<Button variant="outline" size="sm">
<IconFilter aria-hidden={true} className="size-3.5" />
{t`Filters`}
{activeFilterCount > 0 && (
<Badge variant="default" className="ml-1 size-4 justify-center px-0">
{activeFilterCount}
</Badge>
)}
<IconChevronDown
aria-hidden={true}
className={`text-muted-foreground size-3 transition-transform duration-200 ${filtersOpen ? "rotate-180" : ""}`}
/>
</Button>
}
/>
{/* Sort dropdown */}
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm">
<IconSortDescending aria-hidden={true} className="size-3.5" />
{t`Sort`}
</Button>
}
/>
<DropdownMenuContent align="end">
{sortOptions.map((option) => {
const isActive = sortBy === option.sortBy && sortDirection === option.direction;
return (
<DropdownMenuItem
key={`${option.sortBy}-${option.direction}`}
className="cursor-pointer"
onClick={() => onSortChange(option.sortBy, option.direction)}
>
<span className={isActive ? "text-primary font-medium" : ""}>
{option.label}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Collapsible filter strip */}
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
<div className="border-border/15 border-b pt-3 pb-3">
<LibraryFilters
filters={filters}
onFilterChange={onFilterChange}
onClearAll={onClearAll}
activeFilterCount={activeFilterCount}
/>
</div>
</CollapsibleContent>
</Collapsible>
);
}
+3
View File
@@ -1,5 +1,6 @@
import { Trans, useLingui } from "@lingui/react/macro";
import {
IconBooks,
IconCalendarEvent,
IconCompass,
IconHome,
@@ -119,6 +120,7 @@ export function NavBar({
const navLinks = [
{ href: "/dashboard", label: t`Home` },
{ href: "/library", label: t`Library` },
{ href: "/explore", label: t`Explore` },
{ href: "/upcoming", label: t`Upcoming` },
] as const;
@@ -281,6 +283,7 @@ export function MobileTabBar() {
const mobileTabs = [
{ href: "/dashboard", label: t`Home`, icon: IconHome },
{ href: "/library", label: t`Library`, icon: IconBooks },
{ href: "/explore", label: t`Explore`, icon: IconCompass },
{ href: "/upcoming", label: t`Upcoming`, icon: IconCalendarEvent },
{ href: "/settings", label: t`Settings`, icon: IconSettings },
@@ -0,0 +1,75 @@
import { useLingui } from "@lingui/react/macro";
import { IconCheck } from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
import type { Platform } from "@sofa/api/schemas";
interface PlatformGridProps {
platforms: Platform[];
selectedIds: Set<string>;
onToggle: (id: string) => void;
}
export function PlatformGrid({ platforms, selectedIds, onToggle }: PlatformGridProps) {
const { t } = useLingui();
return (
<div className="grid grid-cols-3 gap-2.5 sm:grid-cols-4 md:grid-cols-5">
{platforms.map((platform) => {
const isSelected = selectedIds.has(platform.id);
return (
<button
key={platform.id}
type="button"
onClick={() => onToggle(platform.id)}
className={`group relative flex flex-col items-center gap-2 rounded-xl border p-3 motion-safe:transition-colors motion-safe:duration-150 ${
isSelected
? "border-primary/50 bg-primary/8"
: "border-border/20 hover:border-primary/30 hover:bg-primary/5"
}`}
aria-label={(() => {
const name = platform.name;
return t`Toggle ${name}`;
})()}
aria-pressed={isSelected}
>
{/* Check badge */}
<AnimatePresence>
{isSelected && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ type: "spring", stiffness: 400, damping: 20 }}
className="bg-primary text-primary-foreground absolute -top-1.5 -right-1.5 flex size-4 items-center justify-center rounded-full"
>
<IconCheck className="size-2.5" strokeWidth={3} />
</motion.div>
)}
</AnimatePresence>
{/* Logo */}
{platform.logoPath ? (
<img
src={platform.logoPath}
alt=""
loading="lazy"
decoding="async"
className="size-10 rounded-lg object-cover"
/>
) : (
<div className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg text-xs font-medium">
{platform.name.slice(0, 2)}
</div>
)}
{/* Name */}
<span className="text-muted-foreground line-clamp-1 w-full text-center text-[11px] leading-tight">
{platform.name}
</span>
</button>
);
})}
</div>
);
}
@@ -16,8 +16,9 @@ import {
import { useMutation } from "@tanstack/react-query";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -41,6 +42,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { authClient, signOut } from "@/lib/auth/client";
import { getErrorMessage } from "@/lib/error-messages";
import { useAppForm } from "@/lib/form";
import { client, orpc } from "@/lib/orpc/client";
import type { NormalizedImport } from "@sofa/api/schemas";
import { formatDate } from "@sofa/i18n/format";
@@ -788,60 +790,57 @@ function ImportOptionCheckbox({
function ChangePasswordDialog() {
const { t } = useLingui();
const [open, setOpen] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [revokeOtherSessions, setRevokeOtherSessions] = useState(false);
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
function resetForm() {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setRevokeOtherSessions(false);
setError("");
}
const changePasswordSchema = useMemo(
() =>
z
.object({
currentPassword: z.string().min(1, t`Current password is required`),
newPassword: z.string().min(8, t`New password must be at least 8 characters`),
confirmPassword: z.string().min(1, t`Please confirm your password`),
revokeOtherSessions: z.boolean(),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: t`Passwords do not match`,
path: ["confirmPassword"],
}),
[t],
);
const form = useAppForm({
defaultValues: {
currentPassword: "",
newPassword: "",
confirmPassword: "",
revokeOtherSessions: false,
},
validators: { onSubmit: changePasswordSchema },
onSubmit: async ({ value }) => {
setError("");
try {
const result = await authClient.changePassword({
currentPassword: value.currentPassword,
newPassword: value.newPassword,
revokeOtherSessions: value.revokeOtherSessions,
});
if (result.error) {
setError(t`Failed to change password`);
return;
}
toast.success(t`Password updated`);
handleOpenChange(false);
} catch {
setError(t`Something went wrong`);
}
},
});
function handleOpenChange(nextOpen: boolean) {
setOpen(nextOpen);
if (!nextOpen) resetForm();
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (!currentPassword) {
setError(t`Current password is required`);
return;
}
if (newPassword.length < 8) {
setError(t`New password must be at least 8 characters`);
return;
}
if (newPassword !== confirmPassword) {
setError(t`Passwords do not match`);
return;
}
setIsSubmitting(true);
try {
const result = await authClient.changePassword({
currentPassword,
newPassword,
revokeOtherSessions,
});
if (result.error) {
setError(t`Failed to change password`);
return;
}
toast.success(t`Password updated`);
handleOpenChange(false);
} catch {
setError(t`Something went wrong`);
} finally {
setIsSubmitting(false);
if (!nextOpen) {
form.reset();
setError("");
}
}
@@ -861,7 +860,13 @@ function ChangePasswordDialog() {
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-3">
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="grid gap-3"
>
{error && (
<Alert variant="destructive">
<IconAlertTriangle />
@@ -869,69 +874,114 @@ function ChangePasswordDialog() {
</Alert>
)}
<div className="grid gap-1.5">
<Label htmlFor="current-password">
<Trans>Current password</Trans>
</Label>
<Input
id="current-password"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
disabled={isSubmitting}
/>
</div>
<form.Field name="currentPassword">
{(field) => (
<div className="grid gap-1.5">
<Label htmlFor="current-password">
<Trans>Current password</Trans>
</Label>
<Input
id="current-password"
type="password"
autoComplete="current-password"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
aria-invalid={field.state.meta.errors.length > 0 || undefined}
/>
{field.state.meta.errors.length > 0 && (
<p className="text-destructive text-xs">
{field.state.meta.errors
.map((e) => (typeof e === "string" ? e : ""))
.join(", ")}
</p>
)}
</div>
)}
</form.Field>
<div className="grid gap-1.5">
<Label htmlFor="new-password">
<Trans>New password</Trans>
</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={isSubmitting}
/>
</div>
<form.Field name="newPassword">
{(field) => (
<div className="grid gap-1.5">
<Label htmlFor="new-password">
<Trans>New password</Trans>
</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
aria-invalid={field.state.meta.errors.length > 0 || undefined}
/>
{field.state.meta.errors.length > 0 && (
<p className="text-destructive text-xs">
{field.state.meta.errors
.map((e) => (typeof e === "string" ? e : ""))
.join(", ")}
</p>
)}
</div>
)}
</form.Field>
<div className="grid gap-1.5">
<Label htmlFor="confirm-password">
<Trans>Confirm new password</Trans>
</Label>
<Input
id="confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isSubmitting}
/>
</div>
<form.Field name="confirmPassword">
{(field) => (
<div className="grid gap-1.5">
<Label htmlFor="confirm-password">
<Trans>Confirm new password</Trans>
</Label>
<Input
id="confirm-password"
type="password"
autoComplete="new-password"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
aria-invalid={field.state.meta.errors.length > 0 || undefined}
/>
{field.state.meta.errors.length > 0 && (
<p className="text-destructive text-xs">
{field.state.meta.errors
.map((e) => (typeof e === "string" ? e : ""))
.join(", ")}
</p>
)}
</div>
)}
</form.Field>
<div className="flex items-center gap-2 pt-1">
<Checkbox
id="revoke-sessions"
checked={revokeOtherSessions}
onCheckedChange={(checked) => setRevokeOtherSessions(checked === true)}
disabled={isSubmitting}
/>
<Label htmlFor="revoke-sessions" className="cursor-pointer">
<Trans>Sign out of other sessions</Trans>
</Label>
</div>
<form.Field name="revokeOtherSessions">
{(field) => (
<div className="flex items-center gap-2 pt-1">
<Checkbox
id="revoke-sessions"
checked={field.state.value}
onCheckedChange={field.handleChange}
/>
<Label htmlFor="revoke-sessions" className="cursor-pointer">
<Trans>Sign out of other sessions</Trans>
</Label>
</div>
)}
</form.Field>
<DialogFooter className="pt-2">
<DialogClose render={<Button variant="outline" />}>
<Trans>Cancel</Trans>
</DialogClose>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Spinner className="size-3.5" />}
<Trans>Update password</Trans>
</Button>
</DialogFooter>
<form.Subscribe
selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
>
{({ canSubmit, isSubmitting }) => (
<DialogFooter className="pt-2">
<DialogClose render={<Button variant="outline" />}>
<Trans>Cancel</Trans>
</DialogClose>
<Button type="submit" disabled={!canSubmit || isSubmitting}>
{isSubmitting && <Spinner className="size-3.5" />}
<Trans>Update password</Trans>
</Button>
</DialogFooter>
)}
</form.Subscribe>
</form>
</DialogContent>
</Dialog>
@@ -0,0 +1,129 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { IconChevronDown, IconDeviceTv } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { PlatformGrid } from "@/components/platforms/platform-grid";
import { Card, CardContent, CardDescription, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { client, orpc } from "@/lib/orpc/client";
export function StreamingServicesSection() {
const { t } = useLingui();
const [open, setOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [saved, setSaved] = useState(false);
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const savedTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const selectedIdsRef = useRef<Set<string>>(selectedIds);
const saveCounterRef = useRef(0);
const initialized = useRef(false);
const platformsQuery = useQuery(orpc.platforms.list.queryOptions());
const userPlatformsQuery = useQuery(orpc.account.platforms.queryOptions());
// Initialize selected IDs from server data
useEffect(() => {
if (userPlatformsQuery.data && !initialized.current) {
const ids = new Set(userPlatformsQuery.data.platformIds);
setSelectedIds(ids);
selectedIdsRef.current = ids;
initialized.current = true;
}
}, [userPlatformsQuery.data]);
// Cleanup timers on unmount
useEffect(() => {
return () => {
clearTimeout(saveTimerRef.current);
clearTimeout(savedTimerRef.current);
};
}, []);
const save = useCallback(() => {
clearTimeout(saveTimerRef.current);
const counter = ++saveCounterRef.current;
saveTimerRef.current = setTimeout(async () => {
const ids = selectedIdsRef.current;
await client.account.updatePlatforms({ platformIds: [...ids] });
// Only show "Saved" if no newer save was triggered
if (saveCounterRef.current !== counter) return;
setSaved(true);
clearTimeout(savedTimerRef.current);
savedTimerRef.current = setTimeout(setSaved, 1500, false);
}, 500);
}, []);
const handleToggle = useCallback(
(id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
selectedIdsRef.current = next;
save();
return next;
});
},
[save],
);
const platforms = platformsQuery.data?.platforms ?? [];
const selectedCount = selectedIds.size;
return (
<Card>
<Collapsible open={open} onOpenChange={setOpen}>
<CardContent className={open ? "pb-4" : ""}>
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="bg-primary/10 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<IconDeviceTv className="text-primary size-4" />
</div>
<div className="text-start">
<CardTitle>
<Trans>Streaming Services</Trans>
</CardTitle>
<CardDescription>
{selectedCount > 0 ? (
t`${selectedCount} selected`
) : (
<Trans>Choose your subscriptions</Trans>
)}
</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
<AnimatePresence>
{saved && (
<motion.span
initial={{ opacity: 0, x: 4 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 4 }}
className="text-primary text-xs"
>
<Trans>Saved</Trans>
</motion.span>
)}
</AnimatePresence>
<IconChevronDown
aria-hidden={true}
className={`text-muted-foreground size-4 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
/>
</div>
</CollapsibleTrigger>
</CardContent>
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
<CardContent className="border-border/30 border-t pt-4">
<PlatformGrid platforms={platforms} selectedIds={selectedIds} onToggle={handleToggle} />
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
@@ -93,7 +93,7 @@ function OverflowBadge({ offers }: { offers: AvailabilityOffer[] }) {
{offers.map((offer) =>
offer.watchUrl ? (
<a
key={offer.providerId}
key={offer.platformId}
href={offer.watchUrl}
target="_blank"
rel="noopener noreferrer"
@@ -103,7 +103,7 @@ function OverflowBadge({ offers }: { offers: AvailabilityOffer[] }) {
<span className="text-popover-foreground truncate text-xs">{offer.providerName}</span>
</a>
) : (
<div key={offer.providerId} className="flex items-center gap-2.5 px-2 py-1.5">
<div key={offer.platformId} className="flex items-center gap-2.5 px-2 py-1.5">
<OverflowProviderIcon offer={offer} />
<span className="text-popover-foreground truncate text-xs">{offer.providerName}</span>
</div>
@@ -114,6 +114,48 @@ function OverflowBadge({ offers }: { offers: AvailabilityOffer[] }) {
);
}
function OffersByType({
offers,
offerLabels,
}: {
offers: AvailabilityOffer[];
offerLabels: Record<string, string>;
}) {
const byType: Record<string, AvailabilityOffer[]> = {};
for (const offer of offers) {
if (!byType[offer.offerType]) byType[offer.offerType] = [];
byType[offer.offerType].push(offer);
}
return (
<div className="flex flex-wrap gap-4">
{Object.entries(byType).map(([type, typeOffers]) => {
const visible = typeOffers.slice(0, MAX_VISIBLE);
const overflow = typeOffers.slice(MAX_VISIBLE);
return (
<div key={type} className="space-y-1.5">
<span className="text-muted-foreground/60 text-[10px] font-medium tracking-wider uppercase">
{offerLabels[type] ?? type}
</span>
<div className="flex gap-1.5">
{visible.map((offer) => (
<ProviderBadge
key={offer.platformId}
name={offer.providerName}
logoPath={offer.logoPath}
watchUrl={offer.watchUrl}
/>
))}
{overflow.length > 0 && <OverflowBadge offers={overflow} />}
</div>
</div>
);
})}
</div>
);
}
export function TitleAvailability({ availability }: { availability: AvailabilityOffer[] }) {
const { t } = useLingui();
const offerLabels: Record<string, string> = {
@@ -123,44 +165,44 @@ export function TitleAvailability({ availability }: { availability: Availability
free: t`Free`,
ads: t`With Ads`,
};
const availByType: Record<string, AvailabilityOffer[]> = {};
for (const offer of availability) {
if (!availByType[offer.offerType]) availByType[offer.offerType] = [];
availByType[offer.offerType].push(offer);
if (availability.length === 0) return null;
const userOffers = availability.filter((o) => o.isUserSubscribed);
const otherOffers = availability.filter((o) => !o.isUserSubscribed);
// If user has matching subscriptions, show split view
if (userOffers.length > 0) {
return (
<div className="space-y-4 pt-1">
<div className="space-y-2">
<h2 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
<Trans>On your services</Trans>
</h2>
<OffersByType offers={userOffers} offerLabels={offerLabels} />
</div>
{otherOffers.length > 0 && (
<div className="border-border/30 space-y-2 border-t pt-3">
<h2 className="text-muted-foreground/60 text-[10px] font-medium tracking-wider uppercase">
<Trans>Also available on</Trans>
</h2>
<div className="opacity-60">
<OffersByType offers={otherOffers} offerLabels={offerLabels} />
</div>
</div>
)}
</div>
);
}
if (Object.keys(availByType).length === 0) return null;
// Default: single "Where to Watch" section
return (
<div className="space-y-2 pt-1">
<h2 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
<Trans>Where to Watch</Trans>
</h2>
<div className="flex flex-wrap gap-4">
{Object.entries(availByType).map(([type, offers]) => {
const visible = offers.slice(0, MAX_VISIBLE);
const overflow = offers.slice(MAX_VISIBLE);
return (
<div key={type} className="space-y-1.5">
<span className="text-muted-foreground/60 text-[10px] font-medium tracking-wider uppercase">
{offerLabels[type] ?? type}
</span>
<div className="flex gap-1.5">
{visible.map((offer) => (
<ProviderBadge
key={offer.providerId}
name={offer.providerName}
logoPath={offer.logoPath}
watchUrl={offer.watchUrl}
/>
))}
{overflow.length > 0 && <OverflowBadge offers={overflow} />}
</div>
</div>
);
})}
</div>
<OffersByType offers={availability} offerLabels={offerLabels} />
</div>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { createFormHookContexts } from "@tanstack/react-form";
export const { fieldContext, formContext, useFieldContext, useFormContext } =
createFormHookContexts();
+143
View File
@@ -0,0 +1,143 @@
import type { ReactNode } from "react";
import { useId } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useFieldContext, useFormContext } from "./context";
function normalizeErrors(errors: unknown[]): Array<{ message?: string }> {
return errors.map((e) => (typeof e === "string" ? { message: e } : (e as { message?: string })));
}
function TextField({
label,
description,
className,
...inputProps
}: {
label: ReactNode;
description?: ReactNode;
} & Omit<React.ComponentProps<"input">, "value" | "onChange" | "onBlur">) {
const field = useFieldContext<string>();
const id = useId();
const hasErrors = field.state.meta.errors.length > 0;
return (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
{description && <FieldDescription>{description}</FieldDescription>}
<Input
id={id}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
aria-invalid={hasErrors || undefined}
className={className}
{...inputProps}
/>
<FieldError errors={normalizeErrors(field.state.meta.errors)} />
</Field>
);
}
function TextareaField({
label,
description,
className,
...textareaProps
}: {
label: ReactNode;
description?: ReactNode;
} & Omit<React.ComponentProps<"textarea">, "value" | "onChange" | "onBlur">) {
const field = useFieldContext<string>();
const id = useId();
const hasErrors = field.state.meta.errors.length > 0;
return (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
{description && <FieldDescription>{description}</FieldDescription>}
<Textarea
id={id}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
aria-invalid={hasErrors || undefined}
className={className}
{...textareaProps}
/>
<FieldError errors={normalizeErrors(field.state.meta.errors)} />
</Field>
);
}
function CheckboxField({ label, description }: { label: ReactNode; description?: ReactNode }) {
const field = useFieldContext<boolean>();
const id = useId();
return (
<Field orientation="horizontal">
<Checkbox id={id} checked={field.state.value} onCheckedChange={field.handleChange} />
<FieldLabel htmlFor={id}>{label}</FieldLabel>
{description && <FieldDescription>{description}</FieldDescription>}
</Field>
);
}
function SwitchField({
label,
description,
size,
}: {
label: ReactNode;
description?: ReactNode;
size?: "sm" | "default";
}) {
const field = useFieldContext<boolean>();
const id = useId();
return (
<Field orientation="horizontal">
<Switch
id={id}
checked={field.state.value}
onCheckedChange={field.handleChange}
size={size}
/>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
{description && <FieldDescription>{description}</FieldDescription>}
</Field>
);
}
function SubmitButton({
children,
loadingChildren,
...buttonProps
}: {
children: ReactNode;
loadingChildren?: ReactNode;
} & Omit<React.ComponentProps<typeof Button>, "type" | "disabled">) {
const form = useFormContext();
return (
<form.Subscribe
selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
>
{({ canSubmit, isSubmitting }) => (
<Button type="submit" disabled={!canSubmit || isSubmitting} {...buttonProps}>
{isSubmitting && <Spinner className="size-3.5" />}
{isSubmitting ? (loadingChildren ?? children) : children}
</Button>
)}
</form.Subscribe>
);
}
export { CheckboxField, SwitchField, SubmitButton, TextField, TextareaField };
+20
View File
@@ -0,0 +1,20 @@
import { createFormHook } from "@tanstack/react-form";
import { fieldContext, formContext } from "./context";
import { CheckboxField, SubmitButton, SwitchField, TextareaField, TextField } from "./fields";
const { useAppForm, withForm } = createFormHook({
fieldComponents: {
TextField,
TextareaField,
CheckboxField,
SwitchField,
},
formComponents: {
SubmitButton,
},
fieldContext,
formContext,
});
export { useAppForm, withForm };
+42
View File
@@ -17,6 +17,8 @@ import { Route as AuthRegisterRouteImport } from './routes/_auth/register'
import { Route as AuthLoginRouteImport } from './routes/_auth/login'
import { Route as AppUpcomingRouteImport } from './routes/_app/upcoming'
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
import { Route as AppOnboardingRouteImport } from './routes/_app/onboarding'
import { Route as AppLibraryRouteImport } from './routes/_app/library'
import { Route as AppExploreRouteImport } from './routes/_app/explore'
import { Route as AppDashboardRouteImport } from './routes/_app/dashboard'
import { Route as AppTitlesIdRouteImport } from './routes/_app/titles.$id'
@@ -60,6 +62,16 @@ const AppSettingsRoute = AppSettingsRouteImport.update({
path: '/settings',
getParentRoute: () => AppRoute,
} as any)
const AppOnboardingRoute = AppOnboardingRouteImport.update({
id: '/onboarding',
path: '/onboarding',
getParentRoute: () => AppRoute,
} as any)
const AppLibraryRoute = AppLibraryRouteImport.update({
id: '/library',
path: '/library',
getParentRoute: () => AppRoute,
} as any)
const AppExploreRoute = AppExploreRouteImport.update({
id: '/explore',
path: '/explore',
@@ -86,6 +98,8 @@ export interface FileRoutesByFullPath {
'/setup': typeof SetupRoute
'/dashboard': typeof AppDashboardRoute
'/explore': typeof AppExploreRoute
'/library': typeof AppLibraryRoute
'/onboarding': typeof AppOnboardingRoute
'/settings': typeof AppSettingsRoute
'/upcoming': typeof AppUpcomingRoute
'/login': typeof AuthLoginRoute
@@ -98,6 +112,8 @@ export interface FileRoutesByTo {
'/setup': typeof SetupRoute
'/dashboard': typeof AppDashboardRoute
'/explore': typeof AppExploreRoute
'/library': typeof AppLibraryRoute
'/onboarding': typeof AppOnboardingRoute
'/settings': typeof AppSettingsRoute
'/upcoming': typeof AppUpcomingRoute
'/login': typeof AuthLoginRoute
@@ -113,6 +129,8 @@ export interface FileRoutesById {
'/setup': typeof SetupRoute
'/_app/dashboard': typeof AppDashboardRoute
'/_app/explore': typeof AppExploreRoute
'/_app/library': typeof AppLibraryRoute
'/_app/onboarding': typeof AppOnboardingRoute
'/_app/settings': typeof AppSettingsRoute
'/_app/upcoming': typeof AppUpcomingRoute
'/_auth/login': typeof AuthLoginRoute
@@ -127,6 +145,8 @@ export interface FileRouteTypes {
| '/setup'
| '/dashboard'
| '/explore'
| '/library'
| '/onboarding'
| '/settings'
| '/upcoming'
| '/login'
@@ -139,6 +159,8 @@ export interface FileRouteTypes {
| '/setup'
| '/dashboard'
| '/explore'
| '/library'
| '/onboarding'
| '/settings'
| '/upcoming'
| '/login'
@@ -153,6 +175,8 @@ export interface FileRouteTypes {
| '/setup'
| '/_app/dashboard'
| '/_app/explore'
| '/_app/library'
| '/_app/onboarding'
| '/_app/settings'
| '/_app/upcoming'
| '/_auth/login'
@@ -226,6 +250,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppSettingsRouteImport
parentRoute: typeof AppRoute
}
'/_app/onboarding': {
id: '/_app/onboarding'
path: '/onboarding'
fullPath: '/onboarding'
preLoaderRoute: typeof AppOnboardingRouteImport
parentRoute: typeof AppRoute
}
'/_app/library': {
id: '/_app/library'
path: '/library'
fullPath: '/library'
preLoaderRoute: typeof AppLibraryRouteImport
parentRoute: typeof AppRoute
}
'/_app/explore': {
id: '/_app/explore'
path: '/explore'
@@ -260,6 +298,8 @@ declare module '@tanstack/react-router' {
interface AppRouteChildren {
AppDashboardRoute: typeof AppDashboardRoute
AppExploreRoute: typeof AppExploreRoute
AppLibraryRoute: typeof AppLibraryRoute
AppOnboardingRoute: typeof AppOnboardingRoute
AppSettingsRoute: typeof AppSettingsRoute
AppUpcomingRoute: typeof AppUpcomingRoute
AppPeopleIdRoute: typeof AppPeopleIdRoute
@@ -269,6 +309,8 @@ interface AppRouteChildren {
const AppRouteChildren: AppRouteChildren = {
AppDashboardRoute: AppDashboardRoute,
AppExploreRoute: AppExploreRoute,
AppLibraryRoute: AppLibraryRoute,
AppOnboardingRoute: AppOnboardingRoute,
AppSettingsRoute: AppSettingsRoute,
AppUpcomingRoute: AppUpcomingRoute,
AppPeopleIdRoute: AppPeopleIdRoute,
+2 -8
View File
@@ -23,14 +23,8 @@ export const Route = createFileRoute("/_app/dashboard")({
context.queryClient.ensureQueryData(
orpc.dashboard.upcoming.queryOptions({ input: { days: 7, limit: 5 } }),
),
context.queryClient.ensureInfiniteQueryData(
orpc.dashboard.library.infiniteOptions({
input: (pageParam: number) => ({ page: pageParam }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 10,
}),
context.queryClient.ensureQueryData(
orpc.library.list.queryOptions({ input: { page: 1, limit: 10 } }),
),
]);
},
+3
View File
@@ -4,6 +4,7 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { useMemo } from "react";
import { DiscoverSection } from "@/components/explore/discover-section";
import { FilterableTitleRow } from "@/components/explore/filterable-title-row";
import { HeroBanner } from "@/components/explore/hero-banner";
import { TitleRow } from "@/components/explore/title-row";
@@ -170,6 +171,8 @@ function ExplorePage() {
userStatuses={userStatuses}
episodeProgress={episodeProgress}
/>
<DiscoverSection />
</div>
);
}
+242
View File
@@ -0,0 +1,242 @@
import { useLingui } from "@lingui/react/macro";
import { IconBooks, IconFilterOff } from "@tabler/icons-react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { z } from "zod";
import { TitleGrid, TitleGridSectionSkeleton } from "@/components/dashboard/title-grid";
import { LibraryToolbar } from "@/components/library/library-toolbar";
import { RouteError } from "@/components/route-error";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { orpc } from "@/lib/orpc/client";
const librarySearchSchema = z.object({
search: z.string().optional().catch(undefined),
statuses: z.array(z.string()).optional().catch(undefined),
type: z.enum(["movie", "tv"]).optional().catch(undefined),
genreId: z.number().optional().catch(undefined),
ratingMin: z.number().optional().catch(undefined),
ratingMax: z.number().optional().catch(undefined),
yearMin: z.number().optional().catch(undefined),
yearMax: z.number().optional().catch(undefined),
contentRating: z.string().optional().catch(undefined),
onMyServices: z.boolean().optional().catch(undefined),
sortBy: z.string().optional().catch(undefined),
sortDirection: z.enum(["asc", "desc"]).optional().catch(undefined),
});
type LibrarySearch = z.infer<typeof librarySearchSchema>;
export const Route = createFileRoute("/_app/library")({
validateSearch: zodValidator(librarySearchSchema),
staleTime: 30_000,
loader: async ({ context }) => {
await Promise.all([
context.queryClient.ensureInfiniteQueryData(
orpc.library.list.infiniteOptions({
input: (pageParam: number) => ({ page: pageParam }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 10,
}),
),
context.queryClient.ensureQueryData(orpc.library.genres.queryOptions()),
]);
},
head: () => ({ meta: [{ title: "Library — Sofa" }] }),
pendingComponent: LibrarySkeleton,
errorComponent: RouteError,
component: LibraryPage,
});
function LibrarySkeleton() {
return (
<div className="space-y-6">
<TitleGridSectionSkeleton />
<TitleGridSectionSkeleton />
</div>
);
}
function LibraryPage() {
const { t } = useLingui();
const search = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
// Debounced search — update URL 300ms after the user stops typing
const [prevSearchParam, setPrevSearchParam] = useState(search.search);
const [localSearch, setLocalSearch] = useState(search.search ?? "");
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
// Sync localSearch when URL params change externally (back/forward navigation)
if (prevSearchParam !== search.search) {
setPrevSearchParam(search.search);
setLocalSearch(search.search ?? "");
}
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
const updateSearch = useCallback(
(updates: Partial<LibrarySearch>) => {
void navigate({
search: (prev) => {
const next = { ...prev, ...updates };
// Remove undefined/empty values
for (const [key, value] of Object.entries(next)) {
if (
value === undefined ||
value === "" ||
(Array.isArray(value) && value.length === 0)
) {
delete (next as Record<string, unknown>)[key];
}
}
return next;
},
replace: true,
});
},
[navigate],
);
const handleFilterChange = useCallback(
(key: string, value: unknown) => {
const updates: Partial<LibrarySearch> = {
[key]: value === "" || value === false ? undefined : value,
};
// Clear ratingMax when ratingMin changes (UI only exposes ratingMin now)
if (key === "ratingMin") updates.ratingMax = undefined;
updateSearch(updates);
},
[updateSearch],
);
const handleClearAll = useCallback(() => {
setLocalSearch("");
void navigate({ search: {}, replace: true });
}, [navigate]);
const handleSortChange = useCallback(
(sortBy: string, sortDirection: string) => {
updateSearch({ sortBy, sortDirection: sortDirection as "asc" | "desc" });
},
[updateSearch],
);
// Build input from search params
const queryInput = useMemo(() => {
const input: Record<string, unknown> = {};
if (search.search) input.search = search.search;
if (search.statuses?.length) input.statuses = search.statuses;
if (search.type) input.type = search.type;
if (search.genreId) input.genreId = search.genreId;
if (search.ratingMin) input.ratingMin = search.ratingMin;
if (search.ratingMax) input.ratingMax = search.ratingMax;
if (search.yearMin) input.yearMin = search.yearMin;
if (search.yearMax) input.yearMax = search.yearMax;
if (search.contentRating) input.contentRating = search.contentRating;
if (search.onMyServices) input.onMyServices = true;
if (search.sortBy) input.sortBy = search.sortBy;
if (search.sortDirection) input.sortDirection = search.sortDirection;
return input;
}, [search]);
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
orpc.library.list.infiniteOptions({
input: (pageParam: number) => ({ ...queryInput, page: pageParam }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 10,
}),
);
const sentinelRef = useInfiniteScroll({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
});
const items = data?.pages.flatMap((p) => p.items) ?? [];
const totalResults = data?.pages[0]?.totalResults ?? 0;
// Count active filters (excluding sort)
const activeFilterCount = [
search.statuses?.length ? 1 : 0,
search.type ? 1 : 0,
search.genreId ? 1 : 0,
search.ratingMin || search.ratingMax ? 1 : 0,
search.yearMin || search.yearMax ? 1 : 0,
search.contentRating ? 1 : 0,
search.onMyServices ? 1 : 0,
].reduce((a, b) => a + b, 0);
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<IconBooks aria-hidden={true} className="text-primary size-5" />
<h1 className="font-display text-xl tracking-tight">{t`Library`}</h1>
</div>
<LibraryToolbar
search={localSearch}
onSearchChange={(value: string) => {
setLocalSearch(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
updateSearch({ search: value || undefined });
}, 300);
}}
filters={{
statuses: search.statuses,
type: search.type,
genreId: search.genreId,
ratingMin: search.ratingMin,
ratingMax: search.ratingMax,
yearMin: search.yearMin,
yearMax: search.yearMax,
contentRating: search.contentRating,
onMyServices: search.onMyServices,
}}
onFilterChange={handleFilterChange}
onClearAll={handleClearAll}
sortBy={search.sortBy ?? "added_at"}
sortDirection={search.sortDirection ?? "desc"}
onSortChange={handleSortChange}
totalResults={totalResults}
activeFilterCount={activeFilterCount}
/>
{isPending ? (
<TitleGridSectionSkeleton />
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
<IconFilterOff className="text-muted-foreground/40 size-12" />
<p className="text-muted-foreground text-lg">{t`No titles match your filters`}</p>
{activeFilterCount > 0 && (
<button
type="button"
onClick={handleClearAll}
className="text-primary text-sm hover:underline"
>
{t`Clear all filters`}
</button>
)}
</div>
) : (
<>
<TitleGrid items={items} />
<div ref={sentinelRef} />
{isFetchingNextPage && <TitleGridSectionSkeleton />}
</>
)}
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { Trans } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { motion } from "motion/react";
import { useCallback, useState } from "react";
import { PlatformGrid } from "@/components/platforms/platform-grid";
import { RouteError } from "@/components/route-error";
import { Button } from "@/components/ui/button";
import { client, orpc } from "@/lib/orpc/client";
export const Route = createFileRoute("/_app/onboarding")({
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(orpc.platforms.list.queryOptions());
},
head: () => ({ meta: [{ title: "Get Started — Sofa" }] }),
errorComponent: RouteError,
component: OnboardingPage,
});
function OnboardingPage() {
const navigate = useNavigate();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [saving, setSaving] = useState(false);
const platformsQuery = useQuery(orpc.platforms.list.queryOptions());
const platforms = platformsQuery.data?.platforms ?? [];
const handleToggle = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
async function handleContinue() {
setSaving(true);
try {
if (selectedIds.size > 0) {
await client.account.updatePlatforms({ platformIds: [...selectedIds] });
}
void navigate({ to: "/dashboard" });
} finally {
setSaving(false);
}
}
function handleSkip() {
void navigate({ to: "/dashboard" });
}
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center px-4 py-12">
<motion.div
className="w-full max-w-lg space-y-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 24 }}
>
<div className="space-y-2 text-center">
<h1 className="font-display text-2xl font-medium text-balance">
<Trans>What do you watch on?</Trans>
</h1>
<p className="text-muted-foreground text-sm text-balance">
<Trans>Select your streaming services to personalize your experience</Trans>
</p>
</div>
<PlatformGrid platforms={platforms} selectedIds={selectedIds} onToggle={handleToggle} />
<div className="flex flex-col items-center gap-3">
<Button onClick={handleContinue} disabled={saving} className="w-full max-w-xs">
{saving ? <Trans>Saving</Trans> : <Trans>Continue</Trans>}
</Button>
<button
type="button"
onClick={handleSkip}
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
<Trans>Skip for now</Trans>
</button>
</div>
</motion.div>
</div>
);
}
+4
View File
@@ -19,6 +19,7 @@ import { IntegrationsSection } from "@/components/settings/integrations-section"
import { LanguageSection } from "@/components/settings/language-section";
import { RegistrationSection } from "@/components/settings/registration-section";
import { SettingsShell } from "@/components/settings/settings-shell";
import { StreamingServicesSection } from "@/components/settings/streaming-services-section";
import { SystemHealthCards } from "@/components/settings/system-health-section";
import { UpdateCheckSection } from "@/components/settings/update-check-section";
import { TmdbLogo } from "@/components/tmdb-logo";
@@ -34,6 +35,8 @@ export const Route = createFileRoute("/_app/settings")({
const promises: Promise<unknown>[] = [
context.queryClient.ensureQueryData(orpc.integrations.list.queryOptions()),
context.queryClient.ensureQueryData(orpc.system.status.queryOptions()),
context.queryClient.ensureQueryData(orpc.platforms.list.queryOptions()),
context.queryClient.ensureQueryData(orpc.account.platforms.queryOptions()),
];
const isAdmin = context.session.user.role === "admin";
if (isAdmin) {
@@ -140,6 +143,7 @@ function SettingsPage() {
</h2>
</div>
<LanguageSection />
<StreamingServicesSection />
</div>
<IntegrationsSection />
+74 -2
View File
@@ -1,16 +1,25 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { IconCalendarEvent } from "@tabler/icons-react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { UpcomingRow } from "@/components/dashboard/upcoming-item";
import { RouteError } from "@/components/route-error";
import { Skeleton } from "@/components/ui/skeleton";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { orpc } from "@/lib/orpc/client";
import { groupByDateBucket } from "@sofa/i18n/date-buckets";
const upcomingSearchSchema = z.object({
type: z.enum(["all", "movie", "tv"]).optional().catch(undefined),
status: z.enum(["all", "watching", "watchlist"]).optional().catch(undefined),
});
export const Route = createFileRoute("/_app/upcoming")({
validateSearch: zodValidator(upcomingSearchSchema),
staleTime: 30_000,
loader: async ({ context }) => {
await context.queryClient.ensureInfiniteQueryData(
@@ -53,12 +62,22 @@ function UpcomingSkeleton() {
}
function UpcomingPage() {
const { t } = useLingui();
const search = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const typeFilter = search.type ?? "all";
const statusFilter = search.status ?? "all";
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
orpc.dashboard.upcoming.infiniteOptions({
input: (pageParam: string | undefined) => ({
days: 90,
limit: 20,
cursor: pageParam,
mediaType: typeFilter !== "all" ? (typeFilter as "movie" | "tv") : undefined,
statusFilter:
statusFilter !== "all" ? [statusFilter as "watching" | "watchlist"] : undefined,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
@@ -72,14 +91,66 @@ function UpcomingPage() {
isFetchingNextPage,
});
function setTypeFilter(value: string) {
void navigate({
search: (prev) => ({
...prev,
type: value === "all" ? undefined : (value as "movie" | "tv"),
}),
replace: true,
});
}
function setStatusFilter(value: string) {
void navigate({
search: (prev) => ({
...prev,
status: value === "all" ? undefined : (value as "watching" | "watchlist"),
}),
replace: true,
});
}
const filterToggles = (
<div className="flex flex-wrap gap-3">
<ToggleGroup
value={[typeFilter]}
onValueChange={(values) => {
const next = values.find((v) => v !== typeFilter) ?? "all";
setTypeFilter(next);
}}
variant="outline"
size="sm"
>
<ToggleGroupItem value="all">{t`All`}</ToggleGroupItem>
<ToggleGroupItem value="movie">{t`Movies`}</ToggleGroupItem>
<ToggleGroupItem value="tv">{t`TV Shows`}</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup
value={[statusFilter]}
onValueChange={(values) => {
const next = values.find((v) => v !== statusFilter) ?? "all";
setStatusFilter(next);
}}
variant="outline"
size="sm"
>
<ToggleGroupItem value="all">{t`All`}</ToggleGroupItem>
<ToggleGroupItem value="watching">{t`Watching`}</ToggleGroupItem>
<ToggleGroupItem value="watchlist">{t`Watchlist`}</ToggleGroupItem>
</ToggleGroup>
</div>
);
if (isPending) return <UpcomingSkeleton />;
const allItems = data?.pages.flatMap((p) => p.items) ?? [];
if (allItems.length === 0) {
return (
<div className="space-y-6">
<div className="mx-auto max-w-2xl space-y-6">
<UpcomingHeader />
{filterToggles}
<div className="flex flex-col items-center justify-center py-20 text-center">
<IconCalendarEvent className="text-muted-foreground/40 size-12" />
<p className="text-muted-foreground mt-4 text-sm">
@@ -95,6 +166,7 @@ function UpcomingPage() {
return (
<div className="mx-auto max-w-2xl space-y-6">
<UpcomingHeader />
{filterToggles}
{buckets.map((bucket) => (
<section key={bucket.key}>
<h2 className="font-display text-muted-foreground mb-2 text-sm font-medium tracking-wider uppercase">