mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 03:55:38 -04:00
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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user