mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 00:25:38 -04:00
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:
@@ -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;
|
||||
|
||||
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">
|
||||
Where to Watch
|
||||
</h2>
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import {
|
||||
IconCalendarEvent,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconLoader,
|
||||
IconRefresh,
|
||||
IconStarFilled,
|
||||
} from "@tabler/icons-react";
|
||||
import Image from "next/image";
|
||||
import type { ReactNode } from "react";
|
||||
import { TmdbLogo } from "@/components/tmdb-logo";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { ColorPalette, ResolvedTitle } from "@/lib/types/title";
|
||||
import { GenreCollapse } from "./genre-collapse";
|
||||
import { TrailerDialog } from "./trailer-dialog";
|
||||
import { TypeBadge } from "./type-badge";
|
||||
|
||||
export function TitleHero({
|
||||
title,
|
||||
@@ -100,51 +109,39 @@ export function TitleHero({
|
||||
<h1 className="font-display text-2xl tracking-tight text-balance sm:text-5xl">
|
||||
{title.title}
|
||||
</h1>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
<Badge className="rounded border-0 bg-primary/10 font-semibold uppercase tracking-wider text-primary">
|
||||
{title.type}
|
||||
</Badge>
|
||||
{title.contentRating && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded border-border/50 font-semibold uppercase tracking-wider"
|
||||
>
|
||||
{title.contentRating}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
|
||||
<TypeBadge type={title.type} />
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 [&>*+*]:before:mr-2 [&>*+*]:before:text-border [&>*+*]:before:content-['·']">
|
||||
{title.contentRating && <span>{title.contentRating}</span>}
|
||||
{year && <span>{year}</span>}
|
||||
{title.genres.length > 0 && (
|
||||
<span>{title.genres.join(" · ")}</span>
|
||||
<GenreCollapse genres={title.genres} />
|
||||
)}
|
||||
{title.voteAverage != null && title.voteAverage > 0 && (
|
||||
<span className="flex items-center gap-1 text-primary">
|
||||
★ {title.voteAverage.toFixed(1)}
|
||||
{title.voteCount != null && (
|
||||
<span className="text-muted-foreground">
|
||||
({title.voteCount.toLocaleString()})
|
||||
<span className="inline-flex items-center gap-1 text-primary">
|
||||
<IconStarFilled className="size-3.5" />
|
||||
{title.voteAverage.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{title.status && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded border-border/50 font-normal"
|
||||
>
|
||||
{title.status &&
|
||||
!(title.type === "movie" && title.status === "Released") && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<StatusIcon status={title.status} />
|
||||
{title.status}
|
||||
</Badge>
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={`https://www.themoviedb.org/${title.type === "movie" ? "movie" : "tv"}/${title.tmdbId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{title.overview && (
|
||||
<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 }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user