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

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