"Add to collection…" opens a panel beside the card: the title's library's manual collections tick it in or out, and a name + Enter creates a new one with the title in it. It used to take the Collections page, a new collection, its name and a title search, then another search per title. A lean endpoint lists the collections with whether each holds the title. Admin only, as on the title's page. Also: a collection seeded with a show is made as a show collection (Plex type 2). Measured: as a movie one it came out empty and refused the show after.
1136 lines
51 KiB
TypeScript
1136 lines
51 KiB
TypeScript
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ChevronsLeft,
|
||
ChevronsRight,
|
||
ArrowUpRight,
|
||
PinOff,
|
||
Play,
|
||
RotateCcw,
|
||
SkipBack,
|
||
Star,
|
||
} from "lucide-react";
|
||
import { qk } from "../lib/queryKeys";
|
||
import { plexLang } from "../i18n";
|
||
import {
|
||
api,
|
||
DISCOVER_BUDGETS,
|
||
discoverCardKey,
|
||
type DiscoverBudget,
|
||
type DiscoverCard,
|
||
type DiscoverPayload,
|
||
HERO_TIERS,
|
||
type HeroTier,
|
||
type PlexCard,
|
||
type Video,
|
||
type VideoStatus,
|
||
} from "../lib/api";
|
||
import { notifyActionError } from "../lib/actionErrors";
|
||
import { takeSurprise, useSurpriseRequest } from "../lib/homeIntent";
|
||
import {
|
||
formatCompact,
|
||
formatDecimal,
|
||
formatDuration,
|
||
formatRuntime,
|
||
relativeTime,
|
||
} from "../lib/format";
|
||
import { toggleWatchState } from "../lib/plexWatch";
|
||
import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
|
||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||
import { Button } from "./ui/Button";
|
||
import { Carousel } from "./ui/Carousel";
|
||
import { ContextMenu } from "./ui/ContextMenu";
|
||
import { useContextMenu, useItemTriggers } from "./ui/useContextMenu";
|
||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||
import VideoCard, { VideoHoverActions } from "./VideoCard";
|
||
import { useVideoActions } from "./useVideoActions";
|
||
import { usePlexCardActions } from "./usePlexCardActions";
|
||
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 { MediaResumeButtons } from "./MediaResumeButtons";
|
||
import { MediaCard, MediaPlayGlyph } from "./ui/MediaCard";
|
||
import {
|
||
MEDIA_BAR,
|
||
MEDIA_BADGE,
|
||
MEDIA_HOVER_LAYER,
|
||
MEDIA_RESUME_LAYER,
|
||
MEDIA_IMG,
|
||
MEDIA_PICTURE,
|
||
mediaPictureBox,
|
||
mediaTitleClass,
|
||
} from "./ui/mediaCardStyles";
|
||
|
||
// The Home surface — "Mit nézzek most?".
|
||
//
|
||
// Layout C+, chosen from mockups: the time-budget chips sit ABOVE a single large hero, so the
|
||
// question comes first and the hero is its answer; the shelves follow, compact. The page's whole
|
||
// promise is that it ANSWERS rather than offers, which is why there is one hero and not a grid of
|
||
// three: a shortlist is still a comparison, and comparing is the thing the user cannot do on the
|
||
// evenings this page exists for.
|
||
//
|
||
// Everything domain-shaped is already built: the cards are the feed's `VideoCard` and Plex's
|
||
// `PlexPosterCard`, rendered from the native payload each unified card carries, and the rails are
|
||
// the platform `Carousel`. The two PLAYABLE card faces are reused rather than re-drawn; the only
|
||
// card this file defines is `ShelfTile`, because a pinned channel or playlist has no card anywhere
|
||
// in the app and `PosterTile`'s art is hardcoded to a Plex image URL.
|
||
|
||
// Lazy, exactly as the feed does it: the YouTube IFrame player loads only once something is played.
|
||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||
|
||
// From the api slice's own list, not a second copy: `readonly DiscoverBudget[]` accepts any
|
||
// 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, 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 = 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
|
||
// before the resume rail (20 rows) was exhausted, so the button cycled the same nine cards forever
|
||
// (measured, 2026-09-06). It is bounded at all only because the server will not scan an unbounded
|
||
// exclusion list.
|
||
const SEEN_WINDOW = 32;
|
||
|
||
// The four shelf moves, in the order they appear in the menu.
|
||
const SHELF_MOVES: readonly [ShelfMove, string, typeof ChevronLeft][] = [
|
||
["first", "home.shelf.moveFirst", ChevronsLeft],
|
||
["left", "home.shelf.moveLeft", ChevronLeft],
|
||
["right", "home.shelf.moveRight", ChevronRight],
|
||
["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<DiscoverCard>();
|
||
const pin = useComfortPin(menu.open);
|
||
// A video tile's own action list, the same one the feed's menu shows; the rail keeps its
|
||
// Play row (it resolves `next`) and its shelf rows around it.
|
||
const videoActions = useVideoActions({ onState, onToggleSave }, menu.open);
|
||
// A Plex tile's, likewise (its "Media info" row goes where the tile's `onInfo` does).
|
||
const plexActions = usePlexCardActions(
|
||
{
|
||
onOpen: (p) => {
|
||
const c = cards.find((x) => x.plex === p);
|
||
if (c) onPlay(c);
|
||
},
|
||
onInfo: (p) => {
|
||
const c = cards.find((x) => x.plex === p);
|
||
if (c) onInfo(c);
|
||
},
|
||
onToggleWatched,
|
||
},
|
||
menu.open,
|
||
);
|
||
const openPage = useOpenShelfPage();
|
||
const target = menu.target;
|
||
// What the pin row acts on: a comfort hero's pin is the shelf entry it came from — a pinned show's
|
||
// next episode is not itself pinned, and looking the episode up offered "Pin" under "From your
|
||
// shelf", a second pin (the owner's call, 2026-09-19). Any other card is its own.
|
||
const pinned = target ? (target.pinned_from ?? target) : null;
|
||
const shelfIdx = target?.pin_id != null ? cards.findIndex((c) => c.pin_id === target.pin_id) : -1;
|
||
const items: ActionMenuItem[] = !target
|
||
? []
|
||
: [
|
||
{
|
||
id: "play",
|
||
label: target.position_seconds > 0 ? t("home.hero.continue") : t("home.hero.play"),
|
||
icon: <Play className="w-4 h-4" />,
|
||
// A pinned channel with nothing unwatched left, or an empty playlist, resolves to no
|
||
// `next` — the same condition that stands its tile down. Kept visible and disabled rather
|
||
// than dropped, so the menu does not change shape from tile to tile.
|
||
disabled: !(target.next ?? target).video && !(target.next ?? target).plex,
|
||
onSelect: () => onPlay(target),
|
||
},
|
||
// What the Play row would continue can also start over (C5), with the card menus' words.
|
||
...((target.next ?? target).position_seconds > 0
|
||
? [
|
||
{
|
||
id: "restart",
|
||
label: t("card.restartTitle"),
|
||
icon: <SkipBack className="w-4 h-4" />,
|
||
onSelect: () => onPlay(target, 0),
|
||
},
|
||
]
|
||
: []),
|
||
...(target.video
|
||
? videoActions
|
||
.items(target.video, menu.anchor, { pin: false })
|
||
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
|
||
: target.plex
|
||
? plexActions
|
||
.items(target.plex, { play: false, pin: false, anchor: menu.anchor })
|
||
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
|
||
: target.kind === "channel" || target.kind === "playlist"
|
||
? [{ ...openPage.item(target), separatorBefore: true }]
|
||
: []),
|
||
...(onMove && target.pin_id != null
|
||
? [
|
||
...SHELF_MOVES.map(([to, key, Icon], i) => ({
|
||
id: `shelf-${to}`,
|
||
label: t(key),
|
||
icon: <Icon className="w-4 h-4" />,
|
||
separatorBefore: i === 0,
|
||
// The same rule the move itself runs, so a row is never enabled onto a no-op.
|
||
disabled: moveSaving || shelfMoveTarget(shelfIdx, to, cards.length) < 0,
|
||
note: moveSaving && i === 0 ? t("home.shelf.moveSaving") : undefined,
|
||
onSelect: () => onMove(target, to, cards),
|
||
})),
|
||
{
|
||
id: "shelf-unpin",
|
||
label: t("home.pin.remove"),
|
||
icon: <PinOff className="w-4 h-4" />,
|
||
separatorBefore: true,
|
||
// The only row here that removes a curated entry, and it sat in the same colour as
|
||
// the four harmless reorders. `danger` matches the shared unpin row in
|
||
// `comfortPin.item()`, which is what every OTHER surface (including the hero and the
|
||
// rail below) composes for the same command — the two must not disagree.
|
||
danger: true,
|
||
// The pin id travels on the shelf card, so this needs no lookup — and unlike every
|
||
// other surface it can never be in the "not known yet" state.
|
||
onSelect: () => pin.remove(target.pin_id as number, target.title),
|
||
},
|
||
]
|
||
: pin.item(pinTargetForCard(pinned ?? target), (pinned ?? target).title)),
|
||
];
|
||
return {
|
||
menu,
|
||
items,
|
||
panels: (
|
||
<>
|
||
{videoActions.panel}
|
||
{plexActions.panel}
|
||
</>
|
||
),
|
||
plexAdd: plexActions.add,
|
||
};
|
||
}
|
||
|
||
/** A pinned channel's or playlist's own page: the shelf tile's ↗ and its menu's open row. */
|
||
function useOpenShelfPage() {
|
||
const { t } = useTranslation();
|
||
const { openChannel, setPage } = useNavigationActions();
|
||
const { setSelectedId } = usePlaylistsActions();
|
||
const open = (card: DiscoverCard) => {
|
||
if (card.kind === "channel") openChannel(card.id, card.title);
|
||
else {
|
||
setSelectedId(Number(card.id));
|
||
setPage("playlists");
|
||
}
|
||
};
|
||
const label = (card: DiscoverCard) =>
|
||
card.kind === "channel" ? t("card.menu.openChannel") : t("home.shelf.openPlaylist");
|
||
return {
|
||
open,
|
||
label,
|
||
item: (card: DiscoverCard): ActionMenuItem => ({
|
||
id: "open",
|
||
label: label(card),
|
||
icon: <ArrowUpRight className="w-4 h-4" />,
|
||
onSelect: () => open(card),
|
||
}),
|
||
};
|
||
}
|
||
|
||
function Rail({
|
||
title,
|
||
ariaLabel,
|
||
cards,
|
||
emptyLabel,
|
||
onPlay,
|
||
onInfo,
|
||
onState,
|
||
onToggleSave,
|
||
onToggleWatched,
|
||
onMove,
|
||
moveSaving = false,
|
||
}: {
|
||
title: string;
|
||
ariaLabel: string;
|
||
cards: DiscoverCard[];
|
||
emptyLabel: string;
|
||
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;
|
||
/** Only the comfort shelf is orderable. Given, this rail's menu grows the four move rows; omitted,
|
||
* its tiles offer the plain pin instead. `rendered` is the order this rail is SHOWING — the one a
|
||
* move must act on (see `moveInShelf`). */
|
||
onMove?: (card: DiscoverCard, to: ShelfMove, rendered: DiscoverCard[]) => void;
|
||
/** A reorder is being written: the move rows wait for it (one write at a time). */
|
||
moveSaving?: boolean;
|
||
}) {
|
||
const { menu, items, panels, plexAdd } = useHomeCardMenu({
|
||
cards,
|
||
onPlay,
|
||
onInfo,
|
||
onState,
|
||
onToggleSave,
|
||
onToggleWatched,
|
||
onMove,
|
||
moveSaving,
|
||
});
|
||
|
||
// One stable trigger per card (the menu acts on the unified card, not the `Video`/`PlexCard` the
|
||
// card component holds) — a fresh lambda per render defeated `memo(VideoCard)` on every tile.
|
||
const menuFor = useItemTriggers(cards, menu.triggerProps);
|
||
const buttonFor = useItemTriggers(cards, menu.buttonProps);
|
||
|
||
// An empty rail says why it is empty rather than vanishing: a shelf that silently disappears when
|
||
// you have not pinned anything teaches nothing about how to fill it.
|
||
if (!cards.length) {
|
||
return (
|
||
<section className="mb-6">
|
||
<h2 className="text-sm font-semibold mb-2">{title}</h2>
|
||
<StateMessage compact>{emptyLabel}</StateMessage>
|
||
</section>
|
||
);
|
||
}
|
||
return (
|
||
<section className="mb-6">
|
||
<Carousel
|
||
title={<span className="text-sm font-semibold">{title}</span>}
|
||
ariaLabel={ariaLabel}
|
||
keyboardNav
|
||
>
|
||
{cards.map((c, i) => (
|
||
<div
|
||
key={`${c.source}:${c.kind}:${c.id}`}
|
||
// Every shelf card is landscape, a Plex movie or show too (its wide art): a shelf mixes
|
||
// YouTube and Plex, and a poster beside a 16:9 card stretched the whole row to the
|
||
// poster's height, leaving a hole in every video card (the owner's pick A, 2026-09-26 —
|
||
// Plex's and Netflix's own "Continue watching" rows do the same). A grid cell, so the card
|
||
// fills the slot: every card's meta line (pinned to its frame's bottom) sits on one line.
|
||
className="grid shrink-0 grid-cols-1"
|
||
style={{ width: YT_SLOT }}
|
||
>
|
||
{c.video ? (
|
||
<VideoCard
|
||
video={c.video}
|
||
view="cardsSmall"
|
||
shelf
|
||
width={YT_SLOT}
|
||
navIndex={i}
|
||
onState={onState}
|
||
onToggleSave={onToggleSave}
|
||
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
|
||
menuProps={menuFor[i]}
|
||
menuButtonProps={buttonFor[i]}
|
||
menuOpen={menu.open && menu.target === c}
|
||
/>
|
||
) : c.plex ? (
|
||
<PlexPosterCard
|
||
card={c.plex}
|
||
wide
|
||
shelf
|
||
navIndex={i}
|
||
onOpen={() => onPlay(c)}
|
||
onRestart={() => onPlay(c, 0)}
|
||
onToggleWatched={onToggleWatched}
|
||
onAdd={plexAdd}
|
||
next={c.next?.plex}
|
||
onInfo={() => onInfo(c)}
|
||
menuProps={menuFor[i]}
|
||
menuButtonProps={buttonFor[i]}
|
||
menuOpen={menu.open && menu.target === c}
|
||
/>
|
||
) : (
|
||
<ShelfTile
|
||
card={c}
|
||
onPlay={onPlay}
|
||
menuProps={menuFor[i]?.()}
|
||
menuButtonProps={buttonFor[i]?.()}
|
||
menuOpen={menu.open && menu.target === c}
|
||
navIndex={i}
|
||
/>
|
||
)}
|
||
</div>
|
||
))}
|
||
</Carousel>
|
||
<ContextMenu menu={menu} ariaLabel={ariaLabel} items={items} />
|
||
{panels}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// A pinned CHANNEL or PLAYLIST has no native card of its own — neither is a playable thing, so
|
||
// neither VideoCard nor PlexPosterCard fits. It advertises what is waiting behind it instead of a
|
||
// runtime it does not have.
|
||
function ShelfTile({
|
||
card,
|
||
onPlay,
|
||
menuProps,
|
||
menuButtonProps,
|
||
menuOpen = false,
|
||
navIndex,
|
||
}: {
|
||
card: DiscoverCard;
|
||
onPlay: (card: DiscoverCard) => void;
|
||
menuProps?: Record<string, unknown>;
|
||
/** The rail menu's "…" for this tile. */
|
||
menuButtonProps?: Record<string, unknown>;
|
||
menuOpen?: boolean;
|
||
navIndex?: number;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const openPage = useOpenShelfPage();
|
||
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
|
||
const countLabel =
|
||
card.kind === "channel"
|
||
? t("home.shelf.unwatchedCount", { count: count ?? 0 })
|
||
: t("home.shelf.itemCount", { count: count ?? 0 });
|
||
// The one media card (ui/MediaCard), like the videos and posters beside it on the shelf: the
|
||
// picture is the play button, the count the meta line pinned to the frame's bottom.
|
||
return (
|
||
<MediaCard
|
||
dense
|
||
rootProps={{
|
||
...menuProps,
|
||
...(navIndex !== undefined && navItemAttrs(navIndex)),
|
||
}}
|
||
className={!card.next ? "opacity-60" : undefined}
|
||
picture={
|
||
// The ratio lives on a wrapper, as in the other cards: MEDIA_PICTURE fills its parent, and
|
||
// on the stretched frame itself that would be the whole card's height.
|
||
<div className={mediaPictureBox("wide")}>
|
||
<Pressable
|
||
onClick={() => card.next && onPlay(card)}
|
||
aria-label={card.title}
|
||
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
|
||
// playable, but it IS the tile you most want to take off the shelf, and the only way to
|
||
// do that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is
|
||
// narrower than it looks: a real `disabled` button DOES still receive `contextmenu` and
|
||
// `pointerdown`, so mouse and long-press would both survive it — what it loses is FOCUS,
|
||
// and with it Shift+F10, the only keyboard route to the menu. Inert to the click (the
|
||
// handler guards), alive to all three input paths.
|
||
aria-disabled={!card.next}
|
||
data-nav-open=""
|
||
className={MEDIA_PICTURE}
|
||
>
|
||
{card.thumb ? (
|
||
<img src={card.thumb} alt="" loading="lazy" className={MEDIA_IMG} />
|
||
) : null}
|
||
{/* The Play only when there is something to play: the click plays the channel's newest
|
||
unwatched video, the playlist's first. */}
|
||
<div className={MEDIA_HOVER_LAYER}>{card.next && <MediaPlayGlyph small />}</div>
|
||
</Pressable>
|
||
{/* The rest of what a tile like this offers, as the video and Plex cards beside it do:
|
||
its own page, and the "…" to the whole list (a sibling of the picture's button). */}
|
||
<div className={clsx(MEDIA_BAR, "flex gap-1")}>
|
||
<IconButton
|
||
variant="media"
|
||
onClick={() => openPage.open(card)}
|
||
label={openPage.label(card)}
|
||
>
|
||
<ArrowUpRight />
|
||
</IconButton>
|
||
{menuButtonProps && (
|
||
<MoreButton
|
||
variant="media"
|
||
label={t("card.menu.more")}
|
||
expanded={menuOpen}
|
||
menuProps={menuButtonProps}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
}
|
||
title={
|
||
<span className={`min-h-6 ${mediaTitleClass(true, true)} select-text`} title={card.title}>
|
||
{card.title}
|
||
</span>
|
||
}
|
||
meta={<span className="block text-xs leading-6 text-muted">{countLabel}</span>}
|
||
more={menuButtonProps && { props: menuButtonProps, open: menuOpen }}
|
||
moreLabel={t("card.menu.more")}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function Hero({
|
||
tier,
|
||
card,
|
||
onPlay,
|
||
onInfo,
|
||
onState,
|
||
onToggleSave,
|
||
onToggleWatched,
|
||
onReroll,
|
||
rerolling,
|
||
}: {
|
||
tier: HeroTier;
|
||
card: 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;
|
||
onReroll: () => void;
|
||
rerolling: boolean;
|
||
}) {
|
||
const { t, i18n } = useTranslation();
|
||
const lang = i18n.language;
|
||
// The hero is a card like any other (the owner, 2026-09-27): the same hover row on its picture and
|
||
// the same whole action list on its "…", right-click and long-press — it was a pin row alone.
|
||
const { menu, items, panels, plexAdd } = useHomeCardMenu({
|
||
cards: [card],
|
||
onPlay,
|
||
onInfo,
|
||
onState,
|
||
onToggleSave,
|
||
onToggleWatched,
|
||
});
|
||
const started = card.position_seconds > 0;
|
||
const left = card.remaining_seconds;
|
||
// The kicker is driven by the tier the SERVER chose, never guessed from the card's shape: "where
|
||
// you left off" over something never opened is exactly the quiet lie the house rules forbid.
|
||
const kicker =
|
||
tier === "resume"
|
||
? left != null
|
||
? t("home.hero.resumeLeft", { time: formatDuration(left) })
|
||
: t("home.hero.resume")
|
||
: tier === "comfort"
|
||
? t("home.hero.comfort")
|
||
: t("home.hero.surprise");
|
||
|
||
// One line of facts under the title: a film's year, length and score; a video's views and age.
|
||
// The picture's column is exactly the picture's width (it was 18rem around a 12rem poster: a 6rem
|
||
// hole), and the text stands centred beside it, so a video — shorter picture, fewer facts — fills
|
||
// its hero as well as a film does (the owner's pick, 2026-09-26).
|
||
const plex = card.plex;
|
||
const video = card.video;
|
||
const facts = plex
|
||
? [plex.year, formatRuntime(card.duration_seconds, lang)]
|
||
: video
|
||
? // The channel is the subtitle already.
|
||
[
|
||
video.view_count != null && `${formatCompact(video.view_count, lang)} ${t("card.views")}`,
|
||
relativeTime(video.published_at, lang, t),
|
||
]
|
||
: [];
|
||
const factLine = facts.filter(Boolean).join(" · ");
|
||
// A feed video carries no description, so only a film or an episode has a summary line.
|
||
const summary = plex?.summary;
|
||
// A film's subtitle is its year, which the fact line already says.
|
||
const subtitle = card.subtitle !== String(plex?.year ?? "") ? card.subtitle : null;
|
||
|
||
return (
|
||
// Three quarters of a wide page, centred, so the one answer stands in focus (the owner, 2026-09-27);
|
||
// a narrower window gives it the whole width back. The padding is even on every side.
|
||
<section
|
||
{...menu.triggerProps(card)}
|
||
className={`${SURFACE.panel} mb-6 flex flex-col gap-5 p-5 sm:flex-row sm:items-center lg:mx-auto lg:w-3/4`}
|
||
>
|
||
<div
|
||
className={`group relative shrink-0 ${
|
||
card.aspect === "poster" ? "aspect-[2/3] w-40 sm:w-48" : "aspect-video w-full sm:w-72"
|
||
}`}
|
||
>
|
||
<Pressable
|
||
onClick={() => onPlay(card)}
|
||
className="relative block h-full w-full cursor-pointer overflow-hidden rounded-lg border border-border bg-card"
|
||
aria-label={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`}
|
||
>
|
||
{card.thumb ? (
|
||
<img src={card.thumb} alt="" className="w-full h-full object-cover" />
|
||
) : null}
|
||
{card.duration_seconds != null && (
|
||
// It steps aside while the hover row covers its corner, as a card's length does.
|
||
<span className={`${MEDIA_BADGE} bottom-1.5 right-1.5 ${HOVER_YIELD}`}>
|
||
{formatDuration(card.duration_seconds)}
|
||
</span>
|
||
)}
|
||
{started && card.duration_seconds ? (
|
||
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/60">
|
||
<span
|
||
className="block h-full bg-accent"
|
||
style={{
|
||
width: `${Math.min(100, (card.position_seconds / card.duration_seconds) * 100)}%`,
|
||
}}
|
||
/>
|
||
</span>
|
||
) : null}
|
||
<div className={MEDIA_HOVER_LAYER}>{!started && <MediaPlayGlyph />}</div>
|
||
</Pressable>
|
||
{/* A started hero continues on a click, and offers the start as a card does (C5): the pair
|
||
is a sibling over the picture, never inside its button. */}
|
||
{started && (
|
||
<div className={clsx(MEDIA_RESUME_LAYER, card.aspect === "poster" && "flex-col")}>
|
||
<MediaResumeButtons onContinue={() => onPlay(card)} onRestart={() => onPlay(card, 0)} />
|
||
</div>
|
||
)}
|
||
{/* The card's own hover row, as on every card: a sibling of the picture's button. */}
|
||
{card.video ? (
|
||
<div className={MEDIA_BAR}>
|
||
<VideoHoverActions
|
||
video={card.video}
|
||
onState={onState}
|
||
onToggleSave={onToggleSave}
|
||
media
|
||
more={{ props: menu.buttonProps(card), open: menu.open }}
|
||
/>
|
||
</div>
|
||
) : card.plex ? (
|
||
<PlexHoverBar
|
||
card={card.plex}
|
||
onToggleWatched={onToggleWatched}
|
||
onAdd={plexAdd}
|
||
moreProps={menu.buttonProps(card)}
|
||
moreOpen={menu.open}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="flex min-w-0 flex-1 flex-col">
|
||
<p className="text-2xs font-semibold uppercase tracking-wider text-accent">{kicker}</p>
|
||
<h2 className="mt-1 text-lg font-semibold leading-tight">{card.title}</h2>
|
||
{subtitle && <p className="mt-0.5 text-xs text-muted truncate">{subtitle}</p>}
|
||
{(factLine || plex?.rating != null) && (
|
||
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
|
||
{factLine && <span>{factLine}</span>}
|
||
{plex?.rating != null && (
|
||
<span className="inline-flex items-center gap-0.5">
|
||
{factLine && <span aria-hidden>·</span>}
|
||
<Star className="h-3 w-3 fill-current text-rating" aria-hidden />
|
||
{formatDecimal(plex.rating, lang)}
|
||
</span>
|
||
)}
|
||
</p>
|
||
)}
|
||
{/* Only when there is one (a missing summary leaves no gap, the stack is centred), at the
|
||
reading measure: the eye finds the next line's start again (it ran ~130 characters). */}
|
||
{summary && <p className="mt-2 measure line-clamp-2 text-sm text-muted">{summary}</p>}
|
||
<div className="flex flex-wrap items-center gap-2 pt-4">
|
||
<Button icon={<Play className="h-4 w-4" />} onClick={() => onPlay(card)}>
|
||
{started ? t("home.hero.continue") : t("home.hero.play")}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
icon={<RotateCcw className="h-4 w-4" />}
|
||
loading={rerolling}
|
||
onClick={onReroll}
|
||
>
|
||
{t("home.hero.reroll")}
|
||
</Button>
|
||
{/* On touch the picture's hover row is gone; the "…" stands here instead, as it sits
|
||
beside a card's title. */}
|
||
<MoreButton
|
||
className="touch-only"
|
||
label={t("card.menu.more")}
|
||
expanded={menu.open}
|
||
menuProps={menu.buttonProps(card)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<ContextMenu menu={menu} ariaLabel={card.title} items={items} />
|
||
{panels}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const SAVED_KEY = "sfHome";
|
||
|
||
interface SavedQuestion {
|
||
budget: DiscoverBudget;
|
||
seed: number;
|
||
press: number;
|
||
seen: string[];
|
||
}
|
||
|
||
/** The question this history entry was left with, if it was left from Home. Read from
|
||
* `history.state`, which only this app writes — still checked field by field. */
|
||
function readSavedQuestion(): SavedQuestion | null {
|
||
const raw = (window.history.state as Record<string, unknown> | null)?.[SAVED_KEY] as
|
||
| Partial<SavedQuestion>
|
||
| undefined;
|
||
if (!raw || !DISCOVER_BUDGETS.includes(raw.budget as DiscoverBudget)) return null;
|
||
return {
|
||
budget: raw.budget as DiscoverBudget,
|
||
seed: Number.isInteger(raw.seed) ? (raw.seed as number) : 0,
|
||
press: Number.isInteger(raw.press) ? (raw.press as number) : 0,
|
||
seen: Array.isArray(raw.seen) ? raw.seen.filter((k) => typeof k === "string") : [],
|
||
};
|
||
}
|
||
|
||
export default function Home() {
|
||
const { t, i18n } = useTranslation();
|
||
const qc = useQueryClient();
|
||
const { setPage } = useNavigationActions();
|
||
const { setJumpTo } = usePlexActions();
|
||
|
||
// The question on screen lives on this page's history ENTRY as well: opening a card leaves Home
|
||
// (it unmounts), and Back used to come back to "Doesn't matter" and a different hero — whose card
|
||
// then left the Continue rail, so the keyboard landed on its neighbour (prod UAT 2026-09-19).
|
||
const [saved] = useState(readSavedQuestion);
|
||
const [budget, setBudget] = useState<DiscoverBudget>(saved?.budget ?? "any");
|
||
// 0 = "the hero the page loaded with". Any other value is a reroll, answered by its own query so
|
||
// pressing the button does not refetch the rails under the user's cursor.
|
||
const [seed, setSeed] = useState(saved?.seed ?? 0);
|
||
// Which press this is. It rotates the tier the server leads with, so "Mást kérek" offers a
|
||
// different KIND of answer each time (where you left off → your shelf → a surprise) rather than
|
||
// the next row of the same rail. Reset with the budget: a new question starts the rotation over.
|
||
const [press, setPress] = useState(saved?.press ?? 0);
|
||
// The cards the reroll has already offered this session, newest last. A ROLLING window rather than
|
||
// the whole history: an unbounded list would eventually exclude everything and leave the button
|
||
// with nothing to give, whereas a bounded one cannot. Its size is `SEEN_WINDOW` — see there for
|
||
// why it is 32 and not the 8 this comment used to quote. Kept here (not in the URL or a pref)
|
||
// because it is a property of this sitting.
|
||
const [seen, setSeen] = useState<string[]>(saved?.seen ?? []);
|
||
useEffect(() => {
|
||
const question: SavedQuestion = { budget, seed, press, seen };
|
||
window.history.replaceState({ ...window.history.state, [SAVED_KEY]: question }, "");
|
||
}, [budget, seed, press, seen]);
|
||
const [player, setPlayer] = useState<{ video: Video; startAt: number | null } | null>(null);
|
||
|
||
// A Plex card's title is in the reader's language, so the answer is cached per language.
|
||
const lang = plexLang(i18n.language);
|
||
const q = useQuery({
|
||
queryKey: qk.discover(budget, lang),
|
||
queryFn: () => api.discover(budget, 0, lang),
|
||
// Keep the previous budget's answer on screen while the new one loads. Without this the page
|
||
// falls back to `isPending` and replaces itself — chips included — with a spinner, so the
|
||
// control you just clicked vanishes under your cursor. (Same reason the feed does it.)
|
||
placeholderData: keepPreviousData,
|
||
});
|
||
const pickQ = useQuery({
|
||
queryKey: qk.discoverPick(budget, lang, seed, seen, press),
|
||
queryFn: () => api.discoverPick(budget, seed, seen, press, lang),
|
||
enabled: seed !== 0,
|
||
staleTime: 0,
|
||
// Hold the previous hero while the next one is in flight. Without this the reroll's new query key
|
||
// has no data for a beat, `hero` falls to null, and the page renders "nothing to suggest yet" —
|
||
// an EMPTY state standing in for a loading one, which is the exact dishonesty the house rules
|
||
// forbid. Measured in the browser (2026-09-06); the button's own spinner is the loading signal.
|
||
//
|
||
// But only WITHIN a budget. Plain `keepPreviousData` chains through the disabled seed-0 query a
|
||
// chip click leaves behind, so the first reroll under a new budget held up the card the PREVIOUS
|
||
// question was answered with — under a chip promising something else. Returning
|
||
// undefined instead falls the hero back to this budget's own loaded answer, which is already in
|
||
// hand; the spinner still covers the wait.
|
||
placeholderData: (prev) => (prev?.budget === budget ? prev : undefined),
|
||
});
|
||
// Is a PRESS outstanding? NOT `pickQ.isFetching`. The pick key deliberately sits under the
|
||
// `["discover"]` prefix so a mutation's invalidation reaches the rerolled hero — which also means
|
||
// every mark-watched, bookmark, pin/unpin and shelf move on this page refetches it in the
|
||
// background, and `ui/Button` turns `loading` into `disabled`. So the reroll spun and swallowed
|
||
// clicks after actions the user never associated with it. A press is outstanding exactly
|
||
// while THIS key has never settled — which is what `isFetched` means (react-query counts both a
|
||
// success and an error as settled), and every press mints a fresh seed, so a fresh key.
|
||
//
|
||
// Since corrected: `isPlaceholderData || data === undefined` looked equivalent and was not.
|
||
// A pick that FAILED leaves its key data-less forever (`retry: false`), so the second disjunct
|
||
// stayed true and the next unrelated mutation re-disabled the button — the same defect, surviving
|
||
// in the error branch. Guarding on `!isError` does NOT help either: query-core resets a data-less
|
||
// query to status "pending" for the duration of a refetch, so the error is invisible exactly when
|
||
// it matters. `isFetched` is the only flag that remembers.
|
||
const rerolling = pickQ.isFetching && !pickQ.isFetched;
|
||
// The last pick that actually RESOLVED, tagged with the budget it answered. Every press is a new
|
||
// query key, so a failed one has no data of its own and `keepPreviousData` (pending-only) cannot
|
||
// cover it — without this the hero silently rewound to the card the page loaded with, several
|
||
// presses back, while `core.req`'s own dialog was explaining the failure. A reroll that fails
|
||
// leaves the answer on screen alone.
|
||
//
|
||
// The TAG is load-bearing, not bookkeeping: the chip row resets `seed`/`seen`/`press` but cannot
|
||
// reset an answer that already resolved, so an untagged slot outlived the question it answered —
|
||
// switch to "10 perc", press reroll, let that request fail (`retry: false`, so it is terminal),
|
||
// and the two-hour film picked under "Bármennyi" was still the hero, under a chip promising ten
|
||
// minutes. Taken from the SERVER's `budget` echo rather than this render's state, so the
|
||
// tag cannot be minted from a budget the pick never answered.
|
||
const [lastPick, setLastPick] = useState<{
|
||
budget: DiscoverBudget;
|
||
hero: DiscoverPayload["hero"];
|
||
} | null>(null);
|
||
useEffect(() => {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect -- deliberately OUTLIVES the query: a failed reroll is terminal (retry:false) and must leave the previous answer on screen with the budget tag the SERVER echoed for it. Derived from pickQ it would vanish with the query.
|
||
if (pickQ.data !== undefined) setLastPick({ budget: pickQ.data.budget, hero: pickQ.data.hero });
|
||
}, [pickQ.data]);
|
||
|
||
const data = q.data;
|
||
// Which hero is on screen. The middle branch is the load-bearing one: `keepPreviousData` holds the
|
||
// PREVIOUS reroll, but the FIRST reroll has no previous pick to hold, so without falling back to
|
||
// the loaded payload the page renders "nothing to suggest" over a perfectly good library for a
|
||
// beat. `pickQ.data !== undefined` (not `?? null`) is what keeps a genuinely empty answer honest:
|
||
// once the pick has RESOLVED, a null hero really means nothing is left and the empty state is the
|
||
// truth.
|
||
const hero =
|
||
seed === 0
|
||
? (data?.hero ?? null)
|
||
: pickQ.data !== undefined
|
||
? pickQ.data.hero
|
||
: // A ternary, not `??`: the tagged slot is AUTHORITATIVE for its own budget, and "the last
|
||
// pick for this budget resolved to no hero" is a real answer, not a missing one. Under
|
||
// `??` the two collapsed, so a failed press after an empty pick resurrected the card the
|
||
// page loaded with — which it had already excluded as seen and just said it had nothing
|
||
// beyond. A mismatched or absent tag still falls through to the loaded hero,
|
||
// which is what the FIRST press of a budget needs.
|
||
lastPick?.budget === budget
|
||
? lastPick.hero
|
||
: (data?.hero ?? null);
|
||
// The hero is the top of the resume rail by construction, so leaving it in the rail below prints
|
||
// the same card twice, a hand's width apart. Matched on source+id rather than on the tier alone:
|
||
// a rerolled hero can be a surprise pick that happens to be nothing to do with this rail.
|
||
//
|
||
// Against BOTH of a row's keys, the way `hero.pick`'s own exclusion does. For a Plex series the
|
||
// rail row is the SHOW while the hero is the episode that show RESOLVES to, so a single-key match
|
||
// never fires and the most common Plex resume case printed the series twice.
|
||
const heroKey = hero ? discoverCardKey(hero.card) : null;
|
||
const resumeRail = (data?.continue_watching ?? []).filter(
|
||
(c) => discoverCardKey(c) !== heroKey && discoverCardKey(c.next ?? c) !== heroKey,
|
||
);
|
||
// Empty because the hero ATE the rail, not because nothing is half-finished. An empty rail earns
|
||
// its empty state by teaching you how to fill it; here there is nothing to teach, and "Nothing
|
||
// half-finished right now" under a hero captioned "Where you left off" is the page contradicting
|
||
// itself on one screen.
|
||
const resumeConsumed = !resumeRail.length && (data?.continue_watching ?? []).length > 0;
|
||
|
||
const onState = useCallback(
|
||
(id: string, status: VideoStatus) => {
|
||
// A full refetch, unlike the feed's stale-mark: these rails are two dozen rows, and marking
|
||
// something watched genuinely changes what the page should answer (it can retire the hero).
|
||
// The feed avoids this because it would refetch ten infinite-query pages per click.
|
||
void api
|
||
.setState(id, status)
|
||
.then(() => qc.invalidateQueries({ queryKey: qk.discover() }))
|
||
// Without this the rejection escapes unhandled and the click just does nothing — the
|
||
// feed's equivalents both catch, and the reorder on this same page already does.
|
||
// A WRITE key, not `home.error`: that string is the page's LOAD failure ("could not load
|
||
// your suggestions"), and it is what a detail-less rejection actually shows — an offline
|
||
// click raises a bare fetch TypeError, so there is no server reason to prefer over it. It
|
||
// would describe a state the user can see is untrue, over a fully populated page whose
|
||
// invalidations never even ran (they sit inside `.then`).
|
||
.catch((e: unknown) => notifyActionError(e, t("home.actionFailed")));
|
||
},
|
||
[qc, t],
|
||
);
|
||
|
||
const onToggleSave = useCallback(
|
||
(id: string, saved: boolean) => {
|
||
void (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
|
||
.then(() => {
|
||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||
// And the payload the star is READ from. `VideoCard` derives the filled/empty bookmark
|
||
// straight from `video.saved` and keeps no optimistic state of its own (the feed instead
|
||
// holds a `savedOverrides` map), so without this the icon never changed and a second click
|
||
// re-sent the same call — the control looked dead.
|
||
qc.invalidateQueries({ queryKey: qk.discover() });
|
||
})
|
||
// The write key, for the same reason as `onState` above.
|
||
.catch((e: unknown) => notifyActionError(e, t("home.actionFailed")));
|
||
},
|
||
[qc, t],
|
||
);
|
||
|
||
// One click plays. A shelf tile resolves through its `next` (a show → the episode that would
|
||
// start, a channel → its newest unwatched video); everything else plays itself. Plex hands off to
|
||
// its own module, which owns the hls.js player and its whole provider tree — the alternative would
|
||
// be a second player mounted on the landing page.
|
||
const onPlay = useCallback(
|
||
(card: DiscoverCard, startAt?: number | null) => {
|
||
const target = card.next ?? card;
|
||
if (target.video) {
|
||
setPlayer({ video: target.video, startAt: startAt ?? null });
|
||
return;
|
||
}
|
||
if (target.plex) {
|
||
// `replace`: the Plex module's sub-view rewrites the page entry `setPage` is about to push,
|
||
// so browser-Back from the player returns HERE — not to the Plex Discover root, which this
|
||
// navigation passed through and the user never saw.
|
||
setJumpTo({
|
||
id: target.plex.id,
|
||
type: target.plex.type === "show" ? "show" : "play",
|
||
fromStart: startAt === 0,
|
||
replace: true,
|
||
});
|
||
setPage("plex");
|
||
}
|
||
},
|
||
[setJumpTo, setPage],
|
||
);
|
||
|
||
// The "Media info" row of a Plex tile's menu: the item's own page in the Plex module, exactly where
|
||
// the same row goes from the Plex grid. A show tile's card click lands there too — a show is not
|
||
// playable.
|
||
const onInfo = useCallback(
|
||
(card: DiscoverCard) => {
|
||
if (!card.plex) return;
|
||
setJumpTo({
|
||
id: card.plex.id,
|
||
type: card.plex.type === "show" ? "show" : "movie",
|
||
replace: true,
|
||
});
|
||
setPage("plex");
|
||
},
|
||
[setJumpTo, setPage],
|
||
);
|
||
|
||
// Watched-toggle on a Plex tile. No optimistic cache patch here (unlike the Plex Discover
|
||
// surface): marking something watched can retire the hero as well as the tile, so this page
|
||
// re-asks the server rather than trying to predict a cross-rail consequence.
|
||
const onToggleWatched = useCallback(
|
||
(card: PlexCard) =>
|
||
void toggleWatchState(card, t, {
|
||
optimistic: () => {},
|
||
resync: () => qc.invalidateQueries({ queryKey: qk.discover() }),
|
||
}),
|
||
[qc, t],
|
||
);
|
||
|
||
// Shelf reorder, from the comfort rail's menu. Optimistic on THIS budget's cached payload so the
|
||
// tile moves under the pointer, then written.
|
||
//
|
||
// It acts on the order the rail RENDERED, not on a fresh read of this budget's cache: during a
|
||
// budget switch the rail still shows the previous budget's payload (`keepPreviousData`) while the
|
||
// new key holds nothing yet, so `from` came back -1 and the move silently did nothing — the
|
||
// "looks available, then no-ops" row `shelfMoveTarget` exists to rule out.
|
||
//
|
||
// ONE write at a time (`shelfSaving`; the move rows wait with a note). That is what makes a
|
||
// failure's rollback right: with no overlapping move, "the order the rail showed before this move"
|
||
// is the order the server still holds, so a refused move puts it back instead of leaving the
|
||
// rejected order on screen whenever the follow-up read failed too. (Two overlapping moves, each
|
||
// reverting to its own snapshot, settled on an order neither side ever held — why the rollback
|
||
// used to be a re-read only.)
|
||
const [shelfSaving, setShelfSaving] = useState(false);
|
||
const shelfSavingRef = useRef(false);
|
||
const moveInShelf = useCallback(
|
||
(card: DiscoverCard, to: ShelfMove, rendered: DiscoverCard[]) => {
|
||
if (shelfSavingRef.current) return;
|
||
const key = qk.discover(budget, lang); // the query's own key: setQueryData matches exactly
|
||
const cards = rendered;
|
||
const from = cards.findIndex((c) => c.pin_id === card.pin_id);
|
||
const target = shelfMoveTarget(from, to, cards.length);
|
||
if (target < 0) return;
|
||
const next = [...cards];
|
||
const [moved] = next.splice(from, 1);
|
||
if (!moved) return; // unreachable: `from` is in range whenever `target` is
|
||
next.splice(target, 0, moved);
|
||
qc.setQueryData<DiscoverPayload>(key, (old) => (old ? { ...old, comfort: next } : old));
|
||
// `pin_id` is present on every shelf card by construction; the filter is what proves it to the
|
||
// type, and a short list would be rejected by the server rather than silently reordering half
|
||
// the shelf.
|
||
const ids = next.map((c) => c.pin_id).filter((id): id is number => id != null);
|
||
shelfSavingRef.current = true;
|
||
setShelfSaving(true);
|
||
void api
|
||
.reorderComfortPins(ids)
|
||
.catch((e: unknown) => {
|
||
qc.setQueryData<DiscoverPayload>(key, (old) => (old ? { ...old, comfort: cards } : old));
|
||
notifyActionError(e, t("home.shelf.moveFailed"));
|
||
})
|
||
.finally(() => {
|
||
shelfSavingRef.current = false;
|
||
setShelfSaving(false);
|
||
// The shelf is also the pin menus' source of truth everywhere else in the app.
|
||
void qc.invalidateQueries({ queryKey: qk.comfortPins() });
|
||
// And the PREFIX, not this budget's key: the shelf is budget-independent server-side but
|
||
// cached per budget, so without it the next chip click renders the pre-move order and a
|
||
// move from there writes it back. (The rollback above restores only `comfort`, so a rail
|
||
// refresh that landed meanwhile is kept.)
|
||
void qc.invalidateQueries({ queryKey: qk.discover() });
|
||
});
|
||
},
|
||
[budget, lang, qc, t],
|
||
);
|
||
|
||
const reroll = useCallback(
|
||
(lead?: HeroTier) => {
|
||
// Remember what is on screen so the next pick cannot be the same card, then take a fresh seed —
|
||
// never 0 (which means "the hero the page loaded with") and never the current one (a cache hit
|
||
// would look like a dead button).
|
||
if (heroKey)
|
||
setSeen((prev) => [...prev.filter((k) => k !== heroKey), heroKey].slice(-SEEN_WINDOW));
|
||
// `lead` asks for a tier: the next press whose rotation leads with it. The rotation carries on
|
||
// from there, so the button's following press is the tier after it, as it would have been.
|
||
setPress((n) => {
|
||
let next = n + 1;
|
||
// Guarded on membership: a click handler passed straight in would hand over its event
|
||
// as `lead`, and this loop would never end.
|
||
if (lead && HERO_TIERS.includes(lead))
|
||
while (HERO_TIERS[next % HERO_TIERS.length] !== lead) next += 1;
|
||
return next;
|
||
});
|
||
setSeed((prev) => {
|
||
let next = prev;
|
||
while (next === prev || next === 0) next = Math.floor(Math.random() * 1_000_000) + 1;
|
||
return next;
|
||
});
|
||
},
|
||
[heroKey],
|
||
);
|
||
|
||
// "Meglepetés" from the command palette: the request is made on another page, so it waits as a
|
||
// token until this module's chunk has actually mounted (see lib/homeIntent). The subscribed value
|
||
// is what WAKES this effect; `takeSurprise()` is what decides — it clears the token and answers
|
||
// false to every later pass, including StrictMode's second mount invocation and the re-runs
|
||
// `reroll`'s changing identity causes — and to a token minted for another history entry.
|
||
const surpriseRequest = useSurpriseRequest();
|
||
useEffect(() => {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect -- consuming a cross-page token (lib/homeIntent) that was minted before this module's chunk existed. takeSurprise() clears it and answers false to every later pass, including StrictMode's second mount.
|
||
if (surpriseRequest !== null && takeSurprise()) reroll("surprise");
|
||
}, [surpriseRequest, reroll]);
|
||
|
||
if (q.isPending) return <LoadingState shape="grid" />;
|
||
// `&& !data`, the codebase-wide convention (and the one `ui/QueryState` documents): react-query
|
||
// flips `status` to error on a FAILED REFETCH while keeping the cached payload, and every mutation
|
||
// on this page invalidates `qk.discover()` — so without the guard, marking one card watched over a
|
||
// blip replaced a fully populated landing page with an error line.
|
||
if (q.isError && !data)
|
||
return (
|
||
<StateMessage tone="error" onRetry={() => void q.refetch()}>
|
||
{t("home.error")}
|
||
</StateMessage>
|
||
);
|
||
|
||
return (
|
||
<div className="p-4">
|
||
{/* The caption sits NEXT TO the chips, not opposite them: SegmentedToggle's own `label` prop
|
||
renders a justify-between row, which is right for a narrow filter rail and wrong here — on
|
||
an 80rem page it throws the answers to the far edge, away from the question they answer. */}
|
||
<div className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||
<span className="text-sm text-muted">{t("home.budget.ask")}</span>
|
||
<SegmentedToggle
|
||
size="md"
|
||
ariaLabel={t("home.budget.ask")}
|
||
value={budget}
|
||
onChange={(v) => {
|
||
setBudget(v);
|
||
setSeed(0); // the payload for the new budget carries its own hero
|
||
setSeen([]); // a new question deserves the whole library again
|
||
setPress(0); // …and the tier rotation starts from the page's own priority order
|
||
}}
|
||
options={DISCOVER_BUDGETS.map((b) => ({ value: b, label: t(`home.budget.${b}`) }))}
|
||
/>
|
||
</div>
|
||
|
||
{hero ? (
|
||
<Hero
|
||
tier={hero.tier}
|
||
card={hero.card}
|
||
onPlay={onPlay}
|
||
onInfo={onInfo}
|
||
onState={onState}
|
||
onToggleSave={onToggleSave}
|
||
onToggleWatched={onToggleWatched}
|
||
onReroll={() => reroll()}
|
||
rerolling={rerolling}
|
||
/>
|
||
) : (
|
||
// Honest: nothing in the library fits what was asked. Offering something that does not fit
|
||
// would make the one control on this page meaningless.
|
||
<section className={`${SURFACE.panel} mb-6 p-4`}>
|
||
{/* Not `StateMessage`'s `onRetry` slot: that button is labelled "Retry", which promises
|
||
a re-run of something that failed. Nothing failed — the library simply has no answer
|
||
this short, and the honest offer is to ask a different question. */}
|
||
<StateMessage compact>
|
||
{budget === "any" ? t("home.hero.emptyAny") : t("home.hero.emptyBudget")}
|
||
</StateMessage>
|
||
{budget !== "any" && (
|
||
<div className="flex justify-center pb-4">
|
||
{/* The same action as the Hero's own reroll, so it carries the same in-flight signal.
|
||
Without it the press was unacknowledged — `hero` stays null for the whole round
|
||
trip in this branch, so nothing on the page moved — and a second click spent
|
||
another pick AND skipped a tier in the server's `start=` rotation. The icon comes
|
||
along because `loading` swaps it for the spinner: with no icon the button would
|
||
grow by a spinner's width mid-press. */}
|
||
<Button
|
||
variant="outline"
|
||
size="xs"
|
||
type="button"
|
||
icon={<RotateCcw className="h-4 w-4" />}
|
||
loading={rerolling}
|
||
onClick={() => reroll()}
|
||
>
|
||
{t("home.hero.reroll")}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
<Rail
|
||
title={t("home.comfort.title")}
|
||
ariaLabel={t("home.comfort.title")}
|
||
cards={data?.comfort ?? []}
|
||
emptyLabel={t("home.comfort.empty")}
|
||
onPlay={onPlay}
|
||
onInfo={onInfo}
|
||
onState={onState}
|
||
onToggleSave={onToggleSave}
|
||
onToggleWatched={onToggleWatched}
|
||
onMove={moveInShelf}
|
||
moveSaving={shelfSaving}
|
||
/>
|
||
{!resumeConsumed && (
|
||
<Rail
|
||
title={t("home.continue.title")}
|
||
ariaLabel={t("home.continue.title")}
|
||
cards={resumeRail}
|
||
emptyLabel={t("home.continue.empty")}
|
||
onPlay={onPlay}
|
||
onInfo={onInfo}
|
||
onState={onState}
|
||
onToggleSave={onToggleSave}
|
||
onToggleWatched={onToggleWatched}
|
||
/>
|
||
)}
|
||
|
||
{player && (
|
||
<Suspense fallback={null}>
|
||
<PlayerModal
|
||
video={player.video}
|
||
startAt={player.startAt}
|
||
onClose={() => {
|
||
setPlayer(null);
|
||
// The player saves progress on close, so the rails and the hero are stale by
|
||
// definition once it shuts.
|
||
void qc.invalidateQueries({ queryKey: qk.discover() });
|
||
}}
|
||
onState={onState}
|
||
/>
|
||
</Suspense>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|