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:
+3
-1
@@ -275,7 +275,9 @@ Also in force:
|
|||||||
- **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade
|
- **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade
|
||||||
under it (below). Below ~600px of height the filter row folds into one "Filters · n" button. Every page has
|
under it (below). Below ~600px of height the filter row folds into one "Filters · n" button. Every page has
|
||||||
a visually hidden `h1`; the nav rail and the browser tab title are the visible identity. [WCAG 2.4.6]
|
a visually hidden `h1`; the nav rail and the browser tab title are the visible identity. [WCAG 2.4.6]
|
||||||
Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller. The band
|
Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller; the floating
|
||||||
|
header over it is its header slot (`PageHeader`: the page's own pills, the palette's Jump-to pill where a
|
||||||
|
page has none, `PageHeaderDefault`). The band
|
||||||
holds controls only: an intro paragraph scrolls with the content (in the band it ate three lines of a
|
holds controls only: an intro paragraph scrolls with the content (in the band it ate three lines of a
|
||||||
short window). The fold is `useFilterFold` + `FilterFoldButton`: the same row
|
short window). The fold is `useFilterFold` + `FilterFoldButton`: the same row
|
||||||
shown or hidden in place, never copied into a popover. The hidden `h1` is `PageShell`'s `title`; a
|
shown or hidden in place, never copied into a popover. The hidden `h1` is `PageShell`'s `title`; a
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The floating header knows no page: a page fills it through PageHeader (a portal into the header's
|
||||||
|
// root box), and the shell's PageHeaderDefault shows only while no page does. These pin the slot's
|
||||||
|
// three promises: the page's pills land IN the header, the default steps aside for them and comes
|
||||||
|
// back when the next page has none, and a shell without a header (the channel page) renders the
|
||||||
|
// page's pills nowhere.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, cleanup, screen } from "@testing-library/react";
|
||||||
|
import PageShell, { PageHeader, PageHeaderDefault } from "./PageShell";
|
||||||
|
|
||||||
|
vi.mock("./PageScroller", () => ({
|
||||||
|
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"ResizeObserver",
|
||||||
|
class {
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const hdr = <div data-testid="hdr" />;
|
||||||
|
const fallback = (
|
||||||
|
<PageHeaderDefault>
|
||||||
|
<span>default</span>
|
||||||
|
</PageHeaderDefault>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("the header slot", () => {
|
||||||
|
it("puts a page's pills inside the header box", () => {
|
||||||
|
render(
|
||||||
|
<PageShell header={hdr}>
|
||||||
|
<PageHeader>
|
||||||
|
<span>search</span>
|
||||||
|
</PageHeader>
|
||||||
|
</PageShell>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("hdr").textContent).toBe("search");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the default on a page with no header of its own", () => {
|
||||||
|
render(<PageShell header={hdr}>{fallback}</PageShell>);
|
||||||
|
expect(screen.getByTestId("hdr").textContent).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the default while a page fills the header, and brings it back on the next page", () => {
|
||||||
|
const page = (own: boolean) => (
|
||||||
|
<PageShell header={hdr}>
|
||||||
|
{own && (
|
||||||
|
<PageHeader>
|
||||||
|
<span>search</span>
|
||||||
|
</PageHeader>
|
||||||
|
)}
|
||||||
|
{fallback}
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
const { rerender } = render(page(true));
|
||||||
|
expect(screen.getByTestId("hdr").textContent).toBe("search");
|
||||||
|
rerender(page(false));
|
||||||
|
expect(screen.getByTestId("hdr").textContent).toBe("default");
|
||||||
|
rerender(page(true));
|
||||||
|
expect(screen.getByTestId("hdr").textContent).toBe("search");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the default hidden while one of two PageHeaders on a page remains", () => {
|
||||||
|
const page = (both: boolean) => (
|
||||||
|
<PageShell header={hdr}>
|
||||||
|
<PageHeader>
|
||||||
|
<span>search</span>
|
||||||
|
</PageHeader>
|
||||||
|
{both && (
|
||||||
|
<PageHeader>
|
||||||
|
<span>toggle</span>
|
||||||
|
</PageHeader>
|
||||||
|
)}
|
||||||
|
{fallback}
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
const { rerender } = render(page(true));
|
||||||
|
rerender(page(false));
|
||||||
|
expect(screen.getByTestId("hdr").textContent).toBe("search");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a page's pills nowhere in a shell without a header", () => {
|
||||||
|
render(
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader>
|
||||||
|
<span>search</span>
|
||||||
|
</PageHeader>
|
||||||
|
{fallback}
|
||||||
|
</PageShell>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("search")).toBeNull();
|
||||||
|
expect(screen.queryByText("default")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import PageScroller from "./PageScroller";
|
import PageScroller from "./PageScroller";
|
||||||
import { PageToolbarSlot } from "./pageToolbarSlot";
|
import { PageToolbarSlot } from "./pageToolbarSlot";
|
||||||
|
import { PageHeaderSlot } from "./pageHeaderSlot";
|
||||||
import { PageHeadingProvider } from "./pageHeading";
|
import { PageHeadingProvider } from "./pageHeading";
|
||||||
import { PageMenuSlot, type PageMenuOpener } from "./pageMenuSlot";
|
import { PageMenuSlot, type PageMenuOpener } from "./pageMenuSlot";
|
||||||
import { isBackgroundPoint } from "../../lib/backgroundTarget";
|
import { isBackgroundPoint } from "../../lib/backgroundTarget";
|
||||||
@@ -29,6 +30,25 @@ export function PageToolbar({ children }: { children: ReactNode }) {
|
|||||||
return slot ? createPortal(children, slot) : null;
|
return slot ? createPortal(children, slot) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A page renders its controls in the floating header (its search box, scope toggles, sync chip)
|
||||||
|
// through this, the same way as PageToolbar: a portal, so the controls stay in the page's own React
|
||||||
|
// subtree and reach its state directly (Plex's toggles drive the sub-view that lives in PlexBrowse).
|
||||||
|
// The header itself knows no page. Claiming the header hides the shell's default (PageHeaderDefault).
|
||||||
|
export function PageHeader({ children }: { children: ReactNode }) {
|
||||||
|
const slot = useContext(PageHeaderSlot);
|
||||||
|
const claim = slot?.claim;
|
||||||
|
useLayoutEffect(() => claim?.(), [claim]);
|
||||||
|
return slot?.target ? createPortal(children, slot.target) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the header shows on a page that renders no PageHeader of its own (the palette's entry). App
|
||||||
|
// renders it beside the page, inside the page's Suspense, so a page still loading shows no default
|
||||||
|
// that its own controls would replace a moment later.
|
||||||
|
export function PageHeaderDefault({ children }: { children: ReactNode }) {
|
||||||
|
const slot = useContext(PageHeaderSlot);
|
||||||
|
return slot?.target && !slot.claimed ? createPortal(children, slot.target) : null;
|
||||||
|
}
|
||||||
|
|
||||||
// The page shell: the fixed chrome around a single scrollable content region. It formalizes "what
|
// The page shell: the fixed chrome around a single scrollable content region. It formalizes "what
|
||||||
// is fixed vs what scrolls" per module, so the chrome App used to hand-assemble around every page
|
// is fixed vs what scrolls" per module, so the chrome App used to hand-assemble around every page
|
||||||
// (a floating Header + a top-padded wrapper + PageScroller, plus the channel page's separate
|
// (a floating Header + a top-padded wrapper + PageScroller, plus the channel page's separate
|
||||||
@@ -38,6 +58,7 @@ export function PageToolbar({ children }: { children: ReactNode }) {
|
|||||||
// - `header` — floats (absolute, height `var(--hdr-h)`) over the content column; the wrapper pads
|
// - `header` — floats (absolute, height `var(--hdr-h)`) over the content column; the wrapper pads
|
||||||
// its top by that height so nothing hides under it. Omit it (channel page) → no pad
|
// its top by that height so nothing hides under it. Omit it (channel page) → no pad
|
||||||
// and no fixed top band, so the module's own banner can scroll from the very top.
|
// and no fixed top band, so the module's own banner can scroll from the very top.
|
||||||
|
// The header is an empty band: the page fills it through PageHeader.
|
||||||
// - `title` — the page's name as a visually hidden `h1` (B4): the nav rail and the tab title are
|
// - `title` — the page's name as a visually hidden `h1` (B4): the nav rail and the tab title are
|
||||||
// the visible identity, and a screen reader still lands on a named page. A view that
|
// the visible identity, and a screen reader still lands on a named page. A view that
|
||||||
// shows its own title renders it through `PageHeading`, and this one steps aside.
|
// shows its own title renders it through `PageHeading`, and this one steps aside.
|
||||||
@@ -137,6 +158,18 @@ export default function PageShell({
|
|||||||
ro.observe(headerEl);
|
ro.observe(headerEl);
|
||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, [headerEl, contentEl]);
|
}, [headerEl, contentEl]);
|
||||||
|
// How many PageHeaders fill the header right now: a count, not a flag, so of two on one page the one
|
||||||
|
// that leaves releases only its own claim.
|
||||||
|
const [headerClaims, setHeaderClaims] = useState(0);
|
||||||
|
const claimHeader = useCallback(() => {
|
||||||
|
setHeaderClaims((n) => n + 1);
|
||||||
|
return () => setHeaderClaims((n) => n - 1);
|
||||||
|
}, []);
|
||||||
|
const headerSlot = {
|
||||||
|
target: headerEl,
|
||||||
|
claimed: headerClaims > 0,
|
||||||
|
claim: claimHeader,
|
||||||
|
};
|
||||||
// How many views below render their own page `h1` right now (see ./pageHeading).
|
// How many views below render their own page `h1` right now (see ./pageHeading).
|
||||||
const [ownHeadings, setOwnHeadings] = useState(0);
|
const [ownHeadings, setOwnHeadings] = useState(0);
|
||||||
const registerOwnHeading = useCallback(() => {
|
const registerOwnHeading = useCallback(() => {
|
||||||
@@ -173,26 +206,28 @@ export default function PageShell({
|
|||||||
: header;
|
: header;
|
||||||
return (
|
return (
|
||||||
<PageToolbarSlot.Provider value={toolbarSlot}>
|
<PageToolbarSlot.Provider value={toolbarSlot}>
|
||||||
<PageMenuSlot.Provider value={registerPageMenu}>
|
<PageHeaderSlot.Provider value={headerSlot}>
|
||||||
<PageHeadingProvider value={registerOwnHeading}>
|
<PageMenuSlot.Provider value={registerPageMenu}>
|
||||||
{measuredHeader}
|
<PageHeadingProvider value={registerOwnHeading}>
|
||||||
<div
|
{measuredHeader}
|
||||||
ref={setContentEl}
|
<div
|
||||||
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
|
ref={setContentEl}
|
||||||
onPointerDown={(e) => {
|
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
|
||||||
lastPointer.current = e.pointerType;
|
onPointerDown={(e) => {
|
||||||
}}
|
lastPointer.current = e.pointerType;
|
||||||
onContextMenu={onContextMenu}
|
}}
|
||||||
>
|
onContextMenu={onContextMenu}
|
||||||
{title && ownHeadings === 0 && <h1 className="sr-only">{title}</h1>}
|
>
|
||||||
{banners}
|
{title && ownHeadings === 0 && <h1 className="sr-only">{title}</h1>}
|
||||||
{/* pr-[--sbw]: the toolbar band isn't the scroller, so without this it would run the
|
{banners}
|
||||||
|
{/* pr-[--sbw]: the toolbar band isn't the scroller, so without this it would run the
|
||||||
scrollbar-gutter's width past the scrolled cards on the right. Pad it back to card width. */}
|
scrollbar-gutter's width past the scrolled cards on the right. Pad it back to card width. */}
|
||||||
<div ref={setToolbarSlot} className="shrink-0 pr-[var(--sbw)]" />
|
<div ref={setToolbarSlot} className="shrink-0 pr-[var(--sbw)]" />
|
||||||
<PageScroller scrollKey={scrollKey}>{children}</PageScroller>
|
<PageScroller scrollKey={scrollKey}>{children}</PageScroller>
|
||||||
</div>
|
</div>
|
||||||
</PageHeadingProvider>
|
</PageHeadingProvider>
|
||||||
</PageMenuSlot.Provider>
|
</PageMenuSlot.Provider>
|
||||||
|
</PageHeaderSlot.Provider>
|
||||||
</PageToolbarSlot.Provider>
|
</PageToolbarSlot.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
|
|||||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
||||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||||
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
|
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
|
||||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
||||||
|
|
||||||
## The size contract (E10 S1c)
|
## The size contract (E10 S1c)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
// The floating header's slot (see PageHeader in PageShell): the node a page portals its header
|
||||||
|
// controls into, whether a page has claimed it, and how a page claims it. Null with no shell, or in a
|
||||||
|
// shell without a header (the channel page), so a page renders its header nowhere there. Its own
|
||||||
|
// module so PageShell exports only components (Fast Refresh).
|
||||||
|
export type PageHeaderSlotValue = {
|
||||||
|
target: HTMLElement | null;
|
||||||
|
claimed: boolean;
|
||||||
|
/** Claim the header for this page's controls; the returned function releases it. */
|
||||||
|
claim: () => () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PageHeaderSlot = createContext<PageHeaderSlotValue | null>(null);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// A live-filter search box's Go: the list already filters as you type, so Go/Enter only drops the
|
||||||
|
// input's focus (the feed's Go is different: it escalates to a YouTube search).
|
||||||
|
export const blurActive = () => (document.activeElement as HTMLElement | null)?.blur?.();
|
||||||
@@ -19,12 +19,12 @@ import type { Page } from "./urlState";
|
|||||||
|
|
||||||
// ── The module registry ───────────────────────────────────────────────────────────────────────
|
// ── The module registry ───────────────────────────────────────────────────────────────────────
|
||||||
// One ordered source of truth for every navigable module: its nav label, icon, admin/system
|
// One ordered source of truth for every navigable module: its nav label, icon, admin/system
|
||||||
// placement, whether it carries a live badge, and its availability gate. The nav rail, the header's
|
// placement, whether it carries a live badge, and its availability gate. The nav rail, the command
|
||||||
// ◀/▶ stepper, and (as the registry grows) the page render all read from here, so a new or newly
|
// palette's module group, and (as the registry grows) the page render all read from here, so a new or newly
|
||||||
// gated module shows up everywhere at once — nothing hard-codes the sequence or duplicates the icon.
|
// gated module shows up everywhere at once — nothing hard-codes the sequence or duplicates the icon.
|
||||||
export type ModuleDef = {
|
export type ModuleDef = {
|
||||||
id: Page;
|
id: Page;
|
||||||
// i18n key for the SHORT display name — the same label the nav rail and the header pill show.
|
// i18n key for the SHORT display name — the label the nav rail and the palette show.
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
// The SHORT name under the icon when the user turns the rail labels on (B7): the rail is 3.75rem
|
// The SHORT name under the icon when the user turns the rail labels on (B7): the rail is 3.75rem
|
||||||
// wide with labels or without, so it has to fit there ("Alerts", "Listák"). The full `labelKey`
|
// wide with labels or without, so it has to fit there ("Alerts", "Listák"). The full `labelKey`
|
||||||
@@ -47,10 +47,10 @@ export type ModuleDef = {
|
|||||||
|
|
||||||
const isAdmin = (me: Me) => me.role === "admin";
|
const isAdmin = (me: Me) => me.role === "admin";
|
||||||
|
|
||||||
// Ordered in nav-rail order — this array IS the sequence the rail and the stepper walk.
|
// Ordered in nav-rail order — this array IS the sequence the rail and the palette list.
|
||||||
const MODULES: readonly ModuleDef[] = [
|
const MODULES: readonly ModuleDef[] = [
|
||||||
// Home leads: it is the landing (see navigationInit.loadInitialPage), and the module order IS the
|
// 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
|
// nav order and the palette's. The feed takes the YouTube mark its own toolbar
|
||||||
// already uses, so the two are apart by shape and not just by position.
|
// already uses, so the two are apart by shape and not just by position.
|
||||||
{
|
{
|
||||||
id: "home",
|
id: "home",
|
||||||
|
|||||||
+16
-18
@@ -10,7 +10,7 @@ import { NavigationProvider } from "./features/navigation/NavigationProvider";
|
|||||||
import { FeedFiltersProvider, FeedViewProvider, SavedViewsProvider } from "./modules/feed";
|
import { FeedFiltersProvider, FeedViewProvider, SavedViewsProvider } from "./modules/feed";
|
||||||
import { PlaylistsProvider } from "./modules/playlists";
|
import { PlaylistsProvider } from "./modules/playlists";
|
||||||
import { FilesProvider } from "./modules/files";
|
import { FilesProvider } from "./modules/files";
|
||||||
import { PlexProvider, PlexViewBridge } from "./modules/plex";
|
import { PlexProvider } from "./modules/plex";
|
||||||
import { ChannelsProvider } from "./modules/channels";
|
import { ChannelsProvider } from "./modules/channels";
|
||||||
import { PrefsProvider } from "./features/prefs/PrefsProvider";
|
import { PrefsProvider } from "./features/prefs/PrefsProvider";
|
||||||
import { WizardProvider } from "./features/wizard/WizardProvider";
|
import { WizardProvider } from "./features/wizard/WizardProvider";
|
||||||
@@ -81,25 +81,23 @@ const root =
|
|||||||
<PlaylistsProvider>
|
<PlaylistsProvider>
|
||||||
<FilesProvider>
|
<FilesProvider>
|
||||||
<PlexProvider>
|
<PlexProvider>
|
||||||
<PlexViewBridge>
|
{/* SavedViewsProvider sits below BOTH filter providers so it can switch its
|
||||||
{/* SavedViewsProvider sits below BOTH filter providers so it can switch its
|
|
||||||
source (feed ⟷ plex) by the active page and read the right live filters. */}
|
source (feed ⟷ plex) by the active page and read the right live filters. */}
|
||||||
<SavedViewsProvider>
|
<SavedViewsProvider>
|
||||||
<ChannelsProvider>
|
<ChannelsProvider>
|
||||||
<FeedViewProvider>
|
<FeedViewProvider>
|
||||||
<PrefsProvider>
|
<PrefsProvider>
|
||||||
<WizardProvider>
|
<WizardProvider>
|
||||||
{/* The same quiet screen as the gate's, so a chunk that is still
|
{/* The same quiet screen as the gate's, so a chunk that is still
|
||||||
loading when the gate opens continues it instead of blanking. */}
|
loading when the gate opens continues it instead of blanking. */}
|
||||||
<Suspense fallback={<BootScreen />}>
|
<Suspense fallback={<BootScreen />}>
|
||||||
<App />
|
<App />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</WizardProvider>
|
</WizardProvider>
|
||||||
</PrefsProvider>
|
</PrefsProvider>
|
||||||
</FeedViewProvider>
|
</FeedViewProvider>
|
||||||
</ChannelsProvider>
|
</ChannelsProvider>
|
||||||
</SavedViewsProvider>
|
</SavedViewsProvider>
|
||||||
</PlexViewBridge>
|
|
||||||
</PlexProvider>
|
</PlexProvider>
|
||||||
</FilesProvider>
|
</FilesProvider>
|
||||||
</PlaylistsProvider>
|
</PlaylistsProvider>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import { useFeedFilters, useFeedFiltersActions } from "../feed";
|
|||||||
import { useWizard } from "../../features/wizard/wizardContext";
|
import { useWizard } from "../../features/wizard/wizardContext";
|
||||||
import { useMe } from "../../lib/useMe";
|
import { useMe } from "../../lib/useMe";
|
||||||
import { useTags } from "../../lib/useTags";
|
import { useTags } from "../../lib/useTags";
|
||||||
|
import ChannelsHeader from "./ChannelsHeader";
|
||||||
|
|
||||||
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
|
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
|
||||||
|
|
||||||
@@ -687,6 +688,7 @@ export default function Channels() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<ChannelsHeader />
|
||||||
{pageMenuItems.length > 0 && (
|
{pageMenuItems.length > 0 && (
|
||||||
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
|
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMe } from "../../lib/useMe";
|
||||||
|
import { PageHeader } from "../../components/ui/PageShell";
|
||||||
|
import SearchBar from "../../features/header/SearchBar";
|
||||||
|
import { blurActive } from "../../features/header/blurActive";
|
||||||
|
import { useChannels, useChannelsActions } from "./channelsContext";
|
||||||
|
import SyncStatus from "./SyncStatus";
|
||||||
|
|
||||||
|
// The channel manager's pills in the floating header: the name filter and the per-user sync chip.
|
||||||
|
export default function ChannelsHeader() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const me = useMe();
|
||||||
|
const { search } = useChannels();
|
||||||
|
const { setSearch, goToFullHistory } = useChannelsActions();
|
||||||
|
return (
|
||||||
|
<PageHeader>
|
||||||
|
<SearchBar
|
||||||
|
value={search}
|
||||||
|
placeholder={t("channels.searchPlaceholder")}
|
||||||
|
onChange={setSearch}
|
||||||
|
onGo={blurActive}
|
||||||
|
/>
|
||||||
|
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={goToFullHistory} />
|
||||||
|
</PageHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
// The channel manager's public face: its store, mounted at the root and read app-wide.
|
// The channel manager's public face: its store, mounted at the root and read app-wide, and the sync
|
||||||
|
// chip, which the feed's header shows too.
|
||||||
export { ChannelsProvider } from "./ChannelsProvider";
|
export { ChannelsProvider } from "./ChannelsProvider";
|
||||||
export { useChannels, useChannelsActions } from "./channelsContext";
|
export { useChannels, useChannelsActions } from "./channelsContext";
|
||||||
|
export { default as SyncStatus } from "./SyncStatus";
|
||||||
|
|||||||
+31
-15
@@ -5,36 +5,52 @@
|
|||||||
// website's own endpoint) spends none — so the owner avoided a free search for months.
|
// website's own endpoint) spends none — so the owner avoided a free search for months.
|
||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import { render, screen, cleanup } from "@testing-library/react";
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
|
import type { Me } from "../../lib/api";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => ({
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("react-i18next")>()),
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../features/navigation/navigationContext", () => ({
|
let me = {} as Me;
|
||||||
useNavigation: () => ({ page: "feed" }),
|
vi.mock("../../lib/useMe", () => ({ useMe: () => me }));
|
||||||
|
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||||
|
useNavigationActions: () => ({ enterYtSearch: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../modules/feed/feedFiltersContext", () => ({
|
vi.mock("./feedFiltersContext", () => ({
|
||||||
useFeedFilters: () => ({ q: "cats", sort: "newest" }),
|
useFeedFilters: () => ({ q: "cats", sort: "newest" }),
|
||||||
useFeedFiltersActions: () => ({ setFilters: vi.fn() }),
|
useFeedFiltersActions: () => ({ setFilters: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../modules/playlists/playlistsContext", () => ({
|
vi.mock("../channels", () => ({
|
||||||
usePlaylists: () => ({ search: "" }),
|
useChannelsActions: () => ({ goToFullHistory: vi.fn() }),
|
||||||
usePlaylistsActions: () => ({ setSearch: vi.fn() }),
|
SyncStatus: () => null,
|
||||||
}));
|
}));
|
||||||
vi.mock("../modules/channels/channelsContext", () => ({
|
vi.mock("./FeedScopeToggle", () => ({ default: () => null }));
|
||||||
useChannels: () => ({ search: "" }),
|
vi.mock("../../components/ui/PageScroller", () => ({
|
||||||
useChannelsActions: () => ({ setSearch: vi.fn(), goToFullHistory: vi.fn() }),
|
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
}));
|
}));
|
||||||
vi.mock("../modules/channels/SyncStatus", () => ({ default: () => null }));
|
|
||||||
|
|
||||||
import Header from "./Header";
|
import FeedHeader from "./FeedHeader";
|
||||||
import type { Me } from "../lib/api";
|
import PageShell from "../../components/ui/PageShell";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
function tipFor(source: Me["search_source"]): string | null {
|
function tipFor(source: Me["search_source"]): string | null {
|
||||||
const me = { plex_enabled: false, search_source: source } as Me;
|
vi.stubGlobal(
|
||||||
render(<Header me={me} onYtSearch={() => {}} />);
|
"ResizeObserver",
|
||||||
|
class {
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
me = { search_source: source, role: "user" } as Me;
|
||||||
|
render(
|
||||||
|
<PageShell header={<div />}>
|
||||||
|
<FeedHeader />
|
||||||
|
</PageShell>,
|
||||||
|
);
|
||||||
return screen.getByRole("button", { name: /header\.searchYoutube$/ }).getAttribute("title");
|
return screen.getByRole("button", { name: /header\.searchYoutube$/ }).getAttribute("title");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { YouTubeIcon } from "../../lib/brandIcons";
|
||||||
|
import { useMe } from "../../lib/useMe";
|
||||||
|
import { PageHeader } from "../../components/ui/PageShell";
|
||||||
|
import SearchBar from "../../features/header/SearchBar";
|
||||||
|
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
||||||
|
import { SyncStatus, useChannelsActions } from "../channels";
|
||||||
|
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||||
|
import FeedScopeToggle from "./FeedScopeToggle";
|
||||||
|
|
||||||
|
// The feed's pills in the floating header: the search box (whose Go runs a live YouTube search for
|
||||||
|
// the term), the Mine/Library scope, and the per-user sync chip.
|
||||||
|
export default function FeedHeader() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const me = useMe();
|
||||||
|
const filters = useFeedFilters();
|
||||||
|
const { setFilters } = useFeedFiltersActions();
|
||||||
|
const { enterYtSearch } = useNavigationActions();
|
||||||
|
const { goToFullHistory } = useChannelsActions();
|
||||||
|
const trimmed = filters.q.trim();
|
||||||
|
return (
|
||||||
|
<PageHeader>
|
||||||
|
<SearchBar
|
||||||
|
value={filters.q}
|
||||||
|
placeholder={t("header.searchPlaceholder")}
|
||||||
|
onChange={(q) => {
|
||||||
|
// When a search first appears, rank the feed by relevance — set atomically with the
|
||||||
|
// query (race-free vs. per-keystroke updates). Only overrides the default "newest"
|
||||||
|
// sort; a custom sort the user chose is left alone. Cleared in Feed.
|
||||||
|
const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest";
|
||||||
|
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
|
||||||
|
}}
|
||||||
|
onGo={() => enterYtSearch(trimmed)}
|
||||||
|
goLabel={t("header.searchYoutube")}
|
||||||
|
goTitle={t(
|
||||||
|
me.search_source === "api" ? "header.searchYoutubeTipApi" : "header.searchYoutubeTip",
|
||||||
|
)}
|
||||||
|
goIcon={<YouTubeIcon className="w-4 h-4" />}
|
||||||
|
goDisabled={!trimmed}
|
||||||
|
/>
|
||||||
|
{/* Mine/Library scope sits right of the search box — relocated here from the filter sidebar so
|
||||||
|
the primary "what am I browsing" control is always in reach (mirrors Plex's scope toggle). */}
|
||||||
|
<FeedScopeToggle />
|
||||||
|
{/* Per-user sync status — it used to cost the nav rail a whole block. It sits with the other
|
||||||
|
pills rather than pinned to the far right: on a wide window that just strands it. Only on
|
||||||
|
the two pages it says anything about: the feed those subscriptions fill, and the manager
|
||||||
|
where you curate them. */}
|
||||||
|
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={goToFullHistory} />
|
||||||
|
</PageHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,13 +5,15 @@ import { useFeedView, useFeedViewActions } from "./feedViewContext";
|
|||||||
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
||||||
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
||||||
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||||
|
import FeedHeader from "./FeedHeader";
|
||||||
|
|
||||||
// The two module-registry pages that need per-render wiring (see moduleRegistry), split out of it so
|
// The two module-registry pages that need per-render wiring (see moduleRegistry), split out of it so
|
||||||
// that file exports only data and this one only components (Fast Refresh). Their lazy chunks are
|
// that file exports only data and this one only components (Fast Refresh). Their lazy chunks are
|
||||||
// exported alongside so the registry can attach each chunk's preload to the wrapper that renders it.
|
// exported alongside so the registry can attach each chunk's preload to the wrapper that renders it.
|
||||||
export const Feed = lazyWithPreload(() => import("./Feed"));
|
export const Feed = lazyWithPreload(() => import("./Feed"));
|
||||||
|
|
||||||
// The GLOBAL feed page: wires <Feed> to the app-wide feed filters / view / live-search. (The channel
|
// The GLOBAL feed page: wires <Feed> to the app-wide feed filters / view / live-search, and fills the
|
||||||
|
// header (the channel page's feed has no header). (The channel
|
||||||
// page renders <Feed> itself with its own channel-scoped filters, so those two never share state.)
|
// page renders <Feed> itself with its own channel-scoped filters, so those two never share state.)
|
||||||
export function FeedPage() {
|
export function FeedPage() {
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
@@ -27,6 +29,7 @@ export function FeedPage() {
|
|||||||
const { modalOpen } = useSavedViews();
|
const { modalOpen } = useSavedViews();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<FeedHeader />
|
||||||
<Feed
|
<Feed
|
||||||
filters={filters}
|
filters={filters}
|
||||||
setFilters={setFilters}
|
setFilters={setFilters}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import Pager from "../../components/ui/Pager";
|
|||||||
import { Pressable } from "../../components/ui/Pressable";
|
import { Pressable } from "../../components/ui/Pressable";
|
||||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||||
|
import FilesHeader from "./FilesHeader";
|
||||||
// Lazy: these open on demand, so they stay out of the Files chunk until used (the video editor in
|
// Lazy: these open on demand, so they stay out of the Files chunk until used (the video editor in
|
||||||
// particular is heavy — filmstrip + scrubber).
|
// particular is heavy — filmstrip + scrubber).
|
||||||
const UploadDialog = lazy(() => import("./UploadDialog"));
|
const UploadDialog = lazy(() => import("./UploadDialog"));
|
||||||
@@ -654,6 +655,7 @@ export default function Files() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<FilesHeader />
|
||||||
<PageToolbar>
|
<PageToolbar>
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-wrap items-center gap-x-3 gap-y-2 px-4 pt-2 pb-2">
|
<div className="mx-auto flex w-full max-w-7xl flex-wrap items-center gap-x-3 gap-y-2 px-4 pt-2 pb-2">
|
||||||
{renamingFolder && open?.kind === "own" ? (
|
{renamingFolder && open?.kind === "own" ? (
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { PageHeader } from "../../components/ui/PageShell";
|
||||||
|
import SearchBar from "../../features/header/SearchBar";
|
||||||
|
import { blurActive } from "../../features/header/blurActive";
|
||||||
|
import { useFiles, useFilesActions } from "./filesContext";
|
||||||
|
|
||||||
|
// The files library's pill in the floating header: its name filter.
|
||||||
|
export default function FilesHeader() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { search } = useFiles();
|
||||||
|
const { setSearch } = useFilesActions();
|
||||||
|
return (
|
||||||
|
<PageHeader>
|
||||||
|
<SearchBar
|
||||||
|
value={search}
|
||||||
|
placeholder={t("files.searchPlaceholder")}
|
||||||
|
onChange={setSearch}
|
||||||
|
onGo={blurActive}
|
||||||
|
/>
|
||||||
|
</PageHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ const h = vi.hoisted(() => ({
|
|||||||
notify: vi.fn(),
|
notify: vi.fn(),
|
||||||
confirm: vi.fn(async () => true),
|
confirm: vi.fn(async () => true),
|
||||||
selectedId: 1 as number | null,
|
selectedId: 1 as number | null,
|
||||||
|
// Who reads the selection, so a test's change re-renders them the way the real store does.
|
||||||
|
selectionReaders: new Set<() => void>(),
|
||||||
mounted: false,
|
mounted: false,
|
||||||
mounts: 0,
|
mounts: 0,
|
||||||
player: null as null | {
|
player: null as null | {
|
||||||
@@ -36,7 +38,20 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
|
|||||||
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
||||||
notify: (...a: unknown[]) => h.notify(...a),
|
notify: (...a: unknown[]) => h.notify(...a),
|
||||||
}));
|
}));
|
||||||
vi.mock("./playlistsContext", () => ({ usePlaylists: () => ({ selectedId: h.selectedId }) }));
|
vi.mock("./playlistsContext", async () => {
|
||||||
|
const { useSyncExternalStore } = await import("react");
|
||||||
|
const subscribe = (read: () => void) => {
|
||||||
|
h.selectionReaders.add(read);
|
||||||
|
return () => void h.selectionReaders.delete(read);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
usePlaylists: () => ({
|
||||||
|
selectedId: useSyncExternalStore(subscribe, () => h.selectedId),
|
||||||
|
search: "",
|
||||||
|
}),
|
||||||
|
usePlaylistsActions: () => ({ setSearch: () => {} }),
|
||||||
|
};
|
||||||
|
});
|
||||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm }));
|
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm }));
|
||||||
// The list's own menu is usePlaylistActions' (pinned in its own test); here its rename row, which
|
// The list's own menu is usePlaylistActions' (pinned in its own test); here its rename row, which
|
||||||
// opens THIS page's title field, and its two destructive rows as that hook marks them (danger).
|
// opens THIS page's title field, and its two destructive rows as that hook marks them (danger).
|
||||||
@@ -143,7 +158,14 @@ function mount(
|
|||||||
);
|
);
|
||||||
const tree = () => (strictOverCache ? <StrictMode>{page()}</StrictMode> : page());
|
const tree = () => (strictOverCache ? <StrictMode>{page()}</StrictMode> : page());
|
||||||
const view = render(tree());
|
const view = render(tree());
|
||||||
return { rerender: () => view.rerender(tree()) };
|
// A changed `h.selectedId` reaches the page as a store change, not as a re-render from the top: the
|
||||||
|
// page's own parts re-render because they read the selection, as in the app.
|
||||||
|
return {
|
||||||
|
rerender: () => {
|
||||||
|
act(() => h.selectionReaders.forEach((read) => read()));
|
||||||
|
view.rerender(tree());
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const play = async (title: string) =>
|
const play = async (title: string) =>
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import { HiddenFilterCue } from "../../components/ui/HiddenFilterCue";
|
|||||||
import { PageHeading } from "../../components/ui/Headings";
|
import { PageHeading } from "../../components/ui/Headings";
|
||||||
import { useCopyItems } from "../../components/ui/copyItems";
|
import { useCopyItems } from "../../components/ui/copyItems";
|
||||||
import RemoteImage from "../../components/ui/RemoteImage";
|
import RemoteImage from "../../components/ui/RemoteImage";
|
||||||
|
import PlaylistsHeader from "./PlaylistsHeader";
|
||||||
|
|
||||||
// The playlist detail is a HYBRID table: real sortable/filterable column headers over a
|
// The playlist detail is a HYBRID table: real sortable/filterable column headers over a
|
||||||
// body that is still a drag-reorderable @dnd-kit list.
|
// body that is still a drag-reorderable @dnd-kit list.
|
||||||
@@ -160,7 +161,18 @@ function Row({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The page: its header pill, and the selected playlist's detail (whose loading and empty states
|
||||||
|
// return early, so the header sits outside it).
|
||||||
export default function Playlists() {
|
export default function Playlists() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PlaylistsHeader />
|
||||||
|
<PlaylistDetail />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaylistDetail() {
|
||||||
// The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
|
// The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
|
||||||
// list + its rail controls). This detail pane reacts to the selection.
|
// list + its rail controls). This detail pane reacts to the selection.
|
||||||
const { selectedId } = usePlaylists();
|
const { selectedId } = usePlaylists();
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { PageHeader } from "../../components/ui/PageShell";
|
||||||
|
import SearchBar from "../../features/header/SearchBar";
|
||||||
|
import { blurActive } from "../../features/header/blurActive";
|
||||||
|
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
|
||||||
|
|
||||||
|
// The playlists page's pill in the floating header: the rail's name filter.
|
||||||
|
export default function PlaylistsHeader() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { search } = usePlaylists();
|
||||||
|
const { setSearch } = usePlaylistsActions();
|
||||||
|
return (
|
||||||
|
<PageHeader>
|
||||||
|
<SearchBar
|
||||||
|
value={search}
|
||||||
|
placeholder={t("playlists.searchPlaceholder")}
|
||||||
|
onChange={setSearch}
|
||||||
|
onGo={blurActive}
|
||||||
|
/>
|
||||||
|
</PageHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ import { useFilterFold } from "../../lib/useFilterFold";
|
|||||||
import { StateMessage } from "../../components/ui/QueryState";
|
import { StateMessage } from "../../components/ui/QueryState";
|
||||||
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
|
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
|
||||||
import { usePlex, usePlexActions } from "./plexContext";
|
import { usePlex, usePlexActions } from "./plexContext";
|
||||||
import { usePublishPlexView } from "./plexViewContext";
|
import PlexHeader from "./PlexHeader";
|
||||||
import { EpisodeCard, PlexPosterCard } from "../../features/plexCards/plexCards";
|
import { EpisodeCard, PlexPosterCard } from "../../features/plexCards/plexCards";
|
||||||
import { PlexMenu } from "./PlexMenu";
|
import { PlexMenu } from "./PlexMenu";
|
||||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
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
|
// 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
|
// 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
|
// (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.
|
// 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() {
|
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 { t, i18n } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
// Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the
|
// 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);
|
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.
|
// Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field.
|
||||||
const fieldState = healPlexField(field, scope);
|
const fieldState = healPlexField(field, scope);
|
||||||
// Discover is the landing; the flat grid is the "Browse all" secondary view. A header search
|
// A header search is a browse intent → flip to the grid (below); an explicit toggle returns.
|
||||||
// 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]);
|
|
||||||
|
|
||||||
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
|
// 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.
|
// 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
|
// 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
|
// 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.
|
// picked scope to the provider, and drives the playlists/collections views through the `view` it is handed.
|
||||||
// i18n is key-passthrough and both the provider and the bridge are mocked; SegmentedToggle is a plain
|
// i18n is key-passthrough and the provider is mocked; SegmentedToggle is a plain
|
||||||
// button group (no floating-ui), so no observer stubs are needed.
|
// button group (no floating-ui), so no observer stubs are needed.
|
||||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
@@ -20,13 +20,11 @@ vi.mock("./plexContext", () => ({
|
|||||||
usePlexActions: () => ({ setScope }),
|
usePlexActions: () => ({ setScope }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// The bridge: a controllable current view kind + an open spy.
|
// The sub-view: a controllable current view kind + an open spy.
|
||||||
const controls = { kind: "discover" as string, open: vi.fn() };
|
const controls = { kind: "discover" as PlexViewControls["kind"], open: vi.fn() };
|
||||||
vi.mock("./plexViewContext", () => ({
|
|
||||||
usePlexViewControls: () => controls,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import PlexScopeToggle from "./PlexScopeToggle";
|
import PlexScopeToggle from "./PlexScopeToggle";
|
||||||
|
import type { PlexViewControls } from "./plexSubUrl";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
plexState.scope = "both";
|
plexState.scope = "both";
|
||||||
@@ -48,7 +46,7 @@ const pressed = () =>
|
|||||||
describe("PlexScopeToggle", () => {
|
describe("PlexScopeToggle", () => {
|
||||||
it("renders the three scopes plus the two browse modes, the current scope pressed", () => {
|
it("renders the three scopes plus the two browse modes, the current scope pressed", () => {
|
||||||
plexState.scope = "movie";
|
plexState.scope = "movie";
|
||||||
render(<PlexScopeToggle />);
|
render(<PlexScopeToggle view={controls} />);
|
||||||
expect(labels()).toEqual([
|
expect(labels()).toEqual([
|
||||||
"plex.filter.scopeOpt.both",
|
"plex.filter.scopeOpt.both",
|
||||||
"plex.filter.scopeOpt.movie",
|
"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)", () => {
|
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" }));
|
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.show" }));
|
||||||
expect(setScope).toHaveBeenCalledWith("show");
|
expect(setScope).toHaveBeenCalledWith("show");
|
||||||
expect(controls.open).not.toHaveBeenCalled();
|
expect(controls.open).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the playlists view (not a scope change) for the Playlist mode", () => {
|
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" }));
|
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.playlists" }));
|
||||||
expect(controls.open).toHaveBeenCalledWith({ kind: "playlists" });
|
expect(controls.open).toHaveBeenCalledWith({ kind: "playlists" });
|
||||||
expect(setScope).not.toHaveBeenCalled();
|
expect(setScope).not.toHaveBeenCalled();
|
||||||
@@ -75,13 +73,13 @@ describe("PlexScopeToggle", () => {
|
|||||||
|
|
||||||
it("marks the Collection mode active when the current view is collections", () => {
|
it("marks the Collection mode active when the current view is collections", () => {
|
||||||
controls.kind = "collections";
|
controls.kind = "collections";
|
||||||
render(<PlexScopeToggle />);
|
render(<PlexScopeToggle view={controls} />);
|
||||||
expect(pressed()).toEqual(["plex.filter.scopeOpt.collections"]);
|
expect(pressed()).toEqual(["plex.filter.scopeOpt.collections"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns to the title grid when a scope is picked from a browse mode", () => {
|
it("returns to the title grid when a scope is picked from a browse mode", () => {
|
||||||
controls.kind = "playlists";
|
controls.kind = "playlists";
|
||||||
render(<PlexScopeToggle />);
|
render(<PlexScopeToggle view={controls} />);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.movie" }));
|
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.movie" }));
|
||||||
expect(setScope).toHaveBeenCalledWith("movie");
|
expect(setScope).toHaveBeenCalledWith("movie");
|
||||||
expect(controls.open).toHaveBeenCalledWith({ kind: "grid" });
|
expect(controls.open).toHaveBeenCalledWith({ kind: "grid" });
|
||||||
|
|||||||
@@ -1,34 +1,33 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||||
import { usePlex, usePlexActions } from "./plexContext";
|
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
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// playlists/collections surface returns to the title grid at that scope.
|
||||||
const SCOPES = ["both", "movie", "show"] as const;
|
const SCOPES = ["both", "movie", "show"] as const;
|
||||||
|
|
||||||
export default function PlexScopeToggle() {
|
export default function PlexScopeToggle({ view: ctl }: { view: PlexViewControls }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { scope } = usePlex();
|
const { scope } = usePlex();
|
||||||
const { setScope } = usePlexActions();
|
const { setScope } = usePlexActions();
|
||||||
const ctl = usePlexViewControls();
|
const inPlaylists = ctl.kind === "playlists";
|
||||||
const inPlaylists = ctl?.kind === "playlists";
|
const inCollections = ctl.kind === "collections";
|
||||||
const inCollections = ctl?.kind === "collections";
|
|
||||||
// The active segment: the browse-mode when in one, else the library scope.
|
// The active segment: the browse-mode when in one, else the library scope.
|
||||||
const value = inPlaylists ? "playlists" : inCollections ? "collections" : scope;
|
const value = inPlaylists ? "playlists" : inCollections ? "collections" : scope;
|
||||||
|
|
||||||
const onChange = (v: string) => {
|
const onChange = (v: string) => {
|
||||||
if (v === "playlists" || v === "collections") {
|
if (v === "playlists" || v === "collections") {
|
||||||
ctl?.open({ kind: v });
|
ctl.open({ kind: v });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setScope(v);
|
setScope(v);
|
||||||
// Leaving the playlists/collections surface for a title scope → show the title grid.
|
// 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 (
|
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
|
// 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
|
// 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
|
// 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
|
// 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).
|
// 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,
|
// 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
|
// 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
|
// 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 { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
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 }) };
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
});
|
});
|
||||||
|
|
||||||
const controls = { kind: "discover" as string, open: vi.fn() };
|
const controls = { kind: "discover" as PlexViewControls["kind"], open: vi.fn() };
|
||||||
vi.mock("./plexViewContext", () => ({
|
|
||||||
usePlexViewControls: () => controls,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import PlexViewToggle from "./PlexViewToggle";
|
import PlexViewToggle from "./PlexViewToggle";
|
||||||
|
import type { PlexViewControls } from "./plexSubUrl";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
controls.kind = "discover";
|
controls.kind = "discover";
|
||||||
@@ -27,13 +25,13 @@ afterEach(cleanup);
|
|||||||
|
|
||||||
describe("PlexViewToggle", () => {
|
describe("PlexViewToggle", () => {
|
||||||
it("re-enables pointer events on its root (the header wrapper is pointer-events-none)", () => {
|
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.
|
// Without this class the whole toggle is click-dead inside the pointer-events-none header.
|
||||||
expect(container.querySelector(".pointer-events-auto")).not.toBeNull();
|
expect(container.querySelector(".pointer-events-auto")).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the two options with the current view pressed", () => {
|
it("renders the two options with the current view pressed", () => {
|
||||||
render(<PlexViewToggle />);
|
render(<PlexViewToggle view={controls} />);
|
||||||
const btns = [
|
const btns = [
|
||||||
...screen.getByRole("group", { name: "plex.view.label" }).querySelectorAll("button"),
|
...screen.getByRole("group", { name: "plex.view.label" }).querySelectorAll("button"),
|
||||||
];
|
];
|
||||||
@@ -45,21 +43,21 @@ describe("PlexViewToggle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("opens the grid view when Browse is picked", () => {
|
it("opens the grid view when Browse is picked", () => {
|
||||||
render(<PlexViewToggle />);
|
render(<PlexViewToggle view={controls} />);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "plex.view.browse" }));
|
fireEvent.click(screen.getByRole("button", { name: "plex.view.browse" }));
|
||||||
expect(controls.open).toHaveBeenCalledWith({ kind: "grid" });
|
expect(controls.open).toHaveBeenCalledWith({ kind: "grid" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the discover view when Discover is picked from the grid", () => {
|
it("opens the discover view when Discover is picked from the grid", () => {
|
||||||
controls.kind = "grid";
|
controls.kind = "grid";
|
||||||
render(<PlexViewToggle />);
|
render(<PlexViewToggle view={controls} />);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "plex.discover.tab" }));
|
fireEvent.click(screen.getByRole("button", { name: "plex.discover.tab" }));
|
||||||
expect(controls.open).toHaveBeenCalledWith({ kind: "discover" });
|
expect(controls.open).toHaveBeenCalledWith({ kind: "discover" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders nothing on a deeper view (no Discover/Browse choice there)", () => {
|
it("renders nothing on a deeper view (no Discover/Browse choice there)", () => {
|
||||||
controls.kind = "playlists";
|
controls.kind = "playlists";
|
||||||
const { container } = render(<PlexViewToggle />);
|
const { container } = render(<PlexViewToggle view={controls} />);
|
||||||
expect(container.firstChild).toBeNull();
|
expect(container.firstChild).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
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).
|
// 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).
|
// 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 { t } = useTranslation();
|
||||||
const ctl = usePlexViewControls();
|
|
||||||
// Only the two title-browse views host the Discover/Browse choice; deeper views (show/info/person/…)
|
// Only the two title-browse views host the Discover/Browse choice; deeper views (show/info/person/…)
|
||||||
// and the playlists/collections browse surfaces hide it.
|
// 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
|
// 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
|
// content below); every interactive child must re-enable pointer events or it is click-dead — the
|
||||||
// sibling PlexScopeToggle does the same.
|
// 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.
|
// 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 { PlexProvider } from "./PlexProvider";
|
||||||
export { PlexViewBridge } from "./PlexViewBridge";
|
|
||||||
export { usePlex, usePlexActions } from "./plexContext";
|
export { usePlex, usePlexActions } from "./plexContext";
|
||||||
export { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
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
|
// 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
|
// 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.
|
// (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 =
|
export type Sub =
|
||||||
| { kind: "discover" }
|
| { kind: "discover" }
|
||||||
| { kind: "grid" }
|
| { 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);
|
|
||||||
@@ -60,8 +60,8 @@ const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
|
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
|
||||||
// the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is
|
// the frosted `.glass` surface so it still refracts the ambient backdrop. The page's identity is
|
||||||
// shown by the Header; the tab rail stays as in-page sub-navigation.
|
// the nav rail and the tab title; the tab rail stays as in-page sub-navigation.
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ import SetupWizard from "./SetupWizard";
|
|||||||
import Header from "./Header";
|
import Header from "./Header";
|
||||||
import NavSidebar from "./NavSidebar";
|
import NavSidebar from "./NavSidebar";
|
||||||
import BackToTop from "../components/ui/BackToTop";
|
import BackToTop from "../components/ui/BackToTop";
|
||||||
import PageShell from "../components/ui/PageShell";
|
import PageShell, { PageHeaderDefault } from "../components/ui/PageShell";
|
||||||
|
import { PaletteLauncher } from "../features/header/PaletteLauncher";
|
||||||
import { SkipLink } from "../components/ui/SkipLink";
|
import { SkipLink } from "../components/ui/SkipLink";
|
||||||
import { pageTitleKey } from "../lib/pageMeta";
|
import { pageTitleKey } from "../lib/pageMeta";
|
||||||
import DebugRecorder from "../features/diagnostics/DebugRecorder";
|
import DebugRecorder from "../features/diagnostics/DebugRecorder";
|
||||||
@@ -68,7 +69,7 @@ const ReleaseNotes = lazy(() => import("./ReleaseNotes"));
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { page, channelView, ytSearch } = useNavigation();
|
const { page, channelView, ytSearch } = useNavigation();
|
||||||
const { setPage, enterYtSearch } = useNavigationActions();
|
const { setPage } = useNavigationActions();
|
||||||
// An emailed verification link is confirmed HERE, above the signed-in/signed-out branch below, so
|
// An emailed verification link is confirmed HERE, above the signed-in/signed-out branch below, so
|
||||||
// it works in a browser that already holds a session. It used to be handled inside Welcome, which
|
// it works in a browser that already holds a session. It used to be handled inside Welcome, which
|
||||||
// that branch never renders when signed in — the token was silently dropped. Signed out, the
|
// that branch never renders when signed in — the token was silently dropped. Signed out, the
|
||||||
@@ -452,7 +453,7 @@ export default function App() {
|
|||||||
title={t(pageTitleKey(page))}
|
title={t(pageTitleKey(page))}
|
||||||
scrollKey={normalScrollKey}
|
scrollKey={normalScrollKey}
|
||||||
maxWidth={maxWidth}
|
maxWidth={maxWidth}
|
||||||
header={<Header me={me!} onYtSearch={enterYtSearch} />}
|
header={<Header />}
|
||||||
banners={
|
banners={
|
||||||
<>
|
<>
|
||||||
<VerifyBanner entry={verifyEntry} />
|
<VerifyBanner entry={verifyEntry} />
|
||||||
@@ -462,6 +463,9 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<Suspense fallback={pageFallback}>
|
<Suspense fallback={pageFallback}>
|
||||||
<PageContent />
|
<PageContent />
|
||||||
|
<PageHeaderDefault>
|
||||||
|
<PaletteLauncher />
|
||||||
|
</PageHeaderDefault>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,72 +1,80 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
//
|
//
|
||||||
// Ctrl/Cmd+K had no visible entry point (debt burn B28). The owner's pick: a key cap in the header's
|
// Ctrl/Cmd+K had no visible entry point (debt burn B28). The owner's pick: a key cap in the header's
|
||||||
// search field, and a "Jump to…" pill in its place on a page that has no search field.
|
// search field, and a "Jump to…" pill in its place on a page that has no search field. The shell
|
||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
// composes it the way App does: the page's own pills (a search box here) through PageHeader, and the
|
||||||
|
// pill as the header's default.
|
||||||
|
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => ({
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("react-i18next")>()),
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../components/ui/PageScroller", () => ({
|
||||||
let page = "home";
|
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
vi.mock("../features/navigation/navigationContext", () => ({ useNavigation: () => ({ page }) }));
|
|
||||||
vi.mock("../modules/feed/feedFiltersContext", () => ({
|
|
||||||
useFeedFilters: () => ({ q: "", sort: "newest" }),
|
|
||||||
useFeedFiltersActions: () => ({ setFilters: vi.fn() }),
|
|
||||||
}));
|
}));
|
||||||
vi.mock("../modules/playlists/playlistsContext", () => ({
|
|
||||||
usePlaylists: () => ({ search: "" }),
|
|
||||||
usePlaylistsActions: () => ({ setSearch: vi.fn() }),
|
|
||||||
}));
|
|
||||||
vi.mock("../modules/channels/channelsContext", () => ({
|
|
||||||
useChannels: () => ({ search: "" }),
|
|
||||||
useChannelsActions: () => ({ setSearch: vi.fn(), goToFullHistory: vi.fn() }),
|
|
||||||
}));
|
|
||||||
vi.mock("../modules/channels/SyncStatus", () => ({ default: () => null }));
|
|
||||||
|
|
||||||
import Header from "./Header";
|
import Header from "./Header";
|
||||||
|
import PageShell, { PageHeader, PageHeaderDefault } from "../components/ui/PageShell";
|
||||||
|
import SearchBar from "../features/header/SearchBar";
|
||||||
|
import { PaletteLauncher } from "../features/header/PaletteLauncher";
|
||||||
import { closePalette, usePaletteOpen } from "../lib/keymap";
|
import { closePalette, usePaletteOpen } from "../lib/keymap";
|
||||||
import type { Me } from "../lib/api";
|
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"ResizeObserver",
|
||||||
|
class {
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
act(() => closePalette());
|
act(() => closePalette());
|
||||||
});
|
});
|
||||||
|
|
||||||
function mount() {
|
function mount(withSearch: boolean) {
|
||||||
let open = false;
|
let open = false;
|
||||||
function Probe() {
|
function Probe() {
|
||||||
open = usePaletteOpen();
|
open = usePaletteOpen();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
render(
|
render(
|
||||||
<>
|
<PageShell header={<Header />}>
|
||||||
<Header me={{ plex_enabled: false } as Me} onYtSearch={() => {}} />
|
{withSearch && (
|
||||||
|
<PageHeader>
|
||||||
|
<SearchBar value="" placeholder="search" onChange={() => {}} onGo={() => {}} />
|
||||||
|
</PageHeader>
|
||||||
|
)}
|
||||||
|
<PageHeaderDefault>
|
||||||
|
<PaletteLauncher />
|
||||||
|
</PageHeaderDefault>
|
||||||
<Probe />
|
<Probe />
|
||||||
</>,
|
</PageShell>,
|
||||||
);
|
);
|
||||||
return () => open;
|
return () => open;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("the command palette's visible entry", () => {
|
describe("the command palette's visible entry", () => {
|
||||||
it("is a key cap inside the search field, naming the chord the palette row binds", () => {
|
it("is a key cap inside the search field, naming the chord the palette row binds", () => {
|
||||||
page = "feed";
|
const isOpen = mount(true);
|
||||||
const isOpen = mount();
|
|
||||||
const cap = screen.getByRole("button", { name: /^palette\.open \((Ctrl|⌘) K\)$/ });
|
const cap = screen.getByRole("button", { name: /^palette\.open \((Ctrl|⌘) K\)$/ });
|
||||||
// Label in Name: the name carries what the cap shows, so "click Ctrl K" finds it.
|
// Label in Name: the name carries what the cap shows, so "click Ctrl K" finds it.
|
||||||
expect(cap.getAttribute("aria-label")).toContain(cap.textContent);
|
expect(cap.getAttribute("aria-label")).toContain(cap.textContent);
|
||||||
// …and a tooltip equal to that name, so a screen reader does not read it again as a description.
|
// …and a tooltip equal to that name, so a screen reader does not read it again as a description.
|
||||||
expect(cap.getAttribute("title")).toBe(cap.getAttribute("aria-label"));
|
expect(cap.getAttribute("title")).toBe(cap.getAttribute("aria-label"));
|
||||||
expect(screen.getByRole("textbox")).toBeTruthy();
|
expect(screen.getByRole("textbox")).toBeTruthy();
|
||||||
|
// The search field takes the pill's place: one entry, not two.
|
||||||
|
expect(screen.queryByRole("button", { name: /^palette\.title/ })).toBeNull();
|
||||||
fireEvent.click(cap);
|
fireEvent.click(cap);
|
||||||
expect(isOpen()).toBe(true);
|
expect(isOpen()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is a Jump-to pill on a page with no search field", () => {
|
it("is a Jump-to pill on a page with no search field", () => {
|
||||||
page = "home";
|
const isOpen = mount(false);
|
||||||
const isOpen = mount();
|
|
||||||
expect(screen.queryByRole("textbox")).toBeNull();
|
expect(screen.queryByRole("textbox")).toBeNull();
|
||||||
// Named by what it shows ("Jump to…" and its key cap), not by a label that replaced it.
|
// Named by what it shows ("Jump to…" and its key cap), not by a label that replaced it.
|
||||||
const pill = screen.getByRole("button", { name: /^palette\.title ?(Ctrl|⌘) K$/ });
|
const pill = screen.getByRole("button", { name: /^palette\.title ?(Ctrl|⌘) K$/ });
|
||||||
|
|||||||
+12
-124
@@ -1,110 +1,13 @@
|
|||||||
import { forwardRef, type ReactNode } from "react";
|
import { forwardRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { YouTubeIcon } from "../lib/brandIcons";
|
|
||||||
import type { Me } from "../lib/api";
|
|
||||||
import SearchBar from "../features/header/SearchBar";
|
|
||||||
import { useFiles, useFilesActions } from "../modules/files";
|
|
||||||
// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots.
|
|
||||||
import PlexSearchBox from "../modules/plex/PlexSearchBox";
|
|
||||||
// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots.
|
|
||||||
import PlexScopeToggle from "../modules/plex/PlexScopeToggle";
|
|
||||||
// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots.
|
|
||||||
import PlexViewToggle from "../modules/plex/PlexViewToggle";
|
|
||||||
// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots.
|
|
||||||
import FeedScopeToggle from "../modules/feed/FeedScopeToggle";
|
|
||||||
// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots.
|
|
||||||
import SyncStatus from "../modules/channels/SyncStatus";
|
|
||||||
import { useNavigation } from "../features/navigation/navigationContext";
|
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "../modules/feed";
|
|
||||||
import { usePlaylists, usePlaylistsActions } from "../modules/playlists";
|
|
||||||
import { useChannels, useChannelsActions } from "../modules/channels";
|
|
||||||
import { PaletteLauncher } from "../features/header/PaletteLauncher";
|
|
||||||
|
|
||||||
// The floating top header: a row of glass pills over the content — the module's SearchBar where it
|
|
||||||
// has one, and the per-user sync chip at the right end.
|
|
||||||
// `forwardRef` so PageShell can attach a ref to the header's root box and measure its real (possibly
|
|
||||||
// wrapped) height — that measurement + the `--hdr-h` content pad it drives live in PageShell, which
|
|
||||||
// owns both the header slot and the padded content wrapper.
|
|
||||||
const Header = forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
{
|
|
||||||
me: Me;
|
|
||||||
// Trigger a live YouTube search for the current term (feed only).
|
|
||||||
onYtSearch: (q: string) => void;
|
|
||||||
}
|
|
||||||
>(function Header({ me, onYtSearch }, ref) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { page } = useNavigation();
|
|
||||||
const filters = useFeedFilters();
|
|
||||||
const { setFilters } = useFeedFiltersActions();
|
|
||||||
// Each module's search term lives in its own provider (shared with that module's rail/content):
|
|
||||||
// playlists name filter, Plex search term, channels name filter. The channel sync chip's
|
|
||||||
// "go to full history" jump is a ChannelsProvider action.
|
|
||||||
const { search: playlistsSearch } = usePlaylists();
|
|
||||||
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
|
|
||||||
const { search: channelsSearch } = useChannels();
|
|
||||||
const { search: filesSearch } = useFiles();
|
|
||||||
const { setSearch: setFilesSearch } = useFilesActions();
|
|
||||||
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
|
|
||||||
// Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube).
|
|
||||||
const blur = () => (document.activeElement as HTMLElement | null)?.blur?.();
|
|
||||||
|
|
||||||
// Per-page SearchBar wiring — null for modules without a search.
|
|
||||||
// A page without a search field still gets the palette's entry (PaletteLauncher).
|
|
||||||
let search: ReactNode = <PaletteLauncher />;
|
|
||||||
if (page === "feed") {
|
|
||||||
const trimmed = filters.q.trim();
|
|
||||||
search = (
|
|
||||||
<SearchBar
|
|
||||||
value={filters.q}
|
|
||||||
placeholder={t("header.searchPlaceholder")}
|
|
||||||
onChange={(q) => {
|
|
||||||
// When a search first appears, rank the feed by relevance — set atomically with the
|
|
||||||
// query (race-free vs. per-keystroke updates). Only overrides the default "newest"
|
|
||||||
// sort; a custom sort the user chose is left alone. Cleared in Feed.
|
|
||||||
const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest";
|
|
||||||
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
|
|
||||||
}}
|
|
||||||
onGo={() => onYtSearch(trimmed)}
|
|
||||||
goLabel={t("header.searchYoutube")}
|
|
||||||
goTitle={t(
|
|
||||||
me.search_source === "api" ? "header.searchYoutubeTipApi" : "header.searchYoutubeTip",
|
|
||||||
)}
|
|
||||||
goIcon={<YouTubeIcon className="w-4 h-4" />}
|
|
||||||
goDisabled={!trimmed}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (page === "plex" && me.plex_enabled) {
|
|
||||||
search = <PlexSearchBox />;
|
|
||||||
} else if (page === "channels") {
|
|
||||||
search = (
|
|
||||||
<SearchBar
|
|
||||||
value={channelsSearch}
|
|
||||||
placeholder={t("channels.searchPlaceholder")}
|
|
||||||
onChange={setChannelsSearch}
|
|
||||||
onGo={blur}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (page === "files") {
|
|
||||||
search = (
|
|
||||||
<SearchBar
|
|
||||||
value={filesSearch}
|
|
||||||
placeholder={t("files.searchPlaceholder")}
|
|
||||||
onChange={setFilesSearch}
|
|
||||||
onGo={blur}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (page === "playlists") {
|
|
||||||
search = (
|
|
||||||
<SearchBar
|
|
||||||
value={playlistsSearch}
|
|
||||||
placeholder={t("playlists.searchPlaceholder")}
|
|
||||||
onChange={setPlaylistsSearch}
|
|
||||||
onGo={blur}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// The floating top header: a row of glass pills over the content. It is an empty band that knows no
|
||||||
|
// page: each page renders its own pills into it (ui/PageShell's PageHeader: the search box, a scope
|
||||||
|
// toggle, the sync chip), and a page with none gets the shell's default, the palette's entry.
|
||||||
|
// `forwardRef` so PageShell can attach a ref to the header's root box: that box is the slot the pages
|
||||||
|
// portal into, and PageShell measures its real (possibly wrapped) height — that measurement + the
|
||||||
|
// `--hdr-h` content pad it drives live in PageShell, which owns both the header slot and the padded
|
||||||
|
// content wrapper.
|
||||||
|
const Header = forwardRef<HTMLDivElement>(function Header(_props, ref) {
|
||||||
return (
|
return (
|
||||||
// Absolutely positioned within the (relative) content column, so its left edge tracks the
|
// Absolutely positioned within the (relative) content column, so its left edge tracks the
|
||||||
// actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
|
// actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
|
||||||
@@ -117,25 +20,10 @@ const Header = forwardRef<
|
|||||||
// (feed) = full width. Matches the toolbar band below it. `justify-center` then centres the search-box
|
// (feed) = full width. Matches the toolbar band below it. `justify-center` then centres the search-box
|
||||||
// group (search + scope toggle / sync chip) within that band, so the box sits centred over the cards
|
// group (search + scope toggle / sync chip) within that band, so the box sits centred over the cards
|
||||||
// once the column is wider than the box's max-w — not hugged to the left edge.
|
// once the column is wider than the box's max-w — not hugged to the left edge.
|
||||||
|
// pointer-events-none lets a click on the band's empty area fall through to the content below, so
|
||||||
|
// every pill a page renders here re-enables pointer events itself.
|
||||||
className="pointer-events-none absolute top-3 left-4 right-[calc(1rem+var(--sbw))] mx-auto max-w-[var(--content-mw,none)] z-chrome flex flex-wrap items-center justify-center gap-x-3 gap-y-2"
|
className="pointer-events-none absolute top-3 left-4 right-[calc(1rem+var(--sbw))] mx-auto max-w-[var(--content-mw,none)] z-chrome flex flex-wrap items-center justify-center gap-x-3 gap-y-2"
|
||||||
>
|
/>
|
||||||
{search}
|
|
||||||
{/* Mine/Library scope sits right of the feed search box — relocated here from the filter sidebar
|
|
||||||
so the primary "what am I browsing" control is always in reach (mirrors Plex's scope toggle). */}
|
|
||||||
{page === "feed" && <FeedScopeToggle />}
|
|
||||||
{/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from
|
|
||||||
the filter sidebar so the primary "what am I browsing" control is always in reach. The
|
|
||||||
Discover ⟷ Browse toggle sits right beside it (driven via the PlexViewBridge). */}
|
|
||||||
{page === "plex" && me.plex_enabled && <PlexScopeToggle />}
|
|
||||||
{page === "plex" && me.plex_enabled && <PlexViewToggle />}
|
|
||||||
{/* Per-user sync status — it used to cost the nav rail a whole block. It sits with the other
|
|
||||||
pills rather than pinned to the far right: on a wide window that just strands it. Only on
|
|
||||||
the two modules it says anything about: the feed those subscriptions fill, and the manager
|
|
||||||
where you curate them. It's noise on Plex (a different source entirely), playlists, etc. */}
|
|
||||||
{(page === "feed" || page === "channels") && (
|
|
||||||
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -103,8 +103,8 @@ export default function NavSidebar({
|
|||||||
|
|
||||||
type NavItem = { page: Page; icon: LucideIcon; label: string; badge?: number };
|
type NavItem = { page: Page; icon: LucideIcon; label: string; badge?: number };
|
||||||
// Per-page presentation (icon/label/badge). WHICH pages appear and in what order — and each
|
// Per-page presentation (icon/label/badge). WHICH pages appear and in what order — and each
|
||||||
// module's icon + label — come from the shared module registry (the same source the header's
|
// module's icon + label — come from the shared module registry (the same source the command
|
||||||
// ◀/▶ stepper uses), so the two never drift and a new/gated module updates both at once. The
|
// palette's module group uses), so the two never drift and a new/gated module updates both at once. The
|
||||||
// badge NUMBERS are the only nav-only concern (they need the live polling queries above).
|
// badge NUMBERS are the only nav-only concern (they need the live polling queries above).
|
||||||
const BADGE: Partial<Record<Page, number>> = {
|
const BADGE: Partial<Record<Page, number>> = {
|
||||||
notifications: unread,
|
notifications: unread,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Feed, FeedPage } from "../modules/feed/modulePages";
|
|||||||
|
|
||||||
// The RENDER half of the module registry: which component (and side rail) each page renders. The
|
// 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 /
|
// 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
|
// the palette / App's gate check). Together they let App drive the whole shell from `page` alone, with no
|
||||||
// `page === X` chrome conditionals.
|
// `page === X` chrome conditionals.
|
||||||
//
|
//
|
||||||
// Every content entry is a PROP-FREE component: it reads `me` from useMe() and its module-local state
|
// Every content entry is a PROP-FREE component: it reads `me` from useMe() and its module-local state
|
||||||
|
|||||||
Reference in New Issue
Block a user