Files
ToBePlayed-Frontend/src/components/AppHeader.jsx
T
lobo d3ad19e2fa feat(questions): add questions page with open count badge
Add /questions page and a nav badge with the open count.
Core decides which question is open. The page only shows and answers.
Move RatingStars out of BacklogCard so both pages share it.
2026-10-09 09:31:48 +02:00

296 lines
16 KiB
React

import {useEffect, useState} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Bell, ChartColumn, CircleQuestionMark, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun, Users} 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 {fetchInbox} from '../utils/inbox.js';
import {fetchQuestionCount} from '../utils/questions.js';
import {expireSession, markSession} from '../utils/auth.js';
const NAV_ITEMS = [
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
// The wishlist and the backlog order are tabs of the library.
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist', '/backlog']},
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
{to: '/questions', labelKey: 'NAV.QUESTIONS', Icon: CircleQuestionMark},
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
{to: '/', labelKey: 'NAV.COMMUNITY', Icon: Users, iconOnly: true},
];
const CountBadge = ({count}) => (
<span aria-hidden="true"
className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{count > 9 ? '9+' : count}
</span>
);
const navLinkClass = (isActive) => cn(
'flex items-center gap-2 rounded-lg px-1 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;
// Loaded again on every page change. The inbox page marks the notes read, so it shows no count.
const [unread, setUnread] = useState(0);
useEffect(() => {
if (!isAuthenticated) return undefined;
let current = true;
fetchInbox().then(({ok, data}) => {
if (current && ok) setUnread(data.unread);
});
return () => {
current = false;
};
}, [isAuthenticated, pathname]);
const shownUnread = pathname === '/inbox' ? 0 : unread;
// Loaded again on every page change. The questions page shows the live count in its title, so it has no badge.
const [openQuestions, setOpenQuestions] = useState(0);
useEffect(() => {
if (!isAuthenticated) return undefined;
let current = true;
fetchQuestionCount().then(({ok, data}) => {
if (current && ok) setOpenQuestions(data.count);
});
return () => {
current = false;
};
}, [isAuthenticated, pathname]);
const shownQuestions = pathname === '/questions' ? 0 : openQuestions;
// Below sm the Questions icon shows only with open questions or on its own page; the points chip then gives up its number.
const questionsIconHidden = openQuestions === 0 && pathname !== '/questions';
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 = [], iconOnly}) => {
const count = to === '/questions' ? shownQuestions : 0;
return (
<NavLink key={to} to={to} title={t(labelKey)}
aria-label={count > 0 ? t(`${labelKey}_OPEN`, {count}) : undefined}
className={({isActive}) => cn(navLinkClass(isActive || alsoActiveOn.includes(pathname)), 'relative',
to === '/questions' && questionsIconHidden && 'max-sm:hidden')}>
<Icon className="size-4"/>
{!iconOnly && <span className="hidden lg:inline">{t(labelKey)}</span>}
{count > 0 && <CountBadge count={count}/>}
</NavLink>
);
})}
</nav>
)}
<div className="ml-auto flex min-w-0 items-center gap-1">
{/* The badge hangs on the header's bottom edge at every width: in the row it would push the page sideways. */}
{isAuthenticated && viewingAs && (
<Badge variant="secondary" className="absolute top-full left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none border-border">{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 lg:shrink-0">
<Link to="/points" aria-label={`${t('POINTS.TITLE')}: ${wallet.balance}`}>
<Coins className="text-primary"/>
<span className={cn('min-w-0 truncate tabular-nums', !questionsIconHidden && 'max-sm:hidden')}>{wallet.balance}</span>
</Link>
</Button>
)}
{isAuthenticated && (
<Button asChild variant="ghost" size="icon" title={t('NAV.INBOX')} className="relative shrink-0">
<Link to="/inbox" aria-label={shownUnread > 0 ? t('NAV.INBOX_UNREAD', {count: shownUnread}) : t('NAV.INBOX')}>
<Bell/>
{shownUnread > 0 && <CountBadge count={shownUnread}/>}
</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="/inbox"><Bell/>{t('NAV.INBOX')}</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>
);
}