feat(nav): slim hover-expand sidebar, role tooltip, logout button

The rail now sits slim by default and expands to a labelled overlay on hover
or keyboard focus without pushing content; the manual toggle became a pin
(pinned = the old in-flow expanded rail). The header no longer reserves a fixed
504px left offset — it's absolutely positioned inside the content column, so it
tracks the actual left zone and reclaims the dead gap. The role badge is gone;
role now shows via a title tooltip on the username, and a logout quick-button
joins the language/about cluster.
This commit is contained in:
2026-07-14 21:39:30 +02:00
parent f96c6a74dd
commit ad1cc4586a
5 changed files with 70 additions and 60 deletions
+2 -1
View File
@@ -369,8 +369,9 @@ export default function App() {
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the // Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
// user's preferences so it follows the account across devices; a localStorage cache seeds the // user's preferences so it follows the account across devices; a localStorage cache seeds the
// initial render synchronously so nothing flashes open before the server prefs arrive. // initial render synchronously so nothing flashes open before the server prefs arrive.
// Default unpinned (slim + hover-expand overlay); accounts that explicitly pin keep their choice.
const [navCollapsed, setNavCollapsedState] = useState<boolean>(() => const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.navCollapsed, false)) Boolean(readAccount(LS.navCollapsed, true))
); );
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() => const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.filterCollapsed, false)) Boolean(readAccount(LS.filterCollapsed, false))
+4 -3
View File
@@ -103,9 +103,10 @@ export default function Header({
} }
return ( return (
// Fixed left = the left zone at its widest: nav card (12 margin + 208 + 12 margin = 232) + // Absolutely positioned within the (relative) content column, so its left edge tracks the
// filter sidebar (256) + 16 gap = 504. Constant, so it never shifts on collapse/module change. // actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
<div className="pointer-events-none fixed top-3 left-4 right-[10%] z-30 flex items-center gap-3 md:left-[504px]"> // slim and/or no filter sidebar is present — no hard-coded offset.
<div className="pointer-events-none absolute top-3 left-3 right-[10%] z-30 flex items-center gap-3">
<ModuleName <ModuleName
label={t(moduleLabelKey[page])} label={t(moduleLabelKey[page])}
labels={moduleLabels} labels={moduleLabels}
+60 -52
View File
@@ -6,8 +6,6 @@ import {
Activity, Activity,
BarChart3, BarChart3,
Bell, Bell,
ChevronLeft,
ChevronRight,
Clapperboard, Clapperboard,
Download, Download,
Home, Home,
@@ -15,6 +13,8 @@ import {
ListVideo, ListVideo,
LogOut, LogOut,
MessageSquare, MessageSquare,
Pin,
PinOff,
ScrollText, ScrollText,
Settings, Settings,
Shield, Shield,
@@ -62,6 +62,14 @@ export default function NavSidebar({
onGoToFullHistory: () => void; onGoToFullHistory: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Hover/focus expand: when unpinned, the rail sits slim (icons only) and expands to a labelled
// overlay on pointer hover or keyboard focus WITHOUT pushing content. Pinned keeps it expanded
// in-flow like before. `collapsed` (persisted per-account) now means "unpinned".
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const pinned = !collapsed;
const expanded = pinned || hovered || focused;
const slim = !expanded; // visual: icon-only rail
const [acctOpen, setAcctOpen] = useState(false); const [acctOpen, setAcctOpen] = useState(false);
const acctBtnRef = useRef<HTMLButtonElement | null>(null); const acctBtnRef = useRef<HTMLButtonElement | null>(null);
const acctPanelRef = useRef<HTMLDivElement | null>(null); const acctPanelRef = useRef<HTMLDivElement | null>(null);
@@ -213,21 +221,14 @@ export default function NavSidebar({
const rowBase = const rowBase =
"w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition"; "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 name = me.display_name ?? me.email.split("@")[0];
// Role shown next to the name (expanded) / as an avatar dot (collapsed). Demo isn't a real // Role is surfaced as a plain title tooltip on the account row (no visual badge). Demo isn't a
// role (it's a flag on a `user` row), so surface it first. // real role (it's a flag on a `user` row), so surface it first.
const roleKey: "admin" | "user" | "demo" = me.is_demo const roleKey: "admin" | "user" | "demo" = me.is_demo
? "demo" ? "demo"
: me.role === "admin" : me.role === "admin"
? "admin" ? "admin"
: "user"; : "user";
const roleChipCls = const roleLabel = t(`nav.role.${roleKey}`);
roleKey === "admin"
? "bg-accent/15 text-accent"
: roleKey === "demo"
? "bg-amber-500/20 text-amber-500"
: "bg-muted/15 text-muted";
const roleDotCls =
roleKey === "admin" ? "bg-accent" : roleKey === "demo" ? "bg-amber-500" : "bg-muted";
const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => { const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => {
const active = page === p; const active = page === p;
@@ -239,9 +240,9 @@ export default function NavSidebar({
<button <button
key={p} key={p}
onClick={() => setPage(p)} onClick={() => setPage(p)}
title={collapsed ? label : undefined} title={slim ? label : undefined}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} relative ${ className={`${rowBase} ${slim ? "justify-center px-0" : ""} relative ${
active ? "bg-accent text-accent-fg" : "text-muted hover:text-fg hover:bg-card" active ? "bg-accent text-accent-fg" : "text-muted hover:text-fg hover:bg-card"
}`} }`}
> >
@@ -249,7 +250,7 @@ export default function NavSidebar({
<Icon className="w-[18px] h-[18px]" /> <Icon className="w-[18px] h-[18px]" />
{/* Collapsed rail: a numeric badge centred on a circle at the icon corner; the ring {/* Collapsed rail: a numeric badge centred on a circle at the icon corner; the ring
matches the row background so it reads cleanly whether the row is active or not. */} matches the row background so it reads cleanly whether the row is active or not. */}
{show && collapsed && ( {show && slim && (
<span <span
className={`absolute -top-2 -right-2 min-w-[16px] h-4 px-1 rounded-full text-[10px] font-bold leading-none grid place-items-center ring-2 ${badgeColor} ${ className={`absolute -top-2 -right-2 min-w-[16px] h-4 px-1 rounded-full text-[10px] font-bold leading-none grid place-items-center ring-2 ${badgeColor} ${
active ? "ring-accent" : "ring-bg" active ? "ring-accent" : "ring-bg"
@@ -259,8 +260,8 @@ export default function NavSidebar({
</span> </span>
)} )}
</span> </span>
{!collapsed && <span className="truncate">{label}</span>} {!slim && <span className="truncate">{label}</span>}
{show && !collapsed && ( {show && !slim && (
<span <span
className={`ml-auto min-w-[18px] h-[18px] px-1.5 rounded-full text-[11px] font-semibold leading-none inline-flex items-center justify-center tabular-nums ${badgeColor}`} className={`ml-auto min-w-[18px] h-[18px] px-1.5 rounded-full text-[11px] font-semibold leading-none inline-flex items-center justify-center tabular-nums ${badgeColor}`}
> >
@@ -272,14 +273,25 @@ export default function NavSidebar({
}; };
return ( return (
<nav // Layout slot: reserves only the slim footprint when unpinned, so the expanded rail (below)
className={`glass relative shrink-0 rounded-2xl flex flex-col py-3 m-3 transition-[width] ${ // overlays content instead of pushing it. Pinned reserves the full width (in-flow).
collapsed ? "w-[56px] px-2" : "w-52 px-3" <div
}`} className={`relative shrink-0 transition-[width] ${pinned ? "w-[232px]" : "w-20"}`}
aria-label={t("nav.primary")} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);
}}
> >
<div className={`flex items-center mb-3 ${collapsed ? "justify-center" : "justify-between"}`}> <nav
{!collapsed && ( className={`glass absolute top-0 bottom-0 left-0 z-40 rounded-2xl flex flex-col py-3 m-3 transition-[width] ${
slim ? "w-[56px] px-2" : "w-52 px-3"
}`}
aria-label={t("nav.primary")}
>
<div className={`flex items-center mb-3 ${slim ? "justify-center" : "justify-between"}`}>
{!slim && (
<button <button
onClick={() => setPage("feed")} onClick={() => setPage("feed")}
className="text-lg font-bold tracking-tight select-none cursor-pointer rounded-md -mx-1 px-1 hover:bg-card hover:opacity-90 transition" className="text-lg font-bold tracking-tight select-none cursor-pointer rounded-md -mx-1 px-1 hover:bg-card hover:opacity-90 transition"
@@ -290,15 +302,11 @@ export default function NavSidebar({
)} )}
<button <button
onClick={onToggleCollapse} onClick={onToggleCollapse}
title={collapsed ? t("nav.expand") : t("nav.collapse")} title={pinned ? t("nav.unpin") : t("nav.pin")}
aria-label={collapsed ? t("nav.expand") : t("nav.collapse")} aria-label={pinned ? t("nav.unpin") : t("nav.pin")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition" className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
> >
{collapsed ? ( {pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
</button> </button>
</div> </div>
@@ -308,7 +316,7 @@ export default function NavSidebar({
isAdmin={me.role === "admin"} isAdmin={me.role === "admin"}
onGoToFullHistory={onGoToFullHistory} onGoToFullHistory={onGoToFullHistory}
variant="rail" variant="rail"
collapsed={collapsed} collapsed={slim}
/> />
</div> </div>
@@ -319,7 +327,7 @@ export default function NavSidebar({
> >
{userItems.map(renderItem)} {userItems.map(renderItem)}
{systemItems.length > 0 && {systemItems.length > 0 &&
(collapsed ? ( (slim ? (
// Collapsed rail: a short, thicker centred rule is the clearest "new section" cue // Collapsed rail: a short, thicker centred rule is the clearest "new section" cue
// when there's no room for a label. // when there's no room for a label.
<div className="my-2 mx-auto w-7 border-t-2 border-border" /> <div className="my-2 mx-auto w-7 border-t-2 border-border" />
@@ -337,7 +345,7 @@ export default function NavSidebar({
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1"> <div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
<div <div
className={`flex items-center justify-center gap-2 pb-2 mb-1 border-b border-border ${ className={`flex items-center justify-center gap-2 pb-2 mb-1 border-b border-border ${
collapsed ? "flex-col" : "flex-row" slim ? "flex-col" : "flex-row"
}`} }`}
> >
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} /> <LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
@@ -349,27 +357,35 @@ export default function NavSidebar({
> >
<Info className="w-5 h-5" /> <Info className="w-5 h-5" />
</button> </button>
<button
onClick={logout}
title={t("header.account.signOut")}
aria-label={t("header.account.signOut")}
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<LogOut className="w-5 h-5" />
</button>
</div> </div>
<button <button
onClick={() => setPage("settings")} onClick={() => setPage("settings")}
title={collapsed ? t("header.account.settings") : undefined} title={slim ? t("header.account.settings") : undefined}
aria-current={page === "settings" ? "page" : undefined} aria-current={page === "settings" ? "page" : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} ${ className={`${rowBase} ${slim ? "justify-center px-0" : ""} ${
page === "settings" page === "settings"
? "bg-accent text-accent-fg" ? "bg-accent text-accent-fg"
: "text-muted hover:text-fg hover:bg-card" : "text-muted hover:text-fg hover:bg-card"
}`} }`}
> >
<Settings className="w-[18px] h-[18px] shrink-0" /> <Settings className="w-[18px] h-[18px] shrink-0" />
{!collapsed && <span className="truncate">{t("header.account.settings")}</span>} {!slim && <span className="truncate">{t("header.account.settings")}</span>}
</button> </button>
<div className="relative"> <div className="relative">
<button <button
ref={acctBtnRef} ref={acctBtnRef}
onClick={toggleAccount} onClick={toggleAccount}
title={collapsed ? name : undefined} title={slim ? `${name} · ${roleLabel}` : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`} className={`${rowBase} ${slim ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
> >
<span className="relative shrink-0"> <span className="relative shrink-0">
<AvatarImg <AvatarImg
@@ -377,19 +393,10 @@ export default function NavSidebar({
fallback={name} fallback={name}
className="w-[22px] h-[22px] rounded-full text-[10px]" className="w-[22px] h-[22px] rounded-full text-[10px]"
/> />
{collapsed && (
<span
className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full ring-2 ring-bg ${roleDotCls}`}
title={t(`nav.role.${roleKey}`)}
/>
)}
</span> </span>
{!collapsed && <span className="truncate flex-1 text-left">{name}</span>} {!slim && (
{!collapsed && ( <span title={roleLabel} className="truncate flex-1 text-left">
<span {name}
className={`shrink-0 text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${roleChipCls}`}
>
{t(`nav.role.${roleKey}`)}
</span> </span>
)} )}
</button> </button>
@@ -469,6 +476,7 @@ export default function NavSidebar({
)} )}
</div> </div>
</div> </div>
</nav> </nav>
</div>
); );
} }
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"primary": "Primary navigation", "primary": "Primary navigation",
"collapse": "Collapse sidebar", "pin": "Pin sidebar open",
"expand": "Expand sidebar", "unpin": "Unpin sidebar",
"adminSection": "Admin", "adminSection": "Admin",
"role": { "role": {
"admin": "Admin", "admin": "Admin",
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"primary": "Fő navigáció", "primary": "Fő navigáció",
"collapse": "Oldalsáv összecsukása", "pin": "Oldalsáv rögzítése nyitva",
"expand": "Oldalsáv kinyitása", "unpin": "Oldalsáv rögzítésének feloldása",
"adminSection": "Admin", "adminSection": "Admin",
"role": { "role": {
"admin": "Admin", "admin": "Admin",