generated from nhcarrigan/template
70fcaa8650
### 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>
203 lines
6.1 KiB
Svelte
203 lines
6.1 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { fade, fly } from "svelte/transition";
|
|
import { cubicOut } from "svelte/easing";
|
|
import { listen } from "@tauri-apps/api/event";
|
|
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
|
|
|
let achievements = $state<AchievementUnlockedEvent[]>([]);
|
|
let currentAchievement = $state<AchievementUnlockedEvent | null>(null);
|
|
let showNotification = $state(false);
|
|
|
|
onMount(() => {
|
|
let unlisten: (() => void) | undefined;
|
|
|
|
const setupListener = async () => {
|
|
unlisten = await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
|
|
achievements.push(event.payload);
|
|
if (!showNotification) {
|
|
showNext();
|
|
}
|
|
});
|
|
};
|
|
|
|
setupListener();
|
|
|
|
return () => {
|
|
if (unlisten) {
|
|
unlisten();
|
|
}
|
|
};
|
|
});
|
|
|
|
function showNext() {
|
|
if (achievements.length > 0) {
|
|
currentAchievement = achievements.shift() || null;
|
|
showNotification = true;
|
|
|
|
// Auto-hide after 5 seconds
|
|
setTimeout(() => {
|
|
showNotification = false;
|
|
// Show next achievement after animation completes
|
|
setTimeout(() => showNext(), 300);
|
|
}, 5000);
|
|
}
|
|
}
|
|
|
|
function dismiss() {
|
|
showNotification = false;
|
|
// Show next achievement after animation completes
|
|
setTimeout(() => showNext(), 300);
|
|
}
|
|
|
|
function getRarityColor(rarity: string): string {
|
|
switch (rarity) {
|
|
case "legendary":
|
|
return "from-yellow-400 to-orange-500";
|
|
case "epic":
|
|
return "from-purple-400 to-pink-500";
|
|
case "rare":
|
|
return "from-blue-400 to-indigo-500";
|
|
default:
|
|
return "from-green-400 to-emerald-500";
|
|
}
|
|
}
|
|
|
|
function getAchievementRarity(id: string): string {
|
|
// Determine rarity based on achievement ID
|
|
if (id === "TokenMaster") return "legendary";
|
|
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
|
|
if (
|
|
[
|
|
"BlossomingCoder",
|
|
"CodeWizard",
|
|
"MasterBuilder",
|
|
"EnduranceChamp",
|
|
"DeepDive",
|
|
"CreativeCoder",
|
|
].includes(id)
|
|
)
|
|
return "rare";
|
|
return "common";
|
|
}
|
|
</script>
|
|
|
|
{#if showNotification && currentAchievement}
|
|
<div
|
|
class="fixed top-20 right-4 z-50 max-w-sm"
|
|
in:fly={{ x: 300, duration: 500, easing: cubicOut }}
|
|
out:fade={{ duration: 300 }}
|
|
>
|
|
<!-- Backdrop with animated gradient border -->
|
|
<div class="relative p-[2px] rounded-lg overflow-hidden">
|
|
<!-- Animated gradient border -->
|
|
<div
|
|
class="absolute inset-0 bg-gradient-to-r {getRarityColor(
|
|
getAchievementRarity(currentAchievement.achievement.id)
|
|
)} animate-pulse"
|
|
></div>
|
|
|
|
<!-- Main notification content -->
|
|
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
|
|
<button
|
|
onclick={dismiss}
|
|
onkeydown={(e) => e.key === "Enter" && dismiss()}
|
|
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
|
aria-label="Dismiss notification"
|
|
>
|
|
<svg class="w-5 h-5" 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 class="flex items-start gap-4">
|
|
<!-- Icon with animated sparkles -->
|
|
<div class="relative flex-shrink-0">
|
|
<div class="text-5xl animate-bounce">{currentAchievement.achievement.icon}</div>
|
|
|
|
<!-- Sparkle animations -->
|
|
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping">✨</div>
|
|
<div
|
|
class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"
|
|
>
|
|
✨
|
|
</div>
|
|
<div class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400">
|
|
✨
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Text content -->
|
|
<div class="flex-1 min-w-0 pt-1">
|
|
<h3
|
|
class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
|
|
>
|
|
Achievement Unlocked!
|
|
</h3>
|
|
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
|
|
{currentAchievement.achievement.name}
|
|
</p>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
{currentAchievement.achievement.description}
|
|
</p>
|
|
|
|
<!-- Rarity badge -->
|
|
<div class="mt-2 inline-flex items-center">
|
|
<span
|
|
class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {getRarityColor(
|
|
getAchievementRarity(currentAchievement.achievement.id)
|
|
)} text-white capitalize"
|
|
>
|
|
{getAchievementRarity(currentAchievement.achievement.id)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Celebration confetti effect (CSS only) -->
|
|
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
|
{#each Array(10) as _ (_)}
|
|
<div
|
|
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
|
|
getAchievementRarity(currentAchievement.achievement.id)
|
|
)} rounded-full animate-fall"
|
|
style="left: {Math.random() * 100}%; animation-delay: {Math.random() *
|
|
2}s; animation-duration: {2 + Math.random() * 2}s;"
|
|
></div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
@keyframes fall {
|
|
0% {
|
|
transform: translateY(-20px) rotate(0deg);
|
|
opacity: 1;
|
|
}
|
|
100% {
|
|
transform: translateY(400px) rotate(720deg);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
.animate-fall {
|
|
animation: fall linear infinite;
|
|
}
|
|
|
|
.animation-delay-200 {
|
|
animation-delay: 200ms;
|
|
}
|
|
|
|
.animation-delay-400 {
|
|
animation-delay: 400ms;
|
|
}
|
|
</style>
|