Public Access
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.
296 lines
16 KiB
React
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>
|
|
);
|
|
}
|