Public Access
174 lines
8.7 KiB
React
174 lines
8.7 KiB
React
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
|
import {useTranslation} from 'react-i18next';
|
|
import {ChartColumn, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Megaphone, Moon, Settings, Shield, Sun} from 'lucide-react';
|
|
import {toast} from 'sonner';
|
|
|
|
import {Badge} from '@/components/ui/badge';
|
|
import {Button} from '@/components/ui/button';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubContent,
|
|
DropdownMenuSubTrigger,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import {usePreferences} from '@/context/PreferencesContext.jsx';
|
|
import {cn} from '@/lib/utils';
|
|
import Logo from './Logo.jsx';
|
|
import PageContainer from './PageContainer.jsx';
|
|
import SteamAvatar from './SteamAvatar.jsx';
|
|
import useCurrentUser from '../hooks/useCurrentUser.js';
|
|
import useSteamProfile from '../hooks/useSteamProfile.js';
|
|
import {ROLES, viewAs} from '../utils/admin.js';
|
|
import {expireSession, markSession} from '../utils/auth.js';
|
|
|
|
const NAV_ITEMS = [
|
|
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
|
// The wishlist is a tab of the library.
|
|
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist']},
|
|
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
|
|
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
|
|
];
|
|
|
|
const navLinkClass = (isActive) => cn(
|
|
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
|
isActive
|
|
? 'bg-accent text-accent-foreground'
|
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
);
|
|
|
|
export default function AppHeader({isAuthenticated, onLogout}) {
|
|
const {t, i18n} = useTranslation();
|
|
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
|
|
const {pathname} = useLocation();
|
|
const steamProfile = useSteamProfile(isAuthenticated);
|
|
const currentUser = useCurrentUser(isAuthenticated);
|
|
const navigate = useNavigate();
|
|
const isAdmin = currentUser?.role === 'PLATFORM_ADMIN';
|
|
const viewingAs = currentUser && currentUser.activeRole !== currentUser.role ? currentUser.activeRole : null;
|
|
|
|
const switchRole = async (role) => {
|
|
try {
|
|
const response = await viewAs(role === currentUser.role ? null : role);
|
|
if (response.ok) {
|
|
// New cookies with the other role; the new stamp loads the user and the Steam data again.
|
|
markSession();
|
|
navigate('/dashboard');
|
|
return;
|
|
}
|
|
if (response.status === 401) {
|
|
expireSession();
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
console.error('View as error:', err);
|
|
}
|
|
toast.error(t('NAV.VIEW_AS.ERROR'));
|
|
};
|
|
|
|
// Unsaved, the next login would switch back to Auth's language.
|
|
const toggleLanguage = async () => {
|
|
try {
|
|
const response = await setLanguage(i18n.language === 'de' ? 'en' : 'de');
|
|
if (response && !response.ok) toast.error(t('SETTINGS.ERROR_SAVE'));
|
|
} catch (err) {
|
|
console.error('Language save error:', err);
|
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
}
|
|
};
|
|
const toggleTheme = () => setDarkMode(!isDarkMode);
|
|
const ThemeIcon = isDarkMode ? Sun : Moon;
|
|
|
|
return (
|
|
<header className="sticky top-0 z-50 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
<PageContainer className="flex h-16 items-center gap-2 px-4 md:px-8">
|
|
<Link to={isAuthenticated ? '/dashboard' : '/'} aria-label="ToBePlayed" className="mr-2 flex items-center gap-2 font-bold tracking-tight whitespace-nowrap">
|
|
<Logo className="h-8 w-auto shrink-0"/>
|
|
{/* On a narrow phone the menu and buttons need the room; the logo alone stays. */}
|
|
<span className="hidden text-lg min-[380px]:inline">ToBePlayed</span>
|
|
</Link>
|
|
|
|
{isAuthenticated && (
|
|
<nav className="flex items-center gap-1">
|
|
{NAV_ITEMS.map(({to, labelKey, Icon, alsoActiveOn = []}) => (
|
|
<NavLink key={to} to={to} title={t(labelKey)}
|
|
className={({isActive}) => navLinkClass(isActive || alsoActiveOn.includes(pathname))}>
|
|
<Icon className="size-4"/>
|
|
<span className="hidden sm:inline">{t(labelKey)}</span>
|
|
</NavLink>
|
|
))}
|
|
</nav>
|
|
)}
|
|
|
|
<div className="ml-auto flex items-center gap-1">
|
|
{isAuthenticated && viewingAs && (
|
|
<Badge variant="secondary" className="mr-1">{t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})}</Badge>
|
|
)}
|
|
{isAuthenticated ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" aria-label={t('NAV.MENU')} title={steamProfile?.personaName ?? undefined}>
|
|
<SteamAvatar url={steamProfile?.avatarUrl}/>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-52">
|
|
<DropdownMenuItem asChild>
|
|
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
|
|
</DropdownMenuItem>
|
|
{isAdmin && !viewingAs && (
|
|
<>
|
|
<DropdownMenuItem asChild>
|
|
<Link to="/admin/users"><Shield/>{t('NAV.ADMIN')}</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem asChild>
|
|
<Link to="/admin/hints"><Megaphone/>{t('NAV.ADMIN_HINTS')}</Link>
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
{isAdmin && (
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger><Eye/>{t('NAV.VIEW_AS.MENU')}</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent>
|
|
{ROLES.map((role) => (
|
|
<DropdownMenuItem key={role} disabled={role === currentUser.activeRole}
|
|
onSelect={() => switchRole(role)}>
|
|
{t(`ROLES.${role}`)}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
)}
|
|
<DropdownMenuItem onSelect={toggleLanguage}>
|
|
<Languages/>{t('NAV.LANGUAGE')}: {i18n.language.toUpperCase()}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={toggleTheme}>
|
|
<ThemeIcon/>{t(isDarkMode ? 'NAV.LIGHT_MODE' : 'NAV.DARK_MODE')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator/>
|
|
<DropdownMenuItem variant="destructive" onSelect={onLogout}>
|
|
<LogOut/>{t('MAIN.LOGOUT')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : (
|
|
<>
|
|
<Button variant="ghost" size="sm" onClick={toggleLanguage}>
|
|
{i18n.language.toUpperCase()}
|
|
</Button>
|
|
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t(isDarkMode ? 'NAV.LIGHT_MODE' : 'NAV.DARK_MODE')}>
|
|
<ThemeIcon className="size-5"/>
|
|
</Button>
|
|
<Button asChild className="ml-1">
|
|
<Link to="/login">{t('MAIN.LOGIN')}</Link>
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</PageContainer>
|
|
</header>
|
|
);
|
|
}
|