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:
npeter83 2026-07-13 02:28:06 +02:00
parent 36c27250c3
commit 2739f25e6c
17 changed files with 420 additions and 141 deletions

View file

@ -17,10 +17,13 @@ import { useConfirm } from "./ConfirmProvider";
// the channel's metadata — the scheduler pulls its videos on its next run (see backend).
export default function ChannelDiscovery({
canWrite,
search,
onOpenWizard,
onViewChannel,
}: {
canWrite: boolean;
// Client-side name filter from the shared top SearchBar (matches title + handle).
search: string;
onOpenWizard: () => void;
onViewChannel: (id: string, name: string) => void;
}) {
@ -67,7 +70,10 @@ export default function ChannelDiscovery({
if (ok) subscribe.mutate(c);
};
const rows = query.data ?? [];
const q = search.trim().toLowerCase();
const rows = (query.data ?? []).filter(
(c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)
);
const columns: Column<DiscoveredChannel>[] = [
{

View file

@ -12,7 +12,6 @@ import {
Pencil,
Plus,
RefreshCw,
Search,
UserMinus,
X,
} from "lucide-react";
@ -44,6 +43,8 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
export default function Channels({
canWrite,
isAdmin,
search,
setSearch,
onViewChannel,
onFilterByTag,
onFocusChannel,
@ -58,6 +59,9 @@ export default function Channels({
}: {
canWrite: boolean;
isAdmin: boolean;
// The channel-name filter, lifted to App so the shared top SearchBar drives it.
search: string;
setSearch: (q: string) => void;
onViewChannel: (id: string, name: string) => void;
onFilterByTag: (tagId: number, name: string) => void;
onFocusChannel: (name: string) => void;
@ -190,9 +194,8 @@ export default function Channels({
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
});
// Channel-name search + tag-chip filtering, applied client-side over the status-filtered list
// (prominent controls instead of the DataTable's hidden per-column popovers).
const [search, setSearch] = useState("");
// Channel-name search (from the shared top SearchBar, via props) + tag-chip filtering, applied
// client-side over the status-filtered list.
const [tagFilter, setTagFilter] = useState<number[]>([]);
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
useEffect(() => {
@ -399,7 +402,12 @@ export default function Channels({
return (
<>
{tabs}
<ChannelDiscovery canWrite={canWrite} onOpenWizard={onOpenWizard} onViewChannel={onViewChannel} />
<ChannelDiscovery
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
</>
);
}
@ -482,25 +490,7 @@ export default function Channels({
/>
</p>
{/* Channel-name search */}
<div className="relative mb-3 max-w-sm">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted pointer-events-none" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("channels.searchPlaceholder")}
className="w-full bg-card border border-border rounded-lg pl-8 pr-8 py-2 text-sm outline-none focus:border-accent"
/>
{search && (
<button
onClick={() => setSearch("")}
aria-label={t("common.cancel")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted hover:text-fg"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* 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). */}
<div className="flex flex-wrap items-center gap-1.5 mb-4">

View file

@ -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>
);
}

View file

@ -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<HTMLSpanElement>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [labelW, setLabelW] = useState<number>();
// 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 (
<div className="pointer-events-auto shrink-0 max-w-[55vw] h-11 px-2.5 rounded-2xl glass-menu flex items-center gap-1.5">
<button
onClick={onPrev}
disabled={!canPrev}
title={t("header.prevModule")}
aria-label={t("header.prevModule")}
className={arrow}
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={onNext}
disabled={!canNext}
title={t("header.nextModule")}
aria-label={t("header.nextModule")}
className={arrow}
>
<ChevronRight className="w-4 h-4" />
</button>
<span className="w-px h-5 bg-border/70 mx-1 shrink-0" />
<span className="min-w-0 inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-fg">
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" />
<span
ref={labelRef}
className="min-w-0 truncate"
style={labelW ? { width: labelW } : undefined}
>
{label}
</span>
</span>
</div>
);
}

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";

View file

@ -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 (
<span className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-fg">
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" />
{label}
</span>
);
}

View file

@ -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 && (
<div className="text-xs text-muted px-1 py-2">{t("playlists.noneYet")}</div>
)}
{playlists.length > 0 && visiblePlaylists.length === 0 && (
<div className="text-xs text-muted px-1 py-2">{t("playlists.noMatches")}</div>
)}
<div className="space-y-1">
{sortedPlaylists.map((pl) => (
{visiblePlaylists.map((pl) => (
<button
key={pl.id}
ref={pl.id === selectedId ? selectedRef : null}
@ -636,7 +654,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
</div>
</aside>
<main className="flex-1 min-w-0 overflow-y-auto p-4">
<main className="flex-1 min-w-0 overflow-y-auto px-4 pb-4 pt-[var(--hdr-h)]">
{selectedId == null || !detail ? (
<div className="text-muted text-sm p-4">
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}

View file

@ -0,0 +1,70 @@
import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Search, X } from "lucide-react";
// The floating search pill in the top header. One component for every module that searches
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
// clear button, and a "Go" trigger (Enter also fires it). The feed passes its live-YouTube
// escalation as the Go action; the other modules filter as you type and Go just commits/blurs.
export default function SearchBar({
value,
onChange,
placeholder,
onGo,
goLabel,
goIcon,
goDisabled,
goTitle,
}: {
value: string;
onChange: (q: string) => void;
placeholder: string;
// Enter / the Go button call this. Omit to hide the Go button entirely.
onGo?: () => void;
goLabel?: string;
goIcon?: ReactNode;
goDisabled?: boolean;
goTitle?: string;
}) {
const { t } = useTranslation();
const fire = () => {
if (onGo && !goDisabled) onGo();
};
return (
<div className="pointer-events-auto flex-1 min-w-0 max-w-xl h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2">
<Search className="w-4 h-4 shrink-0 text-muted" />
<input
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") fire();
}}
placeholder={placeholder}
className="flex-1 min-w-0 bg-transparent outline-none text-sm placeholder:text-muted"
/>
{value && (
<button
onClick={() => onChange("")}
title={t("header.clearSearch")}
aria-label={t("header.clearSearch")}
className="shrink-0 p-0.5 rounded-full text-muted transition hover:text-fg hover:bg-border/60"
>
<X className="w-4 h-4" />
</button>
)}
{onGo && (
<button
onClick={fire}
disabled={goDisabled}
title={goTitle ?? goLabel ?? t("header.goTip")}
className="shrink-0 inline-flex items-center gap-1.5 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:opacity-40 disabled:pointer-events-none"
>
{goIcon}
<span className={goIcon ? "hidden md:inline" : undefined}>
{goLabel ?? t("header.go")}
</span>
</button>
)}
</div>
);
}