feat(e24): the Home surface — one question, one answer, both sources
E24 S2. A new `home` module in layout C+ (the mockup the owner picked): the time-budget chips on top, one large hero that answers them, then the comfort shelf and a cross-source resume rail. Home is the landing — deliberately OVER the remembered last module, since landing on whatever you happened to close the app on is the habit this epic exists to replace; `LS.startOnHome = "0"` opts an account back out (Settings toggle lands in S4). No third card component: each unified card carries the native feed / Plex payload, so the rails render through the existing VideoCard (`cardsSmall`) and PlexPosterCard, and `aspect` decides the tile shape without branching on source. Plex playback hands off to the Plex module via a new `jumpTo` type — the player lives there with its lazy hls.js and its provider tree, and mounting a second one on the landing page to save one hand-off would be the expensive way round. Four defects found by driving it in a real browser against the live account, each now pinned by a mutation-checked test: - **The reroll gave the same card back.** A new seed only re-draws the SURPRISE tier; the resume tier deterministically returns the most recently progressed thing that fits, so anyone with something half-finished pressed a dead button. `/pick` now takes an `exclude` list of `<source>:<id>` keys and the client keeps a rolling window of 8. Excluding a SHOW also excludes the episode it resolves to — otherwise the same suggestion comes back wearing a hat. - **"Nothing to suggest yet" flashed over a full library** whenever a reroll was in flight: the new query key has no data for a beat. An empty state standing in for a loading one is the dishonesty the house rules name outright. The hero now falls back to the loaded payload while a pick is pending, and only trusts a null once the pick has RESOLVED. - **A budget change replaced the page — chips included — with a spinner**, so the control you just clicked vanished under your cursor. `keepPreviousData`, the same reason the feed uses it. - **The hero printed twice**, once as itself and once as the first resume tile. Two smaller UI calls made from the same session: the budget caption sits NEXT to its chips rather than in SegmentedToggle's justify-between mode, which on an 80rem page throws the answers to the far edge; and the feed takes the YouTube mark its own toolbar already uses, since Home now owns the house icon. Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 780, pytest 989. Verified end to end on localdev against the real account: budget chips re-answer (matched on REMAINING time — a 51-minute video with 2:46 left is a ten-minute answer), the reroll walks three distinct heroes, and a Plex show tile opens its drill-down in the Plex module.
This commit is contained in:
@@ -32,6 +32,9 @@ import { setupApi } from "./api/setup";
|
||||
export type * from "./api/setup";
|
||||
import { feedApi } from "./api/feed";
|
||||
export type * from "./api/feed";
|
||||
import { discoverApi } from "./api/discover";
|
||||
export type * from "./api/discover";
|
||||
export { DISCOVER_BUDGETS, PIN_KINDS, discoverCardKey } from "./api/discover";
|
||||
export type * from "./api/tagModes";
|
||||
export {
|
||||
DEFAULT_TAG_MODES,
|
||||
@@ -77,6 +80,7 @@ export const api = {
|
||||
...authApi,
|
||||
...setupApi,
|
||||
...feedApi,
|
||||
...discoverApi,
|
||||
...channelsApi,
|
||||
...syncApi,
|
||||
...playlistsApi,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// The Home surface ("Mit nézzek most?", E24): one hero suggestion, a cross-source resume rail, and
|
||||
// the comfort shelf. Types mirror `backend/app/discover/` — the card carries the NATIVE feed / Plex
|
||||
// payload under `video` / `plex`, so the page renders through the existing VideoCard /
|
||||
// PlexPosterCard instead of a third card component.
|
||||
import { req } from "./core";
|
||||
import type { Video } from "./feed";
|
||||
import type { PlexCard } from "./plex";
|
||||
|
||||
// How much time the user says they have. The deterministic replacement for a mood question: the band
|
||||
// also decides the source (ten minutes is a video, an evening is a film). Bands live server-side
|
||||
// (`app/discover/budget.py`) — these are just the names on the wire.
|
||||
export const DISCOVER_BUDGETS = ["short", "medium", "long", "any"] as const;
|
||||
export type DiscoverBudget = (typeof DISCOVER_BUDGETS)[number];
|
||||
|
||||
// Where the hero came from. Travels with the card so the page can label it honestly instead of
|
||||
// inferring "where you left off" over something the user has never opened.
|
||||
export type HeroTier = "resume" | "comfort" | "surprise";
|
||||
|
||||
export type DiscoverKind = "video" | "movie" | "show" | "episode" | "channel" | "playlist";
|
||||
|
||||
// What can go on the comfort shelf. `plex_item` covers a movie or a single episode; a show is pinned
|
||||
// as `plex_show` because "put that show on" resolves to whichever episode is next.
|
||||
export const PIN_KINDS = ["video", "channel", "plex_item", "plex_show", "playlist"] as const;
|
||||
export type PinKind = (typeof PIN_KINDS)[number];
|
||||
|
||||
export interface DiscoverCard {
|
||||
source: "youtube" | "plex";
|
||||
kind: DiscoverKind;
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
thumb: string | null;
|
||||
// The ARTWORK's shape, not the module's: a Plex episode ships a landscape still exactly like a
|
||||
// YouTube video, while movies and shows ship posters. Lets a rail size its tiles without
|
||||
// branching on `source`.
|
||||
aspect: "wide" | "poster";
|
||||
duration_seconds: number | null;
|
||||
position_seconds: number;
|
||||
remaining_seconds: number | null;
|
||||
status: string;
|
||||
// Exactly one is set (both null for a channel / playlist tile, which are not themselves playable).
|
||||
video: Video | null;
|
||||
plex: PlexCard | null;
|
||||
// Shelf entries only.
|
||||
pin_id?: number;
|
||||
/** What actually plays when the tile is clicked: a show's next episode, a channel's newest
|
||||
* unwatched video, or a copy of the tile itself when it plays directly. Null when the pin has
|
||||
* nothing to offer right now (a fully-watched channel, an empty playlist). */
|
||||
next?: DiscoverCard | null;
|
||||
unwatched_count?: number;
|
||||
item_count?: number;
|
||||
}
|
||||
|
||||
export interface DiscoverHero {
|
||||
tier: HeroTier;
|
||||
card: DiscoverCard;
|
||||
}
|
||||
|
||||
export interface DiscoverPayload {
|
||||
budget: DiscoverBudget;
|
||||
hero: DiscoverHero | null;
|
||||
continue_watching: DiscoverCard[];
|
||||
comfort: DiscoverCard[];
|
||||
}
|
||||
|
||||
export const discoverApi = {
|
||||
discover: (budget: DiscoverBudget, seed: number): Promise<DiscoverPayload> =>
|
||||
req(`/api/discover?budget=${budget}&seed=${seed}`),
|
||||
// The "Mást kérek" reroll: a new hero alone, so pressing it does not re-fetch the rails under the
|
||||
// user's cursor.
|
||||
// `exclude` is the rolling list of `<source>:<id>` keys already shown. Without it a reroll only
|
||||
// moves the surprise tier, so a user with something half-finished gets the same hero back.
|
||||
discoverPick: (
|
||||
budget: DiscoverBudget,
|
||||
seed: number,
|
||||
exclude: string[] = [],
|
||||
): Promise<{ budget: DiscoverBudget; hero: DiscoverHero | null }> =>
|
||||
req(
|
||||
`/api/discover/pick?budget=${budget}&seed=${seed}` +
|
||||
(exclude.length ? `&exclude=${encodeURIComponent(exclude.join(","))}` : ""),
|
||||
),
|
||||
comfortPins: (): Promise<{ items: DiscoverCard[] }> => req("/api/discover/pins"),
|
||||
addComfortPin: (
|
||||
kind: PinKind,
|
||||
ref: string,
|
||||
): Promise<{ ok: boolean; id: number; created: boolean }> =>
|
||||
req("/api/discover/pins", { method: "POST", body: JSON.stringify({ kind, ref }) }),
|
||||
removeComfortPin: (pinId: number): Promise<{ deleted: number }> =>
|
||||
req(`/api/discover/pins/${pinId}`, { method: "DELETE" }),
|
||||
reorderComfortPins: (ids: number[]): Promise<{ ok: boolean }> =>
|
||||
req("/api/discover/pins/reorder", { method: "POST", body: JSON.stringify({ ids }) }),
|
||||
};
|
||||
|
||||
/** A card's identity across sources — mirrors `app/discover/cards.py::key`. The reroll's exclusion
|
||||
* list and the "is this tile the hero?" check both speak it, so the two cannot drift. */
|
||||
export function discoverCardKey(card: Pick<DiscoverCard, "source" | "id">): string {
|
||||
return `${card.source}:${card.id}`;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const SLICE_MODULES = [
|
||||
["authApi", () => import("./auth")],
|
||||
["setupApi", () => import("./setup")],
|
||||
["feedApi", () => import("./feed")],
|
||||
["discoverApi", () => import("./discover")],
|
||||
["channelsApi", () => import("./channels")],
|
||||
["syncApi", () => import("./sync")],
|
||||
["playlistsApi", () => import("./playlists")],
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SlidersHorizontal,
|
||||
Tv,
|
||||
Users,
|
||||
Youtube,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { Me } from "./api";
|
||||
@@ -46,7 +47,11 @@ const notDemo = (me: Me) => !me.is_demo;
|
||||
|
||||
// Ordered in nav-rail order — this array IS the sequence the rail and the stepper walk.
|
||||
const MODULES: readonly ModuleDef[] = [
|
||||
{ id: "feed", labelKey: "header.account.feed", icon: Home },
|
||||
// Home leads: it is the landing (see navigationInit.loadInitialPage), and the module order IS the
|
||||
// nav order and the header stepper's sequence. The feed takes the YouTube mark its own toolbar
|
||||
// already uses, so the two are apart by shape and not just by position.
|
||||
{ id: "home", labelKey: "home.navLabel", titleKey: "home.title", icon: Home },
|
||||
{ id: "feed", labelKey: "header.account.feed", icon: Youtube },
|
||||
{
|
||||
id: "channels",
|
||||
labelKey: "header.account.channels",
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Page } from "./urlState";
|
||||
// browser-tab title keys the old hand-written switch produced — pin every page so a future labelKey
|
||||
// rename (or a dropped titleKey) can't silently change a tab title.
|
||||
const EXPECTED: Record<Page, string> = {
|
||||
home: "home.title",
|
||||
feed: "header.account.feed",
|
||||
channels: "header.channelManager",
|
||||
playlists: "header.account.playlists",
|
||||
|
||||
@@ -100,6 +100,12 @@ export const qk = {
|
||||
// array, a "union"/"group"/"contains" discriminator), so they take a variadic pass-through:
|
||||
// the value this factory centralizes is the ROOT string, and TanStack keys are `unknown[]`
|
||||
// anyway. Single-id plex keys stay precisely typed.
|
||||
// The Home surface (E24). `discover()` with no args is the prefix every mutation invalidates —
|
||||
// pinning something changes the shelf AND can change the hero, so the two are never invalidated
|
||||
// separately. The reroll has its own key so pressing "Mást kérek" does not refetch the rails.
|
||||
discover: (...rest: unknown[]) => ["discover", ...rest],
|
||||
discoverPick: (...rest: unknown[]) => ["discover-pick", ...rest],
|
||||
comfortPins: () => ["comfort-pins"] as const,
|
||||
plex: () => ["plex"] as const,
|
||||
plexWatchLink: () => ["plex-watch-link"] as const,
|
||||
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
|
||||
|
||||
@@ -15,6 +15,11 @@ export const LS = {
|
||||
lang: "siftlode.lang",
|
||||
filters: "siftlode.filters",
|
||||
page: "siftlode.page",
|
||||
// E24: start every session on the Home surface rather than on the module you happened to leave
|
||||
// open. Per-account, "0" = opt out and go back to remembering the last module. It has to be
|
||||
// localStorage rather than a server pref because `loadInitialPage` runs before /api/me lands —
|
||||
// a landing page decided after the first paint would be a visible jump.
|
||||
startOnHome: "siftlode.startOnHome",
|
||||
perfMode: "siftlode.perfMode",
|
||||
// The feed's view mode (see lib/feedView.ts) — a cache of the server pref, so a reload of an
|
||||
// established tab paints your view straight away. A brand-new tab still starts on the default
|
||||
|
||||
@@ -119,6 +119,7 @@ export function hasFilterParams(params: URLSearchParams): boolean {
|
||||
// derive from it, so adding a page is a one-line change (no parallel allowlists to keep in
|
||||
// sync). "feed" is the default/fallback.
|
||||
const PAGES = [
|
||||
"home",
|
||||
"feed",
|
||||
"channels",
|
||||
"stats",
|
||||
|
||||
Reference in New Issue
Block a user