mirror of
https://github.com/mue/mue.git
synced 2026-07-16 13:34:03 +02:00
feat(achievements): implement IndexedDB for achievement storage and retrieval
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { achievements } from './index';
|
||||
import statsModule from '../stats';
|
||||
import { getAchievements, saveAchievement } from 'utils/indexedDB';
|
||||
|
||||
export async function checkAchievements() {
|
||||
// First get the events data which contains all user actions
|
||||
const storedAchievements = await getAchievements();
|
||||
const events = await statsModule.getStats();
|
||||
const currentTabId = statsModule.getCurrentTabId();
|
||||
|
||||
// Calculate aggregated stats
|
||||
const aggregatedStats = {
|
||||
@@ -13,83 +15,51 @@ export async function checkAchievements() {
|
||||
level: statsModule.level || 1,
|
||||
};
|
||||
|
||||
console.log('Checking achievements with aggregated stats:', aggregatedStats);
|
||||
const newlyAchieved = [];
|
||||
const updatedAchievements = achievements.map((achievement) => {
|
||||
const stored = storedAchievements.find((a) => a.id === achievement.id);
|
||||
if (stored && stored.achieved) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
achievements.forEach((achievement) => {
|
||||
let achieved = false;
|
||||
switch (achievement.condition.type) {
|
||||
case 'tabsOpened':
|
||||
console.log(
|
||||
`Checking achievement: ${achievement.id}, Condition: ${achievement.condition.type}, Required: ${achievement.condition.amount}, Current: ${aggregatedStats['new-tab']}`,
|
||||
);
|
||||
if (aggregatedStats['new-tab'] >= achievement.condition.amount) {
|
||||
achievement.achieved = true;
|
||||
}
|
||||
achieved = aggregatedStats['new-tab'] >= achievement.condition.amount;
|
||||
break;
|
||||
|
||||
case 'addonInstall':
|
||||
console.log(
|
||||
`Checking achievement: ${achievement.id}, Condition: ${achievement.condition.type}, Required: ${achievement.condition.amount}, Current: ${aggregatedStats['marketplace-install']}`,
|
||||
);
|
||||
if (aggregatedStats['marketplace-install'] >= achievement.condition.amount) {
|
||||
achievement.achieved = true;
|
||||
}
|
||||
achieved = aggregatedStats['marketplace-install'] >= achievement.condition.amount;
|
||||
break;
|
||||
|
||||
case 'reachLevel':
|
||||
console.log(
|
||||
`Checking achievement: ${achievement.id}, Condition: ${achievement.condition.type}, Required: ${achievement.condition.amount}, Current: ${aggregatedStats.level}`,
|
||||
);
|
||||
if (aggregatedStats.level >= achievement.condition.amount) {
|
||||
achievement.achieved = true;
|
||||
}
|
||||
achieved = aggregatedStats.level >= achievement.condition.amount;
|
||||
break;
|
||||
|
||||
case 'earnXp':
|
||||
console.log(
|
||||
`Checking achievement: ${achievement.id}, Condition: ${achievement.condition.type}, Required: ${achievement.condition.amount}, Current: ${aggregatedStats.totalXp}`,
|
||||
);
|
||||
if (aggregatedStats.totalXp >= achievement.condition.amount) {
|
||||
achievement.achieved = true;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`Unknown achievement condition type: ${achievement.condition.type}`);
|
||||
achieved = aggregatedStats.totalXp >= achievement.condition.amount;
|
||||
break;
|
||||
}
|
||||
|
||||
if (achieved && !stored?.achieved) {
|
||||
const updatedAchievement = {
|
||||
...achievement,
|
||||
achieved: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
tabId: currentTabId,
|
||||
};
|
||||
newlyAchieved.push(updatedAchievement);
|
||||
saveAchievement(updatedAchievement);
|
||||
return updatedAchievement;
|
||||
}
|
||||
|
||||
return achievement;
|
||||
});
|
||||
|
||||
return achievements;
|
||||
return { updatedAchievements, newlyAchieved };
|
||||
}
|
||||
|
||||
export async function newAchievements() {
|
||||
// Get previously achieved achievements
|
||||
const oldAchievements = JSON.parse(localStorage.getItem('achievements')) || [];
|
||||
|
||||
// Check for new achievements
|
||||
const checkedAchievements = await checkAchievements();
|
||||
const newAchievements = [];
|
||||
|
||||
checkedAchievements.forEach((achievement) => {
|
||||
const isNewAchievement =
|
||||
achievement.achieved && !oldAchievements.some((a) => a.id === achievement.id);
|
||||
|
||||
if (isNewAchievement) {
|
||||
const newAchievement = {
|
||||
...achievement,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
newAchievements.push(newAchievement);
|
||||
}
|
||||
});
|
||||
|
||||
// Update stored achievements
|
||||
if (newAchievements.length > 0) {
|
||||
const updatedAchievements = [...oldAchievements, ...newAchievements];
|
||||
localStorage.setItem('achievements', JSON.stringify(updatedAchievements));
|
||||
}
|
||||
|
||||
return newAchievements;
|
||||
const { updatedAchievements, newlyAchieved } = await checkAchievements();
|
||||
// Only return newly achieved achievements for toast notifications
|
||||
return newlyAchieved;
|
||||
}
|
||||
|
||||
// Helper function to get achievement progress
|
||||
|
||||
@@ -11,6 +11,8 @@ import variables from 'config/variables';
|
||||
|
||||
export default class Stats {
|
||||
static #currentTabId = null;
|
||||
static #toastTimeout = null;
|
||||
static #processedAchievements = new Set();
|
||||
|
||||
static generateTabId() {
|
||||
return `tab_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
@@ -24,19 +26,29 @@ export default class Stats {
|
||||
}
|
||||
|
||||
static async achievementTrigger(stats) {
|
||||
const newAchievement = await newAchievements(stats);
|
||||
newAchievement.forEach((achievement) => {
|
||||
if (achievement) {
|
||||
const { name } = getLocalisedAchievementData(achievement.id);
|
||||
toast.info(
|
||||
`🏆 ${variables.getMessage('settings:sections.stats.achievement_unlocked', { name: name })}`,
|
||||
{
|
||||
icon: false,
|
||||
closeButton: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
// Clear any pending achievement checks
|
||||
if (this.#toastTimeout) {
|
||||
clearTimeout(this.#toastTimeout);
|
||||
}
|
||||
|
||||
this.#toastTimeout = setTimeout(async () => {
|
||||
const newlyAchieved = await newAchievements(stats);
|
||||
|
||||
newlyAchieved.forEach((achievement) => {
|
||||
// Only show toast if we haven't processed this achievement in this session
|
||||
if (!this.#processedAchievements.has(achievement.id)) {
|
||||
const { name } = getLocalisedAchievementData(achievement.id);
|
||||
toast.info(
|
||||
`🏆 ${variables.getMessage('settings:sections.stats.achievement_unlocked', { name })}`,
|
||||
{
|
||||
icon: false,
|
||||
closeButton: false,
|
||||
},
|
||||
);
|
||||
this.#processedAchievements.add(achievement.id);
|
||||
}
|
||||
});
|
||||
}, 1000); // 1 second debounce
|
||||
}
|
||||
|
||||
static calculateNextLevelXp(level) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
achievements as initialAchievements,
|
||||
checkAchievements,
|
||||
} from 'features/stats/api/achievements';
|
||||
import { clearAchievements } from 'utils/indexedDB';
|
||||
|
||||
const Stats = () => {
|
||||
const [stats, setStats] = useState(() => JSON.parse(localStorage.getItem('statsData')) || {});
|
||||
@@ -26,8 +27,8 @@ const Stats = () => {
|
||||
const [clearmodal, setClearmodal] = useState(false);
|
||||
|
||||
const updateAchievements = useCallback(async () => {
|
||||
const achieved = await checkAchievements();
|
||||
setAchievements(achieved);
|
||||
const { updatedAchievements } = await checkAchievements(); // Destructure the result
|
||||
setAchievements(updatedAchievements); // Use the updatedAchievements array
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,7 +39,7 @@ const Stats = () => {
|
||||
return achievements.filter((achievement) => achievement.achieved).length;
|
||||
}, [achievements]);
|
||||
|
||||
const resetStats = () => {
|
||||
const resetStats = async () => {
|
||||
const emptyStats = {
|
||||
level: 1,
|
||||
totalXp: 0,
|
||||
@@ -48,12 +49,12 @@ const Stats = () => {
|
||||
};
|
||||
localStorage.setItem('statsData', JSON.stringify(emptyStats));
|
||||
localStorage.setItem('eventLog', JSON.stringify([]));
|
||||
localStorage.setItem('achievements', JSON.stringify(initialAchievements));
|
||||
await clearAchievements(); // Clear achievements from IndexedDB
|
||||
setStats(emptyStats);
|
||||
setClearmodal(false);
|
||||
toast(variables.getMessage('toasts.stats_reset'));
|
||||
updateAchievements(); // Call updateAchievements to refresh achievements after reset
|
||||
Stats.clearSessionStats(); // Clear session stats when resetting
|
||||
updateAchievements();
|
||||
Stats.clearSessionStats();
|
||||
};
|
||||
|
||||
const downloadStats = () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { FaTrophy } from 'react-icons/fa';
|
||||
import { MdAccessTime, MdLock } from 'react-icons/md';
|
||||
import { PreferencesWrapper } from 'components/Layout/Settings';
|
||||
import { getLocalisedAchievementData } from 'features/stats/api/achievements';
|
||||
import { achievements as initialAchievements } from 'features/stats/api/achievements';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FaChevronDown, FaChevronUp } from 'react-icons/fa';
|
||||
|
||||
@@ -14,20 +15,43 @@ const AchievementElement = ({ achievementKey, id, achieved, timestamp }) => {
|
||||
const { name, description } = getLocalisedAchievementData(id);
|
||||
|
||||
return (
|
||||
<div className="achievement" key={achievementKey}>
|
||||
{achieved ? (
|
||||
<FaTrophy className="trophy flex-shrink-0" />
|
||||
) : (
|
||||
<MdLock className="trophyLocked flex-shrink-0" />
|
||||
)}
|
||||
<div className={'achievementContent' + (achieved ? ' achieved' : '')}>
|
||||
{achieved && timestamp && (
|
||||
<span className="timestamp">
|
||||
<MdAccessTime /> {new Date(timestamp).toLocaleDateString()}
|
||||
</span>
|
||||
<div
|
||||
className="relative flex items-center gap-6 p-6 rounded-xl backdrop-blur-sm bg-neutral-900/30 border border-neutral-800/50
|
||||
hover:bg-neutral-800/40 transition-all duration-300 group overflow-hidden
|
||||
shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)] hover:shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)] cursor-pointer"
|
||||
key={achievementKey}
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300
|
||||
${achieved ? 'bg-gradient-to-r from-yellow-500/10 to-amber-500/10' : 'bg-gradient-to-r from-neutral-700/10 to-neutral-600/10'}`}
|
||||
/>
|
||||
<div
|
||||
className={`relative flex-shrink-0 p-3 rounded-lg
|
||||
${achieved ? 'bg-gradient-to-br from-yellow-500/20 to-amber-600/20' : 'bg-neutral-800/50'}
|
||||
shadow-xl transition-transform duration-300 group-hover:scale-110`}
|
||||
>
|
||||
{achieved ? (
|
||||
<FaTrophy className="w-8 h-8 text-yellow-500 drop-shadow-[0_0_8px_rgba(234,179,8,0.3)]" />
|
||||
) : (
|
||||
<MdLock className="w-8 h-8 text-neutral-400" />
|
||||
)}
|
||||
<span className="text-base">{name}</span>
|
||||
<span className="text-xs text-neutral-400 leading-tight lowercase">
|
||||
</div>
|
||||
<div className="flex flex-col flex-grow">
|
||||
<div className="flex justify-between items-center">
|
||||
<span
|
||||
className={`text-lg font-bold tracking-tight
|
||||
${achieved ? 'text-neutral-100' : 'text-neutral-400'}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{achieved && timestamp && (
|
||||
<span className="text-xs text-neutral-500 flex items-center gap-1 bg-neutral-800/50 px-2 py-1 rounded-full">
|
||||
<MdAccessTime className="w-3 h-3" />
|
||||
{new Date(timestamp).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-neutral-400 leading-relaxed relative group-hover:text-neutral-300 transition-colors duration-300">
|
||||
{achieved ? description : '?????'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -37,10 +61,32 @@ const AchievementElement = ({ achievementKey, id, achieved, timestamp }) => {
|
||||
|
||||
const Achievements = ({ achievements, getUnlockedCount, STATS_SECTION, variables }) => {
|
||||
const [showLocked, setShowLocked] = useState(false);
|
||||
const [allAchievements, setAllAchievements] = useState([]);
|
||||
|
||||
const toggleLocked = () => {
|
||||
setShowLocked(!showLocked);
|
||||
};
|
||||
useEffect(() => {
|
||||
// Combine stored achievements with initial achievements to ensure we have all of them
|
||||
const combineAchievements = () => {
|
||||
// Start with all possible achievements
|
||||
const combined = initialAchievements.map((achievement) => {
|
||||
// Find if this achievement exists in the provided achievements array
|
||||
const stored = achievements.find((a) => a.id === achievement.id);
|
||||
if (stored) {
|
||||
// If it exists in stored achievements, use that data
|
||||
return stored;
|
||||
}
|
||||
// If it doesn't exist in stored achievements, use the initial data
|
||||
return {
|
||||
...achievement,
|
||||
achieved: false,
|
||||
};
|
||||
});
|
||||
setAllAchievements(combined);
|
||||
};
|
||||
|
||||
combineAchievements();
|
||||
}, [achievements]);
|
||||
|
||||
const totalAchievements = initialAchievements.length; // Get total from initial achievements
|
||||
|
||||
return (
|
||||
<div className="achievementsSection">
|
||||
@@ -50,13 +96,13 @@ const Achievements = ({ achievements, getUnlockedCount, STATS_SECTION, variables
|
||||
</span>
|
||||
<span className="text-neutral-200">
|
||||
{variables.getMessage(`${STATS_SECTION}.unlocked`, {
|
||||
count: `${getUnlockedCount}/${achievements.length}`,
|
||||
count: `${getUnlockedCount}/${totalAchievements}`, // Use total from initialAchievements
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="achievements">
|
||||
<div className="achievementsGrid">
|
||||
{achievements.map((achievement, index) => {
|
||||
{allAchievements.map((achievement, index) => {
|
||||
if (achievement.achieved) {
|
||||
return (
|
||||
<AchievementElement
|
||||
@@ -73,7 +119,7 @@ const Achievements = ({ achievements, getUnlockedCount, STATS_SECTION, variables
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-row justify-between items-center py-5 cursor-pointer"
|
||||
onClick={toggleLocked}
|
||||
onClick={() => setShowLocked(!showLocked)}
|
||||
>
|
||||
<span className="text-xl font-semibold">
|
||||
{variables.getMessage(`${STATS_SECTION}.locked`)}
|
||||
@@ -89,7 +135,7 @@ const Achievements = ({ achievements, getUnlockedCount, STATS_SECTION, variables
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="achievementsGrid preferencesInactive">
|
||||
{achievements.map((achievement, index) => {
|
||||
{allAchievements.map((achievement, index) => {
|
||||
if (!achievement.achieved) {
|
||||
return (
|
||||
<AchievementElement
|
||||
|
||||
@@ -3,6 +3,7 @@ const DB_VERSION = 4; // Increment version for new store
|
||||
const STORE_NAME = 'eventLog';
|
||||
const CUSTOM_STORE_NAME = 'customImages';
|
||||
const SESSION_STORE_NAME = 'sessionStats';
|
||||
const ACHIEVEMENTS_STORE = 'achievements';
|
||||
|
||||
export const openDB = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -27,6 +28,11 @@ export const openDB = () => {
|
||||
if (!db.objectStoreNames.contains(SESSION_STORE_NAME)) {
|
||||
db.createObjectStore(SESSION_STORE_NAME, { keyPath: 'tabId' });
|
||||
}
|
||||
|
||||
// Create achievements store if it doesn't exist
|
||||
if (!db.objectStoreNames.contains(ACHIEVEMENTS_STORE)) {
|
||||
db.createObjectStore(ACHIEVEMENTS_STORE, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
@@ -169,3 +175,40 @@ export const clearSessionStats = () => {
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Add new methods for achievements
|
||||
export const saveAchievement = async (achievement) => {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([ACHIEVEMENTS_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(ACHIEVEMENTS_STORE);
|
||||
const request = store.put(achievement);
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
};
|
||||
|
||||
export const getAchievements = async () => {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([ACHIEVEMENTS_STORE], 'readonly');
|
||||
const store = transaction.objectStore(ACHIEVEMENTS_STORE);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
};
|
||||
|
||||
export const clearAchievements = async () => {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([ACHIEVEMENTS_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(ACHIEVEMENTS_STORE);
|
||||
const request = store.clear();
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user