mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -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;
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user