From 2739f25e6c562468eba865cb9cd1bd38410c2ace Mon Sep 17 00:00:00 2001
From: npeter83
Date: Mon, 13 Jul 2026 02:28:06 +0200
Subject: [PATCH] feat(header): floating module header with cyclic nav and
shared search
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
frontend/src/App.tsx | 20 ++-
frontend/src/components/ChannelDiscovery.tsx | 8 +-
frontend/src/components/Channels.tsx | 38 ++---
frontend/src/components/Header.tsx | 164 ++++++++++---------
frontend/src/components/ModuleName.tsx | 90 ++++++++++
frontend/src/components/NavSidebar.tsx | 63 +++----
frontend/src/components/PageTitle.tsx | 11 --
frontend/src/components/Playlists.tsx | 24 ++-
frontend/src/components/SearchBar.tsx | 70 ++++++++
frontend/src/i18n/locales/de/header.json | 4 +
frontend/src/i18n/locales/de/playlists.json | 2 +
frontend/src/i18n/locales/en/header.json | 4 +
frontend/src/i18n/locales/en/playlists.json | 2 +
frontend/src/i18n/locales/hu/header.json | 4 +
frontend/src/i18n/locales/hu/playlists.json | 2 +
frontend/src/index.css | 5 +
frontend/src/lib/modules.ts | 50 ++++++
17 files changed, 420 insertions(+), 141 deletions(-)
create mode 100644 frontend/src/components/ModuleName.tsx
delete mode 100644 frontend/src/components/PageTitle.tsx
create mode 100644 frontend/src/components/SearchBar.tsx
create mode 100644 frontend/src/lib/modules.ts
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index f65e075..5867d50 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -182,6 +182,10 @@ export default function App() {
const saveMsgTimer = useRef(undefined);
const [sidebarLayout, setSidebarLayoutState] = useState(loadLayout);
const [page, setPageState] = useState(loadInitialPage);
+ // Client-side name filters for the Channels + Playlists lists — lifted here so the shared top
+ // SearchBar can drive them (the modules read/seed them via props).
+ const [channelsSearch, setChannelsSearch] = useState("");
+ const [playlistsSearch, setPlaylistsSearch] = useState("");
// Live YouTube search term (null = normal feed). Ephemeral: not persisted and not in the URL,
// so a reload returns to the normal feed (a search spends quota, so we never auto-replay it).
// The search is a feed SUB-VIEW that owns a browser-history entry (history.state._yt): the
@@ -803,9 +807,20 @@ export default function App() {
setFilters={setFilters}
plexQ={plexQ}
setPlexQ={setPlexQ}
+ channelsSearch={channelsSearch}
+ setChannelsSearch={setChannelsSearch}
+ playlistsSearch={playlistsSearch}
+ setPlaylistsSearch={setPlaylistsSearch}
page={page}
+ setPage={setPage}
onYtSearch={enterYtSearch}
/>
+ {/* The header floats (fixed) over the content, so it no longer occupies flow space. This
+ wrapper clears it with a top pad — EXCEPT on Playlists, whose own left rail must reach
+ the very top (it pads only its content pane internally instead). */}
+
+ {/* The channel-name search now lives in the shared top SearchBar (driven via props). */}
{/* Your tags — click a chip to filter the table by it (add/rename/delete live in the manager). */}
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index 2038bbf..9ee22d1 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -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 = (
+ {
+ // 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={}
+ goDisabled={!trimmed || me.is_demo}
+ />
+ );
+ } else if (page === "plex" && me.plex_enabled) {
+ search = (
+
+ );
+ } else if (page === "channels") {
+ search = (
+
+ );
+ } else if (page === "playlists") {
+ search = (
+
+ );
+ }
return (
-
- {isSearchPage ? (
-
-
-
- {
- 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 && (
-
- )}
-
);
}
diff --git a/frontend/src/components/ModuleName.tsx b/frontend/src/components/ModuleName.tsx
new file mode 100644
index 0000000..304e4e5
--- /dev/null
+++ b/frontend/src/components/ModuleName.tsx
@@ -0,0 +1,90 @@
+import { useLayoutEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+
+// The floating module-name pill in the top header. The label area is sized to the WIDEST
+// reachable module title (measured live, in the current language only — so dropping a locale or
+// gaining/losing a module just changes the input, nothing is hard-coded), which keeps the label
+// column a constant width: the name never shifts and the arrows stay put when paging between
+// modules. Carries the back/forward arrows that step cyclically through the active modules, plus
+// the accent dot (the former PageTitle "eyebrow" look, folded in here).
+export default function ModuleName({
+ label,
+ labels,
+ onPrev,
+ onNext,
+ canPrev,
+ canNext,
+}: {
+ label: string;
+ // Every reachable module's title in the current language — drives the fixed label width.
+ labels: string[];
+ onPrev: () => void;
+ onNext: () => void;
+ canPrev: boolean;
+ canNext: boolean;
+}) {
+ const { t } = useTranslation();
+ const labelRef = useRef(null);
+ const canvasRef = useRef(null);
+ const [labelW, setLabelW] = useState();
+
+ // Measure the widest label using the label span's own rendered font (respects font-scale,
+ // tracking and the uppercase transform). Re-runs whenever the label set changes — i.e. on a
+ // language switch or an account switch that changes which modules are reachable.
+ useLayoutEffect(() => {
+ const el = labelRef.current;
+ if (!el || labels.length === 0) return;
+ const cs = getComputedStyle(el);
+ const canvas = canvasRef.current ?? (canvasRef.current = document.createElement("canvas"));
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ ctx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
+ const ls = parseFloat(cs.letterSpacing) || 0;
+ const upper = cs.textTransform === "uppercase";
+ let max = 0;
+ for (const s of labels) {
+ const txt = upper ? s.toUpperCase() : s;
+ const w = ctx.measureText(txt).width + ls * txt.length;
+ if (w > max) max = w;
+ }
+ setLabelW(Math.ceil(max) + 2);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [labels.join("")]);
+
+ const arrow =
+ "p-1 rounded-lg text-muted transition hover:text-fg hover:bg-card disabled:opacity-25 disabled:pointer-events-none";
+ return (
+
+
+
+
+
+
+
+ {label}
+
+
+
+ );
+}
diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx
index 6a2267f..2e38008 100644
--- a/frontend/src/components/NavSidebar.tsx
+++ b/frontend/src/components/NavSidebar.tsx
@@ -28,6 +28,7 @@ import * as e2ee from "../lib/e2ee";
import { useLiveQuery } from "../lib/useLiveQuery";
import { getUnreadCount, subscribe } from "../lib/notifications";
import type { Page } from "../lib/urlState";
+import { moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
import { type LangCode } from "../i18n";
import AvatarImg from "./Avatar";
import LanguageSwitcher from "./LanguageSwitcher";
@@ -168,35 +169,41 @@ export default function NavSidebar({
const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length;
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number };
+ // Per-page presentation (icon/label/badge). WHICH pages appear and in what order comes from the
+ // shared moduleOrder(me) — the same source the header's ◀/▶ stepper uses — so the two never drift
+ // and a new/gated module updates both at once. Badges live here (nav-only concern).
+ // Icon + badge per page; the label comes from the shared moduleLabelKey so the rail and the
+ // header's ModuleName pill always read the same name.
+ const ICON: Record = {
+ feed: Home,
+ channels: Tv,
+ playlists: ListVideo,
+ plex: Clapperboard,
+ notifications: Bell,
+ messages: MessageSquare,
+ downloads: Download,
+ stats: BarChart3,
+ scheduler: Activity,
+ config: SlidersHorizontal,
+ users: Users,
+ audit: ScrollText,
+ settings: Settings,
+ };
+ const BADGE: Partial> = {
+ notifications: unread,
+ messages: msgUnread,
+ downloads: dlActive,
+ };
+ const META = (p: Page): Omit => ({
+ icon: ICON[p],
+ label: t(moduleLabelKey[p]),
+ badge: BADGE[p],
+ });
+ const sys = new Set(SYSTEM_PAGES);
+ const order = moduleOrder(me);
// User-facing content modules vs. admin/system modules, separated by a divider in the rail.
- const userItems: NavItem[] = [
- { page: "feed", icon: Home, label: t("header.account.feed") },
- { page: "channels", icon: Tv, label: t("header.account.channels") },
- { page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
- // Optional Plex module — browse/play your Plex library. Shown only when the admin enabled it.
- ...(me.plex_enabled ? [{ page: "plex" as Page, icon: Clapperboard, label: t("plex.navLabel") }] : []),
- { page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread },
- // Direct messaging — its own module; hidden for the shared demo account.
- ...(me.is_demo
- ? []
- : [{ page: "messages" as Page, icon: MessageSquare, label: t("messages.navLabel"), badge: msgUnread }]),
- // Download center — YouTube → server → your device. Hidden for the shared demo account
- // (spends server disk + needs a real identity).
- ...(me.is_demo
- ? []
- : [{ page: "downloads" as Page, icon: Download, label: t("downloads.navLabel"), badge: dlActive }]),
- // Per-user sync status + your own API usage (admins get an extra system-wide tab inside).
- { page: "stats", icon: BarChart3, label: t("header.account.stats") },
- ];
- const systemItems: NavItem[] =
- me.role === "admin"
- ? [
- { page: "scheduler", icon: Activity, label: t("header.account.scheduler") },
- { page: "config", icon: SlidersHorizontal, label: t("header.account.config") },
- { page: "users", icon: Users, label: t("header.account.users") },
- { page: "audit", icon: ScrollText, label: t("header.account.audit") },
- ]
- : [];
+ const userItems: NavItem[] = order.filter((p) => !sys.has(p)).map((p) => ({ page: p, ...META(p) }));
+ const systemItems: NavItem[] = order.filter((p) => sys.has(p)).map((p) => ({ page: p, ...META(p) }));
const rowBase =
"w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
diff --git a/frontend/src/components/PageTitle.tsx b/frontend/src/components/PageTitle.tsx
deleted file mode 100644
index 1ea61ae..0000000
--- a/frontend/src/components/PageTitle.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-// The centered module title in the top bar. Every non-feed module renders through this one
-// component, so its styling lives in a single place. Design element (user-picked): a tracked
-// small-caps "eyebrow" with a leading accent dot.
-export default function PageTitle({ label }: { label: string }) {
- return (
-
-
- {label}
-
- );
-}
diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx
index 1e9e466..ee7f9b3 100644
--- a/frontend/src/components/Playlists.tsx
+++ b/frontend/src/components/Playlists.tsx
@@ -183,7 +183,14 @@ function Row({
);
}
-export default function Playlists({ canWrite }: { canWrite: boolean }) {
+export default function Playlists({
+ canWrite,
+ search,
+}: {
+ canWrite: boolean;
+ // The playlist-name filter, from the shared top SearchBar (App-owned state).
+ search: string;
+}) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
@@ -321,6 +328,14 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playlists, plSort]);
+ // Client-side name filter driven by the shared top SearchBar.
+ const q = search.trim().toLowerCase();
+ const visiblePlaylists = useMemo(
+ () => (q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [sortedPlaylists, q]
+ );
+
// Scroll the restored selection into view once after the rail first renders.
useEffect(() => {
if (!scrolledRef.current && selectedRef.current) {
@@ -599,8 +614,11 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
{playlists.length === 0 && !listQuery.isLoading && (