Skip to content

Commit 5aa1e5f

Browse files
committed
feat: command palette
1 parent 041a8d7 commit 5aa1e5f

File tree

5 files changed

+345
-99
lines changed

5 files changed

+345
-99
lines changed

src/components/CommandPalette.js

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import React, { useState, useEffect, useRef } from 'react';
2+
import { useNavigate } from 'react-router-dom';
3+
import { AnimatePresence, motion } from 'framer-motion';
4+
import useSearchableData from '../hooks/useSearchableData';
5+
import { useAnimation } from '../context/AnimationContext';
6+
import { useToast } from '../hooks/useToast';
7+
import { SIDEBAR_KEYS, remove as removeLocalStorageItem } from '../utils/LocalStorageManager';
8+
9+
const CommandPalette = ({ isOpen, setIsOpen }) => {
10+
const [searchTerm, setSearchTerm] = useState('');
11+
const [selectedIndex, setSelectedIndex] = useState(0);
12+
const { items, isLoading } = useSearchableData();
13+
const navigate = useNavigate();
14+
const inputRef = useRef(null);
15+
const resultsRef = useRef(null);
16+
const { isAnimationEnabled, toggleAnimation } = useAnimation();
17+
const { addToast } = useToast();
18+
19+
const filteredItems = searchTerm
20+
? items.filter(item => {
21+
const lowerCaseSearchTerm = searchTerm.toLowerCase();
22+
const titleMatch = item.title.toLowerCase().includes(lowerCaseSearchTerm);
23+
const typeMatch = item.type.toLowerCase().includes(lowerCaseSearchTerm);
24+
const tagMatch = item.tags?.some(tag => tag.toLowerCase().includes(lowerCaseSearchTerm));
25+
const techMatch = item.technologies?.some(tech => tech.toLowerCase().includes(lowerCaseSearchTerm));
26+
const categoryMatch = item.category?.toLowerCase().includes(lowerCaseSearchTerm);
27+
28+
return titleMatch || typeMatch || tagMatch || techMatch || categoryMatch;
29+
})
30+
: items;
31+
32+
useEffect(() => {
33+
if (isOpen) {
34+
inputRef.current?.focus();
35+
}
36+
}, [isOpen]);
37+
38+
useEffect(() => {
39+
setSelectedIndex(0);
40+
}, [searchTerm, items]);
41+
42+
const handleItemClick = (item) => {
43+
if (!item) return;
44+
45+
if (item.type === 'command') {
46+
switch (item.commandId) {
47+
case 'toggleAnimations':
48+
toggleAnimation();
49+
addToast({
50+
title: 'Settings Updated',
51+
message: `Animations have been ${!isAnimationEnabled ? 'enabled' : 'disabled'}.`,
52+
});
53+
break;
54+
case 'resetSidebarState':
55+
SIDEBAR_KEYS.forEach((key) => {
56+
removeLocalStorageItem(key);
57+
});
58+
addToast({
59+
title: 'Success',
60+
message: 'Sidebar state has been reset. The page will now reload.',
61+
duration: 3000,
62+
});
63+
setTimeout(() => {
64+
window.location.reload();
65+
}, 3000);
66+
break;
67+
case 'viewSource':
68+
window.open('https://github.com/fezcode/fezcode.github.io', '_blank', 'noopener,noreferrer');
69+
break;
70+
case 'randomPost':
71+
const posts = items.filter(i => i.type === 'post');
72+
if (posts.length > 0) {
73+
const randomPost = posts[Math.floor(Math.random() * posts.length)];
74+
navigate(randomPost.path);
75+
}
76+
break;
77+
case 'sendEmailFezcode':
78+
window.open('mailto:samil.bulbul@gmail.com', '_blank', 'noopener,noreferrer');
79+
break;
80+
case 'openGitHub':
81+
window.open('https://github.com/fezcode', '_blank', 'noopener,noreferrer');
82+
break;
83+
case 'openTwitter':
84+
window.open('https://x.com/fezcoddy', '_blank', 'noopener,noreferrer');
85+
break;
86+
case 'openLinkedIn':
87+
window.open('https://tr.linkedin.com/in/ahmed-samil-bulbul', '_blank', 'noopener,noreferrer');
88+
break;
89+
default:
90+
break;
91+
}
92+
} else {
93+
navigate(item.path);
94+
}
95+
handleClose();
96+
};
97+
98+
useEffect(() => {
99+
const handleKeyDown = (event) => {
100+
if (!isOpen) return;
101+
102+
if (event.key === 'ArrowUp') {
103+
event.preventDefault();
104+
setSelectedIndex((prevIndex) =>
105+
prevIndex === 0 ? filteredItems.length - 1 : prevIndex - 1
106+
);
107+
} else if (event.key === 'ArrowDown') {
108+
event.preventDefault();
109+
setSelectedIndex((prevIndex) =>
110+
prevIndex === filteredItems.length - 1 ? 0 : prevIndex + 1
111+
);
112+
} else if (event.key === 'Enter') {
113+
event.preventDefault();
114+
if (filteredItems[selectedIndex]) {
115+
handleItemClick(filteredItems[selectedIndex]);
116+
}
117+
} else if (event.key === 'Escape') {
118+
handleClose();
119+
}
120+
};
121+
122+
window.addEventListener('keydown', handleKeyDown);
123+
return () => window.removeEventListener('keydown', handleKeyDown);
124+
}, [isOpen, filteredItems, selectedIndex, isAnimationEnabled]);
125+
126+
useEffect(() => {
127+
const selectedItem = resultsRef.current?.children[selectedIndex];
128+
if (selectedItem) {
129+
selectedItem.scrollIntoView({
130+
block: 'nearest',
131+
});
132+
}
133+
}, [selectedIndex]);
134+
135+
const handleClose = () => {
136+
setIsOpen(false);
137+
setSearchTerm('');
138+
};
139+
140+
return (
141+
<AnimatePresence>
142+
{isOpen && (
143+
<div
144+
className="fixed inset-0 bg-black bg-opacity-70 z-50 flex items-start justify-center pt-16 md:pt-32"
145+
onClick={handleClose}
146+
>
147+
<motion.div
148+
initial={{ opacity: 0, scale: 0.95, y: -20 }}
149+
animate={{ opacity: 1, scale: 1, y: 0 }}
150+
exit={{ opacity: 0, scale: 0.95, y: -20 }}
151+
transition={{ duration: 0.2, ease: 'easeOut' }}
152+
className="bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg shadow-2xl w-full max-w-xl mx-4"
153+
onClick={e => e.stopPropagation()}
154+
>
155+
<div className="p-3">
156+
<input
157+
ref={inputRef}
158+
type="text"
159+
placeholder={isLoading ? "Loading data..." : "Search or type a command..."}
160+
className="w-full bg-transparent text-lg placeholder-gray-500 focus:outline-none"
161+
value={searchTerm}
162+
onChange={e => setSearchTerm(e.target.value)}
163+
disabled={isLoading}
164+
/>
165+
</div>
166+
<div ref={resultsRef} className="border-t border-gray-200 dark:border-gray-700 p-2 max-h-[50vh] overflow-y-auto">
167+
{filteredItems.length > 0 ? (
168+
filteredItems.map((item, index) => (
169+
<div
170+
key={`${item.type}-${item.slug || item.commandId}-${index}`}
171+
className={`p-3 rounded-lg cursor-pointer flex justify-between items-center ${
172+
selectedIndex === index ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-200 dark:hover:bg-gray-700'
173+
}`}
174+
onClick={() => handleItemClick(item)}
175+
onMouseMove={() => setSelectedIndex(index)}
176+
>
177+
<span>{item.title}</span>
178+
<span className="text-xs uppercase bg-gray-300 dark:bg-gray-600 px-2 py-1 rounded">{item.type}</span>
179+
</div>
180+
))
181+
) : (
182+
<div className="p-4 text-center text-gray-500">
183+
{isLoading ? "Loading..." : `No results found for "${searchTerm}"`}
184+
</div>
185+
)}
186+
</div>
187+
<div className="border-t border-gray-200 dark:border-gray-700 p-2 text-xs text-gray-400 dark:text-gray-500 flex items-center justify-between">
188+
<div className="flex items-center gap-2">
189+
<span className="border border-gray-300 dark:border-gray-600 rounded px-1.5 py-0.5">ESC</span> to close
190+
<span className="border border-gray-300 dark:border-gray-600 rounded px-1.5 py-0.5"></span>
191+
<span className="border border-gray-300 dark:border-gray-600 rounded px-1.5 py-0.5"></span> to navigate
192+
<span className="border border-gray-300 dark:border-gray-600 rounded px-1.5 py-0.5"></span> to select
193+
</div>
194+
<div className="font-semibold text-gray-400">
195+
Fez<span className="text-accent-500">codex</span>
196+
</div>
197+
</div>
198+
</motion.div>
199+
</div>
200+
)}
201+
</AnimatePresence>
202+
);
203+
};
204+
205+
export default CommandPalette;

src/components/Footer.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ const Footer = () => {
9696
&copy; {new Date().getFullYear()} fezcode. All rights reserved.{' '}
9797
<code> v{version} </code>
9898
</p>
99+
<p className="text-xs text-gray-500 mt-1">
100+
Hint: Press <kbd className="kbd kbd-sm">Alt</kbd>+<kbd className="kbd kbd-sm">K</kbd> for commands
101+
</p>
99102
</div>
100103
</div>
101104
</div>

src/components/Layout.js

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import Footer from './Footer';
55
import DndNavbar from './dnd/DndNavbar';
66
import DndFooter from './dnd/DndFooter';
77
import { useLocation } from 'react-router-dom';
8-
import Search from './Search'; // Import the Search component
8+
import Search from './Search';
9+
import CommandPalette from './CommandPalette';
910

1011
import { DndProvider } from '../context/DndContext';
1112

1213
const Layout = ({ children, toggleModal, isSearchVisible, toggleSearch }) => {
1314
const [isSidebarOpen, setIsSidebarOpen] = useState(window.innerWidth > 768);
15+
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
1416
const location = useLocation();
1517

1618
useEffect(() => {
@@ -20,10 +22,19 @@ const Layout = ({ children, toggleModal, isSearchVisible, toggleSearch }) => {
2022
}
2123
};
2224

25+
const handleKeyDown = (event) => {
26+
if (event.altKey && event.key === 'k') {
27+
event.preventDefault();
28+
setIsPaletteOpen((open) => !open);
29+
}
30+
};
31+
2332
window.addEventListener('resize', handleResize);
33+
window.addEventListener('keydown', handleKeyDown);
2434

2535
return () => {
2636
window.removeEventListener('resize', handleResize);
37+
window.removeEventListener('keydown', handleKeyDown);
2738
};
2839
}, []);
2940

@@ -44,26 +55,29 @@ const Layout = ({ children, toggleModal, isSearchVisible, toggleSearch }) => {
4455
}
4556

4657
return (
47-
<div className="bg-gray-950 min-h-screen font-sans flex">
48-
<Sidebar
49-
isOpen={isSidebarOpen}
50-
toggleSidebar={toggleSidebar}
51-
toggleModal={toggleModal}
52-
/>
53-
<div
54-
className={`flex-1 flex flex-col transition-all duration-300 ${isSidebarOpen ? 'md:ml-64' : 'md:ml-0'}`}
55-
>
56-
<Navbar
57-
toggleSidebar={toggleSidebar}
58-
isSidebarOpen={isSidebarOpen}
59-
isSearchVisible={isSearchVisible}
60-
toggleSearch={toggleSearch}
61-
/>
62-
{isSearchVisible && <Search isVisible={isSearchVisible} />}
63-
<main className="flex-grow">{children}</main>
64-
<Footer />
58+
<>
59+
<CommandPalette isOpen={isPaletteOpen} setIsOpen={setIsPaletteOpen} />
60+
<div className="bg-gray-950 min-h-screen font-sans flex">
61+
<Sidebar
62+
isOpen={isSidebarOpen}
63+
toggleSidebar={toggleSidebar}
64+
toggleModal={toggleModal}
65+
setIsPaletteOpen={setIsPaletteOpen} // Pass setIsPaletteOpen to Sidebar
66+
/> <div
67+
className={`flex-1 flex flex-col transition-all duration-300 ${isSidebarOpen ? 'md:ml-64' : 'md:ml-0'}`}
68+
>
69+
<Navbar
70+
toggleSidebar={toggleSidebar}
71+
isSidebarOpen={isSidebarOpen}
72+
isSearchVisible={isSearchVisible}
73+
toggleSearch={toggleSearch}
74+
/>
75+
{isSearchVisible && <Search isVisible={isSearchVisible} />}
76+
<main className="flex-grow">{children}</main>
77+
<Footer />
78+
</div>
6579
</div>
66-
</div>
80+
</>
6781
);
6882
};
6983

0 commit comments

Comments
 (0)