feat(nav): group rail modules + move chrome controls into the sidebar

- Split the rail into a content group (Feed/Channels/Playlists) and an admin
  group (Stats/Scheduler) separated by a divider (system group hidden for non-admins).
- Move the language switcher, About and notification bell out of the top header
  into an icon cluster above Settings (horizontal expanded, vertical collapsed).
  Their popovers portal to <body> and anchor right + above the button, escaping the
  nav's backdrop-filter. About is removed from the account popover (now in the cluster).
- LanguageSwitcher/NotificationCenter gain a 'rail' variant for the above.
- Also relocate the Toaster mount into the (now relative) content column.
This commit is contained in:
npeter83 2026-06-17 14:28:29 +02:00
parent dfaa1551dc
commit df0ca24a23
5 changed files with 194 additions and 54 deletions

View file

@ -16,9 +16,12 @@ import {
Tv,
UserPlus,
} from "lucide-react";
import { api, type Me } from "../lib/api";
import { api, type FeedFilters, type Me } from "../lib/api";
import type { Page } from "../lib/urlState";
import { type LangCode } from "../i18n";
import AvatarImg from "./Avatar";
import LanguageSwitcher from "./LanguageSwitcher";
import NotificationCenter from "./NotificationCenter";
// Primary app navigation: a collapsible left rail with icon+label entries (Design C). The
// modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes
@ -28,11 +31,19 @@ export default function NavSidebar({
page,
setPage,
onOpenAbout,
onChangeLanguage,
language,
filters,
setFilters,
}: {
me: Me;
page: Page;
setPage: (p: Page) => void;
onOpenAbout: () => void;
onChangeLanguage: (code: LangCode) => void;
language: LangCode;
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
}) {
const { t } = useTranslation();
const [collapsed, setCollapsed] = useState<boolean>(
@ -105,20 +116,40 @@ export default function NavSidebar({
}
}
const items: { page: Page; icon: typeof Home; label: string }[] = [
type NavItem = { page: Page; icon: typeof Home; label: string };
// 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") },
];
if (me.role === "admin") {
items.push({ page: "stats", icon: BarChart3, label: t("header.account.stats") });
items.push({ page: "scheduler", icon: Activity, label: t("header.account.scheduler") });
}
const systemItems: NavItem[] =
me.role === "admin"
? [
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") },
]
: [];
const rowBase =
"w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
const name = me.display_name ?? me.email.split("@")[0];
const renderItem = ({ page: p, icon: Icon, label }: NavItem) => (
<button
key={p}
onClick={() => setPage(p)}
title={collapsed ? label : undefined}
aria-current={page === p ? "page" : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} ${
page === p ? "bg-accent text-accent-fg" : "text-muted hover:text-fg hover:bg-card"
}`}
>
<Icon className="w-[18px] h-[18px] shrink-0" />
{!collapsed && <span className="truncate">{label}</span>}
</button>
);
return (
<nav
className={`glass relative shrink-0 border-r border-border flex flex-col py-3 transition-[width] ${
@ -151,25 +182,30 @@ export default function NavSidebar({
</div>
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1">
{items.map(({ page: p, icon: Icon, label }) => (
<button
key={p}
onClick={() => setPage(p)}
title={collapsed ? label : undefined}
aria-current={page === p ? "page" : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} ${
page === p
? "bg-accent text-accent-fg"
: "text-muted hover:text-fg hover:bg-card"
}`}
>
<Icon className="w-[18px] h-[18px] shrink-0" />
{!collapsed && <span className="truncate">{label}</span>}
</button>
))}
{userItems.map(renderItem)}
{systemItems.length > 0 && (
<div className="my-1.5 border-t border-border/70" />
)}
{systemItems.map(renderItem)}
</div>
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
<div
className={`flex items-center justify-center gap-2 pb-2 mb-1 border-b border-border ${
collapsed ? "flex-col" : "flex-row"
}`}
>
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
<button
onClick={onOpenAbout}
title={t("header.account.about")}
aria-label={t("header.account.about")}
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Info className="w-5 h-5" />
</button>
<NotificationCenter variant="rail" filters={filters} setFilters={setFilters} />
</div>
<button
onClick={() => setPage("settings")}
title={collapsed ? t("header.account.settings") : undefined}
@ -257,16 +293,6 @@ export default function NavSidebar({
<UserPlus className="w-4 h-4" />
{t("header.account.addAccount")}
</button>
<button
onClick={() => {
onOpenAbout();
setAcctOpen(false);
}}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Info className="w-4 h-4" />
{t("header.account.about")}
</button>
<button
onClick={logout}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"