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.
91 lines
3.4 KiB
TypeScript
91 lines
3.4 KiB
TypeScript
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>
|
|
);
|
|
}
|