Files
siftlode/frontend/src/components/ui/MediaCard.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

101 lines
3.9 KiB
TypeScript

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"): 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>
);
}