diff --git a/frontend/src/components/ChannelAbout.tsx b/frontend/src/components/ChannelAbout.tsx index 57fe8282..bd0331f6 100644 --- a/frontend/src/components/ChannelAbout.tsx +++ b/frontend/src/components/ChannelAbout.tsx @@ -51,7 +51,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) { return ( <> {ch.description ? ( -
+
{linkify(ch.description)}
) : ( diff --git a/frontend/src/components/Home.dom.test.tsx b/frontend/src/components/Home.dom.test.tsx index a0400a4f..346260b8 100644 --- a/frontend/src/components/Home.dom.test.tsx +++ b/frontend/src/components/Home.dom.test.tsx @@ -11,7 +11,7 @@ // a Carousel (useHScroll → ResizeObserver), stubbed for jsdom. import { StrictMode } from "react"; import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest"; -import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { act, render, screen, fireEvent, cleanup, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; @@ -785,3 +785,58 @@ describe("Home · keyboard", () => { expect(rails.map((rail) => rail.querySelectorAll("[data-nav-index]").length)).toEqual([2, 2]); }); }); + +// E28 S3h (the owner, 2026-09-26/27): the hero stands in three quarters of a wide page with its text +// centred beside the picture; one fact line, no repeated year, and prose only where there is some, +// at a readable measure. +describe("Home's hero text", () => { + const film = { + source: "plex", + kind: "movie", + id: "m1", + title: "A Film", + subtitle: "2003", + thumb: null, + aspect: "poster", + duration_seconds: 9240, + position_seconds: 0, + remaining_seconds: null, + status: "new", + video: null, + plex: { + id: "m1", + type: "movie", + title: "A Film", + thumb: "", + year: 2003, + rating: 7.8, + summary: "What the film is about.", + }, + }; + + it("a film: the year once, in the fact line with the length and score; its summary at the measure", async () => { + discover.mockResolvedValue({ ...payload("v1"), hero: { tier: "surprise", card: film } }); + mount(); + const title = await screen.findByRole("heading", { name: "A Film" }); + const text = title.parentElement!; + expect(within(text).getByText(/^2003 · \S/)).toBeTruthy(); + expect(within(text).getByText("7.8")).toBeTruthy(); + // the subtitle was the year alone: it is not printed a second time + expect(within(text).queryByText("2003")).toBeNull(); + expect(within(text).getByText("What the film is about.").className).toContain("measure"); + expect(title.closest("section")!.className).toContain("lg:w-3/4"); + }); + + it("a video: the channel as the subtitle, views and age as the facts, and no summary line", async () => { + discover.mockResolvedValue({ + ...payload("v1"), + hero: { tier: "surprise", card: ytCard("v9", "A video") }, + continue_watching: [], + }); + mount(); + const text = (await screen.findByRole("heading", { name: "A video" })).parentElement!; + expect(within(text).getByText("A channel")).toBeTruthy(); + expect(within(text).getByText(/^10 card\.views · /)).toBeTruthy(); + expect(text.querySelector("p.measure")).toBeNull(); + }); +}); diff --git a/frontend/src/components/Home.pin.dom.test.tsx b/frontend/src/components/Home.pin.dom.test.tsx index 857e86b4..b0db7e68 100644 --- a/frontend/src/components/Home.pin.dom.test.tsx +++ b/frontend/src/components/Home.pin.dom.test.tsx @@ -65,7 +65,13 @@ vi.mock("../lib/api", () => ({ discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`, })); -vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) })); +const openChannel = vi.fn(); +vi.mock("./navigationContext", () => ({ + useNavigationActions: () => ({ + setPage: vi.fn(), + openChannel: (...a: unknown[]) => openChannel(...a), + }), +})); vi.mock("./plexContext", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) })); import Home from "./Home"; @@ -150,6 +156,7 @@ const item = (label: string) => { }; beforeEach(() => { + openChannel.mockReset(); // Home keeps its question on the history entry (Back restores it): each test starts a fresh one. window.history.replaceState(null, ""); discover.mockReset().mockResolvedValue(payload()); @@ -166,6 +173,8 @@ describe("Home — the comfort shelf's context menu", () => { fireEvent.contextMenu(await screen.findByText("Comfort B")); expect(labels()).toEqual([ "home.hero.play", + // A channel tile opens its channel (the owner, 2026-09-27: it had nothing a channel offers). + "card.menu.openChannel", "home.shelf.moveFirst", "home.shelf.moveLeft", "home.shelf.moveRight", @@ -341,3 +350,28 @@ describe("Home — the comfort shelf's context menu", () => { expect(item("home.pin.add").disabled).toBe(true); }); }); + +// The owner, 2026-09-27: every Home card offers what the same card offers elsewhere. +describe("Home — a card's own actions", () => { + it("opens a pinned channel from its tile's ↗", async () => { + mount(); + await screen.findByText("Comfort B"); + fireEvent.click( + screen.getAllByRole("button", { name: "card.menu.openChannel" })[1] as HTMLElement, + ); + expect(openChannel).toHaveBeenCalledWith("UCb", "Comfort B"); + }); + + it("gives the hero the card's whole menu, not the pin row alone", async () => { + discover.mockResolvedValue({ + ...payload(), + hero: { tier: "surprise", card: base("h1", "Hero video") }, + }); + mount(); + fireEvent.contextMenu(await screen.findByRole("heading", { name: "Hero video" })); + const rows = labels(); + expect(rows[0]).toBe("home.hero.play"); + expect(rows).toContain("card.markWatched"); + expect(rows).toContain("card.menu.openChannel"); + }); +}); diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index cbf41baa..927ab45c 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -6,9 +6,11 @@ import { ChevronRight, ChevronsLeft, ChevronsRight, + ArrowUpRight, PinOff, Play, RotateCcw, + Star, } from "lucide-react"; import { qk } from "../lib/queryKeys"; import { @@ -26,10 +28,9 @@ import { } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { takeSurprise, useSurpriseRequest } from "../lib/homeIntent"; -import { formatDuration } from "../lib/format"; +import { formatDuration, formatRuntime, formatViews, relativeTime } from "../lib/format"; import { toggleWatchState } from "../lib/plexWatch"; import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin"; -import { ComfortPinMenu } from "./ComfortPinMenu"; import { type ActionMenuItem } from "./ui/ActionMenu"; import { Button } from "./ui/Button"; import { Carousel } from "./ui/Carousel"; @@ -37,15 +38,29 @@ import { ContextMenu } from "./ui/ContextMenu"; import { useContextMenu, useItemTriggers } from "./ui/useContextMenu"; import SegmentedToggle from "./ui/SegmentedToggle"; import { LoadingState, StateMessage } from "./ui/QueryState"; -import VideoCard from "./VideoCard"; +import VideoCard, { VideoHoverActions } from "./VideoCard"; import { useVideoActions } from "./useVideoActions"; import { usePlexCardActions } from "./usePlexCardActions"; -import { PlexPosterCard } from "./plexCards"; +import { PlexHoverBar, PlexPosterCard } from "./plexCards"; +import { usePlaylistsActions } from "./playlistsContext"; import { useNavigationActions } from "./navigationContext"; import { usePlexActions } from "./plexContext"; import { navItemAttrs } from "../lib/listNav"; import { Pressable } from "./ui/Pressable"; import { SURFACE } from "./ui/surfaces"; +import clsx from "clsx"; +import { IconButton } from "./ui/IconButton"; +import { MoreButton } from "./ui/MoreButton"; +import { HOVER_YIELD } from "./ui/reveal"; +import { MediaCard, MediaPlayGlyph } from "./ui/MediaCard"; +import { + MEDIA_BAR, + MEDIA_HOVER_LAYER, + MEDIA_IMG, + MEDIA_PICTURE, + mediaPictureBox, + mediaTitleClass, +} from "./ui/mediaCardStyles"; // The Home surface (E24) — "Mit nézzek most?". // @@ -68,11 +83,12 @@ const PlayerModal = lazy(() => import("./PlayerModal")); // SUBSET, so a band added server-side and to `DISCOVER_BUDGETS` typechecked green while this row // silently kept offering four. -// A rail tile's slot width. `cardsSmall` is the feed's own dense card (its column bottoms out at -// 180px), so a 200px slot is the same object the feed shows — not a Home-only variant that would -// drift from it. Px on purpose, like that column (`VirtualGrid`'s `minCol` is a layout-engine px value, +// A rail tile's slot width. `cardsSmall` is the feed's own dense card, so the slot holds the same +// object the feed shows — not a Home-only variant that would drift from it. 232, not the 200 it was: +// at 200 the picture was 184×103 and its centred Play overlapped the hover row docked under it by +// 2px (the owner saw them run together, 2026-09-27); at 232 they stand ~7px apart. Px on purpose, like that column (`VirtualGrid`'s `minCol` is a layout-engine px value, // see ui/README): the card also takes it as its MEASURED width, which must be real pixels. -const YT_SLOT = 200; +const YT_SLOT = 232; // How many already-offered cards the reroll remembers. Matched to the server's own cap // (`routes/discover.MAX_EXCLUDE`) rather than to a smaller number of our own: at 8 the window rolled @@ -89,6 +105,151 @@ const SHELF_MOVES: readonly [ShelfMove, string, typeof ChevronLeft][] = [ ["last", "home.shelf.moveLast", ChevronsRight], ]; +/** A Home card's ONE action list — a shelf tile's and the hero's alike: Play (it resolves `next`), + * the card's own rows (a video's or a Plex title's, as the feed and the library show them; a + * channel's or a playlist's open row), then the shelf's rows. */ +function useHomeCardMenu({ + cards, + onPlay, + onInfo, + onState, + onToggleSave, + onToggleWatched, + onMove, + moveSaving = false, +}: { + cards: DiscoverCard[]; + onPlay: (card: DiscoverCard, startAt?: number | null) => void; + onInfo: (card: DiscoverCard) => void; + onState: (id: string, status: VideoStatus) => void; + onToggleSave: (id: string, saved: boolean) => void; + onToggleWatched: (card: PlexCard) => void; + onMove?: (card: DiscoverCard, to: ShelfMove, rendered: DiscoverCard[]) => void; + moveSaving?: boolean; +}) { + const { t } = useTranslation(); + // ONE right-click menu for the rail (see ui/ContextMenu). It is the shelf's whole editor: menu + // rows rather than drag & drop, because dragging inside a horizontal, scrolling Carousel fights + // the scroll, and because these rows are keyboard-reachable (Shift+F10) for free. + const menu = useContextMenu{kicker}
{card.subtitle}
} -{subtitle}
} + {(factLine || plex?.rating != null) && ( +
+ {factLine && {factLine}}
+ {plex?.rating != null && (
+
+ {factLine && ·}
+
{summary}
} +{d.summary}
diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index 8f46b235..61fed3bf 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -70,8 +70,9 @@ function CardRail({ keyboardNav > {cards.map((c, i) => ( -
- {t("plex.info.director")}:{" "}
- {detail.directors.map((d, i) => (
-
-
+ {t("plex.info.director")}:
+ {detail.directors.map((d) => (
+
{detail.summary}
@@ -679,100 +678,18 @@ export default function PlexInfo({- {card.summary} -
- )} -+
{d.biography}
)} @@ -277,8 +277,9 @@ export function PlexPersonView({ keyboardNav > {d.known_for.map((c, i) => ( -{show.summary}
++ {show.summary} +
)}{title}
+ {facts &&{summary}
} ++ Hover a card: its Play and its hover row. The preview, the hero and the info page live + in the app. +
++ Bourne is brought out of hiding once again by a reporter who is trying to unveil an + operation, an upgrade to a programme, in a series of newspaper columns. Information from + the reporter stirs a new set of memories, and Bourne must finally uncover his dark past. +
+