A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
74 lines
3.3 KiB
TypeScript
74 lines
3.3 KiB
TypeScript
import { type ReactNode } from "react";
|
|
import { hueOf, hueTileGradient } from "../../lib/hueTile";
|
|
import { navItemAttrs } from "../../lib/listNav";
|
|
import { MEDIA_IMG } from "../../components/ui/mediaCardStyles";
|
|
import { Pressable } from "../../components/ui/Pressable";
|
|
import { cardImage } from "../../lib/plexImage";
|
|
import RemoteImage from "../../components/ui/RemoteImage";
|
|
|
|
// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}),
|
|
// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable
|
|
// (a full-bleed poster collage drowned it). A group with no art falls back to an all-colour tile, never
|
|
// blank. Shared by the Discover genre tiles and the Playlist/Collection browse tiles.
|
|
//
|
|
// Hover = the media card's: the shadow lifts and the art zooms inside its half, plus the
|
|
// accent edge glass-hover gives a pressable panel. It had `glass-hover` on a tile with no border, so
|
|
// the only visible change was a shadow under a colour band — the owner read it as no hover at all.
|
|
export default function PosterTile({
|
|
title,
|
|
subtitle,
|
|
topRight,
|
|
art,
|
|
onClick,
|
|
className,
|
|
navIndex,
|
|
}: {
|
|
title: string;
|
|
subtitle?: ReactNode; // a bottom sub-line under the title (e.g. "12 items · 4h 30m")
|
|
topRight?: ReactNode; // top-right corner content on the colour band (e.g. an icon)
|
|
art?: string[]; // up to 2 poster rating_keys; fewer/none falls back to the colour hash
|
|
onClick: () => void;
|
|
className?: string; // caller sets the height (e.g. "h-20" / "h-28")
|
|
navIndex?: number; // position in a keyboard-walkable grid (lib/listNav); Enter is the button's own
|
|
}) {
|
|
const posters = (art ?? []).slice(0, 2);
|
|
const band = hueTileGradient(hueOf(title));
|
|
return (
|
|
<Pressable
|
|
selectable
|
|
onClick={onClick}
|
|
title={title}
|
|
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
|
className={`group relative flex flex-col overflow-hidden rounded-xl border border-border shadow-md transition hover:border-accent/55 hover:shadow-2xl ${className ?? ""}`}
|
|
>
|
|
{/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
|
|
<div
|
|
className="grid h-1/2 min-h-0 grid-cols-2 overflow-hidden"
|
|
aria-hidden
|
|
style={posters.length === 0 ? { background: band } : undefined}
|
|
>
|
|
{posters.length > 0 &&
|
|
[0, 1].map((i) =>
|
|
posters[i] ? (
|
|
<RemoteImage
|
|
key={i}
|
|
{...cardImage(`/api/plex/image/${posters[i]}`)}
|
|
alt=""
|
|
loading="lazy"
|
|
className={MEDIA_IMG}
|
|
/>
|
|
) : (
|
|
<div key={i} style={{ background: hueTileGradient(hueOf(title + i)) }} />
|
|
),
|
|
)}
|
|
</div>
|
|
{/* Bottom half: solid colour band — the label lives here so it stays readable over any art. */}
|
|
<div className="relative flex flex-1 flex-col justify-end p-2.5" style={{ background: band }}>
|
|
{topRight != null && <span className="absolute right-2 top-2">{topRight}</span>}
|
|
<div className="truncate text-sm font-semibold text-white drop-shadow">{title}</div>
|
|
{subtitle != null && <div className="truncate text-2xs text-white/80">{subtitle}</div>}
|
|
</div>
|
|
</Pressable>
|
|
);
|
|
}
|