Skip to content

Commit 7ebe458

Browse files
committed
feat(terracotta): add TerracottaSidebar component
1 parent 1f844b6 commit 7ebe458

1 file changed

Lines changed: 295 additions & 0 deletions

File tree

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import React, { useRef, useState, useEffect, useCallback } from 'react';
2+
import { NavLink, Link, useLocation, useNavigate } from 'react-router-dom';
3+
import { motion } from 'framer-motion';
4+
import {
5+
CaretDoubleDownIcon,
6+
CaretDoubleUpIcon,
7+
ArrowRightIcon,
8+
MagnifyingGlassIcon,
9+
GearSixIcon,
10+
ShuffleIcon,
11+
EnvelopeSimpleIcon,
12+
} from '@phosphor-icons/react';
13+
14+
import { version } from '../version';
15+
import usePersistentState from '../hooks/usePersistentState';
16+
import { KEY_SIDEBAR_STATE } from '../utils/LocalStorageManager';
17+
import { useAchievements } from '../context/AchievementContext';
18+
import { useSiteConfig } from '../context/SiteConfigContext';
19+
import { appIcons as ICON_MAP } from '../utils/appIcons';
20+
import piml from 'piml';
21+
22+
const TerracottaSidebar = ({ isOpen, toggleSidebar, toggleModal, setIsPaletteOpen }) => {
23+
const { config } = useSiteConfig();
24+
const [sidebarConfig, setSidebarConfig] = useState(null);
25+
26+
useEffect(() => {
27+
const fetchSidebarConfig = async () => {
28+
try {
29+
const response = await fetch('/sidebar.piml');
30+
if (response.ok) {
31+
const text = await response.text();
32+
const parsed = piml.parse(text);
33+
setSidebarConfig(parsed.sidebar);
34+
}
35+
} catch (error) {
36+
console.error('Failed to load sidebar config:', error);
37+
}
38+
};
39+
fetchSidebarConfig();
40+
}, []);
41+
42+
const [sidebarState, setSidebarState] = usePersistentState(KEY_SIDEBAR_STATE, {
43+
isMainOpen: true,
44+
isContentOpen: true,
45+
isSoftwareOpen: true,
46+
isAppsOpen: true,
47+
isStatusOpen: false,
48+
isExtrasOpen: false,
49+
});
50+
51+
const { unlockAchievement } = useAchievements();
52+
const scrollRef = useRef(null);
53+
const location = useLocation();
54+
const navigate = useNavigate();
55+
const [showScrollGradient, setShowScrollGradient] = useState({
56+
top: false,
57+
bottom: false,
58+
});
59+
60+
const checkScroll = useCallback(() => {
61+
if (scrollRef.current) {
62+
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
63+
const atBottom = Math.ceil(scrollTop + clientHeight) >= scrollHeight;
64+
const atTop = scrollTop <= 0;
65+
const isScrollable = scrollHeight > clientHeight;
66+
setShowScrollGradient({
67+
top: isScrollable && !atTop,
68+
bottom: isScrollable && !atBottom,
69+
});
70+
}
71+
}, []);
72+
73+
useEffect(() => {
74+
checkScroll();
75+
const scrollElement = scrollRef.current;
76+
if (scrollElement) {
77+
scrollElement.addEventListener('scroll', checkScroll);
78+
window.addEventListener('resize', checkScroll);
79+
}
80+
return () => {
81+
if (scrollElement) {
82+
scrollElement.removeEventListener('scroll', checkScroll);
83+
window.removeEventListener('resize', checkScroll);
84+
}
85+
};
86+
}, [isOpen, sidebarState, checkScroll]);
87+
88+
const toggleSection = (section) => {
89+
setSidebarState((prev) => ({ ...prev, [section]: !prev[section] }));
90+
};
91+
92+
const getLinkClass = ({ isActive }) =>
93+
`group flex items-center justify-between px-6 py-3 transition-all duration-300 border-b border-[#1A161320] ${
94+
isActive
95+
? 'bg-[#C96442]/10 text-[#1A1613]'
96+
: 'text-[#2E2620]/70 hover:text-[#1A1613] hover:bg-[#E8DECE]/50'
97+
}`;
98+
99+
const SectionHeader = ({ id, label, isOpen, active }) => (
100+
<button
101+
onClick={() => toggleSection(id)}
102+
className={`flex items-center justify-between w-full px-6 py-4 border-b border-[#1A161320] transition-all duration-300 ${
103+
active
104+
? 'bg-[#C96442]/5 text-[#9E4A2F] border-l-2 border-[#C96442]'
105+
: 'text-[#2E2620]/50 hover:text-[#2E2620] border-l-2 border-transparent'
106+
}`}
107+
>
108+
<span className="font-mono text-[11px] uppercase tracking-[0.2em]">
109+
{'//'} {label}
110+
</span>
111+
<span className={`transform transition-transform duration-300 ${isOpen ? 'rotate-180' : ''}`}>
112+
113+
</span>
114+
</button>
115+
);
116+
117+
const sidebarVariants = {
118+
open: { x: 0, transition: { type: 'circOut', duration: 0.4 } },
119+
closed: { x: '-100%', transition: { type: 'circIn', duration: 0.3 } },
120+
};
121+
122+
return (
123+
<>
124+
<div
125+
className={`fixed inset-0 bg-[#1A1613]/50 backdrop-blur-sm z-40 md:hidden transition-opacity ${
126+
isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
127+
}`}
128+
onClick={toggleSidebar}
129+
/>
130+
131+
<motion.aside
132+
initial={false}
133+
animate={isOpen ? 'open' : 'closed'}
134+
variants={sidebarVariants}
135+
className="fixed top-0 left-0 h-screen w-72 bg-[#F3ECE0] z-50 flex flex-col border-r border-[#1A161320] shadow-[0_40px_80px_-40px_#1A161330]"
136+
>
137+
<div className="p-8 border-b border-[#1A161320] flex flex-col gap-2 bg-[#E8DECE]/50 relative">
138+
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-[#C96442]/70 via-[#B88532]/40 to-transparent" />
139+
<Link
140+
to="/"
141+
className="flex items-center gap-3 group"
142+
onClick={isOpen && window.innerWidth < 768 ? toggleSidebar : undefined}
143+
>
144+
<span className="text-2xl font-playfairDisplay italic tracking-tight text-[#1A1613]">
145+
{config?.hero?.title?.toLowerCase().endsWith('codex') ? (
146+
<>
147+
{config.hero.title.slice(0, -5)}
148+
<span className="text-[#C96442]">codex</span>
149+
</>
150+
) : (
151+
config?.hero?.title || 'Fezcodex'
152+
)}
153+
</span>
154+
</Link>
155+
<span className="font-mono text-[10px] text-[#2E2620]/50 uppercase tracking-widest">
156+
Archive Kernel {'//'} v{version} {'//'} {config?.kernel?.codename}
157+
</span>
158+
</div>
159+
160+
<div className="relative flex-grow overflow-hidden">
161+
{showScrollGradient.top && (
162+
<div className="absolute top-0 left-0 right-0 h-12 flex items-center justify-center bg-gradient-to-b from-[#F3ECE0] to-transparent z-20 pointer-events-none">
163+
<CaretDoubleUpIcon size={16} className="text-[#C96442] mt-2" />
164+
</div>
165+
)}
166+
167+
<div ref={scrollRef} className="h-full overflow-y-auto scrollbar-hide no-scrollbar">
168+
{Array.isArray(sidebarConfig) &&
169+
sidebarConfig.map((section, sectionIdx) => {
170+
const items = Array.isArray(section.content) ? section.content : [];
171+
const isActive = items.some((item) =>
172+
item.to === '/'
173+
? location.pathname === '/'
174+
: item.to && location.pathname.startsWith(item.to),
175+
);
176+
177+
return (
178+
<React.Fragment key={section.id || sectionIdx}>
179+
<SectionHeader
180+
id={section.id}
181+
label={section.label}
182+
isOpen={sidebarState[section.id]}
183+
active={isActive}
184+
/>
185+
{sidebarState[section.id] && (
186+
<nav className="flex flex-col">
187+
{items.map((item, idx) => {
188+
const Icon = ICON_MAP[item.icon] || ArrowRightIcon;
189+
if (
190+
item.external === 'true' ||
191+
item.url ||
192+
(item.to && item.to.startsWith('http'))
193+
) {
194+
return (
195+
<a
196+
key={idx}
197+
href={item.url || item.to}
198+
className="group flex items-center justify-between px-6 py-3 transition-all duration-300 border-b border-[#1A161320] text-[#2E2620]/70 hover:text-[#1A1613] hover:bg-[#E8DECE]/50"
199+
>
200+
<div className="flex items-center gap-4">
201+
<Icon size={18} weight="bold" />
202+
<span className="font-mono text-sm uppercase tracking-widest">
203+
{item.label}
204+
</span>
205+
</div>
206+
<ArrowRightIcon
207+
size={14}
208+
className="opacity-0 group-hover:opacity-100 -translate-x-2 group-hover:translate-x-0 transition-all"
209+
/>
210+
</a>
211+
);
212+
}
213+
return (
214+
<SidebarLink
215+
key={idx}
216+
to={item.to}
217+
icon={Icon}
218+
label={item.label}
219+
getLinkClass={getLinkClass}
220+
/>
221+
);
222+
})}
223+
</nav>
224+
)}
225+
</React.Fragment>
226+
);
227+
})}
228+
</div>
229+
230+
{showScrollGradient.bottom && (
231+
<div className="absolute bottom-0 left-0 right-0 h-12 flex items-center justify-center bg-gradient-to-t from-[#F3ECE0] to-transparent z-20 pointer-events-none">
232+
<CaretDoubleDownIcon size={16} className="text-[#C96442] mb-2" />
233+
</div>
234+
)}
235+
</div>
236+
237+
<div className="p-4 border-t border-[#1A161320] bg-[#E8DECE]/50">
238+
<div className="grid grid-cols-4 gap-2 mb-4">
239+
<FooterButton
240+
onClick={() => setIsPaletteOpen(true)}
241+
icon={MagnifyingGlassIcon}
242+
title="COMMANDS"
243+
/>
244+
<FooterButton
245+
onClick={() => navigate('/settings')}
246+
icon={GearSixIcon}
247+
title="SETTINGS"
248+
/>
249+
<FooterButton
250+
onClick={() => {
251+
navigate('/random');
252+
unlockAchievement('feeling_lucky');
253+
}}
254+
icon={ShuffleIcon}
255+
title="RANDOM"
256+
/>
257+
<FooterButton onClick={toggleModal} icon={EnvelopeSimpleIcon} title="CONTACT" />
258+
</div>
259+
<div className="text-center">
260+
<p className="font-mono text-[10px] text-[#2E2620]/40 uppercase tracking-widest">
261+
{${new Date().getFullYear()} Fezcode // Theme: Terracotta`}
262+
</p>
263+
</div>
264+
</div>
265+
</motion.aside>
266+
</>
267+
);
268+
};
269+
270+
const SidebarLink = ({ to, icon: Icon, label, getLinkClass }) => (
271+
<NavLink to={to} className={getLinkClass}>
272+
<div className="flex items-center gap-4">
273+
<Icon size={18} weight="bold" />
274+
<span className="font-mono text-sm uppercase tracking-widest">{label}</span>
275+
</div>
276+
<ArrowRightIcon
277+
size={14}
278+
className="opacity-0 group-hover:opacity-100 -translate-x-2 group-hover:translate-x-0 transition-all"
279+
/>
280+
</NavLink>
281+
);
282+
283+
const FooterButton = ({ onClick, icon: Icon, title }) => (
284+
<button
285+
onClick={onClick}
286+
title={title}
287+
className="group flex flex-col items-center justify-center p-2 border border-[#1A161320] bg-[#F3ECE0] hover:bg-[#C96442] hover:border-[#C96442] transition-all aspect-square"
288+
>
289+
<div className="text-[#1A1613] group-hover:text-[#F3ECE0] transition-all">
290+
<Icon size={18} weight="bold" />
291+
</div>
292+
</button>
293+
);
294+
295+
export default TerracottaSidebar;

0 commit comments

Comments
 (0)