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