generated from nhcarrigan/template
feat: stats and achievements (#45)
### Explanation _No response_ ### Issue Closes #39 ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Reviewed-on: #45 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #45.
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user