Skip to content

Commit 5f6279e

Browse files
committed
feat: Achievements!!!
1 parent c5389fb commit 5f6279e

16 files changed

+718
-118
lines changed

public/posts/posts.json

Lines changed: 283 additions & 69 deletions
Large diffs are not rendered by default.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
Here at Fezcodex, we believe exploration should be rewarded. That's why we're thrilled to unveil the brand-new **Achievement System** – a fun and engaging way to discover all the hidden corners and cool features of the site!
2+
3+
## What is it?
4+
5+
The Achievement System gamifies your experience on Fezcodex. As you navigate, interact with our apps, explore visual modes, or simply read through our content, you'll be secretly unlocking various badges and trophies. Think of it as a personalized quest log for your journey through the digital world of Fezcodex!
6+
7+
## Why Achievements?
8+
9+
We wanted to make exploring the site more interactive and rewarding. With achievements, you can:
10+
* **Discover Hidden Gems:** Uncover features you might not have found otherwise.
11+
* **Track Your Progress:** See how much of Fezcodex you've truly experienced.
12+
* **Engage More:** Turn casual browsing into a rewarding adventure.
13+
14+
## How it Works
15+
16+
The system operates quietly in the background, tracking specific actions:
17+
18+
1. **Triggers:** Certain interactions, like opening the Command Palette, enabling a unique visual mode, or visiting a specific page, act as triggers.
19+
2. **Local Storage:** Your progress is saved securely and anonymously in your browser's local storage. No data is sent to any server – your achievements are yours alone!
20+
3. **Toast Notifications:** When you unlock a new achievement, a subtle (but celebratory!) toast notification will appear to let you know.
21+
22+
## A Glimpse at Some Achievements
23+
24+
Here are just a few examples of the achievements you can strive for:
25+
26+
* **Hello World:** The very first step on your Fezcodex journey.
27+
* **The Hacker:** For those who master the Command Palette.
28+
* **Curious Soul:** For taking the time to learn more about the creator.
29+
* **The Architect:** For appreciating the structural beauty of certain visual modes.
30+
* **Retro Futurist:** For embracing the aesthetics of a bygone era.
31+
* **Novice Reader, Avid Reader, Bookworm:** For delving into our blog posts and expanding your knowledge!
32+
33+
## Visit the Trophy Room!
34+
35+
Want to see your collection? Head over to the new [Trophy Room page](/#/achievements) (accessible via the sidebar) to view all the achievements you've unlocked and see what challenges still await!
36+
37+
We hope this new feature adds an extra layer of fun and discovery to your Fezcodex experience. Happy hunting!

src/App.js

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import DigitalRain from './components/DigitalRain';
1010
import { AnimationProvider } from './context/AnimationContext'; // Import AnimationProvider
1111
import { CommandPaletteProvider } from './context/CommandPaletteContext';
1212
import { VisualSettingsProvider } from './context/VisualSettingsContext';
13+
import { AchievementProvider } from './context/AchievementContext';
1314

1415
function App() {
1516
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -40,33 +41,35 @@ function App() {
4041

4142
return (
4243
<AnimationProvider>
43-
<VisualSettingsProvider>
44-
<Router>
45-
<DigitalRain isActive={isRainActive} />
46-
<ScrollToTop />
47-
<ToastProvider>
48-
<CommandPaletteProvider>
49-
<Layout
50-
toggleModal={toggleModal}
51-
isSearchVisible={isSearchVisible}
52-
toggleSearch={toggleSearch}
53-
openGenericModal={openGenericModal}
54-
toggleDigitalRain={toggleDigitalRain}
44+
<Router>
45+
<ToastProvider>
46+
<AchievementProvider>
47+
<VisualSettingsProvider>
48+
<DigitalRain isActive={isRainActive} />
49+
<ScrollToTop />
50+
<CommandPaletteProvider>
51+
<Layout
52+
toggleModal={toggleModal}
53+
isSearchVisible={isSearchVisible}
54+
toggleSearch={toggleSearch}
55+
openGenericModal={openGenericModal}
56+
toggleDigitalRain={toggleDigitalRain}
57+
>
58+
<AnimatedRoutes />
59+
</Layout>
60+
</CommandPaletteProvider>
61+
<ContactModal isOpen={isModalOpen} onClose={toggleModal} />
62+
<GenericModal
63+
isOpen={isGenericModalOpen}
64+
onClose={closeGenericModal}
65+
title={genericModalContent.title}
5566
>
56-
<AnimatedRoutes />
57-
</Layout>
58-
</CommandPaletteProvider>
59-
<ContactModal isOpen={isModalOpen} onClose={toggleModal} />
60-
<GenericModal
61-
isOpen={isGenericModalOpen}
62-
onClose={closeGenericModal}
63-
title={genericModalContent.title}
64-
>
65-
{genericModalContent.content}
66-
</GenericModal>
67-
</ToastProvider>
68-
</Router>
69-
</VisualSettingsProvider>
67+
{genericModalContent.content}
68+
</GenericModal>
69+
</VisualSettingsProvider>
70+
</AchievementProvider>
71+
</ToastProvider>
72+
</Router>
7073
</AnimationProvider>
7174
);
7275
}

src/components/AnimatedRoutes.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ const NotebooksPage = lazy(() => import('../pages/notebooks/NotebooksPage'));
8383
const NotebookViewerPage = lazy(() => import('../pages/notebooks/NotebookViewerPage'));
8484
const NewsPage = lazy(() => import('../pages/NewsPage'));
8585
const CommandsPage = lazy(() => import("../pages/CommandsPage"));
86+
const AchievementsPage = lazy(() => import("../pages/AchievementsPage"));
8687

8788
const pageVariants = {
8889
initial: {
@@ -496,6 +497,22 @@ function AnimatedRoutes() {
496497
</motion.div>
497498
}
498499
/>
500+
<Route
501+
path="/achievements"
502+
element={
503+
<motion.div
504+
initial="initial"
505+
animate="in"
506+
exit="out"
507+
variants={pageVariants}
508+
transition={pageTransition}
509+
>
510+
<Suspense fallback={<Loading />}>
511+
<AchievementsPage />
512+
</Suspense>
513+
</motion.div>
514+
}
515+
/>
499516
{/* Hardcoded redirects for fc::apps:: paths */}
500517
<Route path="/apps::ip" element={<Navigate to="/apps/ip" replace />} />
501518
<Route

src/components/CommandPalette.js

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import useSearchableData from '../hooks/useSearchableData';
55
import {useAnimation} from '../context/AnimationContext';
66
import {useToast} from '../hooks/useToast';
77
import {useVisualSettings} from '../context/VisualSettingsContext';
8+
import {useAchievements} from '../context/AchievementContext';
89
import {KEY_SIDEBAR_STATE, remove as removeLocalStorageItem} from '../utils/LocalStorageManager';
910
import {version} from '../version'; // Import the version
1011
import LiveClock from './LiveClock'; // Import LiveClock
@@ -25,17 +26,6 @@ const categoryColorMap = {
2526
'notebook': 'bg-lime-400',
2627
};
2728

28-
const categoryTextColorMap = {
29-
'page': 'text-red-100',
30-
'command': 'text-amber-100',
31-
'post': 'text-blue-100',
32-
'project': 'text-orange-100',
33-
'log': 'text-rose-100',
34-
'app': 'text-teal-100',
35-
'story': 'text-violet-100',
36-
'notebook': 'text-lime-100',
37-
};
38-
3929
const getCategoryColorClass = (type) => {
4030
return categoryColorMap[type] || 'bg-gray-500'; // Default gray for unmapped types
4131
};
@@ -67,13 +57,16 @@ const CommandPalette = ({isOpen, setIsOpen, openGenericModal, toggleDigitalRain}
6757
isGlitch, toggleGlitch
6858
} = useVisualSettings();
6959

60+
const { unlockAchievement } = useAchievements();
61+
7062
const filteredItems = filterItems(items, searchTerm);
7163

7264
useEffect(() => {
7365
if (isOpen) {
7466
inputRef.current?.focus();
67+
unlockAchievement('the_hacker');
7568
}
76-
}, [isOpen]);
69+
}, [isOpen, unlockAchievement]);
7770

7871
useEffect(() => {
7972
setSelectedIndex(0);

src/components/Sidebar.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
CaretDoubleDownIcon,
3131
CaretDoubleUpIcon,
3232
PushPin,
33+
Trophy,
3334
} from '@phosphor-icons/react';
3435

3536
import Fez from './Fez';
@@ -164,6 +165,10 @@ const Sidebar = ({isOpen, toggleSidebar, toggleModal, setIsPaletteOpen}) => {
164165
<UserIcon size={24}/>
165166
<span>About</span>
166167
</NavLink>
168+
<NavLink to="/achievements" className={getLinkClass}>
169+
<Trophy size={24}/>
170+
<span>Trophy Room</span>
171+
</NavLink>
167172
</nav>)}
168173
</div>
169174

src/components/Toast.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
22
import { motion } from 'framer-motion';
33
import { XIcon } from '@phosphor-icons/react';
44

5-
const Toast = ({ id, title, message, duration = 3000, type, removeToast }) => {
5+
const Toast = ({ id, title, message, duration = 3000, type, removeToast, icon }) => {
66
useEffect(() => {
77
const timer = setTimeout(() => {
88
removeToast(id);
@@ -28,7 +28,10 @@ const Toast = ({ id, title, message, duration = 3000, type, removeToast }) => {
2828
className={`${defaultStyle} ${type === 'error' ? `${errorStyle}` : `${successStyle}`}`}
2929
>
3030
<div className="flex flex-col text-sm group w-max flex-grow">
31-
<span className="text-base text-red-100">{title}</span>
31+
<div className="flex items-center gap-2"> {/* New div for icon and title */}
32+
{icon && <span className="text-xl text-red-100">{icon}</span>}
33+
<span className="text-base text-red-100">{title}</span>
34+
</div>
3235
<motion.hr
3336
initial={{ width: 0 }}
3437
animate={{ width: '100%' }}

src/config/achievements.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import {
2+
CompassIcon,
3+
ClockIcon,
4+
TerminalWindowIcon,
5+
PaintBrushIcon,
6+
GameControllerIcon,
7+
ColumnsIcon,
8+
SunglassesIcon,
9+
UserIcon,
10+
LightningIcon,
11+
SkullIcon,
12+
BookOpenIcon,
13+
BookBookmarkIcon,
14+
BooksIcon
15+
} from '@phosphor-icons/react';
16+
17+
export const ACHIEVEMENTS = [
18+
{
19+
id: 'hello_world',
20+
title: 'Hello World',
21+
description: 'Welcome to Fezcodex.',
22+
icon: <CompassIcon size={32} weight="duotone" />,
23+
category: 'Exploration'
24+
},
25+
{
26+
id: 'curious_soul',
27+
title: 'Curious Soul',
28+
description: 'Visited the About Me page to learn who is behind this.',
29+
icon: <UserIcon size={32} weight="duotone" />,
30+
category: 'Exploration'
31+
},
32+
{
33+
id: 'the_hacker',
34+
title: 'The Hacker',
35+
description: 'Opened the Command Palette.',
36+
icon: <TerminalWindowIcon size={32} weight="duotone" />,
37+
category: 'Tools'
38+
},
39+
{
40+
id: 'the_architect',
41+
title: 'The Architect',
42+
description: 'Enabled Blueprint or Hellenic mode.',
43+
icon: <ColumnsIcon size={32} weight="duotone" />,
44+
category: 'Visuals'
45+
},
46+
{
47+
id: 'retro_futurist',
48+
title: 'Retro Futurist',
49+
description: 'Enabled Vaporwave or Cyberpunk mode.',
50+
icon: <SunglassesIcon size={32} weight="duotone" />,
51+
category: 'Visuals'
52+
},
53+
{
54+
id: 'the_artist',
55+
title: 'The Artist',
56+
description: 'Enabled Sketchbook mode.',
57+
icon: <PaintBrushIcon size={32} weight="duotone" />,
58+
category: 'Visuals'
59+
},
60+
{
61+
id: 'retro_gamer',
62+
title: 'Retro Gamer',
63+
description: 'Enabled Game Boy mode.',
64+
icon: <GameControllerIcon size={32} weight="duotone" />,
65+
category: 'Visuals'
66+
},
67+
{
68+
id: 'glitch_hunter',
69+
title: 'Glitch Hunter',
70+
description: 'Discovered the Dystopian Glitch mode.',
71+
icon: <SkullIcon size={32} weight="duotone" />,
72+
category: 'Visuals'
73+
},
74+
{
75+
id: 'power_user',
76+
title: 'Power User',
77+
description: 'Visited the Settings page.',
78+
icon: <LightningIcon size={32} weight="duotone" />,
79+
category: 'Tools'
80+
},
81+
{
82+
id: 'novice_reader',
83+
title: 'Novice Reader',
84+
description: 'Read your first blog post.',
85+
icon: <BookOpenIcon size={32} weight="duotone" />,
86+
category: 'Content'
87+
},
88+
{
89+
id: 'avid_reader',
90+
title: 'Avid Reader',
91+
description: 'Read 5 different blog posts.',
92+
icon: <BookBookmarkIcon size={32} weight="duotone" />,
93+
category: 'Content'
94+
},
95+
{
96+
id: 'bookworm',
97+
title: 'Bookworm',
98+
description: 'Read 10 different blog posts. Impressive!',
99+
icon: <BooksIcon size={32} weight="duotone" />,
100+
category: 'Content'
101+
}
102+
];

src/context/AchievementContext.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React, { createContext, useContext, useEffect, useState } from 'react';
2+
import usePersistentState from '../hooks/usePersistentState';
3+
import { useToast } from '../hooks/useToast';
4+
import { ACHIEVEMENTS } from '../config/achievements';
5+
6+
const AchievementContext = createContext();
7+
8+
export const useAchievements = () => {
9+
return useContext(AchievementContext);
10+
};
11+
12+
export const AchievementProvider = ({ children }) => {
13+
// Store achievements as { [id]: { unlocked: boolean, unlockedAt: string } }
14+
const [unlockedAchievements, setUnlockedAchievements] = usePersistentState('unlocked-achievements', {});
15+
const [readPosts, setReadPosts] = usePersistentState('read-posts', []);
16+
const { addToast } = useToast();
17+
18+
// Helper to unlock an achievement
19+
const unlockAchievement = (id) => {
20+
// Check if valid achievement ID
21+
const achievement = ACHIEVEMENTS.find(a => a.id === id);
22+
if (!achievement) return;
23+
24+
// Check if already unlocked
25+
if (unlockedAchievements[id]?.unlocked) return;
26+
27+
const now = new Date().toISOString();
28+
29+
setUnlockedAchievements(prev => ({
30+
...prev,
31+
[id]: { unlocked: true, unlockedAt: now }
32+
}));
33+
34+
// Trigger Toast
35+
addToast({
36+
title: 'Achievement Unlocked!',
37+
message: achievement.title,
38+
duration: 4000,
39+
// You might want to add a specific type or icon here later for styling
40+
});
41+
};
42+
43+
const trackReadingProgress = (slug) => {
44+
if (!slug) return;
45+
46+
setReadPosts(prev => {
47+
if (prev.includes(slug)) return prev; // Already read
48+
49+
const newReadPosts = [...prev, slug];
50+
const count = newReadPosts.length;
51+
52+
if (count >= 1) unlockAchievement('novice_reader');
53+
if (count >= 5) unlockAchievement('avid_reader');
54+
if (count >= 10) unlockAchievement('bookworm');
55+
56+
return newReadPosts;
57+
});
58+
};
59+
60+
// Automatically unlock 'hello_world' on mount if not already
61+
useEffect(() => {
62+
unlockAchievement('hello_world');
63+
}, []);
64+
65+
return (
66+
<AchievementContext.Provider value={{ unlockedAchievements, unlockAchievement, trackReadingProgress }}>
67+
{children}
68+
</AchievementContext.Provider>
69+
);
70+
};

0 commit comments

Comments
 (0)