diff --git a/frontend/src/components/DailyLeaderboard.tsx b/frontend/src/components/DailyLeaderboard.tsx new file mode 100644 index 0000000..c29605e --- /dev/null +++ b/frontend/src/components/DailyLeaderboard.tsx @@ -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 + if (rank === 2) return + if (rank === 3) return + return {rank} +} + +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('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([]) + const [globalEntries, setGlobalEntries] = useState([]) + const [streakEntries, setStreakEntries] = useState([]) + const [completionistEntries, setCompletionistEntries] = useState([]) + + 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 ( +
+ {t('leaderboard.columns.rank')} + {t('leaderboard.columns.player')} + {t('leaderboard.columns.current')} + {t('leaderboard.columns.best')} +
+ ) + } + if (tab === 'total') { + return ( +
+ {t('leaderboard.columns.rank')} + {t('leaderboard.columns.player')} + {t('leaderboard.columns.solves')} +
+ ) + } + if (tab === 'global') { + return ( +
+ {t('leaderboard.columns.rank')} + {t('leaderboard.columns.player')} + {t('leaderboard.columns.instance')} + {t('leaderboard.columns.time')} +
+ ) + } + // daily, alltime, weekly + return ( +
+ {t('leaderboard.columns.rank')} + {t('leaderboard.columns.player')} + {t('leaderboard.columns.time')} + {t('leaderboard.columns.hints')} + {tab === 'alltime' && {t('leaderboard.columns.date')}} +
+ ) + } + + const renderRow = (i: number) => { + const rank = i + 1 + + if (tab === 'streaks') { + const entry = streakEntries[i] + return ( +
+ + {entry ? : {rank}} + + + {entry ? entry.name || t('common.anonymous') : -} + + + {entry ? ( + <> + + {entry ? entry.best : ''} + +
+ ) + } + + if (tab === 'total') { + const entry = completionistEntries[i] + return ( +
+ + {entry ? : {rank}} + + + {entry ? entry.name || t('common.anonymous') : -} + + + {entry ? entry.count : -} + +
+ ) + } + + if (tab === 'global') { + const entry = globalEntries[i] + return ( +
+ + {entry ? : {rank}} + + + {entry ? entry.name || t('common.anonymous') : -} + + + {entry?.instance || ''} + + + {entry ? ( + <> +
+ ) + } + + // daily, alltime, weekly + const entry = localEntries[i] + return ( +
+ + {entry ? : {rank}} + + + {entry ? entry.name || t('common.anonymous') : -} + + + {entry ? ( + <> + + {entry && entry.hints > 0 ? ( + <> + {tab === 'alltime' && ( + + {entry?.date || ''} + + )} +
+ ) + } + + return ( + + 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] }} + > +
+
+

{t('leaderboard.title')}

+ +
+
+ +
+
+ {TAB_IDS.map(tb => ( + + ))} +
+ + {showPresets(tab) && ( +
+ {PRESETS.map(p => ( + + ))} + {tab === 'daily' && ( + + )} +
+ )} + + {showCleanToggle(tab) && ( + + )} +
+ +
+ + + {renderHeader()} + {Array.from({ length: SLOTS }, (_, i) => renderRow(i))} + + + + + {loading && ( + +
+ + )} + +
+
+ + ) +}