generated from nhcarrigan/template
267 lines
9.6 KiB
Svelte
267 lines
9.6 KiB
Svelte
<script lang="ts">
|
|
import { slide } from "svelte/transition";
|
|
import { quintOut } from "svelte/easing";
|
|
import {
|
|
achievementsStore,
|
|
achievementProgress,
|
|
achievementCategories,
|
|
} from "$lib/stores/achievements";
|
|
import type { Achievement } from "$lib/types/achievements";
|
|
|
|
interface Props {
|
|
isOpen: boolean;
|
|
onClose?: () => void;
|
|
}
|
|
|
|
const { isOpen = $bindable(false), onClose }: Props = $props();
|
|
let selectedCategory = $state<string | null>(null);
|
|
|
|
const achievementsState = $derived($achievementsStore);
|
|
const progress = $derived($achievementProgress);
|
|
|
|
function getRarityColor(rarity: string): string {
|
|
switch (rarity) {
|
|
case "legendary":
|
|
return "text-yellow-500 dark:text-yellow-400";
|
|
case "epic":
|
|
return "text-purple-500 dark:text-purple-400";
|
|
case "rare":
|
|
return "text-blue-500 dark:text-blue-400";
|
|
default:
|
|
return "text-green-500 dark:text-green-400";
|
|
}
|
|
}
|
|
|
|
function getRarityBg(rarity: string): string {
|
|
switch (rarity) {
|
|
case "legendary":
|
|
return "bg-yellow-500/10";
|
|
case "epic":
|
|
return "bg-purple-500/10";
|
|
case "rare":
|
|
return "bg-blue-500/10";
|
|
default:
|
|
return "bg-green-500/10";
|
|
}
|
|
}
|
|
|
|
function formatDate(date: Date | undefined): string {
|
|
if (!date) return "";
|
|
return new Date(date).toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
}
|
|
|
|
function getAchievementsForCategory(categoryIds: string[]): Achievement[] {
|
|
return categoryIds
|
|
.map(
|
|
(id) => achievementsState.achievements[id as keyof typeof achievementsState.achievements]
|
|
)
|
|
.filter(Boolean);
|
|
}
|
|
</script>
|
|
|
|
<!-- Achievements panel -->
|
|
{#if isOpen}
|
|
<div
|
|
class="fixed inset-0 bg-black/50 z-40"
|
|
onclick={onClose}
|
|
onkeydown={(e) => e.key === "Escape" && onClose?.()}
|
|
role="button"
|
|
tabindex="-1"
|
|
aria-label="Close achievements panel"
|
|
transition:slide={{ duration: 300, easing: quintOut }}
|
|
></div>
|
|
|
|
<div
|
|
class="fixed left-0 top-0 h-full w-96 bg-[var(--bg-primary)] border-r border-[var(--border-color)]
|
|
shadow-2xl z-50 flex flex-col"
|
|
transition:slide={{ duration: 300, easing: quintOut, axis: "x" }}
|
|
>
|
|
<!-- Header -->
|
|
<div class="p-6 border-b border-[var(--border-color)]">
|
|
<div class="flex items-center justify-between">
|
|
<h2 class="text-2xl font-bold text-[var(--text-primary)]">Achievements</h2>
|
|
<button
|
|
onclick={onClose}
|
|
onkeydown={(e) => e.key === "Enter" && onClose?.()}
|
|
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
aria-label="Close achievements panel"
|
|
>
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Overall progress -->
|
|
<div class="mt-4">
|
|
<div
|
|
class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400 mb-2"
|
|
>
|
|
<span>{progress.unlocked} / {progress.total} Unlocked</span>
|
|
<span>{progress.percentage}%</span>
|
|
</div>
|
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
|
<div
|
|
class="bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] h-2 rounded-full transition-all duration-500"
|
|
style="width: {progress.percentage}%"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Categories -->
|
|
<div class="flex-1 overflow-y-auto">
|
|
{#each achievementCategories as category (category.name)}
|
|
{@const achievements = getAchievementsForCategory(category.ids)}
|
|
{@const unlockedCount = achievements.filter((a) => a.unlocked).length}
|
|
|
|
<div class="border-b border-[var(--border-color)]">
|
|
<button
|
|
onclick={() =>
|
|
(selectedCategory = selectedCategory === category.name ? null : category.name)}
|
|
onkeydown={(e) =>
|
|
e.key === "Enter" &&
|
|
(selectedCategory = selectedCategory === category.name ? null : category.name)}
|
|
class="w-full p-4 text-left hover:bg-[var(--bg-secondary)] transition-colors"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="font-semibold text-[var(--text-primary)]">{category.name}</h3>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400">{category.description}</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
|
{unlockedCount} / {achievements.length}
|
|
</span>
|
|
<svg
|
|
class="w-5 h-5 transition-transform {selectedCategory === category.name
|
|
? 'rotate-180'
|
|
: ''}"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M19 9l-7 7-7-7"
|
|
></path>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
{#if selectedCategory === category.name}
|
|
<div class="p-4 space-y-3" transition:slide={{ duration: 200, easing: quintOut }}>
|
|
{#each achievements as achievement (achievement.id)}
|
|
<div
|
|
class="p-3 rounded-lg border {achievement.unlocked
|
|
? 'border-[var(--border-color)] bg-[var(--bg-secondary)]'
|
|
: 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 opacity-50'}"
|
|
>
|
|
<div class="flex items-start gap-3">
|
|
<!-- Icon -->
|
|
<div class="text-3xl flex-shrink-0 {achievement.unlocked ? '' : 'grayscale'}">
|
|
{achievement.icon}
|
|
</div>
|
|
|
|
<!-- Details -->
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2">
|
|
<h4 class="font-semibold text-[var(--text-primary)]">
|
|
{achievement.name}
|
|
</h4>
|
|
<span
|
|
class="text-xs px-2 py-0.5 rounded-full {getRarityBg(
|
|
achievement.rarity
|
|
)} {getRarityColor(achievement.rarity)} capitalize"
|
|
>
|
|
{achievement.rarity}
|
|
</span>
|
|
</div>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
{achievement.description}
|
|
</p>
|
|
|
|
{#if achievement.unlocked && achievement.unlockedAt}
|
|
<p class="text-xs text-gray-500 dark:text-gray-500 mt-2">
|
|
Unlocked {formatDate(achievement.unlockedAt)}
|
|
</p>
|
|
{:else if achievement.maxProgress && achievement.progress !== undefined}
|
|
<!-- Progress bar for locked achievements -->
|
|
<div class="mt-2">
|
|
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
|
|
<span>Progress</span>
|
|
<span>{achievement.progress} / {achievement.maxProgress}</span>
|
|
</div>
|
|
<div class="w-full bg-gray-300 dark:bg-gray-700 rounded-full h-1.5">
|
|
<div
|
|
class="bg-gray-500 h-1.5 rounded-full transition-all duration-300"
|
|
style="width: {Math.min(
|
|
(achievement.progress / achievement.maxProgress) * 100,
|
|
100
|
|
)}%"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Footer with last unlocked -->
|
|
{#if achievementsState.lastUnlocked}
|
|
<div class="p-4 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]">
|
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Last Unlocked:</p>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xl">{achievementsState.lastUnlocked.icon}</span>
|
|
<div>
|
|
<p class="font-semibold text-[var(--text-primary)]">
|
|
{achievementsState.lastUnlocked.name}
|
|
</p>
|
|
<p class="text-xs text-gray-500">
|
|
{formatDate(achievementsState.lastUnlocked.unlockedAt)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
/* Custom scrollbar for achievement list */
|
|
:global(.overflow-y-auto::-webkit-scrollbar) {
|
|
width: 8px;
|
|
}
|
|
|
|
:global(.overflow-y-auto::-webkit-scrollbar-track) {
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
:global(.overflow-y-auto::-webkit-scrollbar-thumb) {
|
|
background: var(--border-color);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
:global(.overflow-y-auto::-webkit-scrollbar-thumb:hover) {
|
|
background: var(--accent-primary);
|
|
}
|
|
</style>
|