Files
ToBePlayed-Frontend/src/components/AppHeader.jsx
T
lobo d7810fbf94
Build, Test & Push Frontend / quality-check (push) Successful in 31s
Build, Test & Push Frontend / docker (push) Successful in 24s
fix(layout): fit names on phones without sideways scroll
Display and game names shrink to 0.875rem, then end with an ellipsis
(FitText + fit-text utility, CSS container units, full text in title).
Rows and buttons wrap at large fonts, header points chip can shrink.
Guard: scripts/check-overflow.js.
2026-10-03 12:18:31 +02:00

235 lines
13 KiB
React

import {useEffect, useState} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, 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 {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
import {canCorrect} from '../utils/corrections.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-2 py-2 text-sm font-medium transition-colors sm:px-3',
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, wallet} = 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 seesAdminPage = isAdmin && !viewingAs;
const [pendingCount, setPendingCount] = useState(0);
const [menuOpenings, setMenuOpenings] = useState(0);
// Loaded again on every page change and menu opening, so a decision on the admin page shows soon.
useEffect(() => {
if (!seesAdminPage) return undefined;
let current = true;
fetchPendingCount()
.then(async (response) => {
if (current && response.ok) setPendingCount((await response.json()).count);
})
.catch((err) => console.error('Pending count error:', err));
return () => {
current = false;
};
}, [seesAdminPage, pathname, menuOpenings]);
const shownCount = seesAdminPage ? pendingCount : 0;
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-1 px-4 sm:gap-2 md:px-8">
<Link to={isAuthenticated ? '/dashboard' : '/'} aria-label="ToBePlayed" className="flex items-center gap-2 font-bold tracking-tight sm:mr-2 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-[520px]:inline">ToBePlayed</span>
</Link>
{isAuthenticated && (
<nav className="flex items-center sm: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 lg:inline">{t(labelKey)}</span>
</NavLink>
))}
</nav>
)}
<div className="ml-auto flex min-w-0 items-center gap-1">
{/* Below md the badge hangs on the header's bottom edge: in the row it would push the page sideways. */}
{isAuthenticated && viewingAs && (
<Badge variant="secondary" className="max-md:absolute max-md:top-full max-md:left-1/2 max-md:-translate-x-1/2 max-md:-translate-y-1/2 max-md:pointer-events-none max-md:border-border md:mr-1">{t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})}</Badge>
)}
{isAuthenticated && wallet && (
<Button asChild variant="ghost" size="sm" title={`${t('POINTS.TITLE')}: ${wallet.balance}`} className="min-w-0 shrink">
<Link to="/points"><Coins className="text-primary"/><span className="min-w-0 truncate tabular-nums">{wallet.balance}</span></Link>
</Button>
)}
{isAuthenticated ? (
<DropdownMenu onOpenChange={(open) => open && setMenuOpenings((count) => count + 1)}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('NAV.MENU')} title={steamProfile?.personaName ?? undefined}
className="relative">
<SteamAvatar url={steamProfile?.avatarUrl}/>
{shownCount > 0 && (
<span aria-label={t('NAV.PENDING_REQUESTS', {count: shownCount})}
className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{shownCount > 9 ? '9+' : shownCount}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem asChild>
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/points"><Coins/>{t('NAV.POINTS')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link>
</DropdownMenuItem>
{canCorrect(currentUser) && (
<>
<DropdownMenuItem asChild>
<Link to="/audit/games"><Search/>{t('NAV.GAME_CATALOG')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/audit/links"><Link2/>{t('NAV.LINK_QUEUE')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/audit/log"><ScrollText/>{t('NAV.AUDIT_LOG')}</Link>
</DropdownMenuItem>
</>
)}
{seesAdminPage && (
<>
<DropdownMenuItem asChild>
<Link to="/admin/users">
<Shield/>{t('NAV.ADMIN')}
{shownCount > 0 && <Badge className="ml-auto">{shownCount}</Badge>}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/hints"><Megaphone/>{t('NAV.ADMIN_HINTS')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/news"><Newspaper/>{t('NAV.ADMIN_NEWS')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/broadcasts"><Mail/>{t('NAV.ADMIN_BROADCASTS')}</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>
);
}