refactor(frontend): each page fills the floating header itself; the header knows no page
The header used to pick its pills by `page === …` and import five module controls, and Plex's Discover/Browse toggle needed a context bridge to reach the sub-view that lives in PlexBrowse. Now the header is an empty band: PageShell hands its root box out as a slot, a page portals its own pills into it (PageHeader), and the palette's Jump-to pill shows where no page does (PageHeaderDefault, rendered inside the page's Suspense so a loading page shows no stand-in). Plex's toggles take the sub-view as a prop; PlexViewBridge and its context are gone, and with them the header's five layer suppressions. No visible change: every header pill's box measured identical against the previous build on eight pages, in Hungarian and English.
This commit is contained in:
@@ -43,7 +43,7 @@ import { useFilterFold } from "../../lib/useFilterFold";
|
||||
import { StateMessage } from "../../components/ui/QueryState";
|
||||
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
|
||||
import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { usePublishPlexView } from "./plexViewContext";
|
||||
import PlexHeader from "./PlexHeader";
|
||||
import { EpisodeCard, PlexPosterCard } from "../../features/plexCards/plexCards";
|
||||
import { PlexMenu } from "./PlexMenu";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
@@ -64,7 +64,7 @@ const PlexCollectionView = lazy(() => import("./PlexCollectionView"));
|
||||
|
||||
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
|
||||
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
|
||||
// search term comes from the shared top Header search box (q). A show drill-down rides history
|
||||
// search term comes from its header search box (q, PlexHeader). A show drill-down rides history
|
||||
// (useHistorySubview) so browser Back returns to the grid. Rendered by App for page==="plex" (its own
|
||||
// nav module). The cards/strips live in plexCards; the sub-view branches in plexViews.
|
||||
|
||||
@@ -96,6 +96,20 @@ const BACKABLE_VIEWS: readonly string[] = [
|
||||
];
|
||||
|
||||
export default function PlexBrowse() {
|
||||
// Discover is the landing; the flat grid is the "Browse all" secondary view. The sub-view lives
|
||||
// here, above both the header and the view, because the header's toggles drive it. It rides
|
||||
// history, and entering the plex page mounts this anew, so each entry reseeds it (a page switch
|
||||
// clears ?v and drops _sub: every plex entry starts at the Discover root).
|
||||
const sub = useHistorySubview<Sub>({ kind: "discover" }, PLEX_SUB_URL);
|
||||
return (
|
||||
<>
|
||||
<PlexHeader view={{ kind: sub.view.kind, open: sub.open }} />
|
||||
<PlexBrowseView sub={sub} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>> }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the
|
||||
@@ -122,19 +136,7 @@ export default function PlexBrowse() {
|
||||
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`), lang);
|
||||
// Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field.
|
||||
const fieldState = healPlexField(field, scope);
|
||||
// Discover is the landing; the flat grid is the "Browse all" secondary view. A header search
|
||||
// or an added filter is a browse intent → flip to the grid (below); an explicit toggle returns.
|
||||
const sub = useHistorySubview<Sub>({ kind: "discover" }, PLEX_SUB_URL);
|
||||
|
||||
// Publish the top-level view (discover vs grid) + opener to the header's Discover/Browse toggle via
|
||||
// the bridge; clear it on unmount so the toggle disappears when you leave the plex page. `sub.open`
|
||||
// is referentially stable (lib/history memoises it), so naming it costs nothing and the dep list is
|
||||
// the honest one: the toggle is republished when the view KIND changes, and on nothing else.
|
||||
const publishView = usePublishPlexView();
|
||||
useLayoutEffect(() => {
|
||||
publishView({ kind: sub.view.kind, open: sub.open });
|
||||
return () => publishView(null);
|
||||
}, [sub.view.kind, sub.open, publishView]);
|
||||
// A header search is a browse intent → flip to the grid (below); an explicit toggle returns.
|
||||
|
||||
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
|
||||
// clear the signal so it doesn't re-open.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { PageHeader } from "../../components/ui/PageShell";
|
||||
import PlexSearchBox from "./PlexSearchBox";
|
||||
import PlexScopeToggle from "./PlexScopeToggle";
|
||||
import PlexViewToggle from "./PlexViewToggle";
|
||||
import type { PlexViewControls } from "./plexSubUrl";
|
||||
|
||||
// Plex's pills in the floating header: the search box, then the library scope (movie/show/both, plus
|
||||
// the playlists/collections surfaces) and the Discover ⟷ Browse switch — relocated here from the
|
||||
// filter sidebar so the primary "what am I browsing" controls are always in reach. Both toggles drive
|
||||
// PlexBrowse's sub-view, which PlexBrowse hands down as `view`.
|
||||
export default function PlexHeader({ view }: { view: PlexViewControls }) {
|
||||
return (
|
||||
<PageHeader>
|
||||
<PlexSearchBox />
|
||||
<PlexScopeToggle view={view} />
|
||||
<PlexViewToggle view={view} />
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
//
|
||||
// Contract of the header library-scope pill (scopes + browse modes): it renders the
|
||||
// three library scopes PLUS the Playlist/Collection browse-mode entries, marks the active one, writes a
|
||||
// picked scope to the provider, and drives the playlists/collections views through the PlexViewBridge.
|
||||
// i18n is key-passthrough and both the provider and the bridge are mocked; SegmentedToggle is a plain
|
||||
// picked scope to the provider, and drives the playlists/collections views through the `view` it is handed.
|
||||
// i18n is key-passthrough and the provider is mocked; SegmentedToggle is a plain
|
||||
// button group (no floating-ui), so no observer stubs are needed.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
@@ -20,13 +20,11 @@ vi.mock("./plexContext", () => ({
|
||||
usePlexActions: () => ({ setScope }),
|
||||
}));
|
||||
|
||||
// The bridge: a controllable current view kind + an open spy.
|
||||
const controls = { kind: "discover" as string, open: vi.fn() };
|
||||
vi.mock("./plexViewContext", () => ({
|
||||
usePlexViewControls: () => controls,
|
||||
}));
|
||||
// The sub-view: a controllable current view kind + an open spy.
|
||||
const controls = { kind: "discover" as PlexViewControls["kind"], open: vi.fn() };
|
||||
|
||||
import PlexScopeToggle from "./PlexScopeToggle";
|
||||
import type { PlexViewControls } from "./plexSubUrl";
|
||||
|
||||
beforeEach(() => {
|
||||
plexState.scope = "both";
|
||||
@@ -48,7 +46,7 @@ const pressed = () =>
|
||||
describe("PlexScopeToggle", () => {
|
||||
it("renders the three scopes plus the two browse modes, the current scope pressed", () => {
|
||||
plexState.scope = "movie";
|
||||
render(<PlexScopeToggle />);
|
||||
render(<PlexScopeToggle view={controls} />);
|
||||
expect(labels()).toEqual([
|
||||
"plex.filter.scopeOpt.both",
|
||||
"plex.filter.scopeOpt.movie",
|
||||
@@ -60,14 +58,14 @@ describe("PlexScopeToggle", () => {
|
||||
});
|
||||
|
||||
it("writes a picked scope back to the provider (no view switch when already on a title view)", () => {
|
||||
render(<PlexScopeToggle />);
|
||||
render(<PlexScopeToggle view={controls} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.show" }));
|
||||
expect(setScope).toHaveBeenCalledWith("show");
|
||||
expect(controls.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the playlists view (not a scope change) for the Playlist mode", () => {
|
||||
render(<PlexScopeToggle />);
|
||||
render(<PlexScopeToggle view={controls} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.playlists" }));
|
||||
expect(controls.open).toHaveBeenCalledWith({ kind: "playlists" });
|
||||
expect(setScope).not.toHaveBeenCalled();
|
||||
@@ -75,13 +73,13 @@ describe("PlexScopeToggle", () => {
|
||||
|
||||
it("marks the Collection mode active when the current view is collections", () => {
|
||||
controls.kind = "collections";
|
||||
render(<PlexScopeToggle />);
|
||||
render(<PlexScopeToggle view={controls} />);
|
||||
expect(pressed()).toEqual(["plex.filter.scopeOpt.collections"]);
|
||||
});
|
||||
|
||||
it("returns to the title grid when a scope is picked from a browse mode", () => {
|
||||
controls.kind = "playlists";
|
||||
render(<PlexScopeToggle />);
|
||||
render(<PlexScopeToggle view={controls} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.movie" }));
|
||||
expect(setScope).toHaveBeenCalledWith("movie");
|
||||
expect(controls.open).toHaveBeenCalledWith({ kind: "grid" });
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||
import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { usePlexViewControls } from "./plexViewContext";
|
||||
import type { PlexViewControls } from "./plexSubUrl";
|
||||
|
||||
// The Plex "browse target" as a compact header control next to the search box: the three library
|
||||
// scopes (both | movie | show, which browse TITLES) plus two extra entry points — Playlist and
|
||||
// Collection — that switch the content area to a grid of the user's playlists / of the collections.
|
||||
// The scopes read/write the shared provider state (so scope stays in sync everywhere); the two extra
|
||||
// modes drive PlexBrowse's sub-view through the PlexViewBridge. Picking a scope while in a
|
||||
// modes drive PlexBrowse's sub-view (`view`, from PlexHeader). Picking a scope while in a
|
||||
// playlists/collections surface returns to the title grid at that scope.
|
||||
const SCOPES = ["both", "movie", "show"] as const;
|
||||
|
||||
export default function PlexScopeToggle() {
|
||||
export default function PlexScopeToggle({ view: ctl }: { view: PlexViewControls }) {
|
||||
const { t } = useTranslation();
|
||||
const { scope } = usePlex();
|
||||
const { setScope } = usePlexActions();
|
||||
const ctl = usePlexViewControls();
|
||||
const inPlaylists = ctl?.kind === "playlists";
|
||||
const inCollections = ctl?.kind === "collections";
|
||||
const inPlaylists = ctl.kind === "playlists";
|
||||
const inCollections = ctl.kind === "collections";
|
||||
// The active segment: the browse-mode when in one, else the library scope.
|
||||
const value = inPlaylists ? "playlists" : inCollections ? "collections" : scope;
|
||||
|
||||
const onChange = (v: string) => {
|
||||
if (v === "playlists" || v === "collections") {
|
||||
ctl?.open({ kind: v });
|
||||
ctl.open({ kind: v });
|
||||
return;
|
||||
}
|
||||
setScope(v);
|
||||
// Leaving the playlists/collections surface for a title scope → show the title grid.
|
||||
if (inPlaylists || inCollections) ctl?.open({ kind: "grid" });
|
||||
if (inPlaylists || inCollections) ctl.open({ kind: "grid" });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,7 +17,7 @@ import RemoteImage from "../../components/ui/RemoteImage";
|
||||
|
||||
// The Plex header search box: the shared SearchBar enriched with the search scope-pill (a
|
||||
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
|
||||
// below it. Kept out of the generic SearchBar (and out of Header) so all Plex-specific wiring —
|
||||
// below it. Kept out of the generic SearchBar (and out of the shell's header) so all Plex-specific wiring —
|
||||
// provider state, the suggest query, the jump-to hand-off — lives in one place. Everything reuses
|
||||
// existing platform pieces: ActionMenu (the pill), the /api/plex/library endpoint (limit=10 for the
|
||||
// typeahead), and the provider's jumpTo hand-off (typeahead → PlexBrowse subview).
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
PlexViewPublishContext,
|
||||
PlexViewReadContext,
|
||||
type PlexViewControls,
|
||||
} from "./plexViewContext";
|
||||
|
||||
// A one-way bridge so the always-mounted Header can host the Plex Discover ⟷ Browse toggle even though
|
||||
// the sub-view state lives in (and is owned by) the plex-only PlexBrowse. PlexBrowse PUBLISHES its
|
||||
// current top-level view + opener here (on mount and on view change) and clears it on unmount; the
|
||||
// Header READS it to render the toggle beside the library-scope pill.
|
||||
//
|
||||
// Why a bridge and not a global sub-view provider: PlexBrowse's `useHistorySubview` REMOUNTS every time
|
||||
// you enter the plex page, re-seeding the view from history (a page switch clears ?v + drops _sub, so
|
||||
// each plex entry starts at the Discover root). Lifting that hook to an always-mounted provider would
|
||||
// lose the remount and let the view go stale across navigation. Keeping it in PlexBrowse preserves the
|
||||
// reseed; the bridge only mirrors the small slice the header needs.
|
||||
//
|
||||
// The contexts + hooks live in plexViewContext.ts.
|
||||
|
||||
export function PlexViewBridge({ children }: { children: ReactNode }) {
|
||||
const [controls, setControls] = useState<PlexViewControls | null>(null);
|
||||
return (
|
||||
<PlexViewPublishContext.Provider value={setControls}>
|
||||
<PlexViewReadContext.Provider value={controls}>{children}</PlexViewReadContext.Provider>
|
||||
</PlexViewPublishContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// Contract of the header Discover ⟷ Browse toggle. The floating header wrapper is pointer-events-none,
|
||||
// so the toggle MUST re-enable pointer events on its root or it is click-dead (the bug this test guards
|
||||
// against). Also: it only renders on the two title-browse views, marks the active one, and drives
|
||||
// PlexBrowse's sub-view through the bridge. i18n is key-passthrough; the bridge is mocked.
|
||||
// PlexBrowse's sub-view through the `view` it is handed. i18n is key-passthrough.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
@@ -12,12 +12,10 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
const controls = { kind: "discover" as string, open: vi.fn() };
|
||||
vi.mock("./plexViewContext", () => ({
|
||||
usePlexViewControls: () => controls,
|
||||
}));
|
||||
const controls = { kind: "discover" as PlexViewControls["kind"], open: vi.fn() };
|
||||
|
||||
import PlexViewToggle from "./PlexViewToggle";
|
||||
import type { PlexViewControls } from "./plexSubUrl";
|
||||
|
||||
beforeEach(() => {
|
||||
controls.kind = "discover";
|
||||
@@ -27,13 +25,13 @@ afterEach(cleanup);
|
||||
|
||||
describe("PlexViewToggle", () => {
|
||||
it("re-enables pointer events on its root (the header wrapper is pointer-events-none)", () => {
|
||||
const { container } = render(<PlexViewToggle />);
|
||||
const { container } = render(<PlexViewToggle view={controls} />);
|
||||
// Without this class the whole toggle is click-dead inside the pointer-events-none header.
|
||||
expect(container.querySelector(".pointer-events-auto")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the two options with the current view pressed", () => {
|
||||
render(<PlexViewToggle />);
|
||||
render(<PlexViewToggle view={controls} />);
|
||||
const btns = [
|
||||
...screen.getByRole("group", { name: "plex.view.label" }).querySelectorAll("button"),
|
||||
];
|
||||
@@ -45,21 +43,21 @@ describe("PlexViewToggle", () => {
|
||||
});
|
||||
|
||||
it("opens the grid view when Browse is picked", () => {
|
||||
render(<PlexViewToggle />);
|
||||
render(<PlexViewToggle view={controls} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.view.browse" }));
|
||||
expect(controls.open).toHaveBeenCalledWith({ kind: "grid" });
|
||||
});
|
||||
|
||||
it("opens the discover view when Discover is picked from the grid", () => {
|
||||
controls.kind = "grid";
|
||||
render(<PlexViewToggle />);
|
||||
render(<PlexViewToggle view={controls} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.discover.tab" }));
|
||||
expect(controls.open).toHaveBeenCalledWith({ kind: "discover" });
|
||||
});
|
||||
|
||||
it("renders nothing on a deeper view (no Discover/Browse choice there)", () => {
|
||||
controls.kind = "playlists";
|
||||
const { container } = render(<PlexViewToggle />);
|
||||
const { container } = render(<PlexViewToggle view={controls} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||
import { usePlexViewControls } from "./plexViewContext";
|
||||
import type { PlexViewControls } from "./plexSubUrl";
|
||||
|
||||
// The Discover ⟷ Browse switch, hosted in the header next to the library-scope pill (All/Movies/Shows).
|
||||
// It reflects + drives PlexBrowse's top-level sub-view through the PlexViewBridge, and hides itself on
|
||||
// It reflects + drives PlexBrowse's top-level sub-view (`view`, from PlexHeader), and hides itself on
|
||||
// the deeper views (a show/info/person page has no Discover/Browse choice).
|
||||
export default function PlexViewToggle() {
|
||||
export default function PlexViewToggle({ view: ctl }: { view: PlexViewControls }) {
|
||||
const { t } = useTranslation();
|
||||
const ctl = usePlexViewControls();
|
||||
// Only the two title-browse views host the Discover/Browse choice; deeper views (show/info/person/…)
|
||||
// and the playlists/collections browse surfaces hide it.
|
||||
if (!ctl || (ctl.kind !== "discover" && ctl.kind !== "grid")) return null;
|
||||
if (ctl.kind !== "discover" && ctl.kind !== "grid") return null;
|
||||
// The floating header wrapper is pointer-events-none (so clicks fall through its empty area to the
|
||||
// content below); every interactive child must re-enable pointer events or it is click-dead — the
|
||||
// sibling PlexScopeToggle does the same.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// The Plex module's public face: its store, mounted at the root and read app-wide, and how its filters read as chips.
|
||||
export { PlexProvider } from "./PlexProvider";
|
||||
export { PlexViewBridge } from "./PlexViewBridge";
|
||||
export { usePlex, usePlexActions } from "./plexContext";
|
||||
export { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||||
|
||||
@@ -4,6 +4,13 @@ import { parsePositiveIntId } from "../../lib/parseId";
|
||||
// The Plex module's sub-view union and its address-bar mirror, extracted from PlexBrowse so the
|
||||
// encode/decode round-trip (and its guards) can be unit-tested without pulling the whole component
|
||||
// (and its lazy hls.js player) into the test graph.
|
||||
// What the header's toggles get of PlexBrowse's sub-view: the current top-level view (they derive
|
||||
// their state from it) and the opener (they drive it).
|
||||
export type PlexViewControls = {
|
||||
kind: Sub["kind"];
|
||||
open: (v: Sub) => void;
|
||||
};
|
||||
|
||||
export type Sub =
|
||||
| { kind: "discover" }
|
||||
| { kind: "grid" }
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { type Sub } from "./plexSubUrl";
|
||||
|
||||
// The read + publish sides of the Plex view bridge (see PlexViewBridge for why it is a bridge), split
|
||||
// out of that file so it exports only components (Fast Refresh).
|
||||
export type PlexViewControls = {
|
||||
kind: Sub["kind"]; // the current top-level plex view — the header controls derive their state from it
|
||||
open: (v: Sub) => void;
|
||||
};
|
||||
|
||||
export const PlexViewReadContext = createContext<PlexViewControls | null>(null);
|
||||
export const PlexViewPublishContext = createContext<(c: PlexViewControls | null) => void>(() => {});
|
||||
|
||||
export const usePlexViewControls = () => useContext(PlexViewReadContext);
|
||||
export const usePublishPlexView = () => useContext(PlexViewPublishContext);
|
||||
Reference in New Issue
Block a user