2026-06-16 01:46:32 +02:00
|
|
|
import { useRef, useState } from "react";
|
|
|
|
|
import { createPortal } from "react-dom";
|
2026-06-16 00:42:23 +02:00
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
|
import {
|
|
|
|
|
BarChart3,
|
|
|
|
|
ChevronLeft,
|
|
|
|
|
ChevronRight,
|
|
|
|
|
Home,
|
|
|
|
|
Info,
|
|
|
|
|
ListVideo,
|
|
|
|
|
LogOut,
|
|
|
|
|
Settings,
|
|
|
|
|
Shield,
|
|
|
|
|
Tv,
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
import type { Me } from "../lib/api";
|
|
|
|
|
import type { Page } from "../lib/urlState";
|
|
|
|
|
import AvatarImg from "./Avatar";
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
// a thin icon-only rail. Account actions sit at the bottom in a popover.
|
|
|
|
|
export default function NavSidebar({
|
|
|
|
|
me,
|
|
|
|
|
page,
|
|
|
|
|
setPage,
|
|
|
|
|
onOpenAbout,
|
|
|
|
|
}: {
|
|
|
|
|
me: Me;
|
|
|
|
|
page: Page;
|
|
|
|
|
setPage: (p: Page) => void;
|
|
|
|
|
onOpenAbout: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const [collapsed, setCollapsed] = useState<boolean>(
|
|
|
|
|
() => localStorage.getItem("siftlode.navCollapsed") === "1"
|
|
|
|
|
);
|
|
|
|
|
const [acctOpen, setAcctOpen] = useState(false);
|
2026-06-16 01:46:32 +02:00
|
|
|
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
|
|
|
|
|
// Popover position (fixed, viewport coords). It's portaled to <body> so it escapes the
|
|
|
|
|
// nav's backdrop-filter, which would otherwise trap fixed positioning + stacking and let
|
|
|
|
|
// clicks fall through to the controls behind it.
|
|
|
|
|
const [acctPos, setAcctPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
|
|
|
|
|
|
|
|
|
|
function toggleAccount() {
|
|
|
|
|
if (!acctOpen) {
|
|
|
|
|
const r = acctBtnRef.current?.getBoundingClientRect();
|
|
|
|
|
if (r) setAcctPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom });
|
|
|
|
|
}
|
|
|
|
|
setAcctOpen((o) => !o);
|
|
|
|
|
}
|
2026-06-16 00:42:23 +02:00
|
|
|
|
|
|
|
|
function toggleCollapsed() {
|
|
|
|
|
setCollapsed((c) => {
|
|
|
|
|
const next = !c;
|
|
|
|
|
localStorage.setItem("siftlode.navCollapsed", next ? "1" : "0");
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function logout() {
|
|
|
|
|
await fetch("/auth/logout", { method: "POST", credentials: "include" });
|
|
|
|
|
location.reload();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const items: { page: Page; icon: typeof Home; label: string }[] = [
|
|
|
|
|
{ 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") });
|
|
|
|
|
|
|
|
|
|
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];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<nav
|
2026-06-16 01:11:50 +02:00
|
|
|
className={`glass relative shrink-0 border-r border-border flex flex-col py-3 transition-[width] ${
|
2026-06-16 00:42:23 +02:00
|
|
|
collapsed ? "w-[56px] px-2" : "w-52 px-3"
|
|
|
|
|
}`}
|
|
|
|
|
aria-label={t("nav.primary")}
|
|
|
|
|
>
|
|
|
|
|
<div className={`flex items-center mb-3 ${collapsed ? "justify-center" : "justify-between"}`}>
|
|
|
|
|
{!collapsed && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setPage("feed")}
|
|
|
|
|
className="text-lg font-bold tracking-tight select-none"
|
|
|
|
|
title={t("header.feed")}
|
|
|
|
|
>
|
|
|
|
|
Sift<span className="text-accent">lode</span>
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
<button
|
|
|
|
|
onClick={toggleCollapsed}
|
|
|
|
|
title={collapsed ? t("nav.expand") : t("nav.collapse")}
|
|
|
|
|
aria-label={collapsed ? t("nav.expand") : t("nav.collapse")}
|
|
|
|
|
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
|
|
|
|
>
|
|
|
|
|
{collapsed ? (
|
|
|
|
|
<ChevronRight className="w-4 h-4" />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronLeft className="w-4 h-4" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</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>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
|
|
|
|
|
<button
|
2026-06-16 01:05:05 +02:00
|
|
|
onClick={() => setPage("settings")}
|
2026-06-16 00:42:23 +02:00
|
|
|
title={collapsed ? t("header.account.settings") : undefined}
|
2026-06-16 01:05:05 +02:00
|
|
|
aria-current={page === "settings" ? "page" : undefined}
|
|
|
|
|
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} ${
|
|
|
|
|
page === "settings"
|
|
|
|
|
? "bg-accent text-accent-fg"
|
|
|
|
|
: "text-muted hover:text-fg hover:bg-card"
|
|
|
|
|
}`}
|
2026-06-16 00:42:23 +02:00
|
|
|
>
|
|
|
|
|
<Settings className="w-[18px] h-[18px] shrink-0" />
|
|
|
|
|
{!collapsed && <span className="truncate">{t("header.account.settings")}</span>}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<button
|
2026-06-16 01:46:32 +02:00
|
|
|
ref={acctBtnRef}
|
|
|
|
|
onClick={toggleAccount}
|
2026-06-16 00:42:23 +02:00
|
|
|
title={collapsed ? name : undefined}
|
|
|
|
|
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
|
|
|
|
|
>
|
|
|
|
|
<AvatarImg
|
|
|
|
|
src={me.avatar_url}
|
|
|
|
|
fallback={name}
|
|
|
|
|
className="w-[22px] h-[22px] rounded-full text-[10px] shrink-0"
|
|
|
|
|
/>
|
|
|
|
|
{!collapsed && <span className="truncate flex-1 text-left">{name}</span>}
|
|
|
|
|
</button>
|
|
|
|
|
|
2026-06-16 01:46:32 +02:00
|
|
|
{acctOpen &&
|
|
|
|
|
createPortal(
|
|
|
|
|
<>
|
|
|
|
|
<div className="fixed inset-0 z-40" onClick={() => setAcctOpen(false)} />
|
|
|
|
|
<div
|
|
|
|
|
style={{ position: "fixed", left: acctPos.left, bottom: acctPos.bottom }}
|
|
|
|
|
className="glass-menu w-60 rounded-xl p-3 z-50 animate-[popIn_0.16s_ease]"
|
|
|
|
|
>
|
2026-06-16 00:42:23 +02:00
|
|
|
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
|
|
|
|
<AvatarImg
|
|
|
|
|
src={me.avatar_url}
|
|
|
|
|
fallback={name}
|
|
|
|
|
className="w-10 h-10 rounded-full text-sm shrink-0"
|
|
|
|
|
/>
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<div className="text-sm font-semibold truncate">{name}</div>
|
|
|
|
|
<div className="text-xs text-muted truncate">{me.email}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{me.role === "admin" && (
|
|
|
|
|
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
|
|
|
|
|
<Shield className="w-3.5 h-3.5" />
|
|
|
|
|
{t("header.account.admin")}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div className="mt-2 flex flex-col">
|
|
|
|
|
<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"
|
|
|
|
|
>
|
|
|
|
|
<LogOut className="w-4 h-4" />
|
|
|
|
|
{t("header.account.signOut")}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-06-16 01:46:32 +02:00
|
|
|
</div>
|
|
|
|
|
</>,
|
|
|
|
|
document.body
|
|
|
|
|
)}
|
2026-06-16 00:42:23 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</nav>
|
|
|
|
|
);
|
|
|
|
|
}
|