mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
Add episode progress bar to title cards for in-progress TV shows
Shows a subtle progress bar at the bottom of title cards indicating watched/total episodes, with a tooltip for exact counts. Uses a single efficient SQL query with JOINs to batch-fetch progress for all visible titles on the Explore page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,8 +13,10 @@ import {
|
|||||||
} from "@/components/ui/carousel";
|
} from "@/components/ui/carousel";
|
||||||
import {
|
import {
|
||||||
defaultItemsAtom,
|
defaultItemsAtom,
|
||||||
|
genreEpisodeProgressLoadable,
|
||||||
genreResultsLoadable,
|
genreResultsLoadable,
|
||||||
genreUserStatusesLoadable,
|
genreUserStatusesLoadable,
|
||||||
|
initialEpisodeProgressAtom,
|
||||||
initialUserStatusesAtom,
|
initialUserStatusesAtom,
|
||||||
mediaTypeAtom,
|
mediaTypeAtom,
|
||||||
selectedGenreAtom,
|
selectedGenreAtom,
|
||||||
@@ -41,6 +43,7 @@ interface FilterableTitleRowProps {
|
|||||||
defaultItems: TitleRowItem[];
|
defaultItems: TitleRowItem[];
|
||||||
genres: Genre[];
|
genres: Genre[];
|
||||||
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
|
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
|
||||||
|
episodeProgress?: Record<string, { watched: number; total: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const staggerContainer = {
|
const staggerContainer = {
|
||||||
@@ -65,12 +68,14 @@ export function FilterableTitleRow({
|
|||||||
defaultItems,
|
defaultItems,
|
||||||
genres,
|
genres,
|
||||||
userStatuses,
|
userStatuses,
|
||||||
|
episodeProgress,
|
||||||
}: FilterableTitleRowProps) {
|
}: FilterableTitleRowProps) {
|
||||||
const [store] = useState(() => {
|
const [store] = useState(() => {
|
||||||
const s = createStore();
|
const s = createStore();
|
||||||
s.set(mediaTypeAtom, mediaType);
|
s.set(mediaTypeAtom, mediaType);
|
||||||
s.set(defaultItemsAtom, defaultItems);
|
s.set(defaultItemsAtom, defaultItems);
|
||||||
s.set(initialUserStatusesAtom, userStatuses ?? {});
|
s.set(initialUserStatusesAtom, userStatuses ?? {});
|
||||||
|
s.set(initialEpisodeProgressAtom, episodeProgress ?? {});
|
||||||
return s;
|
return s;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,6 +100,8 @@ function FilterableTitleRowInner({
|
|||||||
const genreResults = useAtomValue(genreResultsLoadable);
|
const genreResults = useAtomValue(genreResultsLoadable);
|
||||||
const initialStatuses = useAtomValue(initialUserStatusesAtom);
|
const initialStatuses = useAtomValue(initialUserStatusesAtom);
|
||||||
const genreStatuses = useAtomValue(genreUserStatusesLoadable);
|
const genreStatuses = useAtomValue(genreUserStatusesLoadable);
|
||||||
|
const initialProgress = useAtomValue(initialEpisodeProgressAtom);
|
||||||
|
const genreProgress = useAtomValue(genreEpisodeProgressLoadable);
|
||||||
|
|
||||||
const loading = selectedGenre !== null && genreResults.state === "loading";
|
const loading = selectedGenre !== null && genreResults.state === "loading";
|
||||||
const items =
|
const items =
|
||||||
@@ -111,6 +118,13 @@ function FilterableTitleRowInner({
|
|||||||
? genreStatuses.data
|
? genreStatuses.data
|
||||||
: initialStatuses;
|
: initialStatuses;
|
||||||
|
|
||||||
|
const episodeProgress =
|
||||||
|
selectedGenre === null
|
||||||
|
? initialProgress
|
||||||
|
: genreProgress.state === "hasData" && genreProgress.data !== null
|
||||||
|
? genreProgress.data
|
||||||
|
: initialProgress;
|
||||||
|
|
||||||
function toggleGenre(genreId: number) {
|
function toggleGenre(genreId: number) {
|
||||||
setSelectedGenre(selectedGenre === genreId ? null : genreId);
|
setSelectedGenre(selectedGenre === genreId ? null : genreId);
|
||||||
}
|
}
|
||||||
@@ -196,6 +210,9 @@ function FilterableTitleRowInner({
|
|||||||
href={`/titles/tmdb-${item.tmdbId}-${item.type}`}
|
href={`/titles/tmdb-${item.tmdbId}-${item.type}`}
|
||||||
showQuickAdd
|
showQuickAdd
|
||||||
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]}
|
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]}
|
||||||
|
episodeProgress={
|
||||||
|
episodeProgress[`${item.tmdbId}-${item.type}`]
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</CarouselItem>
|
</CarouselItem>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
|
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { getUserStatusesByTmdbIds } from "@/lib/services/tracking";
|
import {
|
||||||
|
getEpisodeProgressByTmdbIds,
|
||||||
|
getUserStatusesByTmdbIds,
|
||||||
|
} from "@/lib/services/tracking";
|
||||||
import { getGenres, getPopular, getTrending } from "@/lib/tmdb/client";
|
import { getGenres, getPopular, getTrending } from "@/lib/tmdb/client";
|
||||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||||
import { FilterableTitleRow } from "./filterable-title-row";
|
import { FilterableTitleRow } from "./filterable-title-row";
|
||||||
@@ -49,9 +52,10 @@ export default async function ExplorePage() {
|
|||||||
const popularMovieItems = mapResults(popularMovies.results, "movie");
|
const popularMovieItems = mapResults(popularMovies.results, "movie");
|
||||||
const popularTvItems = mapResults(popularTv.results, "tv");
|
const popularTvItems = mapResults(popularTv.results, "tv");
|
||||||
|
|
||||||
// Fetch user statuses for all visible TMDB IDs
|
// Fetch user statuses and episode progress for all visible TMDB IDs
|
||||||
let userStatuses: Record<string, "watchlist" | "in_progress" | "completed"> =
|
let userStatuses: Record<string, "watchlist" | "in_progress" | "completed"> =
|
||||||
{};
|
{};
|
||||||
|
let episodeProgress: Record<string, { watched: number; total: number }> = {};
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (session) {
|
if (session) {
|
||||||
const allItems = [
|
const allItems = [
|
||||||
@@ -59,10 +63,12 @@ export default async function ExplorePage() {
|
|||||||
...popularMovieItems,
|
...popularMovieItems,
|
||||||
...popularTvItems,
|
...popularTvItems,
|
||||||
];
|
];
|
||||||
userStatuses = getUserStatusesByTmdbIds(
|
const tmdbLookups = allItems.map((i) => ({
|
||||||
session.user.id,
|
tmdbId: i.tmdbId,
|
||||||
allItems.map((i) => ({ tmdbId: i.tmdbId, type: i.type })),
|
type: i.type,
|
||||||
);
|
}));
|
||||||
|
userStatuses = getUserStatusesByTmdbIds(session.user.id, tmdbLookups);
|
||||||
|
episodeProgress = getEpisodeProgressByTmdbIds(session.user.id, tmdbLookups);
|
||||||
}
|
}
|
||||||
|
|
||||||
const heroTitle = trending.results.find(
|
const heroTitle = trending.results.find(
|
||||||
@@ -88,6 +94,7 @@ export default async function ExplorePage() {
|
|||||||
icon={<IconFlame className="size-5 text-primary" />}
|
icon={<IconFlame className="size-5 text-primary" />}
|
||||||
items={trendingItems.slice(0, 20)}
|
items={trendingItems.slice(0, 20)}
|
||||||
userStatuses={userStatuses}
|
userStatuses={userStatuses}
|
||||||
|
episodeProgress={episodeProgress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FilterableTitleRow
|
<FilterableTitleRow
|
||||||
@@ -97,6 +104,7 @@ export default async function ExplorePage() {
|
|||||||
defaultItems={popularMovieItems.slice(0, 20)}
|
defaultItems={popularMovieItems.slice(0, 20)}
|
||||||
genres={movieGenres.genres}
|
genres={movieGenres.genres}
|
||||||
userStatuses={userStatuses}
|
userStatuses={userStatuses}
|
||||||
|
episodeProgress={episodeProgress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FilterableTitleRow
|
<FilterableTitleRow
|
||||||
@@ -106,6 +114,7 @@ export default async function ExplorePage() {
|
|||||||
defaultItems={popularTvItems.slice(0, 20)}
|
defaultItems={popularTvItems.slice(0, 20)}
|
||||||
genres={tvGenres.genres}
|
genres={tvGenres.genres}
|
||||||
userStatuses={userStatuses}
|
userStatuses={userStatuses}
|
||||||
|
episodeProgress={episodeProgress}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface TitleRowProps {
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
items: TitleRowItem[];
|
items: TitleRowItem[];
|
||||||
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
|
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
|
||||||
|
episodeProgress?: Record<string, { watched: number; total: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const staggerContainer = {
|
const staggerContainer = {
|
||||||
@@ -45,6 +46,7 @@ export function TitleRow({
|
|||||||
icon,
|
icon,
|
||||||
items,
|
items,
|
||||||
userStatuses,
|
userStatuses,
|
||||||
|
episodeProgress,
|
||||||
}: TitleRowProps) {
|
}: TitleRowProps) {
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
@@ -81,6 +83,9 @@ export function TitleRow({
|
|||||||
href={`/titles/tmdb-${item.tmdbId}-${item.type}`}
|
href={`/titles/tmdb-${item.tmdbId}-${item.type}`}
|
||||||
showQuickAdd
|
showQuickAdd
|
||||||
userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]}
|
userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]}
|
||||||
|
episodeProgress={
|
||||||
|
episodeProgress?.[`${item.tmdbId}-${item.type}`]
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</CarouselItem>
|
</CarouselItem>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface TitleCardProps {
|
|||||||
onImport?: () => void;
|
onImport?: () => void;
|
||||||
showQuickAdd?: boolean;
|
showQuickAdd?: boolean;
|
||||||
userStatus?: TitleStatus | null;
|
userStatus?: TitleStatus | null;
|
||||||
|
episodeProgress?: { watched: number; total: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type QuickAddState = "idle" | "loading" | "added";
|
type QuickAddState = "idle" | "loading" | "added";
|
||||||
@@ -142,6 +143,26 @@ function StatusBadge({ status }: { status: TitleStatus }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProgressBar({ watched, total }: { watched: number; total: number }) {
|
||||||
|
const pct = total > 0 ? (watched / total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
className="absolute bottom-0 left-0 right-0 z-10 h-1 bg-white/10 cursor-default"
|
||||||
|
render={<div />}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full bg-status-watching transition-all duration-500 ease-out"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{watched}/{total} episodes
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TitleCard({
|
export function TitleCard({
|
||||||
id,
|
id,
|
||||||
tmdbId,
|
tmdbId,
|
||||||
@@ -154,6 +175,7 @@ export function TitleCard({
|
|||||||
onImport,
|
onImport,
|
||||||
showQuickAdd,
|
showQuickAdd,
|
||||||
userStatus,
|
userStatus,
|
||||||
|
episodeProgress,
|
||||||
}: TitleCardProps) {
|
}: TitleCardProps) {
|
||||||
const year = releaseDate?.slice(0, 4);
|
const year = releaseDate?.slice(0, 4);
|
||||||
const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv;
|
const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv;
|
||||||
@@ -209,6 +231,14 @@ export function TitleCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Episode progress bar */}
|
||||||
|
{episodeProgress && episodeProgress.watched > 0 && (
|
||||||
|
<ProgressBar
|
||||||
|
watched={episodeProgress.watched}
|
||||||
|
total={episodeProgress.total}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { db } from "@/lib/db/client";
|
|||||||
import { userTitleStatus } from "@/lib/db/schema";
|
import { userTitleStatus } from "@/lib/db/schema";
|
||||||
import { importTitle } from "@/lib/services/metadata";
|
import { importTitle } from "@/lib/services/metadata";
|
||||||
import {
|
import {
|
||||||
|
getEpisodeProgressByTmdbIds,
|
||||||
getUserStatusesByTmdbIds,
|
getUserStatusesByTmdbIds,
|
||||||
setTitleStatus,
|
setTitleStatus,
|
||||||
} from "@/lib/services/tracking";
|
} from "@/lib/services/tracking";
|
||||||
@@ -19,6 +20,14 @@ export async function fetchUserStatuses(
|
|||||||
return getUserStatusesByTmdbIds(session.user.id, tmdbIds);
|
return getUserStatusesByTmdbIds(session.user.id, tmdbIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchEpisodeProgress(
|
||||||
|
tmdbIds: { tmdbId: number; type: string }[],
|
||||||
|
): Promise<Record<string, { watched: number; total: number }>> {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) return {};
|
||||||
|
return getEpisodeProgressByTmdbIds(session.user.id, tmdbIds);
|
||||||
|
}
|
||||||
|
|
||||||
export async function quickAddToWatchlist(
|
export async function quickAddToWatchlist(
|
||||||
tmdbId: number,
|
tmdbId: number,
|
||||||
type: "movie" | "tv",
|
type: "movie" | "tv",
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { loadable } from "jotai/utils";
|
import { loadable } from "jotai/utils";
|
||||||
import { fetchUserStatuses } from "@/lib/actions/watchlist";
|
import {
|
||||||
|
fetchEpisodeProgress,
|
||||||
|
fetchUserStatuses,
|
||||||
|
} from "@/lib/actions/watchlist";
|
||||||
|
|
||||||
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
||||||
|
|
||||||
@@ -17,6 +20,9 @@ export const selectedGenreAtom = atom<number | null>(null);
|
|||||||
export const mediaTypeAtom = atom<"movie" | "tv">("movie");
|
export const mediaTypeAtom = atom<"movie" | "tv">("movie");
|
||||||
export const defaultItemsAtom = atom<TitleRowItem[]>([]);
|
export const defaultItemsAtom = atom<TitleRowItem[]>([]);
|
||||||
export const initialUserStatusesAtom = atom<Record<string, TitleStatus>>({});
|
export const initialUserStatusesAtom = atom<Record<string, TitleStatus>>({});
|
||||||
|
export const initialEpisodeProgressAtom = atom<
|
||||||
|
Record<string, { watched: number; total: number }>
|
||||||
|
>({});
|
||||||
|
|
||||||
const genreResultsAsyncAtom = atom(async (get) => {
|
const genreResultsAsyncAtom = atom(async (get) => {
|
||||||
const genre = get(selectedGenreAtom);
|
const genre = get(selectedGenreAtom);
|
||||||
@@ -42,3 +48,17 @@ const genreUserStatusesAsyncAtom = atom(async (get) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const genreUserStatusesLoadable = loadable(genreUserStatusesAsyncAtom);
|
export const genreUserStatusesLoadable = loadable(genreUserStatusesAsyncAtom);
|
||||||
|
|
||||||
|
const genreEpisodeProgressAsyncAtom = atom(async (get) => {
|
||||||
|
const genre = get(selectedGenreAtom);
|
||||||
|
if (genre === null) return null;
|
||||||
|
const genreResults = await get(genreResultsAsyncAtom);
|
||||||
|
if (!genreResults || genreResults.length === 0) return {};
|
||||||
|
return fetchEpisodeProgress(
|
||||||
|
genreResults.map((r) => ({ tmdbId: r.tmdbId, type: r.type })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const genreEpisodeProgressLoadable = loadable(
|
||||||
|
genreEpisodeProgressAsyncAtom,
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, count, eq, inArray, sql } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import {
|
import {
|
||||||
episodes,
|
episodes,
|
||||||
@@ -330,6 +330,48 @@ export function getUserStatusesByTmdbIds(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getEpisodeProgressByTmdbIds(
|
||||||
|
userId: string,
|
||||||
|
tmdbIds: { tmdbId: number; type: string }[],
|
||||||
|
): Record<string, { watched: number; total: number }> {
|
||||||
|
const tvIds = tmdbIds.filter((t) => t.type === "tv").map((t) => t.tmdbId);
|
||||||
|
if (tvIds.length === 0) return {};
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
tmdbId: titles.tmdbId,
|
||||||
|
totalEpisodes: count(episodes.id),
|
||||||
|
watchedEpisodes:
|
||||||
|
sql<number>`sum(case when ${userEpisodeWatches.id} is not null then 1 else 0 end)`.as(
|
||||||
|
"watchedEpisodes",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.from(titles)
|
||||||
|
.innerJoin(seasons, eq(seasons.titleId, titles.id))
|
||||||
|
.innerJoin(episodes, eq(episodes.seasonId, seasons.id))
|
||||||
|
.leftJoin(
|
||||||
|
userEpisodeWatches,
|
||||||
|
and(
|
||||||
|
eq(userEpisodeWatches.episodeId, episodes.id),
|
||||||
|
eq(userEpisodeWatches.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(and(inArray(titles.tmdbId, tvIds), eq(titles.type, "tv")))
|
||||||
|
.groupBy(titles.tmdbId)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const result: Record<string, { watched: number; total: number }> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.watchedEpisodes > 0) {
|
||||||
|
result[`${row.tmdbId}-tv`] = {
|
||||||
|
watched: row.watchedEpisodes,
|
||||||
|
total: row.totalEpisodes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function getUserTitleInfo(userId: string, titleId: string) {
|
export function getUserTitleInfo(userId: string, titleId: string) {
|
||||||
const status = db
|
const status = db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
Reference in New Issue
Block a user