feat(design): a Plex title's one action list (C1) — the poster's "…", details and the pin in its menu (E28 S3e2)
Every Plex poster list (library, Discover rails, person, show, season, collection, Home's rails) now opens one menu per list from the right-click, the long-press, Shift+F10 and the card's "…": play/open, watched, add to playlist, media info, copy title, the pin (usePlexCardActions + PlexMenu, replacing the pin-only PlexPinMenu). The card keeps its state at rest and shows watched / add to playlist / "…" on its picture on hover (ui/reveal.ts, shared with the video card); on touch the toggles are gone and a 44px "…" sits beside the title, while the watched pill stays. The hover ⓘ left the card; an episode's add button moved from its caption to the hover row. Seasons get their own short menu (open, watched, add, copy), labelled "Open season" (the card's overlay said "Open show"). Related shows are ordinary poster cards now. A collection's admin "Remove" X moved into its menu, still behind the confirmation. The collection grid is two columns on a phone. The unused .reveal-idle rule is gone.
This commit is contained in:
@@ -1,30 +1,38 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Star, Tv2 } from "lucide-react";
|
||||
import { type CSSProperties } from "react";
|
||||
import { CheckCircle2, Circle, Film, ListPlus, Play, Star, Tv2 } from "lucide-react";
|
||||
import { type CSSProperties, type ReactNode } from "react";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
|
||||
import { plexAddLabelKey } from "./usePlexCardActions";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
||||
// state). Rendered by the browse grid, the person page, and the series show/season views.
|
||||
//
|
||||
// E28 C1 (docs/DESIGN.md "Action placement"): a card shows its state (watched, progress) at rest;
|
||||
// on hover its picture carries the everyday toggles (watched, add to playlist) and the "…" that
|
||||
// opens the title's one action list (`usePlexCardActions`); on touch those toggles are gone and a 44px
|
||||
// "…" sits beside the title. The list's menu is the one door every device has.
|
||||
|
||||
// The reveal class for a status pill (watched / in-progress) that shares a corner with a hover-reveal
|
||||
// control (the watched-toggle). When that control is present it takes over on touch, so the pill yields
|
||||
// via `.reveal-idle` (hidden on touch, faded on hover); when there's no such control the pill is the
|
||||
// sole indicator and just fades on hover. (The single-episode card, where the pill is itself the
|
||||
// full-size watched marker with no fade, keeps its own inline rule.)
|
||||
const swapBadgeReveal = (hasControl: boolean): string =>
|
||||
hasControl ? "reveal-idle" : "group-hover:opacity-0";
|
||||
/** A card's "…": this card's menu-button props (`useContextMenu.buttonProps`) and whether its menu
|
||||
* is the open one. */
|
||||
type More = { props: Record<string, unknown>; open: boolean } | undefined;
|
||||
|
||||
const more = <T,>(
|
||||
row: T,
|
||||
buttonProps: ((row: T) => Record<string, unknown>) | undefined,
|
||||
open: boolean,
|
||||
): More => (buttonProps ? { props: buttonProps(row), open } : undefined);
|
||||
|
||||
// Watched-toggle icon. Distinguishes state by SHAPE (a filled check vs an empty circle), not color
|
||||
// alone — on touch the toggle is the only surviving watched indicator (the text pill is hidden), so a
|
||||
// green-vs-white CheckCircle2 would be a use-of-color (WCAG 1.4.1) gap. Watched = CheckCircle2 (green),
|
||||
// not-watched = Circle (empty, white).
|
||||
// alone (WCAG 1.4.1). Watched = CheckCircle2 (green), not-watched = Circle (empty, white).
|
||||
function WatchedIcon({ watched }: { watched: boolean }) {
|
||||
return watched ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-success" />
|
||||
@@ -33,34 +41,121 @@ function WatchedIcon({ watched }: { watched: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The hover row on a card's picture (top-right). A sibling of the picture's own button, never
|
||||
* inside it, so no control nests in another. */
|
||||
function HoverBar({
|
||||
watched,
|
||||
watchedLabel,
|
||||
onToggleWatched,
|
||||
addLabel,
|
||||
onAdd,
|
||||
more: m,
|
||||
}: {
|
||||
watched: boolean;
|
||||
watchedLabel: string;
|
||||
onToggleWatched?: () => void;
|
||||
addLabel: string;
|
||||
onAdd?: () => void;
|
||||
more: More;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!onToggleWatched && !onAdd && !m) return null;
|
||||
return (
|
||||
<div className={`${HOVER_REVEAL} absolute right-1.5 top-1.5 z-base flex gap-1`}>
|
||||
{onToggleWatched && (
|
||||
<IconButton variant="media" onClick={onToggleWatched} label={watchedLabel}>
|
||||
<WatchedIcon watched={watched} />
|
||||
</IconButton>
|
||||
)}
|
||||
{onAdd && (
|
||||
<IconButton variant="media" onClick={onAdd} label={addLabel}>
|
||||
<ListPlus />
|
||||
</IconButton>
|
||||
)}
|
||||
{m && (
|
||||
<MoreButton
|
||||
variant="media"
|
||||
label={t("card.menu.more")}
|
||||
expanded={m.open}
|
||||
menuProps={m.props}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The caption under a card's picture, with the touch "…" at its end (the hover row's twin). */
|
||||
function Caption({
|
||||
onClick,
|
||||
more: m,
|
||||
className = "",
|
||||
children,
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
more: More;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`mt-1.5 flex items-start gap-1.5 rounded-lg px-2 py-1.5 ${onClick ? "cursor-pointer" : ""} ${className}`}
|
||||
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
{m && (
|
||||
<MoreButton
|
||||
className="touch-only -mr-1 -my-0.5 shrink-0"
|
||||
label={t("card.menu.more")}
|
||||
expanded={m.open}
|
||||
menuProps={m.props}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlexPosterCard({
|
||||
card,
|
||||
onOpen,
|
||||
onInfo,
|
||||
onToggleWatched,
|
||||
onAdd,
|
||||
menuProps,
|
||||
menuButtonProps,
|
||||
menuOpen = false,
|
||||
navIndex,
|
||||
}: {
|
||||
card: PlexCard;
|
||||
onOpen: () => void;
|
||||
onInfo: () => void;
|
||||
onToggleWatched: (c: PlexCard) => void;
|
||||
/** Right-click / long-press props for the GRID's one context menu (E24 S3 — the comfort pin).
|
||||
* A function of the card rather than a ready-made object, so every card surface in the app takes
|
||||
* `menuProps={menu.triggerProps}` and reads the same at every call site. */
|
||||
onToggleWatched?: (c: PlexCard) => void;
|
||||
/** Add a movie or an episode to a playlist (the list's `usePlexCardActions().add`). */
|
||||
onAdd?: (c: PlexCard) => void;
|
||||
/** Right-click / long-press props for the list's ONE context menu. A function of the card rather
|
||||
* than a ready-made object, so every card surface takes `menuProps={menu.triggerProps}`. */
|
||||
menuProps?: (c: PlexCard) => Record<string, unknown>;
|
||||
/** The same menu's "…" (`useContextMenu.buttonProps`), on the picture on hover and beside the
|
||||
* title on touch. */
|
||||
menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
|
||||
/** This card's menu is the open one (the "…" says so with `aria-expanded`). */
|
||||
menuOpen?: boolean;
|
||||
/** Position in a keyboard-walkable grid or rail (lib/listNav); the poster is what Enter opens. */
|
||||
navIndex?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
|
||||
const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down
|
||||
const watchedLabel = card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched");
|
||||
const watched = card.status === "watched";
|
||||
const runtime = formatRuntime(card.duration_seconds);
|
||||
const pct =
|
||||
inProgress && card.duration_seconds
|
||||
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
|
||||
: 0;
|
||||
const m = more(card, menuButtonProps, menuOpen);
|
||||
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(card) : undefined;
|
||||
const add = isPlayable && onAdd ? () => onAdd(card) : undefined;
|
||||
// A corner pill steps aside while the hover row covers its corner; at rest, and on touch, it stays.
|
||||
const yieldTop = toggle || add || m ? HOVER_YIELD : "";
|
||||
return (
|
||||
<div
|
||||
className="group text-left"
|
||||
@@ -69,160 +164,139 @@ export function PlexPosterCard({
|
||||
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
|
||||
>
|
||||
<div
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-nav-open=""
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`}
|
||||
>
|
||||
<img
|
||||
src={card.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
{/* Playable-cost tint (always shown — a quick "will it direct-play" cue). z-menu keeps the dot
|
||||
ABOVE the full-card hover-reveal overlay below (absolute inset-0, no pointer-events-none):
|
||||
without it the overlay sits on top and swallows the cursor, so the dot's native `title`
|
||||
tooltip never fired on hover (a regression once the reveal overlay was added). */}
|
||||
{card.playable &&
|
||||
(() => {
|
||||
const cls = playableClass(card.playable, card.transcode_hw);
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={t(`plex.playable.${cls}`)}
|
||||
className={`absolute top-1.5 left-1.5 z-menu w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[cls] ?? "bg-muted"}`}
|
||||
title={t(`plex.playable.${cls}`)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{/* Rest-state watched badge (top-right). Yields to the hover quick-actions on a playable card
|
||||
(reveal-idle: gone on touch where the toggle is always shown); a watched show keeps it. */}
|
||||
{card.status === "watched" && (
|
||||
<span
|
||||
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${swapBadgeReveal(
|
||||
isPlayable,
|
||||
)}`}
|
||||
>
|
||||
{t("plex.watched")}
|
||||
</span>
|
||||
)}
|
||||
{/* Aggregate in-progress badge for a partially-watched show. */}
|
||||
{card.type === "show" && card.status === "in_progress" && (
|
||||
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
|
||||
{t("plex.inProgress")}
|
||||
</span>
|
||||
)}
|
||||
{/* Rest-state bottom badges — hidden on hover, where the scrim takes over the bottom. */}
|
||||
{card.type === "show" && card.season_count != null && (
|
||||
<span className="pointer-events-none absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm transition-opacity group-hover:opacity-0">
|
||||
{t("plex.seasons", { count: card.season_count })}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-1.5 right-1.5 rounded-sm bg-black/65 p-0.5 text-white/90 transition-opacity group-hover:opacity-0"
|
||||
title={t(
|
||||
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
|
||||
)}
|
||||
<div className="relative">
|
||||
<div
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-nav-open=""
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`}
|
||||
>
|
||||
{card.type === "show" ? <Tv2 className="w-3 h-3" /> : <Film className="w-3 h-3" />}
|
||||
</span>
|
||||
|
||||
{/* Hover reveal: a soft dim, a less-flat play ring, and a bottom scrim that slides up with the
|
||||
synopsis + rating (the info you'd otherwise click through for). */}
|
||||
<div className="absolute inset-0 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<div className="absolute inset-0 bg-black/25" />
|
||||
<div className="absolute inset-x-0 top-[30%] flex justify-center">
|
||||
<div
|
||||
className="grid h-12 w-12 scale-90 place-items-center rounded-full text-accent-fg shadow-xl transition-transform duration-200 group-hover:scale-100"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at 38% 32%, color-mix(in srgb, var(--accent) 72%, white), var(--accent))",
|
||||
boxShadow:
|
||||
"0 10px 22px -6px color-mix(in srgb, var(--accent) 60%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent)",
|
||||
}}
|
||||
<img
|
||||
src={card.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
{/* Playable-cost tint (always shown — a quick "will it direct-play" cue). z-menu keeps the
|
||||
dot ABOVE the full-card hover-reveal overlay below (absolute inset-0, no
|
||||
pointer-events-none): without it the overlay sits on top and swallows the cursor, so the
|
||||
dot's native `title` tooltip never fired on hover. */}
|
||||
{card.playable &&
|
||||
(() => {
|
||||
const cls = playableClass(card.playable, card.transcode_hw);
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={t(`plex.playable.${cls}`)}
|
||||
className={`absolute top-1.5 left-1.5 z-menu w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[cls] ?? "bg-muted"}`}
|
||||
title={t(`plex.playable.${cls}`)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{/* Rest-state watched badge (top-right). */}
|
||||
{watched && (
|
||||
<span
|
||||
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${yieldTop}`}
|
||||
>
|
||||
<Play className="w-5 h-5 translate-x-px fill-current" />
|
||||
{t("plex.watched")}
|
||||
</span>
|
||||
)}
|
||||
{/* Aggregate in-progress badge for a partially-watched show. */}
|
||||
{card.type === "show" && card.status === "in_progress" && (
|
||||
<span
|
||||
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm ${yieldTop}`}
|
||||
>
|
||||
{t("plex.inProgress")}
|
||||
</span>
|
||||
)}
|
||||
{/* Rest-state bottom badges — hidden on hover, where the scrim takes over the bottom. */}
|
||||
{card.type === "show" && card.season_count != null && (
|
||||
<span className="pointer-events-none absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm transition-opacity group-hover:opacity-0">
|
||||
{t("plex.seasons", { count: card.season_count })}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-1.5 right-1.5 rounded-sm bg-black/65 p-0.5 text-white/90 transition-opacity group-hover:opacity-0"
|
||||
title={t(
|
||||
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
|
||||
)}
|
||||
>
|
||||
{card.type === "show" ? <Tv2 className="w-3 h-3" /> : <Film className="w-3 h-3" />}
|
||||
</span>
|
||||
|
||||
{/* Hover reveal: a soft dim, a less-flat play ring, and a bottom scrim that slides up with
|
||||
the synopsis + rating (the info you'd otherwise click through for). */}
|
||||
<div className="absolute inset-0 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<div className="absolute inset-0 bg-black/25" />
|
||||
<div className="absolute inset-x-0 top-[30%] flex justify-center">
|
||||
<div
|
||||
className="grid h-12 w-12 scale-90 place-items-center rounded-full text-accent-fg shadow-xl transition-transform duration-200 group-hover:scale-100"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at 38% 32%, color-mix(in srgb, var(--accent) 72%, white), var(--accent))",
|
||||
boxShadow:
|
||||
"0 10px 22px -6px color-mix(in srgb, var(--accent) 60%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
<Play className="w-5 h-5 translate-x-px fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
{(card.summary || card.rating != null) && (
|
||||
<div className="absolute inset-x-0 bottom-0 translate-y-1.5 bg-gradient-to-t from-black/95 via-black/85 to-transparent p-2.5 transition-transform duration-200 group-hover:translate-y-0">
|
||||
<div className="line-clamp-1 text-xs font-semibold leading-snug text-white">
|
||||
{card.title}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-2xs text-white/75">
|
||||
{(card.year || runtime) && (
|
||||
<span>{[card.year, runtime].filter(Boolean).join(" · ")}</span>
|
||||
)}
|
||||
{card.rating != null && (
|
||||
<span className="inline-flex items-center gap-0.5 text-rating-media">
|
||||
<Star className="w-3 h-3 fill-current" />
|
||||
{card.rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{card.summary && (
|
||||
<p className="mt-1 line-clamp-3 text-2xs leading-snug text-white/85">
|
||||
{card.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(card.summary || card.rating != null) && (
|
||||
<div className="absolute inset-x-0 bottom-0 translate-y-1.5 bg-gradient-to-t from-black/95 via-black/85 to-transparent p-2.5 transition-transform duration-200 group-hover:translate-y-0">
|
||||
<div className="line-clamp-1 text-xs font-semibold leading-snug text-white">
|
||||
{card.title}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-2xs text-white/75">
|
||||
{(card.year || runtime) && (
|
||||
<span>{[card.year, runtime].filter(Boolean).join(" · ")}</span>
|
||||
)}
|
||||
{card.rating != null && (
|
||||
<span className="inline-flex items-center gap-0.5 text-rating-media">
|
||||
<Star className="w-3 h-3 fill-current" />
|
||||
{card.rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{card.summary && (
|
||||
<p className="mt-1 line-clamp-3 text-2xs leading-snug text-white/85">
|
||||
{card.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{pct > 0 && (
|
||||
<div className="absolute inset-x-0 bottom-0 z-base h-1 bg-black/40">
|
||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick actions (playable cards): Info + watched toggle — revealed on hover, always on touch. */}
|
||||
{isPlayable && (
|
||||
<div className="reveal absolute right-1.5 top-1.5 z-base flex gap-1">
|
||||
<IconButton
|
||||
variant="media"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onInfo();
|
||||
}}
|
||||
label={t("plex.info.title")}
|
||||
>
|
||||
<Info />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="media"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleWatched(card);
|
||||
}}
|
||||
label={watchedLabel}
|
||||
>
|
||||
<WatchedIcon watched={card.status === "watched"} />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pct > 0 && (
|
||||
<div className="absolute inset-x-0 bottom-0 z-base h-1 bg-black/40">
|
||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<HoverBar
|
||||
watched={watched}
|
||||
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
|
||||
onToggleWatched={toggle}
|
||||
addLabel={t(plexAddLabelKey(card))}
|
||||
onAdd={add}
|
||||
more={m}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
onClick={onOpen}
|
||||
className="mt-1.5 rounded-lg px-2 py-1.5 cursor-pointer"
|
||||
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
||||
>
|
||||
<Caption onClick={onOpen} more={m}>
|
||||
<div className="text-sm font-medium line-clamp-2 leading-tight min-h-[2lh] hover:text-accent">
|
||||
{card.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted">
|
||||
{[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
</Caption>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -232,106 +306,90 @@ export function SeasonCard({
|
||||
onOpen,
|
||||
onToggleWatched,
|
||||
onAdd,
|
||||
menuProps,
|
||||
menuButtonProps,
|
||||
menuOpen = false,
|
||||
navIndex,
|
||||
}: {
|
||||
se: PlexSeasonDetail;
|
||||
onOpen: () => void;
|
||||
onToggleWatched?: () => void;
|
||||
onAdd?: () => void;
|
||||
/** The show page's season menu (see `PlexPosterCard`). */
|
||||
menuProps?: (se: PlexSeasonDetail) => Record<string, unknown>;
|
||||
menuButtonProps?: (se: PlexSeasonDetail) => Record<string, unknown>;
|
||||
menuOpen?: boolean;
|
||||
navIndex?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const watched = se.status === "watched";
|
||||
const watchedLabel = watched
|
||||
? t("plex.series.markSeasonUnwatched")
|
||||
: t("plex.series.markSeasonWatched");
|
||||
const m = more(se, menuButtonProps, menuOpen);
|
||||
return (
|
||||
<div
|
||||
className="group text-left"
|
||||
{...menuProps?.(se)}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex, se.title))}
|
||||
>
|
||||
<div
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-nav-open=""
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`}
|
||||
>
|
||||
<img
|
||||
src={se.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
||||
<div className="flex flex-col items-center gap-1 text-white">
|
||||
<Play className="w-9 h-9" fill="currentColor" />
|
||||
<span className="text-xs font-medium">{t("plex.openShow")}</span>
|
||||
<div className="relative">
|
||||
<div
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-nav-open=""
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`}
|
||||
>
|
||||
<img
|
||||
src={se.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
||||
<div className="flex flex-col items-center gap-1 text-white">
|
||||
<Play className="w-9 h-9" fill="currentColor" />
|
||||
<span className="text-xs font-medium">{t("plex.series.openSeason")}</span>
|
||||
</div>
|
||||
</div>
|
||||
{watched && (
|
||||
<span
|
||||
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${
|
||||
onToggleWatched || onAdd || m ? HOVER_YIELD : ""
|
||||
}`}
|
||||
>
|
||||
{t("plex.watched")}
|
||||
</span>
|
||||
)}
|
||||
{/* In-progress lives at the top-LEFT (the hover row owns the top-right) and stays visible:
|
||||
a season has no progress bar, so this badge is its sole in-progress cue. */}
|
||||
{se.status === "in_progress" && (
|
||||
<span className="pointer-events-none absolute top-1.5 left-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm">
|
||||
{t("plex.inProgress")}
|
||||
</span>
|
||||
)}
|
||||
<span className="absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
||||
{t("plex.series.episodeCount", { count: se.episode_count })}
|
||||
</span>
|
||||
</div>
|
||||
{/* Quick watched toggle (whole season), on hover. */}
|
||||
{onToggleWatched && (
|
||||
<IconButton
|
||||
variant="media"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleWatched();
|
||||
}}
|
||||
label={watchedLabel}
|
||||
className="absolute top-1.5 right-1.5 reveal"
|
||||
>
|
||||
<WatchedIcon watched={watched} />
|
||||
</IconButton>
|
||||
)}
|
||||
{/* Add whole season to a playlist, on hover. */}
|
||||
{onAdd && (
|
||||
<IconButton
|
||||
variant="media"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAdd();
|
||||
}}
|
||||
label={t("plex.playlist.addSeason")}
|
||||
className="absolute bottom-1.5 right-1.5 reveal"
|
||||
>
|
||||
<ListPlus />
|
||||
</IconButton>
|
||||
)}
|
||||
{watched && (
|
||||
<span
|
||||
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${swapBadgeReveal(
|
||||
!!onToggleWatched,
|
||||
)}`}
|
||||
>
|
||||
{t("plex.watched")}
|
||||
</span>
|
||||
)}
|
||||
{/* In-progress lives at the top-LEFT (the watched-toggle owns the top-right) and stays visible
|
||||
on touch: unlike movie/episode cards, a season has no progress bar, and the always-shown
|
||||
toggle reads as unwatched for a partial season — so this badge is the sole in-progress cue
|
||||
and must not be a reveal-idle swap-badge (which display:none-s on touch). */}
|
||||
{se.status === "in_progress" && (
|
||||
<span className="pointer-events-none absolute top-1.5 left-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm">
|
||||
{t("plex.inProgress")}
|
||||
</span>
|
||||
)}
|
||||
<span className="absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
||||
{t("plex.series.episodeCount", { count: se.episode_count })}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
onClick={onOpen}
|
||||
className="mt-1.5 rounded-lg px-2 py-1.5 text-sm font-medium line-clamp-2 leading-tight cursor-pointer hover:text-accent"
|
||||
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
||||
>
|
||||
{se.title}
|
||||
<HoverBar
|
||||
watched={watched}
|
||||
watchedLabel={
|
||||
watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
|
||||
}
|
||||
onToggleWatched={onToggleWatched}
|
||||
addLabel={t("plex.playlist.addSeason")}
|
||||
onAdd={onAdd}
|
||||
more={m}
|
||||
/>
|
||||
</div>
|
||||
<Caption onClick={onOpen} more={m} className="text-sm font-medium leading-tight">
|
||||
<div className="line-clamp-2 hover:text-accent">{se.title}</div>
|
||||
</Caption>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -415,11 +473,16 @@ export function RelatedStrip({
|
||||
related,
|
||||
onOpen,
|
||||
menuProps,
|
||||
menuButtonProps,
|
||||
openId = null,
|
||||
}: {
|
||||
related: PlexCard[];
|
||||
onOpen: (id: string) => void;
|
||||
/** The comfort-pin context menu's trigger (a related show is pinnable like any poster). */
|
||||
onOpen: (c: PlexCard) => void;
|
||||
/** The page's Plex menu (a related show is a Plex card like any other: E28 C1). */
|
||||
menuProps?: (c: PlexCard) => Record<string, unknown>;
|
||||
menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
|
||||
/** The id whose menu is open. */
|
||||
openId?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -430,32 +493,16 @@ export function RelatedStrip({
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
|
||||
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")} keyboardNav>
|
||||
{related.map((r, i) => (
|
||||
<Pressable
|
||||
key={r.id}
|
||||
{...navItemAttrs(i)}
|
||||
{...menuProps?.(r)}
|
||||
onClick={() => onOpen(r.id)}
|
||||
className="group w-32.5 shrink-0"
|
||||
>
|
||||
<div
|
||||
className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200`}
|
||||
>
|
||||
<img
|
||||
src={r.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
{r.status === "watched" && (
|
||||
<span className="absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
||||
{t("plex.watched")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight min-h-[2lh]">
|
||||
{r.title}
|
||||
</div>
|
||||
</Pressable>
|
||||
<div key={r.id} className="w-37.5 shrink-0">
|
||||
<PlexPosterCard
|
||||
card={r}
|
||||
navIndex={i}
|
||||
onOpen={() => onOpen(r)}
|
||||
menuProps={menuProps}
|
||||
menuButtonProps={menuButtonProps}
|
||||
menuOpen={openId === r.id}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
</section>
|
||||
@@ -468,6 +515,9 @@ export function EpisodeCard({
|
||||
onAdd,
|
||||
onToggleWatched,
|
||||
withShowTitle,
|
||||
menuProps,
|
||||
menuButtonProps,
|
||||
menuOpen = false,
|
||||
navIndex,
|
||||
}: {
|
||||
ep: PlexCard;
|
||||
@@ -475,93 +525,88 @@ export function EpisodeCard({
|
||||
onAdd?: () => void;
|
||||
onToggleWatched?: () => void;
|
||||
withShowTitle?: boolean;
|
||||
/** The episode list's menu (see `PlexPosterCard`). */
|
||||
menuProps?: (c: PlexCard) => Record<string, unknown>;
|
||||
menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
|
||||
menuOpen?: boolean;
|
||||
navIndex?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
|
||||
const watchedLabel = ep.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched");
|
||||
const watched = ep.status === "watched";
|
||||
const inProgress = (ep.position_seconds ?? 0) > 0 && !watched;
|
||||
const pct =
|
||||
inProgress && ep.duration_seconds
|
||||
? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
|
||||
: 0;
|
||||
const m = more(ep, menuButtonProps, menuOpen);
|
||||
return (
|
||||
<div className="group" {...(navIndex !== undefined && navItemAttrs(navIndex, ep.title))}>
|
||||
<div
|
||||
onClick={onPlay}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-nav-open=""
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onPlay();
|
||||
}
|
||||
}}
|
||||
className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer"
|
||||
>
|
||||
<img
|
||||
src={ep.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
||||
<Play className="w-9 h-9 text-white" fill="currentColor" />
|
||||
</div>
|
||||
{/* Quick watched toggle (single episode), on hover. */}
|
||||
{onToggleWatched && (
|
||||
<IconButton
|
||||
variant="media"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleWatched();
|
||||
}}
|
||||
label={watchedLabel}
|
||||
className="absolute top-1.5 right-1.5 reveal"
|
||||
>
|
||||
<WatchedIcon watched={ep.status === "watched"} />
|
||||
</IconButton>
|
||||
)}
|
||||
{ep.status === "watched" && (
|
||||
<span
|
||||
className={`pointer-events-none absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 ${
|
||||
onToggleWatched ? "reveal-idle" : ""
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 text-success" />
|
||||
</span>
|
||||
)}
|
||||
{pct > 0 && (
|
||||
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
<div
|
||||
className="group"
|
||||
{...menuProps?.(ep)}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex, ep.title))}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
onClick={onPlay}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-nav-open=""
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onPlay();
|
||||
}
|
||||
}}
|
||||
className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer"
|
||||
>
|
||||
<img
|
||||
src={ep.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
||||
<Play className="w-9 h-9 text-white" fill="currentColor" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="mt-1.5 flex items-start gap-1.5 rounded-lg px-2 py-1.5"
|
||||
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
{withShowTitle && ep.show_title && (
|
||||
<div className="text-2xs text-muted truncate">
|
||||
{ep.show_title}
|
||||
{ep.season_number != null && ep.episode_number != null
|
||||
? ` · S${ep.season_number}·E${ep.episode_number}`
|
||||
: ""}
|
||||
{watched && (
|
||||
<span
|
||||
className={`pointer-events-none absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 ${
|
||||
onToggleWatched || onAdd || m ? HOVER_YIELD : ""
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 text-success" />
|
||||
</span>
|
||||
)}
|
||||
{pct > 0 && (
|
||||
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium leading-tight">
|
||||
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
|
||||
{ep.title}
|
||||
</div>
|
||||
<div className="text-2xs text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
|
||||
</div>
|
||||
{onAdd && (
|
||||
<IconButton onClick={onAdd} label={t("plex.playlist.addEpisode")}>
|
||||
<ListPlus />
|
||||
</IconButton>
|
||||
)}
|
||||
<HoverBar
|
||||
watched={watched}
|
||||
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
|
||||
onToggleWatched={onToggleWatched}
|
||||
addLabel={t("plex.playlist.addEpisode")}
|
||||
onAdd={onAdd}
|
||||
more={m}
|
||||
/>
|
||||
</div>
|
||||
<Caption more={m}>
|
||||
{withShowTitle && ep.show_title && (
|
||||
<div className="text-2xs text-muted truncate">
|
||||
{ep.show_title}
|
||||
{ep.season_number != null && ep.episode_number != null
|
||||
? ` · S${ep.season_number}·E${ep.episode_number}`
|
||||
: ""}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium leading-tight">
|
||||
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
|
||||
{ep.title}
|
||||
</div>
|
||||
<div className="text-2xs text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
|
||||
</Caption>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user