leaderboards
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { IconTrophyFilled, IconClockFilled, IconBulbFilled, IconAwardFilled, IconRosetteDiscountCheckFilled } from '@tabler/icons-react'
|
||||
import { motion } from 'motion/react'
|
||||
import * as api from '../api'
|
||||
import type { LeaderboardEntry } from '../api'
|
||||
import { useStore } from '../store'
|
||||
|
||||
type Entry = LeaderboardEntry
|
||||
|
||||
function formatTime(secs: number) {
|
||||
const m = Math.floor(secs / 60)
|
||||
const s = secs % 60
|
||||
return m > 0 ? `${m}:${s.toString().padStart(2, '0')}` : `${s}s`
|
||||
}
|
||||
|
||||
function RankIcon({ rank }: { rank: number }) {
|
||||
if (rank === 1) return <span aria-label="1st place"><IconTrophyFilled size={13} style={{ color: '#fbbf24' }} /></span>
|
||||
if (rank === 2) return <span aria-label="2nd place"><IconAwardFilled size={13} style={{ color: '#94a3b8' }} /></span>
|
||||
if (rank === 3) return <span aria-label="3rd place"><IconRosetteDiscountCheckFilled size={13} style={{ color: '#d97706' }} /></span>
|
||||
return <span>{rank}.</span>
|
||||
}
|
||||
|
||||
export default function Leaderboard() {
|
||||
const { t } = useTranslation()
|
||||
const puzzleId = useStore(s => s.puzzleId)
|
||||
const elapsed = useStore(s => s.elapsed)
|
||||
const hintsUsed = useStore(s => s.hintsUsed)
|
||||
const [entries, setEntries] = useState<Entry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!puzzleId) return
|
||||
const t0 = setTimeout(() => setLoading(true), 0)
|
||||
api.fetchLeaderboard(puzzleId).then(data => {
|
||||
setEntries(data.entries || [])
|
||||
setLoading(false)
|
||||
}).catch(() => setLoading(false))
|
||||
return () => clearTimeout(t0)
|
||||
}, [puzzleId])
|
||||
|
||||
if (loading || entries.length === 0) return null
|
||||
|
||||
const userRank = entries.findIndex(e => e.time > elapsed) + 1 || entries.length + 1
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="leaderboard"
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.5 }}
|
||||
>
|
||||
<div className="leaderboard-header">
|
||||
<IconTrophyFilled size={12} aria-hidden="true" />
|
||||
<span>{t('leaderboard.title')}</span>
|
||||
</div>
|
||||
<div className="leaderboard-list">
|
||||
{entries.slice(0, 10).map((entry, i) => (
|
||||
<div key={i} className={`leaderboard-row ${i < 3 ? 'leaderboard-top' : ''}`}>
|
||||
<span className="leaderboard-rank"><RankIcon rank={i + 1} /></span>
|
||||
<span className="leaderboard-name">{entry.name || t('common.anonymous')}</span>
|
||||
<span className="leaderboard-time">
|
||||
<IconClockFilled size={10} aria-hidden="true" /> {formatTime(entry.time)}
|
||||
</span>
|
||||
{entry.hints > 0 && (
|
||||
<span className="leaderboard-hints">
|
||||
<IconBulbFilled size={9} aria-hidden="true" /> {entry.hints}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="leaderboard-you">
|
||||
{t('leaderboard.yourTime', { time: formatTime(elapsed), detail: (hintsUsed > 0 ? (hintsUsed === 1 ? t('leaderboard.hintCount', { count: hintsUsed }) : t('leaderboard.hintCount_plural', { count: hintsUsed })) + ', ' : '') + '#' + userRank })}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user