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