mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 00:25: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:
@@ -1,4 +1,4 @@
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { IconBooks } from "@tabler/icons-react";
|
||||
import { getNewAvailableFeed } from "@/lib/services/discovery";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import { FeedSection } from "./feed-section";
|
||||
@@ -21,7 +21,7 @@ export async function LibrarySection({ userId }: { userId: string }) {
|
||||
return (
|
||||
<FeedSection
|
||||
title="In Your Library"
|
||||
icon={<IconSparkles className="size-5 text-primary" />}
|
||||
icon={<IconBooks className="size-5 text-primary" />}
|
||||
>
|
||||
<TitleGrid items={items} />
|
||||
</FeedSection>
|
||||
|
||||
@@ -82,14 +82,11 @@ function StatCard({
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<motion.p
|
||||
className={`relative z-10 mt-2 font-display text-2xl tabular-nums tracking-tight ${color} transition-opacity ${loading ? "opacity-40" : ""}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: loading ? 0.4 : 1 }}
|
||||
transition={{ delay: index * 0.08 + 0.2 }}
|
||||
<p
|
||||
className={`relative z-10 mt-2 font-display text-2xl tabular-nums tracking-tight ${color} transition-opacity duration-300 ${loading ? "opacity-40" : "opacity-100"}`}
|
||||
>
|
||||
{value}
|
||||
</motion.p>
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function StatsSection({ userId }: { userId: string }) {
|
||||
<StatsDisplay stats={stats} />
|
||||
{isEmpty && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-border/50 py-16 text-center">
|
||||
<div className="animate-gentle-float rounded-full bg-primary/10 p-4">
|
||||
<div className="rounded-full bg-primary/10 p-4">
|
||||
<IconDeviceTv className="size-8 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -137,7 +137,7 @@ function FilterableTitleRowInner({
|
||||
</div>
|
||||
|
||||
{/* Genre chips */}
|
||||
<div className="feed-scroll -mx-4 flex gap-2 overflow-x-auto px-4 pb-1 sm:-mx-0 sm:flex-wrap sm:px-0">
|
||||
<div className="no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4 pb-1 sm:-mx-0 sm:flex-wrap sm:px-0">
|
||||
{genres.map((genre) => (
|
||||
<button
|
||||
key={genre.id}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+32
-103
@@ -95,122 +95,51 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
/* Film grain texture overlay */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 0.015;
|
||||
background-image: 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");
|
||||
}
|
||||
|
||||
/* Restore hand cursor on interactive elements */
|
||||
/* https://ui.shadcn.com/docs/components/radix/button#cursor */
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled),
|
||||
[role="switch"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* Film grain texture overlay */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 0.015;
|
||||
background-image: 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");
|
||||
}
|
||||
|
||||
/* Custom keyframe animations */
|
||||
@keyframes card-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(1 0 0 / 15%);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(1 0 0 / 25%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes check-pop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
@layer utilities {
|
||||
/* Horizontal scroll row for feeds */
|
||||
.no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes star-fill {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gentle-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Warm skeleton pulse override */
|
||||
@keyframes warm-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="skeleton"] {
|
||||
animation: warm-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
background: oklch(0.25 0.01 55);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(1 0 0 / 15%);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(1 0 0 / 25%);
|
||||
}
|
||||
|
||||
/* Horizontal scroll row for feeds */
|
||||
.feed-scroll {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.feed-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Utility animations */
|
||||
.animate-gentle-float {
|
||||
animation: gentle-float 3s ease-in-out infinite;
|
||||
}
|
||||
.animate-check-pop {
|
||||
animation: check-pop 0.3s ease-out;
|
||||
}
|
||||
.animate-star-fill {
|
||||
animation: star-fill 0.3s ease-out;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export function CommandPalette() {
|
||||
(result: SearchResult) => {
|
||||
setCommandPaletteOpen(false);
|
||||
if (result.type === "person") {
|
||||
router.push(`/person/tmdb-${result.tmdbId}`);
|
||||
router.push(`/people/tmdb-${result.tmdbId}`);
|
||||
} else {
|
||||
router.push(`/titles/tmdb-${result.tmdbId}-${result.type}`);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
IconPlus,
|
||||
IconStarFilled,
|
||||
} from "@tabler/icons-react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
@@ -173,10 +173,8 @@ function CardInner({
|
||||
: "ring-white/[0.06]";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`relative overflow-hidden rounded-xl bg-card ring-1 transition-shadow hover:ring-primary/25 hover:shadow-lg hover:shadow-primary/5 ${ringClass}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-xl bg-card ring-1 transition-all duration-200 ease-out hover:scale-[1.02] hover:ring-primary/25 hover:shadow-lg hover:shadow-primary/5 ${ringClass}`}
|
||||
>
|
||||
<div className="aspect-[2/3] overflow-hidden bg-card">
|
||||
{posterPath ? (
|
||||
@@ -248,7 +246,7 @@ function CardInner({
|
||||
total={episodeProgress.total}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user