achievements panel

This commit is contained in:
2026-05-25 20:42:26 +03:00
parent 621cc04345
commit 5e5b930a26
@@ -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>
)
}