Add watch status indicators to title cards on Explore page

Title cards now show the user's existing watch status (watchlist,
watching, completed) with a color-coded badge on the poster and a
status-aware quick-add button that prevents re-adding tracked titles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 10:17:29 -05:00
co-authored by Claude Opus 4.6
parent fa01a0d4a0
commit 843bf35513
7 changed files with 184 additions and 5 deletions
@@ -14,6 +14,8 @@ import {
import {
defaultItemsAtom,
genreResultsLoadable,
genreUserStatusesLoadable,
initialUserStatusesAtom,
mediaTypeAtom,
selectedGenreAtom,
} from "@/lib/atoms/filterable-row";
@@ -38,6 +40,7 @@ interface FilterableTitleRowProps {
mediaType: "movie" | "tv";
defaultItems: TitleRowItem[];
genres: Genre[];
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
}
const staggerContainer = {
@@ -61,11 +64,13 @@ export function FilterableTitleRow({
mediaType,
defaultItems,
genres,
userStatuses,
}: FilterableTitleRowProps) {
const [store] = useState(() => {
const s = createStore();
s.set(mediaTypeAtom, mediaType);
s.set(defaultItemsAtom, defaultItems);
s.set(initialUserStatusesAtom, userStatuses ?? {});
return s;
});
@@ -88,6 +93,8 @@ function FilterableTitleRowInner({
const [selectedGenre, setSelectedGenre] = useAtom(selectedGenreAtom);
const defaults = useAtomValue(defaultItemsAtom);
const genreResults = useAtomValue(genreResultsLoadable);
const initialStatuses = useAtomValue(initialUserStatusesAtom);
const genreStatuses = useAtomValue(genreUserStatusesLoadable);
const loading = selectedGenre !== null && genreResults.state === "loading";
const items =
@@ -97,6 +104,13 @@ function FilterableTitleRowInner({
? genreResults.data
: [];
const userStatuses =
selectedGenre === null
? initialStatuses
: genreStatuses.state === "hasData" && genreStatuses.data !== null
? genreStatuses.data
: initialStatuses;
function toggleGenre(genreId: number) {
setSelectedGenre(selectedGenre === genreId ? null : genreId);
}
@@ -181,6 +195,7 @@ function FilterableTitleRowInner({
voteAverage={item.voteAverage}
href={`/titles/tmdb-${item.tmdbId}-${item.type}`}
showQuickAdd
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]}
/>
</motion.div>
</CarouselItem>
+22
View File
@@ -1,4 +1,7 @@
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { getUserStatusesByTmdbIds } from "@/lib/services/tracking";
import { getGenres, getPopular, getTrending } from "@/lib/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image";
import { FilterableTitleRow } from "./filterable-title-row";
@@ -46,6 +49,22 @@ export default async function ExplorePage() {
const popularMovieItems = mapResults(popularMovies.results, "movie");
const popularTvItems = mapResults(popularTv.results, "tv");
// Fetch user statuses for all visible TMDB IDs
let userStatuses: Record<string, "watchlist" | "in_progress" | "completed"> =
{};
const session = await auth.api.getSession({ headers: await headers() });
if (session) {
const allItems = [
...trendingItems,
...popularMovieItems,
...popularTvItems,
];
userStatuses = getUserStatusesByTmdbIds(
session.user.id,
allItems.map((i) => ({ tmdbId: i.tmdbId, type: i.type })),
);
}
const heroTitle = trending.results.find(
(r) =>
r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"),
@@ -68,6 +87,7 @@ export default async function ExplorePage() {
heading="Trending Today"
icon={<IconFlame className="size-5 text-primary" />}
items={trendingItems.slice(0, 20)}
userStatuses={userStatuses}
/>
<FilterableTitleRow
@@ -76,6 +96,7 @@ export default async function ExplorePage() {
mediaType="movie"
defaultItems={popularMovieItems.slice(0, 20)}
genres={movieGenres.genres}
userStatuses={userStatuses}
/>
<FilterableTitleRow
@@ -84,6 +105,7 @@ export default async function ExplorePage() {
mediaType="tv"
defaultItems={popularTvItems.slice(0, 20)}
genres={tvGenres.genres}
userStatuses={userStatuses}
/>
</div>
);
+8 -1
View File
@@ -22,6 +22,7 @@ interface TitleRowProps {
heading: string;
icon: React.ReactNode;
items: TitleRowItem[];
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
}
const staggerContainer = {
@@ -39,7 +40,12 @@ const staggerItem = {
},
};
export function TitleRow({ heading, icon, items }: TitleRowProps) {
export function TitleRow({
heading,
icon,
items,
userStatuses,
}: TitleRowProps) {
if (items.length === 0) return null;
return (
@@ -74,6 +80,7 @@ export function TitleRow({ heading, icon, items }: TitleRowProps) {
voteAverage={item.voteAverage}
href={`/titles/tmdb-${item.tmdbId}-${item.type}`}
showQuickAdd
userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]}
/>
</motion.div>
</CarouselItem>