From 5e5b930a264fcde9eff40633eb13b88f73acb617 Mon Sep 17 00:00:00 2001 From: lashman Date: Mon, 25 May 2026 20:42:26 +0300 Subject: [PATCH] achievements panel --- frontend/src/components/AchievementsPanel.tsx | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 frontend/src/components/AchievementsPanel.tsx diff --git a/frontend/src/components/AchievementsPanel.tsx b/frontend/src/components/AchievementsPanel.tsx new file mode 100644 index 0000000..6515aff --- /dev/null +++ b/frontend/src/components/AchievementsPanel.tsx @@ -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 + +type AchievementIcon = typeof IconPuzzleFilled + +function iconFor(icons: Record, 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([]) + 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 ( + + 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 }}> +
+

+ + {t('achievements.title')} + {unlocked.length}/{achievements.length} +

+ +
+ + {loading ? ( +
{t('common.loading')}
+ ) : ( +
+ {achievements.map(a => { + const Icon = iconFor(ICON_MAP, a.icon) + return ( +
+
+ +
+
+
{a.name}
+
{a.description}
+
+
+ ) + })} +
+ )} +
+
+ ) +}