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.
This commit is contained in:
lobo committed 2026-10-09 09:31:48 +02:00
1 parent 1dab069a4d
commit d3ad19e2fa
15 files changed
+567 -33

No files matched your search

+44 -15
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Bell, ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun, Users} from 'lucide-react';
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';
@@ -26,6 +26,7 @@ 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 = [
@@ -33,10 +34,18 @@ const NAV_ITEMS = [
// 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
@@ -86,6 +95,22 @@ export default function AppHeader({isAuthenticated, onLogout}) {
}, [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);
@@ -129,13 +154,19 @@ export default function AppHeader({isAuthenticated, onLogout}) {
{isAuthenticated && (
<nav className="flex items-center sm:gap-1">
{NAV_ITEMS.map(({to, labelKey, Icon, alsoActiveOn = [], iconOnly}) => (
<NavLink key={to} to={to} title={t(labelKey)}
className={({isActive}) => navLinkClass(isActive || alsoActiveOn.includes(pathname))}>
<Icon className="size-4"/>
{!iconOnly && <span className="hidden lg:inline">{t(labelKey)}</span>}
</NavLink>
))}
{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>
)}
@@ -146,19 +177,17 @@ export default function AppHeader({isAuthenticated, onLogout}) {
)}
{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"><Coins className="text-primary"/><span className="min-w-0 truncate tabular-nums">{wallet.balance}</span></Link>
<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 && (
<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">
{shownUnread > 9 ? '9+' : shownUnread}
</span>
)}
{shownUnread > 0 && <CountBadge count={shownUnread}/>}
</Link>
</Button>
)}