Persist dashboard periods and explore genre filters in URL via nuqs

- Add `nuqs` dependency; define `dashboardSearchParams` and
  `exploreSearchParams` loaders in new `search-params.ts` files
- Thread `moviePeriod`/`episodePeriod` from URL into `StatsSection`
  and `StatsDisplay`, replacing local `useState` with `useQueryStates`
- Rename `getUserStats` fields `moviesThisMonth`/`episodesThisWeek` →
  `movieCount`/`episodeCount` and accept period args so SSR stats
  match the active URL params
- Pre-fetch genre-filtered results server-side on the explore page
  when `movieGenre`/`tvGenre` params are present; pass as
  `initialGenreItems` to `FilterableTitleRow` so first paint is
  already populated
- Render genre chips as `<Link>` elements (with click-handler
  fallback) so genre selections produce shareable, bookmarkable URLs
This commit is contained in:
2026-03-08 14:32:09 -04:00
parent 0838e5fb74
commit 2bb5af042b
12 changed files with 213 additions and 70 deletions
@@ -1,5 +1,7 @@
"use client";
import Link from "next/link";
import { useQueryStates } from "nuqs";
import { useState, useTransition } from "react";
import { TitleCardSkeleton } from "@/components/skeletons";
import { TitleCard } from "@/components/title-card";
@@ -10,6 +12,7 @@ import {
fetchEpisodeProgress,
fetchUserStatuses,
} from "@/lib/actions/watchlist";
import { exploreSearchParams, serializeExploreParams } from "../search-params";
interface Genre {
id: number;
@@ -32,6 +35,7 @@ interface FilterableTitleRowProps {
icon: React.ReactNode;
mediaType: "movie" | "tv";
defaultItems: TitleRowItem[];
initialGenreItems: TitleRowItem[] | null;
genres: Genre[];
userStatuses?: Record<string, TitleStatus>;
episodeProgress?: Record<string, { watched: number; total: number }>;
@@ -42,18 +46,28 @@ export function FilterableTitleRow({
icon,
mediaType,
defaultItems,
initialGenreItems,
genres,
userStatuses: initialStatuses = {},
episodeProgress: initialProgress = {},
}: FilterableTitleRowProps) {
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
const [genreResults, setGenreResults] = useState<TitleRowItem[] | null>(null);
const paramKey = mediaType === "movie" ? "movieGenre" : ("tvGenre" as const);
const [params, setParams] = useQueryStates(exploreSearchParams, {
history: "push",
});
const selectedGenre = params[paramKey];
const setSelectedGenre = (value: number | null) =>
setParams({ [paramKey]: value });
const [genreResults, setGenreResults] = useState<TitleRowItem[] | null>(
initialGenreItems,
);
const [genreStatuses, setGenreStatuses] = useState<
Record<string, TitleStatus>
>({});
>(initialGenreItems ? initialStatuses : {});
const [genreProgress, setGenreProgress] = useState<
Record<string, { watched: number; total: number }>
>({});
>(initialGenreItems ? initialProgress : {});
const [isPending, startTransition] = useTransition();
const items = selectedGenre === null ? defaultItems : (genreResults ?? []);
@@ -103,26 +117,37 @@ export function FilterableTitleRow({
{/* Genre chips */}
<ScrollArea scrollFade hideScrollbar>
<div className="flex gap-2">
{genres.map((genre) => (
<Button
key={genre.id}
variant={selectedGenre === genre.id ? "default" : "outline"}
size="sm"
onClick={() => toggleGenre(genre.id)}
className={`shrink-0 rounded-full ${
selectedGenre === genre.id
? "border-primary bg-primary/10 text-primary hover:bg-primary/20"
: "border-border/50 bg-card/50 text-muted-foreground hover:border-primary/20 hover:text-foreground"
}`}
>
{genre.name}
</Button>
))}
{genres.map((genre) => {
const isSelected = selectedGenre === genre.id;
const href = serializeExploreParams("/explore", {
...params,
[paramKey]: isSelected ? null : genre.id,
});
return (
<Button
key={genre.id}
render={<Link href={href} prefetch={false} />}
variant={isSelected ? "default" : "outline"}
size="sm"
onClick={(e) => {
e.preventDefault();
toggleGenre(genre.id);
}}
className={`shrink-0 rounded-full ${
isSelected
? "border-primary bg-primary/10 text-primary hover:bg-primary/20"
: "border-border/50 bg-card/50 text-muted-foreground hover:border-primary/20 hover:text-foreground"
}`}
>
{genre.name}
</Button>
);
})}
</div>
</ScrollArea>
{/* Loading skeleton */}
{isPending && (
{(isPending || (selectedGenre !== null && genreResults === null)) && (
<div className="-mx-4 flex gap-4 overflow-hidden px-4 sm:-mx-0 sm:px-0">
{Array.from({ length: 8 }).map((_, i) => (
<div
@@ -137,11 +162,14 @@ export function FilterableTitleRow({
)}
{/* Empty state */}
{!isPending && selectedGenre !== null && items.length === 0 && (
<p className="py-8 text-center text-muted-foreground text-sm">
No titles found for this genre.
</p>
)}
{!isPending &&
selectedGenre !== null &&
genreResults !== null &&
items.length === 0 && (
<p className="py-8 text-center text-muted-foreground text-sm">
No titles found for this genre.
</p>
)}
{/* Title cards */}
{!isPending && items.length > 0 && (