feat(header): floating module header with cyclic nav and shared search
Replace the full-width top bar with a fixed floating header of glass pills: a fixed-position ModuleName (accent dot + label + prev/next cyclic module stepper) and a shared SearchBar. The header's left edge is anchored as if the nav rail and filter sidebar were both open, so it never shifts on collapse or module change; the right edge leaves a 10% margin. - ModuleName label width is measured live from the reachable modules' titles in the current language, recomputing on a language or account change. - Module names come from a shared moduleLabelKey (the same short labels as the nav rail); NavSidebar and the header now read from one source (lib/modules.ts). - The arrows step cyclically through moduleOrder(me) — dynamic, no hard-coded targets, wrapping at both ends. - Search is hoisted into the SearchBar for feed (YouTube search), plex, channels (including the Discovery tab) and playlists (new); Go button, Enter triggers it. - The Playlists left rail now reaches the top; header clearance via --hdr-h.
This commit is contained in:
@@ -1,20 +1,28 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search, X, Youtube } from "lucide-react";
|
||||
import { Youtube } from "lucide-react";
|
||||
import type { FeedFilters, Me } from "../lib/api";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import { pageTitleKey } from "../lib/pageMeta";
|
||||
import PageTitle from "./PageTitle";
|
||||
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
|
||||
import ModuleName from "./ModuleName";
|
||||
import SearchBar from "./SearchBar";
|
||||
|
||||
// Contextual top bar over the content column. Primary navigation, account and the per-user sync
|
||||
// status live in the left NavSidebar; the feed's scope toggle now lives in the filter sidebar.
|
||||
// The header carries just the feed search (or the current page title).
|
||||
// The floating top header: a fixed-position row of glass pills over the content — a ModuleName
|
||||
// pill (label + accent dot + back/forward arrows) and, where a module searches, a SearchBar pill.
|
||||
// The row's left edge is a constant (as if the nav rail AND filter sidebar were both open), so it
|
||||
// never shifts when either collapses or when paging between modules; the right edge leaves ~10%.
|
||||
export default function Header({
|
||||
me,
|
||||
filters,
|
||||
setFilters,
|
||||
plexQ,
|
||||
setPlexQ,
|
||||
channelsSearch,
|
||||
setChannelsSearch,
|
||||
playlistsSearch,
|
||||
setPlaylistsSearch,
|
||||
page,
|
||||
setPage,
|
||||
onYtSearch,
|
||||
}: {
|
||||
me: Me;
|
||||
@@ -23,78 +31,88 @@ export default function Header({
|
||||
// Plex has its own ephemeral search term (see App) — the box is shared UI, the state is not.
|
||||
plexQ: string;
|
||||
setPlexQ: (q: string) => void;
|
||||
channelsSearch: string;
|
||||
setChannelsSearch: (q: string) => void;
|
||||
playlistsSearch: string;
|
||||
setPlaylistsSearch: (q: string) => void;
|
||||
page: Page;
|
||||
// Trigger a live YouTube search for the current term (hidden for the demo account, which
|
||||
// can't spend the shared quota).
|
||||
setPage: (p: Page) => void;
|
||||
// Trigger a live YouTube search for the current term (feed only; hidden for the demo account,
|
||||
// which can't spend the shared quota).
|
||||
onYtSearch: (q: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const canSearchYt = !me.is_demo;
|
||||
// The search box serves both the YouTube feed and the Plex module (integrated search); the live
|
||||
// YouTube-search escalation (Enter / button) is feed-only. On Plex it drives `plexQ`, on the feed
|
||||
// the persisted `filters.q`.
|
||||
const isSearchPage = page === "feed" || page === "plex";
|
||||
const isPlex = page === "plex";
|
||||
const isYtCapable = page === "feed" && canSearchYt;
|
||||
const searchValue = isPlex ? plexQ : filters.q;
|
||||
const trimmedQ = searchValue.trim();
|
||||
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
|
||||
// it stays correct as modules are added/gated). Labels of every active module feed ModuleName's
|
||||
// dynamic width so the pill is sized to the widest reachable title in the current language.
|
||||
const order = moduleOrder(me);
|
||||
const moduleLabels = order.map((p) => t(moduleLabelKey[p]));
|
||||
const multiModule = order.length > 1;
|
||||
// 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.
|
||||
let search: ReactNode = null;
|
||||
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("header.searchYoutubeTip")}
|
||||
goIcon={<Youtube className="w-4 h-4" />}
|
||||
goDisabled={!trimmed || me.is_demo}
|
||||
/>
|
||||
);
|
||||
} else if (page === "plex" && me.plex_enabled) {
|
||||
search = (
|
||||
<SearchBar
|
||||
value={plexQ}
|
||||
placeholder={t("plex.searchPlaceholder")}
|
||||
onChange={setPlexQ}
|
||||
onGo={blur}
|
||||
/>
|
||||
);
|
||||
} else if (page === "channels") {
|
||||
search = (
|
||||
<SearchBar
|
||||
value={channelsSearch}
|
||||
placeholder={t("channels.searchPlaceholder")}
|
||||
onChange={setChannelsSearch}
|
||||
onGo={blur}
|
||||
/>
|
||||
);
|
||||
} else if (page === "playlists") {
|
||||
search = (
|
||||
<SearchBar
|
||||
value={playlistsSearch}
|
||||
placeholder={t("playlists.searchPlaceholder")}
|
||||
onChange={setPlaylistsSearch}
|
||||
onGo={blur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
|
||||
{isSearchPage ? (
|
||||
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
value={searchValue}
|
||||
onChange={(e) => {
|
||||
const q = e.target.value;
|
||||
if (isPlex) {
|
||||
setPlexQ(q);
|
||||
return;
|
||||
}
|
||||
// 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" } : {}) });
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// On the feed, Enter escalates the typed term to a live YouTube search (the box
|
||||
// itself filters the local catalog / Plex library as you type).
|
||||
if (e.key === "Enter" && trimmedQ && isYtCapable) onYtSearch(trimmedQ);
|
||||
}}
|
||||
placeholder={page === "plex" ? t("plex.searchPlaceholder") : t("header.searchPlaceholder")}
|
||||
className="w-full bg-card border border-border rounded-full pl-9 pr-9 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
{searchValue && (
|
||||
<button
|
||||
onClick={() => (isPlex ? setPlexQ("") : setFilters({ ...filters, q: "" }))}
|
||||
title={t("header.clearSearch")}
|
||||
aria-label={t("header.clearSearch")}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded-full text-muted hover:text-fg hover:bg-border/60 transition"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{trimmedQ && isYtCapable && (
|
||||
<button
|
||||
onClick={() => onYtSearch(trimmedQ)}
|
||||
title={t("header.searchYoutubeTip")}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-2 text-xs font-medium text-muted hover:text-accent hover:border-accent transition"
|
||||
>
|
||||
<Youtube className="w-4 h-4" />
|
||||
<span className="hidden md:inline">{t("header.searchYoutube")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex justify-center">
|
||||
<PageTitle label={t(pageTitleKey(page))} />
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<div className="pointer-events-none fixed top-3 left-4 right-[10%] z-30 flex items-center gap-3 md:left-[480px]">
|
||||
<ModuleName
|
||||
label={t(moduleLabelKey[page])}
|
||||
labels={moduleLabels}
|
||||
onPrev={() => setPage(stepModule(me, page, -1))}
|
||||
onNext={() => setPage(stepModule(me, page, 1))}
|
||||
canPrev={multiModule}
|
||||
canNext={multiModule}
|
||||
/>
|
||||
{search}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user