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:
@@ -37,7 +37,8 @@ import SetupWizard from "./SetupWizard";
|
||||
import Header from "./Header";
|
||||
import NavSidebar from "./NavSidebar";
|
||||
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 { pageTitleKey } from "../lib/pageMeta";
|
||||
import DebugRecorder from "../features/diagnostics/DebugRecorder";
|
||||
@@ -68,7 +69,7 @@ const ReleaseNotes = lazy(() => import("./ReleaseNotes"));
|
||||
export default function App() {
|
||||
const { t, i18n } = useTranslation();
|
||||
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
|
||||
// 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
|
||||
@@ -452,7 +453,7 @@ export default function App() {
|
||||
title={t(pageTitleKey(page))}
|
||||
scrollKey={normalScrollKey}
|
||||
maxWidth={maxWidth}
|
||||
header={<Header me={me!} onYtSearch={enterYtSearch} />}
|
||||
header={<Header />}
|
||||
banners={
|
||||
<>
|
||||
<VerifyBanner entry={verifyEntry} />
|
||||
@@ -462,6 +463,9 @@ export default function App() {
|
||||
>
|
||||
<Suspense fallback={pageFallback}>
|
||||
<PageContent />
|
||||
<PageHeaderDefault>
|
||||
<PaletteLauncher />
|
||||
</PageHeaderDefault>
|
||||
</Suspense>
|
||||
</PageShell>
|
||||
)}
|
||||
|
||||
@@ -1,72 +1,80 @@
|
||||
// @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
|
||||
// search field, and a "Jump to…" pill in its place on a page that has no search field.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
// search field, and a "Jump to…" pill in its place on a page that has no search field. The shell
|
||||
// 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";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
let page = "home";
|
||||
vi.mock("../features/navigation/navigationContext", () => ({ useNavigation: () => ({ page }) }));
|
||||
vi.mock("../modules/feed/feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ q: "", sort: "newest" }),
|
||||
useFeedFiltersActions: () => ({ setFilters: vi.fn() }),
|
||||
vi.mock("../components/ui/PageScroller", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
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 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 type { Me } from "../lib/api";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
act(() => closePalette());
|
||||
});
|
||||
|
||||
function mount() {
|
||||
function mount(withSearch: boolean) {
|
||||
let open = false;
|
||||
function Probe() {
|
||||
open = usePaletteOpen();
|
||||
return null;
|
||||
}
|
||||
render(
|
||||
<>
|
||||
<Header me={{ plex_enabled: false } as Me} onYtSearch={() => {}} />
|
||||
<PageShell header={<Header />}>
|
||||
{withSearch && (
|
||||
<PageHeader>
|
||||
<SearchBar value="" placeholder="search" onChange={() => {}} onGo={() => {}} />
|
||||
</PageHeader>
|
||||
)}
|
||||
<PageHeaderDefault>
|
||||
<PaletteLauncher />
|
||||
</PageHeaderDefault>
|
||||
<Probe />
|
||||
</>,
|
||||
</PageShell>,
|
||||
);
|
||||
return () => open;
|
||||
}
|
||||
|
||||
describe("the command palette's visible entry", () => {
|
||||
it("is a key cap inside the search field, naming the chord the palette row binds", () => {
|
||||
page = "feed";
|
||||
const isOpen = mount();
|
||||
const isOpen = mount(true);
|
||||
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.
|
||||
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.
|
||||
expect(cap.getAttribute("title")).toBe(cap.getAttribute("aria-label"));
|
||||
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);
|
||||
expect(isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("is a Jump-to pill on a page with no search field", () => {
|
||||
page = "home";
|
||||
const isOpen = mount();
|
||||
const isOpen = mount(false);
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
// 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$/ });
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The live-search button's tooltip says what a search costs under the search source the instance
|
||||
// actually runs. It used to say "uses shared API quota" always, while the default source (the
|
||||
// website's own endpoint) spends none — so the owner avoided a free search for months.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../features/navigation/navigationContext", () => ({
|
||||
useNavigation: () => ({ page: "feed" }),
|
||||
}));
|
||||
vi.mock("../modules/feed/feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ q: "cats", 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 type { Me } from "../lib/api";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function tipFor(source: Me["search_source"]): string | null {
|
||||
const me = { plex_enabled: false, search_source: source } as Me;
|
||||
render(<Header me={me} onYtSearch={() => {}} />);
|
||||
return screen.getByRole("button", { name: /header\.searchYoutube$/ }).getAttribute("title");
|
||||
}
|
||||
|
||||
describe("the live-search tooltip", () => {
|
||||
it("does not claim a quota cost under the free website source", () => {
|
||||
expect(tipFor("scrape")).toBe("header.searchYoutubeTip");
|
||||
});
|
||||
|
||||
it("names the quota cost when the instance searches through the official API", () => {
|
||||
expect(tipFor("api")).toBe("header.searchYoutubeTipApi");
|
||||
});
|
||||
});
|
||||
+12
-124
@@ -1,110 +1,13 @@
|
||||
import { forwardRef, type ReactNode } 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
import { forwardRef } from "react";
|
||||
|
||||
// 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 (
|
||||
// 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
|
||||
@@ -117,25 +20,10 @@ const Header = forwardRef<
|
||||
// (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
|
||||
// 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"
|
||||
>
|
||||
{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 };
|
||||
// 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
|
||||
// ◀/▶ stepper uses), so the two never drift and a new/gated module updates both at once. The
|
||||
// module's icon + label — come from the shared module registry (the same source the command
|
||||
// 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).
|
||||
const BADGE: Partial<Record<Page, number>> = {
|
||||
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
|
||||
// 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.
|
||||
//
|
||||
// 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