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