Files
siftlode/frontend/src/components/moduleRegistry.tsx
T
peter f3c0b7be23 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.
2026-09-06 04:14:14 +02:00

137 lines
6.1 KiB
TypeScript

import { lazy, type ComponentType } from "react";
import type { Page } from "../lib/urlState";
import type { PanelId, PanelLayout } from "../lib/panelLayout";
import { useMe } from "../lib/useMe";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useFeedView, useFeedViewActions } from "./FeedViewProvider";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import Sidebar from "./Sidebar";
import PlaylistsRail from "./PlaylistsRail";
import PersonalViewsModal from "./PersonalViewsModal";
import { useSavedViews } from "./SavedViewsProvider";
// The RENDER half of the module registry: which component (and side rail) each page renders. The
// metadata half — nav order, label, icon, gate — lives in lib/modules.ts (imported by NavSidebar /
// Header / App's gate check). Together they let App drive the whole shell from `page` alone, with no
// `page === X` chrome conditionals.
//
// Every content entry is a PROP-FREE component: it reads `me` from useMe() and its module-local state
// from the scoped providers, so App renders `<Content />` uniformly. The two that need per-render
// wiring get a thin wrapper here (FeedPage supplies the GLOBAL feed scope; the channel page threads
// its own isolated scope to <Feed> directly — see the scope note in FeedFiltersProvider).
// Route-level code splitting: each page is its own lazy chunk (admin-only pages never reach
// non-admins; the logged-out landing pulls only the shell). Rendered inside App's <Suspense>.
const Home = lazy(() => import("./Home"));
const Feed = lazy(() => import("./Feed"));
const Channels = lazy(() => import("./Channels"));
const Playlists = lazy(() => import("./Playlists"));
const PlexBrowse = lazy(() => import("./PlexBrowse"));
const PlexSidebar = lazy(() => import("./PlexSidebar"));
const NotificationsPanel = lazy(() => import("./NotificationsPanel"));
const Messages = lazy(() => import("./Messages"));
const DownloadCenter = lazy(() => import("./DownloadCenter"));
const Stats = lazy(() => import("./Stats"));
const Scheduler = lazy(() => import("./Scheduler"));
const ConfigPanel = lazy(() => import("./ConfigPanel"));
const AdminUsers = lazy(() => import("./AdminUsers"));
const AuditLog = lazy(() => import("./AuditLog"));
const SettingsPanel = lazy(() => import("./SettingsPanel"));
// The GLOBAL feed page: wires <Feed> to the app-wide feed filters / view / live-search. (The channel
// page renders <Feed> itself with its own channel-scoped filters, so those two never share state.)
function FeedPage() {
const me = useMe();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
const view = useFeedView();
const { setView } = useFeedViewActions();
const { ytSearch } = useNavigation();
const { enterYtSearch, exitYtSearch } = useNavigationActions();
// The Personal Views modal lives at the feed-page level (always mounted while on the feed, unmounts
// on navigation) rather than inside the collapsible filter rail — otherwise opening it from the
// always-visible toolbar view chip while the rail is unpinned would render nothing and latch open.
const { modalOpen } = useSavedViews();
return (
<>
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={ytSearch}
onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch}
/>
{modalOpen && !me.is_demo && <PersonalViewsModal />}
</>
);
}
function MessagesPage() {
const me = useMe();
return (
<div className="p-4 max-w-3xl w-full mx-auto">
<Messages meId={me.id} />
</div>
);
}
export type PageDef = {
// Prop-free by construction: reads `me` + module state from hooks/providers.
Component: ComponentType;
// Channels + Audit render a table with a sticky header at the scroller top; the shell's top fade
// would sit on it and flip on with the first scroll, so they turn it off. Defaults to on.
fadeTop?: boolean;
// The content column's max width. The header + toolbar centre on this same width (via --content-mw)
// so the floating chrome lines up with the centred content instead of spanning the whole column.
// MUST match the max-w the page's own content wrapper uses. Omit for a full-width page (feed).
maxWidth?: string;
};
// page id → the component App renders in the content column (+ its shell chrome config).
export const PAGE_CONTENT: Record<Page, PageDef> = {
home: { Component: Home, maxWidth: "80rem" },
feed: { Component: FeedPage },
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
playlists: { Component: Playlists, fadeTop: false, maxWidth: "64rem" },
plex: { Component: PlexBrowse, maxWidth: "1600px" },
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
messages: { Component: MessagesPage },
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
stats: { Component: Stats },
scheduler: { Component: Scheduler },
config: { Component: ConfigPanel },
users: { Component: AdminUsers, maxWidth: "48rem" },
audit: { Component: AuditLog, fadeTop: false },
settings: { Component: SettingsPanel },
};
// The uniform side-rail contract — shell layout/collapse only; the rail reads its module state from
// its provider and `me` from useMe().
type RailProps = {
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
onToggleCollapse: () => void;
};
// Which collapse flag a rail uses (feed + plex share the filter rail's flag; playlists has its own).
type RailCollapseKey = "filter" | "playlists";
export type RailDef = {
Rail: ComponentType<RailProps>;
// The persisted panel-layout record this rail edits (App owns the panelLayouts map).
panelId: PanelId;
collapse: RailCollapseKey;
};
// page id → its side rail (only the three pages that have one). App renders it in the rail slot.
export const PAGE_RAIL: Partial<Record<Page, RailDef>> = {
feed: { Rail: Sidebar, panelId: "feed", collapse: "filter" },
plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" },
playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" },
};