import { useLingui } from "@lingui/react/macro"; import { IconBooks, IconFilterOff } from "@tabler/icons-react"; import { useInfiniteQuery } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { zodValidator } from "@tanstack/zod-adapter"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { z } from "zod"; import { TitleGrid, TitleGridSectionSkeleton } from "@/components/dashboard/title-grid"; import { LibraryToolbar } from "@/components/library/library-toolbar"; import { RouteError } from "@/components/route-error"; import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; import { orpc } from "@/lib/orpc/client"; const librarySearchSchema = z.object({ search: z.string().optional().catch(undefined), statuses: z.array(z.string()).optional().catch(undefined), type: z.enum(["movie", "tv"]).optional().catch(undefined), genreId: z.number().optional().catch(undefined), ratingMin: z.number().optional().catch(undefined), ratingMax: z.number().optional().catch(undefined), yearMin: z.number().optional().catch(undefined), yearMax: z.number().optional().catch(undefined), contentRating: z.string().optional().catch(undefined), onMyServices: z.boolean().optional().catch(undefined), sortBy: z.string().optional().catch(undefined), sortDirection: z.enum(["asc", "desc"]).optional().catch(undefined), }); type LibrarySearch = z.infer; export const Route = createFileRoute("/_app/library")({ validateSearch: zodValidator(librarySearchSchema), staleTime: 30_000, loader: async ({ context }) => { await Promise.all([ context.queryClient.ensureInfiniteQueryData( orpc.library.list.infiniteOptions({ input: (pageParam: number) => ({ page: pageParam }), initialPageParam: 1, getNextPageParam: (lastPage) => lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined, }), ), context.queryClient.ensureQueryData(orpc.library.genres.queryOptions()), ]); }, head: () => ({ meta: [{ title: "Library — Sofa" }] }), pendingComponent: LibrarySkeleton, errorComponent: RouteError, component: LibraryPage, }); function LibrarySkeleton() { return (
); } function LibraryPage() { const { t } = useLingui(); const search = Route.useSearch(); const navigate = useNavigate({ from: Route.fullPath }); // Debounced search — update URL 300ms after the user stops typing const [prevSearchParam, setPrevSearchParam] = useState(search.search); const [localSearch, setLocalSearch] = useState(search.search ?? ""); const debounceRef = useRef>(null); // Sync localSearch when URL params change externally (back/forward navigation) if (prevSearchParam !== search.search) { setPrevSearchParam(search.search); setLocalSearch(search.search ?? ""); } useEffect(() => { return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, []); const updateSearch = useCallback( (updates: Partial) => { void navigate({ search: (prev) => { const next = { ...prev, ...updates }; // Remove undefined/empty values for (const [key, value] of Object.entries(next)) { if ( value === undefined || value === "" || (Array.isArray(value) && value.length === 0) ) { delete (next as Record)[key]; } } return next; }, replace: true, }); }, [navigate], ); const handleFilterChange = useCallback( (key: string, value: unknown) => { const updates: Partial = { [key]: value === "" || value === false ? undefined : value, }; // Clear ratingMax when ratingMin changes (UI only exposes ratingMin now) if (key === "ratingMin") updates.ratingMax = undefined; updateSearch(updates); }, [updateSearch], ); const handleClearAll = useCallback(() => { setLocalSearch(""); void navigate({ search: {}, replace: true }); }, [navigate]); const handleSortChange = useCallback( (sortBy: string, sortDirection: string) => { updateSearch({ sortBy, sortDirection: sortDirection as "asc" | "desc" }); }, [updateSearch], ); // Build input from search params const queryInput = useMemo(() => { const input: Record = {}; if (search.search) input.search = search.search; if (search.statuses?.length) input.statuses = search.statuses; if (search.type) input.type = search.type; if (search.genreId) input.genreId = search.genreId; if (search.ratingMin) input.ratingMin = search.ratingMin; if (search.ratingMax) input.ratingMax = search.ratingMax; if (search.yearMin) input.yearMin = search.yearMin; if (search.yearMax) input.yearMax = search.yearMax; if (search.contentRating) input.contentRating = search.contentRating; if (search.onMyServices) input.onMyServices = true; if (search.sortBy) input.sortBy = search.sortBy; if (search.sortDirection) input.sortDirection = search.sortDirection; return input; }, [search]); const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError } = useInfiniteQuery( orpc.library.list.infiniteOptions({ input: (pageParam: number) => ({ ...queryInput, page: pageParam }), initialPageParam: 1, getNextPageParam: (lastPage) => lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined, }), ); const sentinelRef = useInfiniteScroll({ fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError, }); const items = data?.pages.flatMap((p) => p.items) ?? []; const totalResults = data?.pages[0]?.totalResults ?? 0; // Count active filters (excluding sort) const activeFilterCount = [ search.statuses?.length ? 1 : 0, search.type ? 1 : 0, search.genreId ? 1 : 0, search.ratingMin || search.ratingMax ? 1 : 0, search.yearMin || search.yearMax ? 1 : 0, search.contentRating ? 1 : 0, search.onMyServices ? 1 : 0, ].reduce((a, b) => a + b, 0); return (

{t`Library`}

{ setLocalSearch(value); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { updateSearch({ search: value || undefined }); }, 300); }} filters={{ statuses: search.statuses, type: search.type, genreId: search.genreId, ratingMin: search.ratingMin, ratingMax: search.ratingMax, yearMin: search.yearMin, yearMax: search.yearMax, contentRating: search.contentRating, onMyServices: search.onMyServices, }} onFilterChange={handleFilterChange} onClearAll={handleClearAll} sortBy={search.sortBy ?? "added_at"} sortDirection={search.sortDirection ?? "desc"} onSortChange={handleSortChange} totalResults={totalResults} activeFilterCount={activeFilterCount} /> {isPending ? ( ) : items.length === 0 ? (

{t`No titles match your filters`}

{activeFilterCount > 0 && ( )}
) : ( <>
{isFetchingNextPage && } )}
); }