feat(design): one media card, and Home's hero and shelves as cards (E28 S3h)
- ui/MediaCard + mediaCardStyles: one frame for every video, Plex title and Home tile; the meta line pinned to the bottom edge, the length a badge on the picture, the media Play only where the click plays, the hover row at the picture's bottom-right (the owner's pick C). - Shelf density: on any strip a one-line title over one meta line; mixed Home shelves are landscape; rails turn content-visibility off (a card's reserved height had become the strip's). Home slot 232px, so the Play and the hover row no longer touch; a landscape shelf takes the small Play. - Home hero is a card (hover row, the whole menu, a touch "…"), three quarters wide and centred; channel / playlist tiles get Play, open and "…"; a show tile acts on its next episode, ⓘ opens the show. - Plex preview: a ~0.9 s mouse rest opens a passive panel with the whole title, facts and synopsis (popover `pointerOnly`); the synopsis and title tooltips are gone. - Compact list row B: the title leads, "…" at the end, toggles over the date on hover. - Media-info layout B (collections, crew, cast beside the facts sidebar; an empty column is not drawn); filtering values are chips there and on the show page; "Cast" over the cast-only strips. - Browse tiles hover like a card; prose capped by the `measure` utility.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Play } from "lucide-react";
|
||||
import { iconButtonClass } from "./buttonStyles";
|
||||
import { MoreButton } from "./MoreButton";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
// The ONE media card (docs/DESIGN.md "Media card", E28 S3h): a YouTube video, a Plex movie, show,
|
||||
// season or episode — in the feed, the Plex library, Home's shelves and the media pages' strips — is
|
||||
// the same object: one glass frame holding the picture, then the title, then a meta line PINNED to the
|
||||
// frame's bottom edge. Cards in a grid row stretch to the row's height, so pinning the meta puts every
|
||||
// card's meta on one line whatever its title's length; a one-line title leaves room between the title
|
||||
// and the meta, never an empty line under it (the owner's pick, option C, 2026-09-26). The length is
|
||||
// a badge ON the picture, as YouTube draws it.
|
||||
//
|
||||
// The callers keep what differs (a YouTube picture is a link, a Plex picture is the card's play
|
||||
// button) and build it from the pieces here and in ./mediaCardStyles, so the look cannot drift
|
||||
// apart again.
|
||||
|
||||
/** The Play drawn on a picture whose whole box is the play button: the `media` IconButton's look
|
||||
* (so it matches the YouTube card's real one), but only a picture of it — the box around it is the
|
||||
* control, and a button inside a button is not allowed. Only where a click PLAYS: a show or a season
|
||||
* opens a page, and a Play there would be a promise the click does not keep. */
|
||||
export function MediaPlayGlyph({ small = false }: { small?: boolean }) {
|
||||
return (
|
||||
<span aria-hidden className={iconButtonClass({ variant: "media", size: small ? "md" : "lg" })}>
|
||||
<Play className="translate-x-px fill-current" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** How far into it the viewer is: a bar along the picture's bottom edge. */
|
||||
export function MediaProgress({ pct }: { pct: number }) {
|
||||
if (pct <= 0) return null;
|
||||
return (
|
||||
<div className="absolute inset-x-0 bottom-0 z-base h-1 bg-black/40">
|
||||
<div className="h-full bg-accent" style={{ width: `${Math.min(100, pct)}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaCard({
|
||||
rootProps,
|
||||
dense = false,
|
||||
dim = false,
|
||||
picture,
|
||||
leading,
|
||||
title,
|
||||
meta,
|
||||
more,
|
||||
moreLabel = "",
|
||||
className,
|
||||
}: {
|
||||
/** The card's own attributes: its right-click menu, its keyboard-walk position, a test id. */
|
||||
rootProps?: HTMLAttributes<HTMLDivElement> & Record<string, unknown>;
|
||||
/** A narrow column (a small card, a rail, a poster grid): tighter padding and a smaller title. */
|
||||
dense?: boolean;
|
||||
/** Fades the whole card (a watched video in the feed). */
|
||||
dim?: boolean;
|
||||
picture: ReactNode;
|
||||
/** Beside the text, before the title (the YouTube channel's avatar). */
|
||||
leading?: ReactNode;
|
||||
title: ReactNode;
|
||||
/** The line pinned to the frame's bottom edge; omit for a card with none. */
|
||||
meta?: ReactNode;
|
||||
/** The card's "…": on touch it sits beside the title as a 44px target (the hover row's twin). */
|
||||
more?: { props: Record<string, unknown>; open: boolean };
|
||||
/** The "…"'s name (the caller's words: this platform file speaks no feature namespace). */
|
||||
moreLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
{...rootProps}
|
||||
className={clsx(
|
||||
`group ${SURFACE.card} glass-hover flex flex-col text-left transition-all duration-150`,
|
||||
dense ? "p-2" : "p-2.5",
|
||||
dim && "opacity-55",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{picture}
|
||||
<div className={clsx("flex flex-1 gap-3 px-0.5 pb-0.5", dense ? "mt-2" : "mt-2.5")}>
|
||||
{leading}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{title}
|
||||
{meta && <div className="mt-auto pt-1">{meta}</div>}
|
||||
</div>
|
||||
{more && (
|
||||
<MoreButton
|
||||
className="touch-only -mr-1 shrink-0"
|
||||
label={moreLabel}
|
||||
expanded={more.open}
|
||||
menuProps={more.props}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user