daily leaderboard tabs

This commit is contained in:
2026-01-05 19:28:53 +02:00
parent a2d76be65a
commit e30bc93a90
@@ -0,0 +1,350 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { IconClockFilled, IconBulbFilled, IconWorldFilled,
IconAwardFilled, IconRosetteDiscountCheckFilled, IconX, IconCrownFilled,
IconFlameFilled, IconTrophyFilled } from '@tabler/icons-react'
import { motion, AnimatePresence } from 'motion/react'
import * as api from '../api'
import type { LeaderboardEntry } from '../api'
import DatePicker from './ui/DatePicker'
import { useFocusTrap } from '../hooks/useFocusTrap'
type Tab = 'daily' | 'alltime' | 'weekly' | 'streaks' | 'total' | 'global'
type LocalEntry = LeaderboardEntry
type FederatedEntry = LeaderboardEntry
type StreakEntry = LeaderboardEntry
type CompletionistEntry = LeaderboardEntry
function formatTime(secs: number) {
if (secs <= 0) return '--:--'
const m = Math.floor(secs / 60)
const s = secs % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
function RankBadge({ rank }: { rank: number }) {
if (rank === 1) return <span aria-label="1st place"><IconCrownFilled size={14} style={{ color: '#eab308' }} /></span>
if (rank === 2) return <span aria-label="2nd place"><IconAwardFilled size={14} style={{ color: '#94a3b8' }} /></span>
if (rank === 3) return <span aria-label="3rd place"><IconRosetteDiscountCheckFilled size={14} style={{ color: '#b45309' }} /></span>
return <span className="lb-rank-num">{rank}</span>
}
const PRESETS = [
{ id: 'glyph', name: 'Glyph' },
{ id: 'rune', name: 'Rune' },
{ id: 'scroll', name: 'Scroll' },
{ id: 'codex', name: 'Codex' },
{ id: 'grimoire', name: 'Grimoire' },
]
const TAB_IDS: { id: Tab; icon?: typeof IconWorldFilled }[] = [
{ id: 'daily' },
{ id: 'alltime' },
{ id: 'weekly' },
{ id: 'streaks', icon: IconFlameFilled },
{ id: 'total', icon: IconTrophyFilled },
{ id: 'global', icon: IconWorldFilled },
]
const SLOTS = 10
const showPresets = (tab: Tab) => tab !== 'streaks' && tab !== 'total'
const showCleanToggle = (tab: Tab) => tab === 'daily' || tab === 'alltime' || tab === 'weekly'
export default function DailyLeaderboard({ onClose }: { onClose: () => void }) {
const trapRef = useFocusTrap(true)
const { t } = useTranslation()
const [tab, setTab] = useState<Tab>('daily')
const [preset, setPreset] = useState('scroll')
const [date, setDate] = useState(() => new Date().toISOString().split('T')[0])
const [cleanOnly, setCleanOnly] = useState(false)
const [loading, setLoading] = useState(false)
const [localEntries, setLocalEntries] = useState<LocalEntry[]>([])
const [globalEntries, setGlobalEntries] = useState<FederatedEntry[]>([])
const [streakEntries, setStreakEntries] = useState<StreakEntry[]>([])
const [completionistEntries, setCompletionistEntries] = useState<CompletionistEntry[]>([])
useEffect(() => {
let cancelled = false
const done = () => { if (!cancelled) setLoading(false) }
const clear = () => {
setLocalEntries([]); setGlobalEntries([])
setStreakEntries([]); setCompletionistEntries([])
}
queueMicrotask(() => { if (!cancelled) { setLoading(true); clear() } })
if (tab === 'daily') {
const fn = cleanOnly ? api.fetchCleanLeaderboard : api.fetchDailyLeaderboard
fn(date, preset)
.then(d => { if (!cancelled) setLocalEntries(d.entries || []) })
.catch(() => {})
.finally(done)
} else if (tab === 'alltime') {
api.fetchAlltimeLeaderboard(preset)
.then(d => { if (!cancelled) setLocalEntries(d.entries || []) })
.catch(() => {})
.finally(done)
} else if (tab === 'weekly') {
if (cleanOnly) {
api.fetchCleanLeaderboard(date, preset)
.then(d => { if (!cancelled) setLocalEntries(d.entries || []) })
.catch(() => {})
.finally(done)
} else {
api.fetchWeeklyLeaderboard(preset)
.then(d => { if (!cancelled) setLocalEntries(d.entries || []) })
.catch(() => {})
.finally(done)
}
} else if (tab === 'streaks') {
api.fetchStreakLeaderboard()
.then(d => { if (!cancelled) setStreakEntries(d.entries || []) })
.catch(() => {})
.finally(done)
} else if (tab === 'total') {
api.fetchCompletionistLeaderboard()
.then(d => { if (!cancelled) setCompletionistEntries(d.entries || []) })
.catch(() => {})
.finally(done)
} else if (tab === 'global') {
api.fetchFederatedLeaderboard(date, preset)
.then(d => { if (!cancelled) setGlobalEntries(d.entries || []) })
.catch(() => {})
.finally(done)
}
return () => { cancelled = true }
}, [tab, preset, date, cleanOnly])
const renderHeader = () => {
if (tab === 'streaks') {
return (
<div className="lb-table-header" role="row">
<span className="lb-col-rank" role="columnheader">{t('leaderboard.columns.rank')}</span>
<span className="lb-col-name" role="columnheader">{t('leaderboard.columns.player')}</span>
<span className="lb-col-time" role="columnheader">{t('leaderboard.columns.current')}</span>
<span className="lb-col-hints" role="columnheader">{t('leaderboard.columns.best')}</span>
</div>
)
}
if (tab === 'total') {
return (
<div className="lb-table-header" role="row">
<span className="lb-col-rank" role="columnheader">{t('leaderboard.columns.rank')}</span>
<span className="lb-col-name" role="columnheader">{t('leaderboard.columns.player')}</span>
<span className="lb-col-time" role="columnheader">{t('leaderboard.columns.solves')}</span>
</div>
)
}
if (tab === 'global') {
return (
<div className="lb-table-header" role="row">
<span className="lb-col-rank" role="columnheader">{t('leaderboard.columns.rank')}</span>
<span className="lb-col-name" role="columnheader">{t('leaderboard.columns.player')}</span>
<span className="lb-col-instance" role="columnheader">{t('leaderboard.columns.instance')}</span>
<span className="lb-col-time" role="columnheader">{t('leaderboard.columns.time')}</span>
</div>
)
}
// daily, alltime, weekly
return (
<div className="lb-table-header" role="row">
<span className="lb-col-rank" role="columnheader">{t('leaderboard.columns.rank')}</span>
<span className="lb-col-name" role="columnheader">{t('leaderboard.columns.player')}</span>
<span className="lb-col-time" role="columnheader">{t('leaderboard.columns.time')}</span>
<span className="lb-col-hints" role="columnheader">{t('leaderboard.columns.hints')}</span>
{tab === 'alltime' && <span className="lb-col-date" role="columnheader">{t('leaderboard.columns.date')}</span>}
</div>
)
}
const renderRow = (i: number) => {
const rank = i + 1
if (tab === 'streaks') {
const entry = streakEntries[i]
return (
<div key={i} className={`lb-row ${entry && rank <= 3 ? 'lb-row-top' : ''} ${!entry ? 'lb-row-empty' : ''}`} role="row">
<span className="lb-col-rank" role="cell">
{entry ? <RankBadge rank={rank} /> : <span className="lb-rank-num lb-rank-dim">{rank}</span>}
</span>
<span className="lb-col-name" role="cell">
{entry ? entry.name || t('common.anonymous') : <span className="lb-empty-dash">-</span>}
</span>
<span className="lb-col-time" role="cell">
{entry ? (
<><IconFlameFilled size={10} aria-hidden="true" /> {entry.current}</>
) : <span className="lb-empty-dash">-</span>}
</span>
<span className="lb-col-hints" role="cell">
{entry ? entry.best : ''}
</span>
</div>
)
}
if (tab === 'total') {
const entry = completionistEntries[i]
return (
<div key={i} className={`lb-row ${entry && rank <= 3 ? 'lb-row-top' : ''} ${!entry ? 'lb-row-empty' : ''}`} role="row">
<span className="lb-col-rank" role="cell">
{entry ? <RankBadge rank={rank} /> : <span className="lb-rank-num lb-rank-dim">{rank}</span>}
</span>
<span className="lb-col-name" role="cell">
{entry ? entry.name || t('common.anonymous') : <span className="lb-empty-dash">-</span>}
</span>
<span className="lb-col-time" role="cell">
{entry ? entry.count : <span className="lb-empty-dash">-</span>}
</span>
</div>
)
}
if (tab === 'global') {
const entry = globalEntries[i]
return (
<div key={i} className={`lb-row ${entry && rank <= 3 ? 'lb-row-top' : ''} ${!entry ? 'lb-row-empty' : ''}`} role="row">
<span className="lb-col-rank" role="cell">
{entry ? <RankBadge rank={rank} /> : <span className="lb-rank-num lb-rank-dim">{rank}</span>}
</span>
<span className="lb-col-name" role="cell">
{entry ? entry.name || t('common.anonymous') : <span className="lb-empty-dash">-</span>}
</span>
<span className="lb-col-instance" role="cell">
{entry?.instance || ''}
</span>
<span className="lb-col-time" role="cell">
{entry ? (
<><IconClockFilled size={10} aria-hidden="true" /> {formatTime(entry.time)}</>
) : <span className="lb-empty-dash">--:--</span>}
</span>
</div>
)
}
// daily, alltime, weekly
const entry = localEntries[i]
return (
<div key={i} className={`lb-row ${entry && rank <= 3 ? 'lb-row-top' : ''} ${!entry ? 'lb-row-empty' : ''}`} role="row">
<span className="lb-col-rank" role="cell">
{entry ? <RankBadge rank={rank} /> : <span className="lb-rank-num lb-rank-dim">{rank}</span>}
</span>
<span className="lb-col-name" role="cell">
{entry ? entry.name || t('common.anonymous') : <span className="lb-empty-dash">-</span>}
</span>
<span className="lb-col-time" role="cell">
{entry ? (
<><IconClockFilled size={10} aria-hidden="true" /> {formatTime(entry.time)}</>
) : <span className="lb-empty-dash">--:--</span>}
</span>
<span className="lb-col-hints" role="cell">
{entry && entry.hints > 0 ? (
<><IconBulbFilled size={9} aria-hidden="true" /> {entry.hints}</>
) : ''}
</span>
{tab === 'alltime' && (
<span className="lb-col-date" role="cell">
{entry?.date || ''}
</span>
)}
</div>
)
}
return (
<motion.div
className="settings-overlay"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<motion.div
ref={trapRef}
className="lb-modal"
onClick={e => e.stopPropagation()}
role="dialog" aria-modal="true" aria-label={t('leaderboard.title')}
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 12 }}
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
>
<div className="lb-top">
<div className="lb-title-row">
<h3 className="lb-title">{t('leaderboard.title')}</h3>
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label={t('common.close')}><IconX size={16} aria-hidden="true" /></button>
</div>
</div>
<div className="lb-controls">
<div className="lb-tabs" role="tablist">
{TAB_IDS.map(tb => (
<button key={tb.id}
className={`lb-tab ${tab === tb.id ? 'lb-tab-active' : ''}`}
onClick={() => setTab(tb.id)}
role="tab"
aria-selected={tab === tb.id}
aria-controls="lb-tabpanel"
id={`lb-tab-${tb.id}`}>
{tb.icon && <tb.icon size={9} style={{ marginRight: 3, verticalAlign: -1 }} aria-hidden="true" />}
{t(`leaderboard.tabs.${tb.id}`)}
</button>
))}
</div>
{showPresets(tab) && (
<div className="lb-presets">
{PRESETS.map(p => (
<button key={p.id}
className={`lb-preset ${preset === p.id ? 'lb-preset-active' : ''}`}
onClick={() => setPreset(p.id)}>
{p.name}
</button>
))}
{tab === 'daily' && (
<DatePicker value={date} onChange={setDate} />
)}
</div>
)}
{showCleanToggle(tab) && (
<label className="lb-clean-toggle">
<input type="checkbox" checked={cleanOnly} onChange={e => setCleanOnly(e.target.checked)} />
{t('leaderboard.cleanOnly')}
</label>
)}
</div>
<div className="lb-table" role="tabpanel" id="lb-tabpanel" aria-labelledby={`lb-tab-${tab}`}>
<AnimatePresence mode="wait">
<motion.div
key={`${tab}-${preset}-${date}-${cleanOnly}`}
role="table"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{renderHeader()}
{Array.from({ length: SLOTS }, (_, i) => renderRow(i))}
</motion.div>
</AnimatePresence>
<AnimatePresence>
{loading && (
<motion.div className="lb-loading" key="lb-load"
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
<div className="spinner" style={{ width: 16, height: 16 }} />
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
</motion.div>
)
}