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}} +