Skip to content

Commit 1f844b6

Browse files
committed
feat(terracotta): add TerracottaCommandPalette component
1 parent 188c6dc commit 1f844b6

1 file changed

Lines changed: 255 additions & 0 deletions

File tree

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import React, { useState, useEffect, useRef, useCallback } from 'react';
2+
import { useNavigate } from 'react-router-dom';
3+
import { AnimatePresence, motion } from 'framer-motion';
4+
import useSearchableData from '../hooks/useSearchableData';
5+
import { useAchievements } from '../context/AchievementContext';
6+
import { TerminalWindowIcon } from '@phosphor-icons/react';
7+
import { useCommandPalette } from '../context/CommandPaletteContext';
8+
import { filterItems } from '../utils/search';
9+
import { useCommandRegistry } from '../hooks/useCommandRegistry';
10+
11+
const categoryColorMap = {
12+
page: 'bg-[#9E4A2F]',
13+
command: 'bg-[#B88532]',
14+
post: 'bg-[#6B8E23]',
15+
project: 'bg-[#C96442]',
16+
log: 'bg-[#9E4A2F]',
17+
app: 'bg-[#6B8E23]',
18+
story: 'bg-[#8A6A32]',
19+
notebook: 'bg-[#6B8E23]',
20+
};
21+
22+
const getCategoryColorClass = (type) => categoryColorMap[type] || 'bg-[#2E2620]';
23+
24+
const TerracottaCommandPalette = ({
25+
isOpen,
26+
setIsOpen,
27+
openGenericModal,
28+
toggleDigitalRain,
29+
toggleBSOD,
30+
}) => {
31+
const [searchTerm, setSearchTerm] = useState('');
32+
const [selectedIndex, setSelectedIndex] = useState(0);
33+
const { items, isLoading } = useSearchableData();
34+
const navigate = useNavigate();
35+
const inputRef = useRef(null);
36+
const resultsRef = useRef(null);
37+
38+
const { unlockAchievement } = useAchievements();
39+
const { setTriggerCommand } = useCommandPalette();
40+
41+
const { executeCommand } = useCommandRegistry({
42+
openGenericModal,
43+
toggleDigitalRain,
44+
toggleBSOD,
45+
items,
46+
});
47+
48+
const filteredItems = filterItems(items, searchTerm);
49+
50+
useEffect(() => {
51+
if (isOpen) {
52+
inputRef.current?.focus();
53+
unlockAchievement('the_hacker');
54+
}
55+
}, [isOpen, unlockAchievement]);
56+
57+
useEffect(() => {
58+
const lowerTerm = searchTerm.toLowerCase();
59+
if (lowerTerm === 'hello?' || lowerTerm === 'is anyone there?') {
60+
unlockAchievement('echo_in_the_void');
61+
}
62+
if (lowerTerm === 'command palette' || lowerTerm === 'the hacker') {
63+
unlockAchievement('the_paradox');
64+
}
65+
if (lowerTerm === '0028:c0de1337') {
66+
unlockAchievement('code_1337');
67+
}
68+
}, [searchTerm, unlockAchievement]);
69+
70+
useEffect(() => {
71+
setSelectedIndex(0);
72+
}, [searchTerm, items]);
73+
74+
const handleClose = useCallback(() => {
75+
setIsOpen(false);
76+
setSearchTerm('');
77+
}, [setIsOpen]);
78+
79+
const triggerBSOD = useCallback(() => {
80+
unlockAchievement('bsod');
81+
toggleBSOD();
82+
}, [unlockAchievement, toggleBSOD]);
83+
84+
useEffect(() => {
85+
setTriggerCommand(() => executeCommand);
86+
}, [setTriggerCommand, executeCommand]);
87+
88+
const handleItemClick = useCallback(
89+
(item) => {
90+
if (!item) return;
91+
if (item.type === 'command') {
92+
executeCommand(item.commandId);
93+
} else {
94+
navigate(item.path);
95+
}
96+
handleClose();
97+
},
98+
[executeCommand, navigate, handleClose],
99+
);
100+
101+
useEffect(() => {
102+
const handleKeyDown = (event) => {
103+
if (!isOpen) return;
104+
105+
if (event.key === 'ArrowUp') {
106+
event.preventDefault();
107+
setSelectedIndex((prev) => (prev === 0 ? filteredItems.length - 1 : prev - 1));
108+
} else if (event.key === 'ArrowDown') {
109+
event.preventDefault();
110+
setSelectedIndex((prev) => (prev === filteredItems.length - 1 ? 0 : prev + 1));
111+
} else if (event.key === 'Enter') {
112+
event.preventDefault();
113+
if (searchTerm.toLowerCase() === 'bsod') {
114+
triggerBSOD();
115+
handleClose();
116+
} else if (filteredItems[selectedIndex]) {
117+
handleItemClick(filteredItems[selectedIndex]);
118+
}
119+
} else if (event.key === 'Escape') {
120+
handleClose();
121+
} else if (event.key === 'PageUp') {
122+
event.preventDefault();
123+
setSelectedIndex((prev) => (prev <= 0 ? filteredItems.length - 1 : prev - 10));
124+
} else if (event.key === 'PageDown') {
125+
event.preventDefault();
126+
setSelectedIndex((prev) => (prev >= filteredItems.length - 10 ? 0 : prev + 10));
127+
}
128+
};
129+
130+
window.addEventListener('keydown', handleKeyDown);
131+
return () => window.removeEventListener('keydown', handleKeyDown);
132+
}, [isOpen, filteredItems, selectedIndex, searchTerm, triggerBSOD, handleItemClick, handleClose]);
133+
134+
useEffect(() => {
135+
const selectedItem = resultsRef.current?.children[selectedIndex];
136+
if (selectedItem) selectedItem.scrollIntoView({ block: 'nearest' });
137+
}, [selectedIndex]);
138+
139+
return (
140+
<AnimatePresence>
141+
{isOpen && (
142+
<div
143+
className="fixed inset-0 bg-[#1A1613]/60 backdrop-blur-sm z-[1000] flex items-start justify-center pt-16 md:pt-32"
144+
onClick={handleClose}
145+
>
146+
<motion.div
147+
initial={{ opacity: 0, scale: 0.98, y: -10 }}
148+
animate={{ opacity: 1, scale: 1, y: 0 }}
149+
exit={{ opacity: 0, scale: 0.98, y: -10 }}
150+
transition={{ duration: 0.2, ease: 'easeOut' }}
151+
className="bg-[#F3ECE0] text-[#1A1613] rounded-sm border border-[#1A161320] shadow-[0_40px_80px_-40px_#1A161360] w-full max-w-2xl mx-4 overflow-hidden"
152+
onClick={(e) => e.stopPropagation()}
153+
>
154+
<div className="p-6 flex items-center gap-4 bg-[#E8DECE]/50">
155+
<TerminalWindowIcon size={32} weight="fill" className="text-[#C96442]" />
156+
<input
157+
ref={inputRef}
158+
type="text"
159+
placeholder={isLoading ? 'Initialising...' : 'Search registry...'}
160+
className="w-full bg-transparent text-xl md:text-2xl font-playfairDisplay italic placeholder-[#2E2620]/30 focus:outline-none tracking-tight text-[#1A1613]"
161+
value={searchTerm}
162+
onChange={(e) => setSearchTerm(e.target.value)}
163+
disabled={isLoading}
164+
/>
165+
</div>
166+
167+
<div
168+
ref={resultsRef}
169+
className="border-t border-[#1A161320] max-h-[50vh] overflow-y-auto scrollbar-hide bg-[#F3ECE0]"
170+
>
171+
{filteredItems.length > 0 ? (
172+
filteredItems.map((item, index) => (
173+
<div
174+
key={`${item.type}-${item.slug || item.commandId}-${index}`}
175+
className={`px-6 py-4 cursor-pointer flex justify-between items-center transition-all duration-200 border-l-2 ${
176+
selectedIndex === index
177+
? 'bg-[#E8DECE] border-[#C96442] translate-x-1'
178+
: 'hover:bg-[#E8DECE]/60 border-transparent'
179+
}`}
180+
onClick={() => handleItemClick(item)}
181+
onMouseMove={() => setSelectedIndex(index)}
182+
>
183+
<div className="flex flex-col gap-0.5">
184+
<span
185+
className={`text-lg font-playfairDisplay italic tracking-tight ${
186+
selectedIndex === index ? 'text-[#1A1613]' : 'text-[#2E2620]/80'
187+
}`}
188+
>
189+
{item.title}
190+
</span>
191+
{item.description && (
192+
<span className="text-[10px] font-mono uppercase tracking-widest text-[#2E2620]/50 truncate max-w-md">
193+
{item.description}
194+
</span>
195+
)}
196+
</div>
197+
198+
<span
199+
className={`px-2 py-0.5 text-[9px] font-mono font-bold uppercase tracking-widest text-[#F3ECE0] ${getCategoryColorClass(item.type)} rounded-sm`}
200+
>
201+
{item.type}
202+
</span>
203+
</div>
204+
))
205+
) : (
206+
<div className="p-12 text-center">
207+
<p className="font-mono text-sm text-[#2E2620]/50 uppercase tracking-widest">
208+
{isLoading
209+
? 'Fetching registry...'
210+
: `No matches found for "${searchTerm}"`}
211+
</p>
212+
</div>
213+
)}
214+
</div>
215+
216+
<div className="border-t border-[#1A161320] p-4 bg-[#E8DECE]/50 flex items-center justify-between text-[10px] font-mono uppercase tracking-[0.2em]">
217+
<div className="flex items-center gap-6 text-[#2E2620]/60">
218+
<div className="flex items-center gap-2">
219+
<kbd className="px-1.5 py-0.5 bg-[#F3ECE0] rounded border border-[#1A161320] text-[#1A1613]">
220+
ESC
221+
</kbd>
222+
<span>Close</span>
223+
</div>
224+
<div className="flex items-center gap-2">
225+
<div className="flex gap-1">
226+
<kbd className="px-1.5 py-0.5 bg-[#F3ECE0] rounded border border-[#1A161320] text-[#1A1613]">
227+
228+
</kbd>
229+
<kbd className="px-1.5 py-0.5 bg-[#F3ECE0] rounded border border-[#1A161320] text-[#1A1613]">
230+
231+
</kbd>
232+
</div>
233+
<span>Navigate</span>
234+
</div>
235+
<div className="flex items-center gap-2">
236+
<kbd className="px-1.5 py-0.5 bg-[#F3ECE0] rounded border border-[#1A161320] text-[#1A1613]">
237+
238+
</kbd>
239+
<span>Select</span>
240+
</div>
241+
</div>
242+
243+
<div className="font-bold text-[#2E2620] flex items-center gap-2">
244+
<span className="h-1 w-1 bg-[#C96442] rounded-full animate-pulse" />
245+
Terra<span className="text-[#9E4A2F]">cotta</span>
246+
</div>
247+
</div>
248+
</motion.div>
249+
</div>
250+
)}
251+
</AnimatePresence>
252+
);
253+
};
254+
255+
export default TerracottaCommandPalette;

0 commit comments

Comments
 (0)