daily leaderboard tabs
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user