achievements panel
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { motion } from 'motion/react'
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap'
|
||||
import { IconX, IconTrophyFilled, IconStarFilled, IconFlameFilled,
|
||||
IconShieldCheckFilled, IconBoltFilled, IconDiamondFilled,
|
||||
IconCrownFilled, IconPuzzleFilled, IconCircleFilled, IconSquareFilled,
|
||||
IconHexagonFilled, IconOctagonFilled, IconCompassFilled, IconRosetteFilled,
|
||||
IconAwardFilled, IconBadgeFilled, IconHeartFilled, IconSunriseFilled,
|
||||
IconCalendarFilled, IconMoonFilled, IconShieldFilled, IconMeteorFilled,
|
||||
IconGlobeFilled, IconEyeFilled, IconAtom2Filled, IconBulbFilled,
|
||||
IconClockFilled, IconSparklesFilled } from '@tabler/icons-react'
|
||||
|
||||
const ICON_MAP = {
|
||||
'puzzle-filled': IconPuzzleFilled,
|
||||
'star-filled': IconStarFilled,
|
||||
'badge-filled': IconBadgeFilled,
|
||||
'award-filled': IconAwardFilled,
|
||||
'trophy-filled': IconTrophyFilled,
|
||||
'diamond-filled': IconDiamondFilled,
|
||||
'heart-filled': IconHeartFilled,
|
||||
'crown-filled': IconCrownFilled,
|
||||
'flame-filled': IconFlameFilled,
|
||||
'sunrise-filled': IconSunriseFilled,
|
||||
'calendar-filled': IconCalendarFilled,
|
||||
'moon-filled': IconMoonFilled,
|
||||
'shield-filled': IconShieldFilled,
|
||||
'meteor-filled': IconMeteorFilled,
|
||||
'globe-filled': IconGlobeFilled,
|
||||
'shield-check-filled': IconShieldCheckFilled,
|
||||
'eye-filled': IconEyeFilled,
|
||||
'atom-filled': IconAtom2Filled,
|
||||
'circle-filled': IconCircleFilled,
|
||||
'square-filled': IconSquareFilled,
|
||||
'hexagon-filled': IconHexagonFilled,
|
||||
'octagon-filled': IconOctagonFilled,
|
||||
'compass-filled': IconCompassFilled,
|
||||
'rosette-filled': IconRosetteFilled,
|
||||
'bolt-filled': IconBoltFilled,
|
||||
'sparkles-filled': IconSparklesFilled,
|
||||
'clock-filled': IconClockFilled,
|
||||
'bulb-filled': IconBulbFilled,
|
||||
} satisfies Record<string, typeof IconPuzzleFilled>
|
||||
|
||||
type AchievementIcon = typeof IconPuzzleFilled
|
||||
|
||||
function iconFor(icons: Record<string, AchievementIcon>, name: string): AchievementIcon {
|
||||
return icons[name] ?? IconStarFilled
|
||||
}
|
||||
|
||||
interface Achievement {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
color1: string
|
||||
color2: string
|
||||
unlocked: boolean
|
||||
unlocked_at: string | null
|
||||
}
|
||||
|
||||
export default function AchievementsPanel({ onClose }: { onClose: () => void }) {
|
||||
const trapRef = useFocusTrap(true)
|
||||
const { t } = useTranslation()
|
||||
const [achievements, setAchievements] = useState<Achievement[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/me/achievements')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setAchievements(data.achievements || [])
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const unlocked = achievements.filter(a => a.unlocked)
|
||||
|
||||
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="achievements-panel" onClick={e => e.stopPropagation()}
|
||||
role="dialog" aria-modal="true" aria-label={t('achievements.title')}
|
||||
initial={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
transition={{ duration: 0.2 }}>
|
||||
<div className="settings-header ach-sticky-header">
|
||||
<h3>
|
||||
<IconTrophyFilled size={18} style={{ verticalAlign: -3, marginRight: 6 }} />
|
||||
{t('achievements.title')}
|
||||
<span className="ach-count">{unlocked.length}/{achievements.length}</span>
|
||||
</h3>
|
||||
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label={t('common.close')}><IconX size={16} aria-hidden="true" /></button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-tertiary)' }}>{t('common.loading')}</div>
|
||||
) : (
|
||||
<div className="ach-grid">
|
||||
{achievements.map(a => {
|
||||
const Icon = iconFor(ICON_MAP, a.icon)
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`ach-badge ${a.unlocked ? 'ach-unlocked' : 'ach-locked'}`}
|
||||
title={`${a.name}: ${a.description}`}
|
||||
>
|
||||
<div
|
||||
className="ach-icon"
|
||||
style={a.unlocked ? {
|
||||
background: `linear-gradient(135deg, ${a.color1}, ${a.color2})`,
|
||||
} : undefined}
|
||||
>
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<div className="ach-info">
|
||||
<div className="ach-name">{a.name}</div>
|
||||
<div className="ach-desc">{a.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user