Files
romm-web-ui/src/components/Sidebar.tsx
2026-03-25 16:08:46 -04:00

90 lines
4.3 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { rommApiClient } from '../api/client';
import { FocusContext } from '@noriginmedia/norigin-spatial-navigation';
import { useFocusableAutoScroll } from '../hooks/useFocusableAutoScroll';
export const Sidebar = () => {
const { logout } = useAuth();
const { data: user } = useQuery({ queryKey: ['currentUser'], queryFn: () => rommApiClient.fetchCurrentUser() });
const [imgError, setImgError] = useState(false);
const location = useLocation();
const navigate = useNavigate();
// Create physical bounding box for Sidebar natively mapping navigation paths
const { ref: containerRef, focusKey: sidebarFocusKey } = useFocusableAutoScroll();
const fallbackName = user?.username || "Admin";
const avatarFallback = `https://ui-avatars.com/api/?name=${encodeURIComponent(fallbackName)}&background=2563eb&color=fff&bold=true`;
const avatarUrl = !imgError && user?.avatarUrl ? user.avatarUrl : avatarFallback;
const NavItem = ({ path, icon, label, autoFocus }: { path: string, icon: string, label: string, autoFocus?: boolean }) => {
const isActive = location.pathname === path || (path !== '/' && location.pathname.startsWith(path));
const { ref, focused, focusSelf } = useFocusableAutoScroll({
onEnterPress: () => navigate(path),
});
useEffect(() => {
if (autoFocus) {
focusSelf();
}
}, [autoFocus, focusSelf]);
return (
<Link
to={path}
ref={ref}
className={`mx-2 py-3 px-4 flex items-center gap-3 transition-all active:scale-95 rounded-[12px]
${isActive
? "bg-[#2563eb]/20 text-[#2563eb]"
: "text-[#c3c6d7] hover:text-white hover:bg-white/5"
} ${focused ? "scale-105 ring-2 ring-[#2563eb] bg-white/10 z-10 shadow-lg" : ""}`}
>
<span className="material-symbols-outlined" style={isActive ? { fontVariationSettings: "'FILL' 1" } : {}}>{icon}</span>
<span className="geist-mono tracking-tight text-sm uppercase">{label}</span>
</Link>
);
};
return (
<FocusContext.Provider value={sidebarFocusKey}>
<aside ref={containerRef} className="w-64 h-screen fixed left-0 top-0 bg-[#181b28] shadow-[20px_0_50px_rgba(0,0,0,0.3)] flex flex-col py-8 z-[60]">
<div className="px-6 mb-10">
<h1 className="text-xl font-black text-white tracking-tighter uppercase geist-mono">{import.meta.env.VITE_LIBRARY_TITLE || "Romm"}</h1>
<p className="text-[10px] geist-mono uppercase tracking-[0.05em] text-[#c3c6d7] opacity-60">{import.meta.env.VITE_LIBRARY_SUBTITLE || "Game Manager"}</p>
</div>
<nav className="flex-1 flex flex-col gap-1">
<NavItem path="/" icon="home" label="Home" autoFocus />
<NavItem path="/games" icon="sports_esports" label="Games" />
<NavItem path="/collections" icon="library_books" label="Collections" />
</nav>
<div className="mt-auto flex flex-col gap-1 pt-6">
<NavItem path="/settings" icon="settings" label="Settings" />
<div className="px-6 mt-4 flex items-center gap-3 overflow-hidden">
<div className="w-8 h-8 rounded-full bg-white/10 overflow-hidden shrink-0 ring-1 ring-white/20">
<img
alt="User profile avatar"
className="w-full h-full object-cover"
src={avatarUrl}
onError={() => setImgError(true)}
/>
</div>
<div className="flex flex-col leading-none flex-1 min-w-0">
<span className="text-xs font-bold text-white geist-mono truncate">{user?.username || "Loading"}</span>
<span className="text-[10px] text-[#2563eb] font-bold uppercase tracking-widest geist-mono truncate">{user?.roleName || "..."}</span>
</div>
<button onClick={logout} className="text-[#c3c6d7] hover:text-white hover:bg-red-500/20 p-2 shrink-0 rounded-full transition-all active:scale-95 flex items-center justify-center" title="Log Out">
<span className="material-symbols-outlined text-sm">logout</span>
</button>
</div>
</div>
</aside>
</FocusContext.Provider>
);
};