From 4fc6b2f8d1171b5e393777853e52275922582039 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Tue, 24 Mar 2026 09:07:14 -0400 Subject: [PATCH] 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- AGENTS.md | 2 +- apps/native/package.json | 2 +- apps/native/src/app/(tabs)/(home)/index.tsx | 9 +- .../native/src/app/(tabs)/(home)/upcoming.tsx | 74 +- .../src/app/(tabs)/(library)/_layout.tsx | 8 + .../native/src/app/(tabs)/(library)/index.tsx | 249 ++ apps/native/src/app/title/[id].tsx | 2 +- .../src/components/library/filter-sheet.tsx | 400 ++ .../src/components/library/sort-menu.tsx | 69 + .../components/navigation/native-tab-bar.tsx | 4 + apps/server/src/index.ts | 4 + apps/server/src/orpc/procedures/account.ts | 11 + apps/server/src/orpc/procedures/dashboard.ts | 30 +- apps/server/src/orpc/procedures/discover.ts | 31 +- apps/server/src/orpc/procedures/explore.ts | 13 + apps/server/src/orpc/procedures/library.ts | 47 + apps/server/src/orpc/procedures/platforms.ts | 18 + apps/server/src/orpc/procedures/titles.ts | 4 +- apps/server/src/orpc/router.ts | 13 +- apps/web/package.json | 2 + apps/web/src/components/auth-form.tsx | 206 +- .../components/dashboard/library-section.tsx | 29 +- .../components/explore/discover-section.tsx | 403 ++ .../components/library/library-filters.tsx | 327 ++ .../components/library/library-toolbar.tsx | 143 + apps/web/src/components/nav-bar.tsx | 3 + .../components/platforms/platform-grid.tsx | 75 + .../components/settings/account-section.tsx | 270 +- .../settings/streaming-services-section.tsx | 129 + .../components/titles/title-availability.tsx | 108 +- apps/web/src/lib/form/context.tsx | 4 + apps/web/src/lib/form/fields.tsx | 143 + apps/web/src/lib/form/index.tsx | 20 + apps/web/src/routeTree.gen.ts | 42 + apps/web/src/routes/_app/dashboard.tsx | 10 +- apps/web/src/routes/_app/explore.tsx | 3 + apps/web/src/routes/_app/library.tsx | 242 ++ apps/web/src/routes/_app/onboarding.tsx | 91 + apps/web/src/routes/_app/settings.tsx | 4 + apps/web/src/routes/_app/upcoming.tsx | 76 +- bun.lock | 133 +- .../docs/api/dashboard/dashboard.library.mdx | 19 - .../api/explore/explore.watchProviders.mdx | 19 + .../docs/api/library/library.genres.mdx | 19 + .../content/docs/api/library/library.list.mdx | 20 + docs/public/openapi.json | 870 ++++- package.json | 1 + packages/api/src/contract.ts | 94 +- packages/api/src/schemas.ts | 117 +- packages/config/src/index.ts | 4 + packages/core/package.json | 2 + packages/core/src/availability.ts | 22 +- packages/core/src/discovery.ts | 28 +- packages/core/src/library.ts | 39 + packages/core/src/metadata.ts | 23 +- packages/core/src/platforms.ts | 31 + packages/core/src/providers.ts | 119 +- packages/core/test/availability.test.ts | 18 +- packages/core/test/discovery.test.ts | 93 +- packages/core/test/library.test.ts | 145 + packages/core/test/upcoming.test.ts | 13 +- .../migration.sql | 35 + .../snapshot.json | 3377 +++++++++++++++++ packages/db/package.json | 3 +- packages/db/src/queries/availability.ts | 58 +- packages/db/src/queries/cron.ts | 20 +- packages/db/src/queries/discovery.ts | 119 +- packages/db/src/queries/image-cache.ts | 17 +- packages/db/src/queries/library.ts | 329 ++ packages/db/src/queries/metadata.ts | 17 +- packages/db/src/queries/user-platforms.ts | 68 + packages/db/src/schema.ts | 55 +- packages/db/src/seed-platforms.ts | 198 + packages/i18n/src/po/en.po | 346 ++ packages/test/src/db.ts | 46 +- packages/tmdb/src/client.ts | 23 + 76 files changed, 8918 insertions(+), 942 deletions(-) create mode 100644 apps/native/src/app/(tabs)/(library)/_layout.tsx create mode 100644 apps/native/src/app/(tabs)/(library)/index.tsx create mode 100644 apps/native/src/components/library/filter-sheet.tsx create mode 100644 apps/native/src/components/library/sort-menu.tsx create mode 100644 apps/server/src/orpc/procedures/library.ts create mode 100644 apps/server/src/orpc/procedures/platforms.ts create mode 100644 apps/web/src/components/explore/discover-section.tsx create mode 100644 apps/web/src/components/library/library-filters.tsx create mode 100644 apps/web/src/components/library/library-toolbar.tsx create mode 100644 apps/web/src/components/platforms/platform-grid.tsx create mode 100644 apps/web/src/components/settings/streaming-services-section.tsx create mode 100644 apps/web/src/lib/form/context.tsx create mode 100644 apps/web/src/lib/form/fields.tsx create mode 100644 apps/web/src/lib/form/index.tsx create mode 100644 apps/web/src/routes/_app/library.tsx create mode 100644 apps/web/src/routes/_app/onboarding.tsx delete mode 100644 docs/content/docs/api/dashboard/dashboard.library.mdx create mode 100644 docs/content/docs/api/explore/explore.watchProviders.mdx create mode 100644 docs/content/docs/api/library/library.genres.mdx create mode 100644 docs/content/docs/api/library/library.list.mdx create mode 100644 packages/core/src/library.ts create mode 100644 packages/core/src/platforms.ts create mode 100644 packages/core/test/library.test.ts create mode 100644 packages/db/drizzle/20260324003410_clever_gertrude_yorkes/migration.sql create mode 100644 packages/db/drizzle/20260324003410_clever_gertrude_yorkes/snapshot.json create mode 100644 packages/db/src/queries/library.ts create mode 100644 packages/db/src/queries/user-platforms.ts create mode 100644 packages/db/src/seed-platforms.ts diff --git a/AGENTS.md b/AGENTS.md index ee6929c..dbef215 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,7 +122,7 @@ Cross-package imports: - **JSX text** → `` from `@lingui/react/macro` - **Strings in hooks/components** → `const { t } = useLingui()` from `@lingui/react/macro`, then `` t`string` `` - **Strings outside React** (plain modules) → `import { msg } from "@lingui/core/macro"` + `import { i18n } from "@sofa/i18n"`, then ``i18n._(msg`string`)`` -- **Pluralization** → `import { plural } from "@lingui/core/macro"`, use inside `t`: `` t`${plural(count, { one: "# item", other: "# items" })}` `` +- **Pluralization** → `import { plural } from "@lingui/core/macro"`. Use standalone when it's the whole string: `plural(count, { one: "# item", other: "# items" })`. Nest inside `t` only when there's surrounding text: `` t`You have ${plural(count, { one: "# item", other: "# items" })} in your cart` `` - **DO NOT use** `t(i18n)` — it's deprecated in v5 and removed in v6. Use `i18n._(msg`...`)` instead. - **Date/number formatting** → use `formatDate`, `formatRelativeTime`, `formatNumber`, `formatBytes` from `@sofa/i18n/format` (Intl-based, locale-aware). Never use `date-fns` in app code. - **Native Intl polyfills** → `@formatjs/intl-*` polyfills loaded in `apps/native/src/lib/intl-polyfills.ts` (strict dependency order, with locale data for all 6 languages). diff --git a/apps/native/package.json b/apps/native/package.json index d565728..6baadec 100644 --- a/apps/native/package.json +++ b/apps/native/package.json @@ -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:", diff --git a/apps/native/src/app/(tabs)/(home)/index.tsx b/apps/native/src/app/(tabs)/(home)/index.tsx index 5bf1e34..695a4d0 100644 --- a/apps/native/src/app/(tabs)/(home)/index.tsx +++ b/apps/native/src/app/(tabs)/(home)/index.tsx @@ -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 */} - + push("/(tabs)/(library)")} + /> {library.isPending ? ( diff --git a/apps/native/src/app/(tabs)/(home)/upcoming.tsx b/apps/native/src/app/(tabs)/(home)/upcoming.tsx index 8e2154c..3152306 100644 --- a/apps/native/src/app/(tabs)/(home)/upcoming.tsx +++ b/apps/native/src/app/(tabs)/(home)/upcoming.tsx @@ -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 ( + { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + onPress(); + }} + accessibilityRole="button" + accessibilityState={{ selected: isSelected }} + className={`rounded-full px-3 py-1.5 ${isSelected ? "bg-primary" : "bg-secondary"}`} + > + + {label} + + + ); +} + 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`} + + + setMediaType("all")} + /> + setMediaType("movie")} + /> + setMediaType("tv")} + /> + + + setStatusFilter("all")} + /> + setStatusFilter("watching")} + /> + setStatusFilter("watchlist")} + /> + + {!isPending && allItems.length === 0 ? ( ; +} diff --git a/apps/native/src/app/(tabs)/(library)/index.tsx b/apps/native/src/app/(tabs)/(library)/index.tsx new file mode 100644 index 0000000..4b9450f --- /dev/null +++ b/apps/native/src/app/(tabs)/(library)/index.tsx @@ -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({}); + const [filterOpen, setFilterOpen] = useState(false); + const [sortBy, setSortBy] = useState("added_at"); + const [sortDirection, setSortDirection] = useState("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 }) => ( + + + + ), + [handleQuickAdd, addingId], + ); + + const keyExtractor = useCallback((item: LibraryItem) => item.id, []); + + const filterButton = ( + + + setFilterOpen(true)} + accessibilityRole="button" + accessibilityLabel={t`Filters`} + hitSlop={8} + > + + + {activeFilterCount > 0 && ( + + + {activeFilterCount} + + + )} + + + + ); + + return ( + + filterButton, + }} + /> + setSearch(e.nativeEvent.text)} + onCancelButtonPress={() => setSearch("")} + onClose={() => setSearch("")} + hideWhenScrolling={false} + placement={process.env.EXPO_OS === "ios" ? "integrated" : undefined} + allowToolbarIntegration={process.env.EXPO_OS === "ios" ? true : undefined} + /> + + {libraryQuery.isPending ? ( + + + + ) : libraryQuery.isError ? ( + libraryQuery.refetch()} + /> + ) : allItems.length === 0 ? ( + + {debouncedSearch.length > 0 ? ( + + + No results for "{debouncedSearch}" + + + ) : activeFilterCount > 0 ? ( + setFilters({})} + /> + ) : ( + + )} + + ) : ( + } + onEndReached={() => { + if (libraryQuery.hasNextPage && !libraryQuery.isFetchingNextPage) { + libraryQuery.fetchNextPage(); + } + }} + onEndReachedThreshold={0.5} + ListFooterComponent={ + libraryQuery.isFetchingNextPage ? ( + + + + ) : null + } + /> + )} + + + + ); +} diff --git a/apps/native/src/app/title/[id].tsx b/apps/native/src/app/title/[id].tsx index b4739ad..ff9dc5c 100644 --- a/apps/native/src/app/title/[id].tsx +++ b/apps/native/src/app/title/[id].tsx @@ -477,7 +477,7 @@ export default function TitleDetailScreen() { contentContainerStyle={titleAvailabilityContentStyle} > {availability.map((offer) => ( - + {offer.logoPath && ( 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 ( + { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + onPress(); + }} + accessibilityRole="button" + accessibilityState={{ selected: isSelected }} + className={`rounded-full px-3 py-1.5 ${isSelected ? "bg-primary" : "bg-secondary"}`} + > + + {label} + + + ); +} + +function SectionLabel({ children }: { children: string }) { + return ( + + {children} + + ); +} + +function RatingStar({ filled, onPress }: { filled: boolean; onPress: () => void }) { + const primaryColor = useCSSVariable("--color-primary") as string; + const mutedColor = useCSSVariable("--color-muted-foreground") as string; + + return ( + { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + onPress(); + }} + hitSlop={4} + accessibilityRole="button" + > + + + ); +} + +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(() => ({ ...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 ( + <> + handleClose()}> + handleClose()}> + e.stopPropagation()} + > + {/* Header */} + + + {t`Filters`} + + + + + {/* Status */} + + {t`Status`} + + {statuses.map((s) => ( + toggleStatus(s.value)} + /> + ))} + + + + {/* Type */} + + {t`Type`} + + {types.map((typ) => ( + setLocal((prev) => ({ ...prev, type: typ.value }))} + /> + ))} + + + + {/* Genre */} + + {t`Genre`} + setGenreModalOpen(true)} + className="bg-secondary flex-row items-center rounded-xl px-4 py-3" + > + + + {selectedGenreLabel} + + {"\u203A"} + + + + {/* Rating */} + + {t`Rating`} + + + {t`Min`} + + {[1, 2, 3, 4, 5].map((star) => ( + + setLocal((prev) => ({ + ...prev, + ratingMin: prev.ratingMin === star ? undefined : star, + })) + } + /> + ))} + + + + {t`Max`} + + {[1, 2, 3, 4, 5].map((star) => ( + + setLocal((prev) => ({ + ...prev, + ratingMax: prev.ratingMax === star ? undefined : star, + })) + } + /> + ))} + + + + + + {/* Year */} + + {t`Year`} + + {DECADES.map((decade) => ( + selectDecade(decade)} + /> + ))} + + + + {/* Content Rating */} + + {t`Content Rating`} + setContentRatingModalOpen(true)} + className="bg-secondary flex-row items-center rounded-xl px-4 py-3" + > + + {local.contentRating ?? t`All`} + + {"\u203A"} + + + + {/* Streaming */} + + {t`Streaming`} + + {t`On my services`} + + setLocal((prev) => ({ + ...prev, + onMyServices: checked || undefined, + })) + } + accessibilityLabel={t`On my services`} + /> + + + + + + {/* Bottom buttons */} + + + + + + + + + + setLocal((prev) => ({ + ...prev, + genreId: value === "" ? undefined : Number(value), + })) + } + /> + + + setLocal((prev) => ({ + ...prev, + contentRating: value === "" ? undefined : value, + })) + } + /> + + ); +} diff --git a/apps/native/src/components/library/sort-menu.tsx b/apps/native/src/components/library/sort-menu.tsx new file mode 100644 index 0000000..3d64903 --- /dev/null +++ b/apps/native/src/components/library/sort-menu.tsx @@ -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 ( + + + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)} + accessibilityRole="button" + accessibilityLabel={t`Sort`} + hitSlop={8} + > + + + + + + {sortOptions.map((option) => { + const isSelected = option.sortBy === sortBy && option.sortDirection === sortDirection; + return ( + onSortChange(option.sortBy, option.sortDirection)} + > + + {option.label} + + ); + })} + + + ); +} diff --git a/apps/native/src/components/navigation/native-tab-bar.tsx b/apps/native/src/components/navigation/native-tab-bar.tsx index cfe2561..0f2ec3c 100644 --- a/apps/native/src/components/navigation/native-tab-bar.tsx +++ b/apps/native/src/components/navigation/native-tab-bar.tsx @@ -28,6 +28,10 @@ export function NativeTabBar({ showSettingsBadge }: { showSettingsBadge: boolean {t`Home`} + + {t`Library`} + + {t`Explore`} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 7ded166..4a0fcb6 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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) { diff --git a/apps/server/src/orpc/procedures/account.ts b/apps/server/src/orpc/procedures/account.ts index 984380d..54ef78f 100644 --- a/apps/server/src/orpc/procedures/account.ts +++ b/apps/server/src/orpc/procedures/account.ts @@ -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); + }); diff --git a/apps/server/src/orpc/procedures/dashboard.ts b/apps/server/src/orpc/procedures/dashboard.ts index 3d9370d..a7a48d8 100644 --- a/apps/server/src/orpc/procedures/dashboard.ts +++ b/apps/server/src/orpc/procedures/dashboard.ts @@ -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) => ({ diff --git a/apps/server/src/orpc/procedures/discover.ts b/apps/server/src/orpc/procedures/discover.ts index e5d2431..8b1985c 100644 --- a/apps/server/src/orpc/procedures/discover.ts +++ b/apps/server/src/orpc/procedures/discover.ts @@ -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 = { + 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[number] & { title?: string; diff --git a/apps/server/src/orpc/procedures/explore.ts b/apps/server/src/orpc/procedures/explore.ts index c70a875..2970353 100644 --- a/apps/server/src/orpc/procedures/explore.ts +++ b/apps/server/src/orpc/procedures/explore.ts @@ -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"), + })), + }; +}); diff --git a/apps/server/src/orpc/procedures/library.ts b/apps/server/src/orpc/procedures/library.ts new file mode 100644 index 0000000..63a948a --- /dev/null +++ b/apps/server/src/orpc/procedures/library.ts @@ -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) }; +}); diff --git a/apps/server/src/orpc/procedures/platforms.ts b/apps/server/src/orpc/procedures/platforms.ts new file mode 100644 index 0000000..dcade39 --- /dev/null +++ b/apps/server/src/orpc/procedures/platforms.ts @@ -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, + })), + }; +}); diff --git a/apps/server/src/orpc/procedures/titles.ts b/apps/server/src/orpc/procedures/titles.ts index 61492dc..14c2920 100644 --- a/apps/server/src/orpc/procedures/titles.ts +++ b/apps/server/src/orpc/procedures/titles.ts @@ -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", diff --git a/apps/server/src/orpc/router.ts b/apps/server/src/orpc/router.ts index b7bf9a8..873c4e5 100644 --- a/apps/server/src/orpc/router.ts +++ b/apps/server/src/orpc/router.ts @@ -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, diff --git a/apps/web/package.json b/apps/web/package.json index 5c568c9..150ee17 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/components/auth-form.tsx b/apps/web/src/components/auth-form.tsx index 66fc580..6ce9249 100644 --- a/apps/web/src/components/auth-form.tsx +++ b/apps/web/src/components/auth-form.tsx @@ -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 && ( { + e.preventDefault(); + form.handleSubmit(); + }} className="space-y-4" initial="hidden" animate="visible" @@ -170,72 +172,94 @@ export function AuthForm({ }} > {isRegister && ( - - - setName(e.target.value)} - className={authInputClass} - placeholder={t`Your name…`} - /> - + + {(field) => ( + + + field.handleChange(e.target.value)} + className={authInputClass} + placeholder={t`Your name…`} + /> + + )} + )} - - - setEmail(e.target.value)} - className={authInputClass} - placeholder="wwhite@graymatter.biz" - /> - + + {(field) => ( + + + field.handleChange(e.target.value)} + className={authInputClass} + placeholder="wwhite@graymatter.biz" + /> + + )} + - - - setPassword(e.target.value)} - className={authInputClass} - placeholder={t`Min 8 characters…`} - /> - + + {(field) => ( + + + field.handleChange(e.target.value)} + className={authInputClass} + placeholder={t`Min 8 characters…`} + /> + + )} + - - - + state.isSubmitting}> + {(isSubmitting) => ( + + + + )} + )} diff --git a/apps/web/src/components/dashboard/library-section.tsx b/apps/web/src/components/dashboard/library-section.tsx index 701a17a..4d2eb82 100644 --- a/apps/web/src/components/dashboard/library-section.tsx +++ b/apps/web/src/components/dashboard/library-section.tsx @@ -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 ; - const items = data?.pages.flatMap((p) => p.items) ?? []; + const items = data?.items ?? []; if (items.length === 0) return null; return ( - }> + } + seeAllLink="/library" + > -
- {isFetchingNextPage && } ); } diff --git a/apps/web/src/components/explore/discover-section.tsx b/apps/web/src/components/explore/discover-section.tsx new file mode 100644 index 0000000..b9d3006 --- /dev/null +++ b/apps/web/src/components/explore/discover-section.tsx @@ -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(undefined); + const [yearMin, setYearMin] = useState(undefined); + const [yearMax, setYearMax] = useState(undefined); + const [ratingMin, setRatingMin] = useState(undefined); + type DiscoverSortBy = + | "popularity.desc" + | "vote_average.desc" + | "primary_release_date.desc" + | "primary_release_date.asc"; + const [sortBy, setSortBy] = useState(undefined); + const [language, setLanguage] = useState(undefined); + const [providerId, setProviderId] = useState(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 = { + "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 = { + 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 ( + }> +
+ {/* Type toggle: Movie | TV */} + { + const next = values.find((v) => v !== type); + if (next === "movie" || next === "tv") { + setType(next); + setGenreId(undefined); + setProviderId(undefined); + setSelectedPlatformId(""); + } + }} + variant="outline" + size="sm" + > + {t`Movie`} + {t`TV`} + + + {/* Divider */} +
+ + {/* Genre select */} + + + {/* Year select (decade presets) */} + + + {/* Rating select (minimum TMDB rating) */} + + + {/* Sort select */} + + + {/* Language select */} + + + {/* Provider select */} + +
+ + {/* Results */} + {isPending ? ( +
+ +
+ ) : items.length === 0 ? ( +

+ {t`No titles found. Try adjusting your filters.`} +

+ ) : ( + <> + +
+ {isFetchingNextPage && ( +
+ +
+ )} + + )} + + ); +} diff --git a/apps/web/src/components/library/library-filters.tsx b/apps/web/src/components/library/library-filters.tsx new file mode 100644 index 0000000..506b3b5 --- /dev/null +++ b/apps/web/src/components/library/library-filters.tsx @@ -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
; +} + +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 ( +
+ {/* Type pills */} + {types.map((typ) => ( + + ))} + + + + {/* Status — multi-select dropdown */} + + 0 ? "" : undefined} + className="data-[active]:border-primary/40 data-[active]:text-foreground" + > + {statusLabel} + + + } + /> + + {statuses.map((s) => { + const isActive = (filters.statuses ?? []).includes(s.value); + return ( + + ); + })} + + + + {/* Genre */} + + + {/* Rating */} + + + {/* Year */} + + + {/* Content Rating */} + + + + + {/* On my services */} + + + {/* Clear */} + {activeFilterCount > 0 && ( + <> +
+ + + )} +
+ ); +} diff --git a/apps/web/src/components/library/library-toolbar.tsx b/apps/web/src/components/library/library-toolbar.tsx new file mode 100644 index 0000000..609755a --- /dev/null +++ b/apps/web/src/components/library/library-toolbar.tsx @@ -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 ( + +
+
+ + onSearchChange(e.target.value)} + placeholder={t`Search library...`} + className="pl-7" + aria-label={t`Search library`} + /> +
+ +
+ + {plural(totalResults, { one: "# result", other: "# results" })} + + + {/* Filter toggle */} + + + {t`Filters`} + {activeFilterCount > 0 && ( + + {activeFilterCount} + + )} + + + } + /> + + {/* Sort dropdown */} + + + + {t`Sort`} + + } + /> + + {sortOptions.map((option) => { + const isActive = sortBy === option.sortBy && sortDirection === option.direction; + return ( + onSortChange(option.sortBy, option.direction)} + > + + {option.label} + + + ); + })} + + +
+
+ + {/* Collapsible filter strip */} + +
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/nav-bar.tsx b/apps/web/src/components/nav-bar.tsx index 725f0c6..9dbde50 100644 --- a/apps/web/src/components/nav-bar.tsx +++ b/apps/web/src/components/nav-bar.tsx @@ -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 }, diff --git a/apps/web/src/components/platforms/platform-grid.tsx b/apps/web/src/components/platforms/platform-grid.tsx new file mode 100644 index 0000000..48a69e9 --- /dev/null +++ b/apps/web/src/components/platforms/platform-grid.tsx @@ -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; + onToggle: (id: string) => void; +} + +export function PlatformGrid({ platforms, selectedIds, onToggle }: PlatformGridProps) { + const { t } = useLingui(); + + return ( +
+ {platforms.map((platform) => { + const isSelected = selectedIds.has(platform.id); + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/settings/account-section.tsx b/apps/web/src/components/settings/account-section.tsx index 9a3c868..efdaad2 100644 --- a/apps/web/src/components/settings/account-section.tsx +++ b/apps/web/src/components/settings/account-section.tsx @@ -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() { -
+ { + e.preventDefault(); + form.handleSubmit(); + }} + className="grid gap-3" + > {error && ( @@ -869,69 +874,114 @@ function ChangePasswordDialog() { )} -
- - setCurrentPassword(e.target.value)} - disabled={isSubmitting} - /> -
+ + {(field) => ( +
+ + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + aria-invalid={field.state.meta.errors.length > 0 || undefined} + /> + {field.state.meta.errors.length > 0 && ( +

+ {field.state.meta.errors + .map((e) => (typeof e === "string" ? e : "")) + .join(", ")} +

+ )} +
+ )} +
-
- - setNewPassword(e.target.value)} - disabled={isSubmitting} - /> -
+ + {(field) => ( +
+ + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + aria-invalid={field.state.meta.errors.length > 0 || undefined} + /> + {field.state.meta.errors.length > 0 && ( +

+ {field.state.meta.errors + .map((e) => (typeof e === "string" ? e : "")) + .join(", ")} +

+ )} +
+ )} +
-
- - setConfirmPassword(e.target.value)} - disabled={isSubmitting} - /> -
+ + {(field) => ( +
+ + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + aria-invalid={field.state.meta.errors.length > 0 || undefined} + /> + {field.state.meta.errors.length > 0 && ( +

+ {field.state.meta.errors + .map((e) => (typeof e === "string" ? e : "")) + .join(", ")} +

+ )} +
+ )} +
-
- setRevokeOtherSessions(checked === true)} - disabled={isSubmitting} - /> - -
+ + {(field) => ( +
+ + +
+ )} +
- - }> - Cancel - - - + ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })} + > + {({ canSubmit, isSubmitting }) => ( + + }> + Cancel + + + + )} +
diff --git a/apps/web/src/components/settings/streaming-services-section.tsx b/apps/web/src/components/settings/streaming-services-section.tsx new file mode 100644 index 0000000..3121d6f --- /dev/null +++ b/apps/web/src/components/settings/streaming-services-section.tsx @@ -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>(new Set()); + const [saved, setSaved] = useState(false); + const saveTimerRef = useRef>(undefined); + const savedTimerRef = useRef>(undefined); + const selectedIdsRef = useRef>(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 ( + + + + +
+
+ +
+
+ + Streaming Services + + + {selectedCount > 0 ? ( + t`${selectedCount} selected` + ) : ( + Choose your subscriptions + )} + +
+
+
+ + {saved && ( + + Saved + + )} + + +
+
+
+ + + + + + +
+
+ ); +} diff --git a/apps/web/src/components/titles/title-availability.tsx b/apps/web/src/components/titles/title-availability.tsx index fe97b9c..d3ef75b 100644 --- a/apps/web/src/components/titles/title-availability.tsx +++ b/apps/web/src/components/titles/title-availability.tsx @@ -93,7 +93,7 @@ function OverflowBadge({ offers }: { offers: AvailabilityOffer[] }) { {offers.map((offer) => offer.watchUrl ? ( {offer.providerName} ) : ( -
+
{offer.providerName}
@@ -114,6 +114,48 @@ function OverflowBadge({ offers }: { offers: AvailabilityOffer[] }) { ); } +function OffersByType({ + offers, + offerLabels, +}: { + offers: AvailabilityOffer[]; + offerLabels: Record; +}) { + const byType: Record = {}; + for (const offer of offers) { + if (!byType[offer.offerType]) byType[offer.offerType] = []; + byType[offer.offerType].push(offer); + } + + return ( +
+ {Object.entries(byType).map(([type, typeOffers]) => { + const visible = typeOffers.slice(0, MAX_VISIBLE); + const overflow = typeOffers.slice(MAX_VISIBLE); + + return ( +
+ + {offerLabels[type] ?? type} + +
+ {visible.map((offer) => ( + + ))} + {overflow.length > 0 && } +
+
+ ); + })} +
+ ); +} + export function TitleAvailability({ availability }: { availability: AvailabilityOffer[] }) { const { t } = useLingui(); const offerLabels: Record = { @@ -123,44 +165,44 @@ export function TitleAvailability({ availability }: { availability: Availability free: t`Free`, ads: t`With Ads`, }; - const availByType: Record = {}; - 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 ( +
+
+

+ On your services +

+ +
+ + {otherOffers.length > 0 && ( +
+

+ Also available on +

+
+ +
+
+ )} +
+ ); } - if (Object.keys(availByType).length === 0) return null; - + // Default: single "Where to Watch" section return (

Where to Watch

-
- {Object.entries(availByType).map(([type, offers]) => { - const visible = offers.slice(0, MAX_VISIBLE); - const overflow = offers.slice(MAX_VISIBLE); - - return ( -
- - {offerLabels[type] ?? type} - -
- {visible.map((offer) => ( - - ))} - {overflow.length > 0 && } -
-
- ); - })} -
+
); } diff --git a/apps/web/src/lib/form/context.tsx b/apps/web/src/lib/form/context.tsx new file mode 100644 index 0000000..c7a22c3 --- /dev/null +++ b/apps/web/src/lib/form/context.tsx @@ -0,0 +1,4 @@ +import { createFormHookContexts } from "@tanstack/react-form"; + +export const { fieldContext, formContext, useFieldContext, useFormContext } = + createFormHookContexts(); diff --git a/apps/web/src/lib/form/fields.tsx b/apps/web/src/lib/form/fields.tsx new file mode 100644 index 0000000..6b68012 --- /dev/null +++ b/apps/web/src/lib/form/fields.tsx @@ -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, "value" | "onChange" | "onBlur">) { + const field = useFieldContext(); + const id = useId(); + const hasErrors = field.state.meta.errors.length > 0; + + return ( + + {label} + {description && {description}} + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + aria-invalid={hasErrors || undefined} + className={className} + {...inputProps} + /> + + + ); +} + +function TextareaField({ + label, + description, + className, + ...textareaProps +}: { + label: ReactNode; + description?: ReactNode; +} & Omit, "value" | "onChange" | "onBlur">) { + const field = useFieldContext(); + const id = useId(); + const hasErrors = field.state.meta.errors.length > 0; + + return ( + + {label} + {description && {description}} +