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>
+78 -3
View File
@@ -1,10 +1,13 @@
"use client";
import {
IconBookmarkFilled,
IconCheck,
IconCircleCheckFilled,
IconDeviceTv,
IconLoader,
IconMovie,
IconPlayerPlayFilled,
IconPlus,
IconStarFilled,
} from "@tabler/icons-react";
@@ -19,6 +22,8 @@ import {
} from "@/components/ui/tooltip";
import { quickAddToWatchlist } from "@/lib/actions/watchlist";
type TitleStatus = "watchlist" | "in_progress" | "completed";
interface TitleCardProps {
id?: string;
tmdbId: number;
@@ -30,18 +35,47 @@ interface TitleCardProps {
href?: string;
onImport?: () => void;
showQuickAdd?: boolean;
userStatus?: TitleStatus | null;
}
type QuickAddState = "idle" | "loading" | "added";
const statusConfig = {
watchlist: {
icon: IconBookmarkFilled,
label: "On Watchlist",
badgeClass: "bg-status-watching/90 text-white",
},
in_progress: {
icon: IconPlayerPlayFilled,
label: "Watching",
badgeClass: "bg-status-watching/90 text-white",
},
completed: {
icon: IconCircleCheckFilled,
label: "Completed",
badgeClass: "bg-status-completed/90 text-white",
},
} as const;
function QuickAddButton({
tmdbId,
type,
userStatus,
}: {
tmdbId: number;
type: "movie" | "tv";
userStatus?: TitleStatus | null;
}) {
const [state, setState] = useState<QuickAddState>("idle");
const [state, setState] = useState<QuickAddState>(
userStatus ? "added" : "idle",
);
const [addedStatus, setAddedStatus] = useState<TitleStatus | null>(
userStatus ?? null,
);
const effectiveStatus = addedStatus;
const config = effectiveStatus ? statusConfig[effectiveStatus] : null;
async function handleClick(e: React.MouseEvent) {
e.preventDefault();
@@ -49,13 +83,33 @@ function QuickAddButton({
if (state === "loading" || state === "added") return;
setState("loading");
try {
await quickAddToWatchlist(tmdbId, type);
const result = await quickAddToWatchlist(tmdbId, type);
setState("added");
setAddedStatus(result.alreadyAdded ? null : "watchlist");
if (result.alreadyAdded) {
// Already existed — keep as added but we don't know exact status
setAddedStatus("watchlist");
}
} catch {
setState("idle");
}
}
if (state === "added" && config) {
const StatusIcon = config.icon;
return (
<Tooltip>
<TooltipTrigger
className="absolute top-2 right-2 z-10 flex size-8 items-center justify-center rounded-full bg-black/50 backdrop-blur-sm text-white cursor-default"
render={<div />}
>
<StatusIcon className="size-4" />
</TooltipTrigger>
<TooltipContent side="bottom">{config.label}</TooltipContent>
</Tooltip>
);
}
return (
<Tooltip>
<TooltipTrigger
@@ -74,6 +128,20 @@ function QuickAddButton({
);
}
function StatusBadge({ status }: { status: TitleStatus }) {
const config = statusConfig[status];
const StatusIcon = config.icon;
return (
<div
className={`absolute bottom-2 left-2 z-10 flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold leading-tight shadow-md backdrop-blur-sm ${config.badgeClass}`}
>
<StatusIcon className="size-3" />
{config.label}
</div>
);
}
export function TitleCard({
id,
tmdbId,
@@ -85,6 +153,7 @@ export function TitleCard({
href,
onImport,
showQuickAdd,
userStatus,
}: TitleCardProps) {
const year = releaseDate?.slice(0, 4);
const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv;
@@ -120,6 +189,8 @@ export function TitleCard({
</div>
</div>
)}
{/* Status badge on poster */}
{userStatus && <StatusBadge status={userStatus} />}
{/* Hover gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
</div>
@@ -145,7 +216,11 @@ export function TitleCard({
return (
<div className="relative group">
{showQuickAdd && (
<QuickAddButton tmdbId={tmdbId} type={type as "movie" | "tv"} />
<QuickAddButton
tmdbId={tmdbId}
type={type as "movie" | "tv"}
userStatus={userStatus}
/>
)}
<Link href={href ?? `/titles/${id}`}>{cardInner}</Link>
</div>
+12 -1
View File
@@ -6,7 +6,18 @@ import { auth } from "@/lib/auth/server";
import { db } from "@/lib/db/client";
import { userTitleStatus } from "@/lib/db/schema";
import { importTitle } from "@/lib/services/metadata";
import { setTitleStatus } from "@/lib/services/tracking";
import {
getUserStatusesByTmdbIds,
setTitleStatus,
} from "@/lib/services/tracking";
export async function fetchUserStatuses(
tmdbIds: { tmdbId: number; type: string }[],
): Promise<Record<string, "watchlist" | "in_progress" | "completed">> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return {};
return getUserStatusesByTmdbIds(session.user.id, tmdbIds);
}
export async function quickAddToWatchlist(
tmdbId: number,
+16
View File
@@ -1,5 +1,8 @@
import { atom } from "jotai";
import { loadable } from "jotai/utils";
import { fetchUserStatuses } from "@/lib/actions/watchlist";
type TitleStatus = "watchlist" | "in_progress" | "completed";
interface TitleRowItem {
tmdbId: number;
@@ -13,6 +16,7 @@ interface TitleRowItem {
export const selectedGenreAtom = atom<number | null>(null);
export const mediaTypeAtom = atom<"movie" | "tv">("movie");
export const defaultItemsAtom = atom<TitleRowItem[]>([]);
export const initialUserStatusesAtom = atom<Record<string, TitleStatus>>({});
const genreResultsAsyncAtom = atom(async (get) => {
const genre = get(selectedGenreAtom);
@@ -26,3 +30,15 @@ const genreResultsAsyncAtom = atom(async (get) => {
});
export const genreResultsLoadable = loadable(genreResultsAsyncAtom);
const genreUserStatusesAsyncAtom = atom(async (get) => {
const genre = get(selectedGenreAtom);
if (genre === null) return null;
const genreResults = await get(genreResultsAsyncAtom);
if (!genreResults || genreResults.length === 0) return {};
return fetchUserStatuses(
genreResults.map((r) => ({ tmdbId: r.tmdbId, type: r.type })),
);
});
export const genreUserStatusesLoadable = loadable(genreUserStatusesAsyncAtom);
+33
View File
@@ -297,6 +297,39 @@ export function rateTitleStars(
.run();
}
export function getUserStatusesByTmdbIds(
userId: string,
tmdbIds: { tmdbId: number; type: string }[],
): Record<string, "watchlist" | "in_progress" | "completed"> {
if (tmdbIds.length === 0) return {};
const allTmdbIds = tmdbIds.map((t) => t.tmdbId);
const rows = db
.select({
tmdbId: titles.tmdbId,
type: titles.type,
status: userTitleStatus.status,
})
.from(userTitleStatus)
.innerJoin(titles, eq(userTitleStatus.titleId, titles.id))
.where(
and(
eq(userTitleStatus.userId, userId),
inArray(titles.tmdbId, allTmdbIds),
),
)
.all();
const result: Record<string, "watchlist" | "in_progress" | "completed"> = {};
for (const row of rows) {
result[`${row.tmdbId}-${row.type}`] = row.status as
| "watchlist"
| "in_progress"
| "completed";
}
return result;
}
export function getUserTitleInfo(userId: string, titleId: string) {
const status = db
.select()