mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 05:05:38 -04:00
- Add composite indexes on userEpisodeWatches and userMovieWatches for hot queries - Batch episode tracking: wrap season/batch watches in single transaction (~8 queries vs 8*N) - Fix N+1 patterns in credits, recommendations, and filmography with batch prefetch+insert - Stream TV season hydration via Suspense instead of blocking page render - Optimize webhook logs (per-connection LIMIT 10) and system health queries - Merge genre filter waterfalls into single Promise.all fetch - Migrate deprecated Jotai loadable() to unwrap() - Replace isolated createStore()+Provider with useHydrateAtoms on root store - Remove unnecessary atomWithStorage SSR guards (handled by Jotai internally) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
353 lines
11 KiB
TypeScript
353 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
IconBookmarkFilled,
|
|
IconCheck,
|
|
IconCircleCheckFilled,
|
|
IconDeviceTv,
|
|
IconLoader,
|
|
IconMovie,
|
|
IconPlayerPlayFilled,
|
|
IconPlus,
|
|
IconStarFilled,
|
|
} from "@tabler/icons-react";
|
|
import { type MotionStyle, type MotionValue, motion } from "motion/react";
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { useState } from "react";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { useTiltEffect } from "@/hooks/use-tilt-effect";
|
|
import { quickAddToWatchlist } from "@/lib/actions/watchlist";
|
|
|
|
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
|
|
|
interface TiltStyles {
|
|
imageStyle: MotionStyle;
|
|
glareBackground: MotionValue<string>;
|
|
glareOpacity: MotionValue<number>;
|
|
}
|
|
|
|
interface CardInnerProps {
|
|
title: string;
|
|
type: string;
|
|
posterPath: string | null;
|
|
releaseDate?: string | null;
|
|
voteAverage?: number | null;
|
|
userStatus?: TitleStatus | null;
|
|
episodeProgress?: { watched: number; total: number } | null;
|
|
tiltStyles?: TiltStyles;
|
|
}
|
|
|
|
interface TitleCardProps extends CardInnerProps {
|
|
id: string;
|
|
tmdbId: number;
|
|
}
|
|
|
|
interface ExploreTitleCardProps extends CardInnerProps {
|
|
tmdbId: number;
|
|
href: string;
|
|
userStatus?: TitleStatus | null;
|
|
episodeProgress?: { watched: number; total: number } | 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>(
|
|
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();
|
|
e.stopPropagation();
|
|
if (state === "loading" || state === "added") return;
|
|
setState("loading");
|
|
try {
|
|
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
|
|
onClick={handleClick}
|
|
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 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 hover:bg-black/70"
|
|
render={<button type="button" />}
|
|
>
|
|
{state === "idle" && <IconPlus className="size-4" />}
|
|
{state === "loading" && <IconLoader className="size-4 animate-spin" />}
|
|
{state === "added" && <IconCheck className="size-4 text-green-400" />}
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
{state === "added" ? "Added to Watchlist" : "Add to Watchlist"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
function CardInner({
|
|
title,
|
|
type,
|
|
posterPath,
|
|
releaseDate,
|
|
voteAverage,
|
|
userStatus,
|
|
episodeProgress,
|
|
tiltStyles,
|
|
}: CardInnerProps) {
|
|
const year = releaseDate?.slice(0, 4);
|
|
const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv;
|
|
|
|
const ringClass = userStatus
|
|
? "ring-primary/25 shadow-sm shadow-primary/5"
|
|
: "ring-white/[0.06]";
|
|
|
|
return (
|
|
<div
|
|
className={`relative overflow-hidden rounded-xl bg-card ring-1 transition-[box-shadow,ring-color] duration-200 ease-out hover:ring-primary/25 hover:shadow-lg hover:shadow-primary/5 ${ringClass}`}
|
|
>
|
|
<div className="aspect-[2/3] overflow-hidden bg-card">
|
|
{posterPath ? (
|
|
<motion.div style={tiltStyles?.imageStyle}>
|
|
<Image
|
|
src={posterPath}
|
|
alt={title}
|
|
width={300}
|
|
height={450}
|
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
/>
|
|
</motion.div>
|
|
) : (
|
|
<div className="relative flex h-full items-center justify-center overflow-hidden bg-gradient-to-br from-card via-secondary to-muted">
|
|
<div
|
|
className="pointer-events-none absolute inset-0 opacity-[0.06]"
|
|
style={{
|
|
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
|
|
}}
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-primary/10 via-transparent to-transparent" />
|
|
<div className="relative px-3 text-center">
|
|
<p className="font-display text-sm leading-snug tracking-tight text-foreground/70">
|
|
{title}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<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" />
|
|
{tiltStyles && (
|
|
<motion.div
|
|
className="pointer-events-none absolute inset-0 z-[5] rounded-xl"
|
|
style={{
|
|
background: tiltStyles.glareBackground,
|
|
opacity: tiltStyles.glareOpacity,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="px-3 pb-3 pt-2.5">
|
|
<div className="flex items-center gap-1.5">
|
|
{userStatus && (
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
className="cursor-default shrink-0"
|
|
render={<div className="flex items-center" />}
|
|
>
|
|
<span className="relative flex size-2">
|
|
<span
|
|
className={`absolute inline-flex h-full w-full rounded-full opacity-40 ${userStatus === "completed" ? "bg-status-completed" : "bg-status-watching"}`}
|
|
/>
|
|
<span
|
|
className={`relative inline-flex size-2 rounded-full ${userStatus === "completed" ? "bg-status-completed" : "bg-status-watching"}`}
|
|
/>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{statusConfig[userStatus].label}</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<p className="line-clamp-1 text-sm font-medium leading-snug">
|
|
{title}
|
|
</p>
|
|
</div>
|
|
<div className="mt-1.5 flex items-center gap-2 text-xs text-muted-foreground">
|
|
<TypeIcon className="size-3.5 shrink-0 text-primary/60" />
|
|
{year && <span>{year}</span>}
|
|
{voteAverage != null && voteAverage > 0 && (
|
|
<span className="ml-auto flex items-center gap-0.5 text-primary/80">
|
|
<IconStarFilled className="size-[11px]" />
|
|
{voteAverage.toFixed(1)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{episodeProgress && episodeProgress.watched > 0 && (
|
|
<ProgressBar
|
|
watched={episodeProgress.watched}
|
|
total={episodeProgress.total}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Linked title card for library grids, recommendations, dashboards */
|
|
export function TitleCard({
|
|
id,
|
|
tmdbId: _tmdbId,
|
|
type,
|
|
title,
|
|
posterPath,
|
|
releaseDate,
|
|
voteAverage,
|
|
userStatus,
|
|
episodeProgress,
|
|
}: TitleCardProps) {
|
|
const tilt = useTiltEffect();
|
|
return (
|
|
<Link href={`/titles/${id}`} className="group">
|
|
<motion.div ref={tilt.ref} style={tilt.containerStyle} {...tilt.handlers}>
|
|
<CardInner
|
|
title={title}
|
|
type={type}
|
|
posterPath={posterPath}
|
|
releaseDate={releaseDate}
|
|
voteAverage={voteAverage}
|
|
userStatus={userStatus}
|
|
episodeProgress={episodeProgress}
|
|
tiltStyles={{
|
|
imageStyle: tilt.imageStyle,
|
|
glareBackground: tilt.glareBackground,
|
|
glareOpacity: tilt.glareOpacity,
|
|
}}
|
|
/>
|
|
</motion.div>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
/** Explore/browse card with quick-add button and custom href */
|
|
export function ExploreTitleCard({
|
|
tmdbId,
|
|
type,
|
|
title,
|
|
posterPath,
|
|
releaseDate,
|
|
voteAverage,
|
|
href,
|
|
userStatus,
|
|
episodeProgress,
|
|
}: ExploreTitleCardProps) {
|
|
const tilt = useTiltEffect();
|
|
return (
|
|
<div className="relative group">
|
|
<QuickAddButton
|
|
tmdbId={tmdbId}
|
|
type={type as "movie" | "tv"}
|
|
userStatus={userStatus}
|
|
/>
|
|
<Link href={href}>
|
|
<motion.div
|
|
ref={tilt.ref}
|
|
style={tilt.containerStyle}
|
|
{...tilt.handlers}
|
|
>
|
|
<CardInner
|
|
title={title}
|
|
type={type}
|
|
posterPath={posterPath}
|
|
releaseDate={releaseDate}
|
|
voteAverage={voteAverage}
|
|
userStatus={userStatus}
|
|
episodeProgress={episodeProgress}
|
|
tiltStyles={{
|
|
imageStyle: tilt.imageStyle,
|
|
glareBackground: tilt.glareBackground,
|
|
glareOpacity: tilt.glareOpacity,
|
|
}}
|
|
/>
|
|
</motion.div>
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|