Skip to content

Commit 997f044

Browse files
committed
feat: new app
1 parent 927b2eb commit 997f044

File tree

4 files changed

+376
-0
lines changed

4 files changed

+376
-0
lines changed

public/apps/apps.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,15 @@
139139
"icon": "CirclesFourIcon",
140140
"created_at": "2025-11-25T15:43:25+03:00"
141141
},
142+
{
143+
"slug": "alchemy-lab",
144+
"to": "/apps/alchemy-lab",
145+
"title": "Alchemy Lab",
146+
"description": "Combine elements and discover the secrets of the digital universe.",
147+
"icon": "FlaskIcon",
148+
"pinned_order": 2,
149+
"created_at": "2026-01-04T17:00:00+03:00"
150+
},
142151
{
143152
"slug": "roguelike-game",
144153
"to": "/apps/roguelike-game",

src/components/AnimatedRoutes.jsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ const NotepadPage = lazy(() => import('../pages/apps/NotepadPage'));
136136
const CozyAppPage = lazy(() => import('../pages/apps/CozyAppPage'));
137137
const SpirographPage = lazy(() => import('../pages/apps/SpirographPage'));
138138
const FractalFloraPage = lazy(() => import('../pages/apps/FractalFloraPage'));
139+
const AlchemyLabPage = lazy(() => import('../pages/apps/AlchemyLabPage'));
139140

140141
const AbstractWavesPage = lazy(() => import('../pages/apps/AbstractWavesPage'));
141142
const TopographicMapPage = lazy(
@@ -2472,6 +2473,22 @@ const AnimatedRoutes = ({
24722473
</motion.div>
24732474
}
24742475
/>
2476+
<Route
2477+
path="/apps/alchemy-lab"
2478+
element={
2479+
<motion.div
2480+
initial="initial"
2481+
animate="in"
2482+
exit="out"
2483+
variants={pageVariants}
2484+
transition={pageTransition}
2485+
>
2486+
<Suspense fallback={<Loading />}>
2487+
<AlchemyLabPage />
2488+
</Suspense>
2489+
</motion.div>
2490+
}
2491+
/>
24752492
<Route
24762493
path="/apps/abstract-waves"
24772494
element={

src/pages/apps/AlchemyLabPage.jsx

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
import React, { useState, useCallback } from 'react';
2+
import { Link } from 'react-router-dom';
3+
import { motion, AnimatePresence } from 'framer-motion';
4+
import {
5+
FireIcon,
6+
WavesIcon,
7+
WindIcon,
8+
SkullIcon,
9+
SwordIcon,
10+
PersonIcon,
11+
HeartIcon,
12+
LightningIcon,
13+
CloudRainIcon,
14+
SunIcon,
15+
MoonIcon,
16+
AtomIcon,
17+
PlantIcon,
18+
TreeIcon,
19+
BugIcon,
20+
GlobeIcon,
21+
HardDriveIcon,
22+
CodeIcon,
23+
PlusIcon,
24+
ArrowLeftIcon
25+
} from '@phosphor-icons/react';
26+
import Fez from '../../components/Fez';
27+
import usePersistentState from '../../hooks/usePersistentState';
28+
import { useToast } from '../../hooks/useToast';
29+
import useSeo from '../../hooks/useSeo';
30+
import BreadcrumbTitle from '../../components/BreadcrumbTitle';
31+
import BrutalistDialog from '../../components/BrutalistDialog';
32+
33+
const ITEMS = {
34+
// Base Elements
35+
fire: { id: 'fire', name: 'Fire', icon: FireIcon, color: 'text-orange-500' },
36+
water: { id: 'water', name: 'Water', icon: WavesIcon, color: 'text-blue-500' },
37+
air: { id: 'air', name: 'Air', icon: WindIcon, color: 'text-cyan-400' },
38+
earth: { id: 'earth', name: 'Earth', icon: GlobeIcon, color: 'text-amber-700' },
39+
40+
// Tier 1
41+
lava: { id: 'lava', name: 'Lava', icon: FireIcon, color: 'text-red-600' },
42+
mud: { id: 'mud', name: 'Mud', icon: GlobeIcon, color: 'text-yellow-900' },
43+
steam: { id: 'steam', name: 'Steam', icon: WindIcon, color: 'text-gray-300' },
44+
dust: { id: 'dust', name: 'Dust', icon: WindIcon, color: 'text-yellow-600' },
45+
rain: { id: 'rain', name: 'Rain', icon: CloudRainIcon, color: 'text-blue-300' },
46+
energy: { id: 'energy', name: 'Energy', icon: LightningIcon, color: 'text-yellow-400' },
47+
48+
// Tier 2
49+
stone: { id: 'stone', name: 'Stone', icon: GlobeIcon, color: 'text-gray-500' },
50+
life: { id: 'life', name: 'Life', icon: HeartIcon, color: 'text-pink-500' },
51+
plant: { id: 'plant', name: 'Plant', icon: PlantIcon, color: 'text-green-500' },
52+
metal: { id: 'metal', name: 'Metal', icon: AtomIcon, color: 'text-blue-100' },
53+
sun: { id: 'sun', name: 'Sun', icon: SunIcon, color: 'text-yellow-500' },
54+
moon: { id: 'moon', name: 'Moon', icon: MoonIcon, color: 'text-blue-100' },
55+
56+
// Tier 3
57+
sword: { id: 'sword', name: 'Sword', icon: SwordIcon, color: 'text-gray-300' },
58+
organism: { id: 'organism', name: 'Organism', icon: BugIcon, color: 'text-green-400' },
59+
tree: { id: 'tree', name: 'Tree', icon: TreeIcon, color: 'text-green-700' },
60+
electricity: { id: 'electricity', name: 'Electricity', icon: LightningIcon, color: 'text-yellow-300' },
61+
62+
// Tier 4
63+
human: { id: 'human', name: 'Human', icon: PersonIcon, color: 'text-orange-200' },
64+
computer: { id: 'computer', name: 'Computer', icon: HardDriveIcon, color: 'text-blue-400' },
65+
knight: { id: 'knight', name: 'Knight', icon: SwordIcon, color: 'text-indigo-400' },
66+
67+
// Tier 5 (Ultimate)
68+
code: { id: 'code', name: 'Code', icon: CodeIcon, color: 'text-emerald-500' },
69+
fezcodex: { id: 'fezcodex', name: 'Fezcodex', icon: Fez, color: 'text-emerald-400' },
70+
ghost: { id: 'ghost', name: 'Ghost', icon: SkullIcon, color: 'text-purple-300' },
71+
};
72+
73+
const RECIPES = [
74+
{ a: 'fire', b: 'earth', result: 'lava' },
75+
{ a: 'water', b: 'earth', result: 'mud' },
76+
{ a: 'fire', b: 'water', result: 'steam' },
77+
{ a: 'air', b: 'earth', result: 'dust' },
78+
{ a: 'air', b: 'water', result: 'rain' },
79+
{ a: 'air', b: 'fire', result: 'energy' },
80+
{ a: 'lava', b: 'air', result: 'stone' },
81+
{ a: 'energy', b: 'mud', result: 'life' },
82+
{ a: 'life', b: 'earth', result: 'plant' },
83+
{ a: 'stone', b: 'fire', result: 'metal' },
84+
{ a: 'energy', b: 'fire', result: 'sun' },
85+
{ a: 'stone', b: 'sun', result: 'moon' },
86+
{ a: 'metal', b: 'fire', result: 'sword' },
87+
{ a: 'life', b: 'water', result: 'organism' },
88+
{ a: 'plant', b: 'earth', result: 'tree' },
89+
{ a: 'metal', b: 'energy', result: 'electricity' },
90+
{ a: 'organism', b: 'earth', result: 'human' },
91+
{ a: 'electricity', b: 'metal', result: 'computer' },
92+
{ a: 'human', b: 'sword', result: 'knight' },
93+
{ a: 'human', b: 'computer', result: 'code' },
94+
{ a: 'code', b: 'life', result: 'fezcodex' },
95+
{ a: 'human', b: 'energy', result: 'ghost' },
96+
];
97+
98+
const AlchemyLabPage = () => {
99+
const [discovered, setDiscovered] = usePersistentState('alchemy-discovered', ['fire', 'water', 'air', 'earth']);
100+
const [slot1, setSlot1] = useState(null);
101+
const [slot2, setSlot2] = useState(null);
102+
const [lastResult, setLastResult] = useState(null);
103+
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
104+
const { addToast } = useToast();
105+
106+
useSeo({
107+
title: 'Alchemy Lab | Fezcodex',
108+
description: 'Combine base elements to discover the secrets of the digital universe in this atmospheric alchemy game.',
109+
keywords: ['alchemy', 'game', 'crafting', 'elements', 'fezcodex'],
110+
});
111+
112+
const combine = useCallback(() => {
113+
if (!slot1 || !slot2) return;
114+
115+
const recipe = RECIPES.find(r =>
116+
(r.a === slot1 && r.b === slot2) || (r.a === slot2 && r.b === slot1)
117+
);
118+
119+
if (recipe) {
120+
const result = recipe.result;
121+
setLastResult(result);
122+
if (!discovered.includes(result)) {
123+
setDiscovered([...discovered, result]);
124+
addToast({
125+
title: 'Transmutation Success',
126+
message: `Conceptualized: ${ITEMS[result].name.toUpperCase()}`,
127+
duration: 3000
128+
});
129+
}
130+
} else {
131+
setLastResult('fail');
132+
}
133+
134+
// Auto clear slots after attempt
135+
setTimeout(() => {
136+
setSlot1(null);
137+
setSlot2(null);
138+
setLastResult(null);
139+
}, 1500);
140+
}, [slot1, slot2, discovered, setDiscovered, addToast]);
141+
142+
const handleItemClick = (id) => {
143+
if (!slot1) setSlot1(id);
144+
else if (!slot2) setSlot2(id);
145+
};
146+
147+
return (
148+
<div className="min-h-screen bg-[#050505] text-white font-mono selection:bg-emerald-500/30 overflow-hidden flex flex-col">
149+
{/* Header */}
150+
<div className="p-6 md:p-12 border-b border-white/10 flex justify-between items-center bg-black/50 backdrop-blur-md z-50">
151+
<div className="flex items-center gap-8">
152+
<Link to="/apps" className="text-gray-500 hover:text-white transition-colors">
153+
<ArrowLeftIcon size={24} weight="bold" />
154+
</Link>
155+
<div>
156+
<BreadcrumbTitle title="Alchemy Lab" slug="al" variant="brutalist" />
157+
<p className="text-[10px] text-gray-600 uppercase tracking-widest mt-1">Transmutation_Protocol_Active</p>
158+
</div>
159+
</div>
160+
161+
<div className="flex gap-2">
162+
<button
163+
onClick={() => setIsResetDialogOpen(true)}
164+
className="px-3 py-1 border border-red-500/20 text-red-500 hover:bg-red-500 hover:text-black text-[10px] font-bold uppercase transition-all"
165+
>
166+
Purge_Discoveries
167+
</button>
168+
</div>
169+
</div>
170+
171+
<BrutalistDialog
172+
isOpen={isResetDialogOpen}
173+
onClose={() => setIsResetDialogOpen(false)}
174+
onConfirm={() => {
175+
setDiscovered(['fire', 'water', 'air', 'earth']);
176+
setIsResetDialogOpen(false);
177+
addToast({ title: 'System Reset', message: 'All elemental data has been purged.', type: 'info' });
178+
}}
179+
title="PURGE_DATABASE"
180+
message="This will reset all your discoveries to the 4 base elements. proceed?"
181+
confirmText="CONFIRM_PURGE"
182+
cancelText="ABORT_RESET"
183+
/>
184+
185+
{/* Main Content */}
186+
<div className="flex-grow flex flex-col md:flex-row overflow-hidden">
187+
{/* Lab Area */}
188+
<div className="flex-grow flex flex-col items-center justify-center p-8 bg-[radial-gradient(#ffffff05_1px,transparent_1px)] [background-size:40px_40px]">
189+
<div className="flex items-center gap-4 md:gap-12 mb-12">
190+
{/* Slot 1 */}
191+
<div
192+
onClick={() => setSlot1(null)}
193+
className={`w-24 h-24 md:w-32 md:h-32 border-2 flex items-center justify-center relative transition-all cursor-pointer
194+
${slot1 ? 'border-white bg-white/5 scale-110 shadow-[0_0_30px_rgba(255,255,255,0.05)]' : 'border-dashed border-white/10 hover:border-white/30'}`}
195+
>
196+
{slot1 ? (
197+
<div className="flex flex-col items-center">
198+
{React.createElement(ITEMS[slot1].icon, {
199+
size: 48,
200+
weight: 'bold',
201+
className: ITEMS[slot1].color
202+
})}
203+
<span className="text-[9px] mt-2 uppercase font-black tracking-tighter">{ITEMS[slot1].name}</span>
204+
</div>
205+
) : (
206+
<PlusIcon size={24} className="opacity-10" />
207+
)}
208+
</div>
209+
210+
<div className="text-white/20">
211+
<PlusIcon size={32} weight="bold" />
212+
</div>
213+
214+
{/* Slot 2 */}
215+
<div
216+
onClick={() => setSlot2(null)}
217+
className={`w-24 h-24 md:w-32 md:h-32 border-2 flex items-center justify-center relative transition-all cursor-pointer
218+
${slot2 ? 'border-white bg-white/5 scale-110 shadow-[0_0_30px_rgba(255,255,255,0.05)]' : 'border-dashed border-white/10 hover:border-white/30'}`}
219+
>
220+
{slot2 ? (
221+
<div className="flex flex-col items-center">
222+
{React.createElement(ITEMS[slot2].icon, {
223+
size: 48,
224+
weight: 'bold',
225+
className: ITEMS[slot2].color
226+
})}
227+
<span className="text-[9px] mt-2 uppercase font-black tracking-tighter">{ITEMS[slot2].name}</span>
228+
</div>
229+
) : (
230+
<PlusIcon size={24} className="opacity-10" />
231+
)}
232+
</div>
233+
</div>
234+
235+
<div className="h-32 flex items-center justify-center">
236+
<AnimatePresence mode="wait">
237+
{slot1 && slot2 && !lastResult && (
238+
<motion.button
239+
initial={{ opacity: 0, y: 20 }}
240+
animate={{ opacity: 1, y: 0 }}
241+
exit={{ opacity: 0, scale: 0.8 }}
242+
onClick={combine}
243+
className="px-12 py-4 bg-white text-black font-black uppercase tracking-[0.3em] text-xs hover:bg-emerald-400 transition-colors"
244+
>
245+
Transmute
246+
</motion.button>
247+
)}
248+
249+
{lastResult === 'fail' && (
250+
<motion.div
251+
initial={{ opacity: 0, scale: 0.9 }}
252+
animate={{ opacity: 1, scale: 1 }}
253+
exit={{ opacity: 0 }}
254+
className="text-red-500 font-bold uppercase tracking-widest text-[10px] border border-red-500/20 px-4 py-2"
255+
>
256+
Conceptual_Mismatch: No Reaction
257+
</motion.div>
258+
)}
259+
260+
{lastResult && lastResult !== 'fail' && (
261+
<motion.div
262+
initial={{ opacity: 0, scale: 0.5, rotate: -180 }}
263+
animate={{ opacity: 1, scale: 1.5, rotate: 0 }}
264+
exit={{ opacity: 0, scale: 2 }}
265+
className="flex flex-col items-center"
266+
>
267+
{React.createElement(ITEMS[lastResult].icon, {
268+
size: 64,
269+
weight: 'bold',
270+
className: ITEMS[lastResult].color
271+
})}
272+
<span className="text-[10px] mt-4 uppercase font-black tracking-widest text-emerald-400">
273+
{ITEMS[lastResult].name}
274+
</span>
275+
</motion.div>
276+
)}
277+
</AnimatePresence>
278+
</div>
279+
</div>
280+
281+
{/* Inventory Sidebar */}
282+
<div className="w-full md:w-80 border-l border-white/10 bg-black/30 backdrop-blur-sm flex flex-col">
283+
<div className="p-4 border-b border-white/10 flex justify-between items-center">
284+
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500">Inventory</span>
285+
<span className="text-[10px] font-bold text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded-full">
286+
{discovered.length} / {Object.keys(ITEMS).length}
287+
</span>
288+
</div>
289+
<div className="flex-grow overflow-y-auto p-4 grid grid-cols-3 gap-2 scrollbar-hide">
290+
{discovered.map(id => (
291+
<motion.div
292+
key={id}
293+
whileHover={{ scale: 1.05 }}
294+
whileTap={{ scale: 0.95 }}
295+
onClick={() => handleItemClick(id)}
296+
className={`aspect-square border flex flex-col items-center justify-center cursor-pointer transition-all
297+
${slot1 === id || slot2 === id ? 'border-emerald-500 bg-emerald-500/10' : 'border-white/5 hover:border-white/20 bg-white/[0.02]'}`}
298+
>
299+
{React.createElement(ITEMS[id].icon, {
300+
size: 24,
301+
weight: 'bold',
302+
className: slot1 === id || slot2 === id ? 'text-emerald-400' : ITEMS[id].color
303+
})}
304+
<span className="text-[7px] mt-1.5 uppercase font-bold text-center leading-none tracking-tighter opacity-60">
305+
{ITEMS[id].name}
306+
</span>
307+
</motion.div>
308+
))}
309+
</div>
310+
</div>
311+
</div>
312+
313+
{/* Footer */}
314+
<footer className="p-4 bg-black/80 border-t border-white/10 flex justify-between items-center text-[9px] font-mono text-gray-600 uppercase tracking-widest">
315+
<div className="flex gap-6">
316+
<span>Alchemy_V1.0</span>
317+
<span>Buffer_Cleared</span>
318+
</div>
319+
<div className="flex items-center gap-2">
320+
<div className={`w-2 h-2 rounded-full ${discovered.length === Object.keys(ITEMS).length ? 'bg-emerald-500 animate-pulse' : 'bg-yellow-500'}`} />
321+
<span>{discovered.length === Object.keys(ITEMS).length ? 'Master_Alchemist_Status' : 'Research_In_Progress'}</span>
322+
</div>
323+
</footer>
324+
</div>
325+
);
326+
};
327+
328+
export default AlchemyLabPage;

0 commit comments

Comments
 (0)