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.
137 lines
6.1 KiB
TypeScript
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" },
|
|
};
|