Declutter title page metadata row with cleaner visual hierarchy

Replace mixed badge styles with a single icon type badge (with tooltip),
CSS-based dot separators, collapsed genres with hover popover, status
icons, and subtle TMDB link. Remove vote count for less noise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 21:26:47 -05:00
co-authored by Claude Opus 4.6
parent 9c68768295
commit 11143d0803
4 changed files with 120 additions and 42 deletions
@@ -0,0 +1,41 @@
"use client";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export function GenreCollapse({ genres }: { genres: string[] }) {
if (genres.length === 0) return null;
if (genres.length === 1) return <span>{genres[0]}</span>;
const remaining = genres.slice(1);
return (
<span className="inline-flex items-center gap-1">
<span>{genres[0]}</span>
<Popover>
<PopoverTrigger
openOnHover
delay={0}
closeDelay={300}
className="cursor-default text-muted-foreground/70 transition-colors hover:text-muted-foreground"
aria-label={`${remaining.length} more genre${remaining.length > 1 ? "s" : ""}`}
>
+{remaining.length}
</PopoverTrigger>
<PopoverContent className="flex w-auto min-w-28 max-w-48 flex-col gap-0 p-1">
{remaining.map((genre) => (
<span
key={genre}
className="px-2 py-1 text-xs text-popover-foreground"
>
{genre}
</span>
))}
</PopoverContent>
</Popover>
</span>
);
}
@@ -143,7 +143,7 @@ export function TitleAvailability({
if (Object.keys(availByType).length === 0) return null; if (Object.keys(availByType).length === 0) return null;
return ( return (
<div className="space-y-3"> <div className="pt-1 space-y-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Where to Watch Where to Watch
</h2> </h2>
@@ -1,9 +1,18 @@
import {
IconCalendarEvent,
IconCircleCheck,
IconCircleX,
IconLoader,
IconRefresh,
IconStarFilled,
} from "@tabler/icons-react";
import Image from "next/image"; import Image from "next/image";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { TmdbLogo } from "@/components/tmdb-logo"; import { TmdbLogo } from "@/components/tmdb-logo";
import { Badge } from "@/components/ui/badge";
import type { ColorPalette, ResolvedTitle } from "@/lib/types/title"; import type { ColorPalette, ResolvedTitle } from "@/lib/types/title";
import { GenreCollapse } from "./genre-collapse";
import { TrailerDialog } from "./trailer-dialog"; import { TrailerDialog } from "./trailer-dialog";
import { TypeBadge } from "./type-badge";
export function TitleHero({ export function TitleHero({
title, title,
@@ -100,51 +109,39 @@ export function TitleHero({
<h1 className="font-display text-2xl tracking-tight text-balance sm:text-5xl"> <h1 className="font-display text-2xl tracking-tight text-balance sm:text-5xl">
{title.title} {title.title}
</h1> </h1>
<div className="mt-2 flex flex-wrap items-center gap-3 text-sm text-muted-foreground"> <div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
<Badge className="rounded border-0 bg-primary/10 font-semibold uppercase tracking-wider text-primary"> <TypeBadge type={title.type} />
{title.type} <div className="flex flex-wrap items-center gap-x-2 gap-y-1 [&>*+*]:before:mr-2 [&>*+*]:before:text-border [&>*+*]:before:content-['·']">
</Badge> {title.contentRating && <span>{title.contentRating}</span>}
{title.contentRating && (
<Badge
variant="outline"
className="rounded border-border/50 font-semibold uppercase tracking-wider"
>
{title.contentRating}
</Badge>
)}
{year && <span>{year}</span>} {year && <span>{year}</span>}
{title.genres.length > 0 && ( {title.genres.length > 0 && (
<span>{title.genres.join(" · ")}</span> <GenreCollapse genres={title.genres} />
)} )}
{title.voteAverage != null && title.voteAverage > 0 && ( {title.voteAverage != null && title.voteAverage > 0 && (
<span className="flex items-center gap-1 text-primary"> <span className="inline-flex items-center gap-1 text-primary">
{title.voteAverage.toFixed(1)} <IconStarFilled className="size-3.5" />
{title.voteCount != null && ( {title.voteAverage.toFixed(1)}
<span className="text-muted-foreground">
({title.voteCount.toLocaleString()})
</span> </span>
)} )}
</span> {title.status &&
)} !(title.type === "movie" && title.status === "Released") && (
{title.status && ( <span className="inline-flex items-center gap-1">
<Badge <StatusIcon status={title.status} />
variant="outline"
className="rounded border-border/50 font-normal"
>
{title.status} {title.status}
</Badge> </span>
)} )}
<a <a
href={`https://www.themoviedb.org/${title.type === "movie" ? "movie" : "tv"}/${title.tmdbId}`} href={`https://www.themoviedb.org/${title.type === "movie" ? "movie" : "tv"}/${title.tmdbId}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label="View on TMDB" aria-label="View on TMDB"
className="inline-flex h-5 items-center rounded border border-border/50 px-2 text-xs text-muted-foreground transition-colors hover:border-border hover:text-foreground" className="inline-flex items-center opacity-70 transition-opacity hover:opacity-40"
> >
<TmdbLogo className="h-2.5 w-auto" /> <TmdbLogo className="h-2.5 w-auto" />
</a> </a>
</div> </div>
</div> </div>
</div>
{title.overview && ( {title.overview && (
<p className="max-w-2xl break-words leading-relaxed text-muted-foreground"> <p className="max-w-2xl break-words leading-relaxed text-muted-foreground">
@@ -161,6 +158,21 @@ export function TitleHero({
); );
} }
const statusIcons: Record<string, React.ReactNode> = {
Ended: <IconCircleCheck className="size-3.5" />,
Canceled: <IconCircleX className="size-3.5" />,
"Returning Series": <IconRefresh className="size-3.5" />,
"In Production": <IconLoader className="size-3.5" />,
"Post Production": <IconLoader className="size-3.5" />,
Planned: <IconCalendarEvent className="size-3.5" />,
Pilot: <IconCalendarEvent className="size-3.5" />,
Rumored: <IconCalendarEvent className="size-3.5" />,
};
function StatusIcon({ status }: { status: string }) {
return statusIcons[status] ?? null;
}
function AmbientGlow({ palette }: { palette: ColorPalette | null }) { function AmbientGlow({ palette }: { palette: ColorPalette | null }) {
if (!palette) return null; if (!palette) return null;
@@ -0,0 +1,25 @@
"use client";
import { IconDeviceTv, IconMovie } from "@tabler/icons-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function TypeBadge({ type }: { type: "movie" | "tv" }) {
const isMovie = type === "movie";
return (
<Tooltip>
<TooltipTrigger className="inline-flex cursor-default items-center justify-center rounded bg-primary/10 p-1 text-primary">
{isMovie ? (
<IconMovie aria-hidden className="size-3.5" />
) : (
<IconDeviceTv aria-hidden className="size-3.5" />
)}
</TooltipTrigger>
<TooltipContent>{isMovie ? "Movie" : "TV Series"}</TooltipContent>
</Tooltip>
);
}