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:
2026-07-13 02:28:06 +02:00
parent a7f9032e0e
commit d159f35f31
17 changed files with 420 additions and 141 deletions
+35 -28
View File
@@ -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<Page, typeof Home> = {
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<Record<Page, number>> = {
notifications: unread,
messages: msgUnread,
downloads: dlActive,
};
const META = (p: Page): Omit<NavItem, "page"> => ({
icon: ICON[p],
label: t(moduleLabelKey[p]),
badge: BADGE[p],
});
const sys = new Set<Page>(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";