Rename /person to /people, simplify cast to actors-only, and polish UI

Rename the person detail route from `app/(pages)/person/[id]` to
`app/(pages)/people/[id]` and update all internal links accordingly.

Strip crew members from the cast carousel so only actors are shown;
remove the `crew` prop from `CastCarousel` and `TitleCast` entirely.

Move the trailer trigger from the metadata row into a centered overlay
on the backdrop image using a new `variant="backdrop"` prop on
`TrailerDialog`, replacing the previous inline button approach.

Swap several icons for better semantic matches: `IconBooks` for the
library section, `IconCheck` for the "Mark Watched" button,
`IconSparkles` on the Recommendations heading, and `IconDeviceTvOld`
on the Seasons heading.

Replace a nested `motion.p` with a plain `<p>` using CSS
`transition-opacity` in `StatsDisplay`. Remove the `animate-gentle-float`
keyframe and the `feed-scroll` utility from `globals.css`, replacing the
latter with the existing `no-scrollbar` class.
This commit is contained in:
2026-03-05 15:32:05 -05:00
parent cd5d773e98
commit 71b39d5aca
20 changed files with 115 additions and 190 deletions
@@ -29,11 +29,10 @@ const staggerItem = {
interface CastCarouselProps {
actors: CastMember[];
crew: CastMember[];
titleType: "movie" | "tv";
}
export function CastCarousel({ actors, crew, titleType }: CastCarouselProps) {
export function CastCarousel({ actors, titleType }: CastCarouselProps) {
return (
<section className="space-y-4">
<div className="flex items-center gap-2">
@@ -64,7 +63,7 @@ export function CastCarousel({ actors, crew, titleType }: CastCarouselProps) {
>
<motion.div variants={staggerItem}>
<Link
href={`/person/${member.personId}`}
href={`/people/${member.personId}`}
className="group flex flex-col items-center gap-2"
>
<div className="size-20 overflow-hidden rounded-full ring-1 ring-white/10 transition-all group-hover:ring-primary/25 sm:size-24">
@@ -106,23 +105,6 @@ export function CastCarousel({ actors, crew, titleType }: CastCarouselProps) {
</Carousel>
</motion.div>
)}
{crew.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-sm text-muted-foreground">
{crew.map((member) => (
<Link
key={member.id}
href={`/person/${member.personId}`}
className="transition-colors hover:text-foreground"
>
{member.name}
<span className="ml-1 text-xs text-muted-foreground/60">
({member.job})
</span>
</Link>
))}
</div>
)}
</section>
);
}
@@ -1,5 +1,6 @@
"use client";
import { IconSparkles } from "@tabler/icons-react";
import { motion } from "motion/react";
import { TitleCard } from "@/components/title-card";
import type { RecommendedTitle } from "@/lib/types/title";
@@ -26,7 +27,10 @@ export function RecommendationsGrid({
}) {
return (
<div className="space-y-4">
<h2 className="font-display text-2xl tracking-tight">Recommended</h2>
<div className="flex items-center gap-2">
<IconSparkles className="size-5 text-primary" />
<h2 className="font-display text-2xl tracking-tight">Recommended</h2>
</div>
<motion.div
className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6"
variants={staggerContainer}
@@ -1,6 +1,6 @@
"use client";
import { IconPlayerPlay } from "@tabler/icons-react";
import { IconCheck } from "@tabler/icons-react";
import { useAtomValue } from "jotai";
import {
titleTypeAtom,
@@ -30,7 +30,7 @@ export function TitleActions() {
onClick={handleWatchMovie}
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all active:scale-[0.97] hover:shadow-md hover:shadow-primary/20"
>
<IconPlayerPlay className="size-3.5" />
<IconCheck className="size-3.5" />
Mark Watched
</button>
)}
@@ -8,9 +8,8 @@ interface TitleCastProps {
export function TitleCast({ cast, titleType }: TitleCastProps) {
const actors = cast.filter((c) => c.department === "Acting");
const crew = cast.filter((c) => c.department !== "Acting");
if (actors.length === 0 && crew.length === 0) return null;
if (actors.length === 0) return null;
return <CastCarousel actors={actors} crew={crew} titleType={titleType} />;
return <CastCarousel actors={actors} titleType={titleType} />;
}
@@ -57,6 +57,11 @@ export function TitleHero({
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")`,
}}
/>
{trailerVideoKey && (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<TrailerDialog videoKey={trailerVideoKey} variant="backdrop" />
</div>
)}
</div>
)}
@@ -122,7 +127,6 @@ export function TitleHero({
>
<TmdbLogo className="h-2.5 w-auto" />
</a>
{trailerVideoKey && <TrailerDialog videoKey={trailerVideoKey} />}
</div>
</div>
@@ -5,6 +5,7 @@ import {
IconChecks,
IconChevronDown,
IconChevronUp,
IconDeviceTvOld,
} from "@tabler/icons-react";
import { useAtomValue } from "jotai";
import { AnimatePresence, motion } from "motion/react";
@@ -47,7 +48,10 @@ export function TitleSeasons() {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-display text-2xl tracking-tight">Seasons</h2>
<div className="flex items-center gap-2">
<IconDeviceTvOld className="size-5 text-primary" />
<h2 className="font-display text-2xl tracking-tight">Seasons</h2>
</div>
{userStatus && userStatus !== "completed" && (
<AlertDialog open={markAllOpen} onOpenChange={setMarkAllOpen}>
<AlertDialogTrigger
@@ -1,56 +1,64 @@
"use client";
import { IconPlayerPlayFilled } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { useState } from "react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
export function TrailerDialog({ videoKey }: { videoKey: string }) {
const [open, setOpen] = useState(false);
const [loaded, setLoaded] = useState(false);
const YoutubeVideo = dynamic(() => import("youtube-video-element/react"), {
ssr: false,
});
const MediaThemeSutro = dynamic(() => import("@player.style/sutro/react"), {
ssr: false,
});
useEffect(() => {
if (open && !loaded) {
import("youtube-video-element");
import("@player.style/sutro");
setLoaded(true);
}
}, [open, loaded]);
export function TrailerDialog({
videoKey,
variant = "badge",
}: {
videoKey: string;
variant?: "badge" | "backdrop";
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-5 items-center gap-1 rounded border border-border/50 px-2 text-xs text-muted-foreground transition-colors hover:border-border hover:text-foreground"
>
<IconPlayerPlayFilled className="h-2.5 w-2.5" />
Trailer
</button>
<DialogContent
showCloseButton={false}
className="sm:max-w-4xl p-0 overflow-hidden bg-black border-white/10"
>
{variant === "backdrop" ? (
<button
type="button"
onClick={() => setOpen(true)}
className="group flex size-12 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/20 backdrop-blur-md transition-all duration-300 hover:scale-105 hover:bg-white/20 hover:ring-white/30 active:scale-100 sm:size-16"
>
<IconPlayerPlayFilled className="size-6 text-white drop-shadow-lg sm:size-8" />
</button>
) : (
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-5 items-center gap-1 rounded border border-border/50 px-2 text-xs text-muted-foreground transition-colors hover:border-border hover:text-foreground"
>
<IconPlayerPlayFilled className="h-2.5 w-2.5" />
Trailer
</button>
)}
<DialogContent className="sm:max-w-4xl p-0 overflow-hidden bg-black border-white/10">
<DialogTitle className="sr-only">Trailer</DialogTitle>
<div className="aspect-video w-full">
{loaded && (
// @ts-expect-error -- web components not in JSX types
<media-theme-sutro
style={{
<MediaThemeSutro
style={
{
width: "100%",
height: "100%",
"--media-primary-color": "hsl(35 100% 66%)",
"--media-accent-color": "hsl(35 100% 66%)",
}}
>
{/* @ts-expect-error -- web component */}
<youtube-video
slot="media"
src={`https://www.youtube.com/watch?v=${videoKey}`}
crossorigin=""
/>
{/* @ts-expect-error -- web component */}
</media-theme-sutro>
)}
} as React.CSSProperties
}
>
<YoutubeVideo
slot="media"
src={`https://www.youtube-nocookie.com/watch?v=${videoKey}`}
playsInline
crossOrigin="anonymous"
/>
</MediaThemeSutro>
</div>
</DialogContent>
</Dialog>
+2 -2
View File
@@ -94,10 +94,10 @@ export default async function TitleDetailPage({
<TitleAvailability availability={availability} />
</TitleHero>
<TitleCast cast={cast} titleType={title.type} />
{title.type === "tv" && seasons.length > 0 && <TitleSeasons />}
<TitleCast cast={cast} titleType={title.type} />
<TitleKeyboardShortcuts />
</TitleProvider>