mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-01-17 22:28:32 +01:00
Add support for CMD + K search (#3944)
* Add command k input * On Enter open the page * chore: backend fix * Refactor pages and add retrieval * Group separation, no result handling and filtering * Fix responsiveness of command menu * Activate on CMD+K and focus * Add icons to menu * Add page filtering * Add search icon in navigation --------- Co-authored-by: Arik Chakma <arikchangma@gmail.com>
This commit is contained in:
parent
83057d65cd
commit
51d986b86f
201
src/components/CommandMenu/CommandMenu.tsx
Normal file
201
src/components/CommandMenu/CommandMenu.tsx
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import BestPracticesIcon from '../../icons/best-practices.svg';
|
||||||
|
import HomeIcon from '../../icons/home.svg';
|
||||||
|
import UserIcon from '../../icons/user.svg';
|
||||||
|
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||||
|
import GuideIcon from '../../icons/guide.svg';
|
||||||
|
import VideoIcon from '../../icons/video.svg';
|
||||||
|
import { httpGet } from '../../lib/http';
|
||||||
|
import { useKeydown } from '../../hooks/use-keydown';
|
||||||
|
import { isLoggedIn } from '../../lib/jwt';
|
||||||
|
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||||
|
|
||||||
|
type PageType = {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
group: string;
|
||||||
|
icon?: string;
|
||||||
|
isProtected?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultPages: PageType[] = [
|
||||||
|
{ url: '/', title: 'Home', group: 'Pages', icon: HomeIcon },
|
||||||
|
{
|
||||||
|
url: '/settings/update-profile',
|
||||||
|
title: 'Account',
|
||||||
|
group: 'Pages',
|
||||||
|
icon: UserIcon,
|
||||||
|
isProtected: true,
|
||||||
|
},
|
||||||
|
{ url: '/roadmaps', title: 'Roadmaps', group: 'Pages', icon: RoadmapIcon },
|
||||||
|
{
|
||||||
|
url: '/best-practices',
|
||||||
|
title: 'Best Practices',
|
||||||
|
group: 'Pages',
|
||||||
|
icon: BestPracticesIcon,
|
||||||
|
},
|
||||||
|
{ url: '/guides', title: 'Guides', group: 'Pages', icon: GuideIcon },
|
||||||
|
{ url: '/videos', title: 'Videos', group: 'Pages', icon: VideoIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
function shouldShowPage(page: PageType) {
|
||||||
|
const isUser = isLoggedIn();
|
||||||
|
|
||||||
|
return !page.isProtected || isUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandMenu() {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const modalRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isActive, setIsActive] = useState(false);
|
||||||
|
const [allPages, setAllPages] = useState<PageType[]>([]);
|
||||||
|
const [searchResults, setSearchResults] = useState<PageType[]>(defaultPages);
|
||||||
|
const [searchedText, setSearchedText] = useState('');
|
||||||
|
const [activeCounter, setActiveCounter] = useState(0);
|
||||||
|
|
||||||
|
useKeydown('mod_k', () => {
|
||||||
|
setIsActive(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
useOutsideClick(modalRef, () => {
|
||||||
|
setIsActive(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleToggleTopic(e: any) {
|
||||||
|
setIsActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(`command.k`, handleToggleTopic);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(`command.k`, handleToggleTopic);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive || !inputRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputRef.current.focus();
|
||||||
|
}, [isActive]);
|
||||||
|
|
||||||
|
async function getAllPages() {
|
||||||
|
if (allPages.length > 0) {
|
||||||
|
return allPages;
|
||||||
|
}
|
||||||
|
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
|
||||||
|
if (!response) {
|
||||||
|
return defaultPages.filter(shouldShowPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAllPages([...defaultPages, ...response].filter(shouldShowPage));
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchedText) {
|
||||||
|
setSearchResults(defaultPages.filter(shouldShowPage));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSearchText = searchedText.trim().toLowerCase();
|
||||||
|
getAllPages().then((unfilteredPages = defaultPages) => {
|
||||||
|
const filteredPages = unfilteredPages
|
||||||
|
.filter((currPage: PageType) => {
|
||||||
|
return (
|
||||||
|
currPage.title.toLowerCase().indexOf(normalizedSearchText) !== -1
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
setActiveCounter(0);
|
||||||
|
setSearchResults(filteredPages);
|
||||||
|
});
|
||||||
|
}, [searchedText]);
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed left-0 right-0 top-0 z-50 flex h-full justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||||
|
<div className="relative top-0 h-full w-full max-w-lg p-2 sm:top-20 md:h-auto">
|
||||||
|
<div className="relative rounded-lg bg-white shadow" ref={modalRef}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
autofocus={true}
|
||||||
|
type="text"
|
||||||
|
value={searchedText}
|
||||||
|
className="w-full rounded-t-md border-b p-4 text-sm focus:bg-gray-50 focus:outline-0"
|
||||||
|
placeholder="Search roadmaps, guides or pages .."
|
||||||
|
autocomplete="off"
|
||||||
|
onInput={(e) => {
|
||||||
|
const value = (e.target as HTMLInputElement).value.trim();
|
||||||
|
setSearchedText(value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
const canGoNext = activeCounter < searchResults.length - 1;
|
||||||
|
setActiveCounter(canGoNext ? activeCounter + 1 : 0);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
const canGoPrev = activeCounter > 0;
|
||||||
|
setActiveCounter(
|
||||||
|
canGoPrev ? activeCounter - 1 : searchResults.length - 1
|
||||||
|
);
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setIsActive(false);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
const activePage = searchResults[activeCounter];
|
||||||
|
if (activePage) {
|
||||||
|
window.location.href = activePage.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="px-2 py-2">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{searchResults.length === 0 && (
|
||||||
|
<div class="p-5 text-center text-sm text-gray-400">
|
||||||
|
No results found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchResults.map((page, counter) => {
|
||||||
|
const prevPage = searchResults[counter - 1];
|
||||||
|
const groupChanged = prevPage && prevPage.group !== page.group;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{groupChanged && (
|
||||||
|
<div class="border-b border-gray-100"></div>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
class={`flex w-full items-center rounded p-2 text-sm ${
|
||||||
|
counter === activeCounter ? 'bg-gray-100' : ''
|
||||||
|
}`}
|
||||||
|
onMouseOver={() => setActiveCounter(counter)}
|
||||||
|
href={page.url}
|
||||||
|
>
|
||||||
|
{!page.icon && (
|
||||||
|
<span class="mr-2 text-gray-400">{page.group}</span>
|
||||||
|
)}
|
||||||
|
{page.icon && (
|
||||||
|
<img src={page.icon} class="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{page.title}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -7,7 +7,6 @@ import AccountDropdown from './AccountDropdown.astro';
|
|||||||
<nav class='container flex items-center justify-between'>
|
<nav class='container flex items-center justify-between'>
|
||||||
<a class='flex items-center text-lg font-medium text-white' href='/' aria-label="roadmap.sh">
|
<a class='flex items-center text-lg font-medium text-white' href='/' aria-label="roadmap.sh">
|
||||||
<Icon icon='logo' />
|
<Icon icon='logo' />
|
||||||
<span class='ml-3 hidden md:block'>roadmap.sh</span>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Desktop navigation items -->
|
<!-- Desktop navigation items -->
|
||||||
@ -26,6 +25,12 @@ import AccountDropdown from './AccountDropdown.astro';
|
|||||||
<li>
|
<li>
|
||||||
<a href='/videos' class='hidden lg:inline text-gray-400 hover:text-white'>Videos</a>
|
<a href='/videos' class='hidden lg:inline text-gray-400 hover:text-white'>Videos</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<button data-command-menu class="hidden lg:flex items-center gap-2 text-gray-400 border border-gray-800 rounded-md px-2.5 py-1 text-sm hover:bg-gray-800 hover:cursor-pointer">
|
||||||
|
<!-- <Icon icon='search' class='h-3 w-3' /> -->
|
||||||
|
⌘ K
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ul class='hidden h-8 w-[172px] items-center justify-end gap-5 sm:flex'>
|
<ul class='hidden h-8 w-[172px] items-center justify-end gap-5 sm:flex'>
|
||||||
<li data-guest-required class='hidden'>
|
<li data-guest-required class='hidden'>
|
||||||
|
@ -34,6 +34,12 @@ function bindEvents() {
|
|||||||
.querySelector('[data-account-dropdown]')
|
.querySelector('[data-account-dropdown]')
|
||||||
?.classList.toggle('hidden');
|
?.classList.toggle('hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector('[data-command-menu]')
|
||||||
|
?.addEventListener('click', () => {
|
||||||
|
window.dispatchEvent(new CustomEvent('command.k'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
bindEvents();
|
bindEvents();
|
||||||
|
@ -3,7 +3,16 @@ import { useEffect } from 'preact/hooks';
|
|||||||
export function useKeydown(keyName: string, callback: any, deps: any[] = []) {
|
export function useKeydown(keyName: string, callback: any, deps: any[] = []) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = (event: any) => {
|
const listener = (event: any) => {
|
||||||
if (event.key.toLowerCase() === keyName.toLowerCase()) {
|
if (
|
||||||
|
!keyName.startsWith('mod_') &&
|
||||||
|
event.key.toLowerCase() === keyName.toLowerCase()
|
||||||
|
) {
|
||||||
|
callback();
|
||||||
|
} else if (
|
||||||
|
keyName.startsWith('mod_') &&
|
||||||
|
event.metaKey &&
|
||||||
|
event.key.toLowerCase() === keyName.replace('mod_', '').toLowerCase()
|
||||||
|
) {
|
||||||
callback();
|
callback();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
export function useOutsideClick(ref: any, callback: any) {
|
export function useOutsideClick(ref: any, callback: any) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
9
src/icons/best-practices.svg
Normal file
9
src/icons/best-practices.svg
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="10" x2="21" y1="6" y2="6"></line>
|
||||||
|
<line x1="10" x2="21" y1="12" y2="12"></line>
|
||||||
|
<line x1="10" x2="21" y1="18" y2="18"></line>
|
||||||
|
<polyline points="3 6 4 7 6 5"></polyline>
|
||||||
|
<polyline points="3 12 4 13 6 11"></polyline>
|
||||||
|
<polyline points="3 18 4 19 6 17"></polyline>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 491 B |
3
src/icons/guide.svg
Normal file
3
src/icons/guide.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 6.75V15m6-6v8.25m.503 3.498l4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 00-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0z" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 525 B |
3
src/icons/home.svg
Normal file
3
src/icons/home.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 436 B |
1
src/icons/roadmap.svg
Normal file
1
src/icons/roadmap.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-milestone"><path d="M18 6H5a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h13l4-3.5L18 6Z"></path><path d="M12 13v8"></path><path d="M12 3v3"></path></svg>
|
After Width: | Height: | Size: 343 B |
3
src/icons/user.svg
Normal file
3
src/icons/user.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 346 B |
3
src/icons/video.svg
Normal file
3
src/icons/video.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||||
|
<path stroke-linecap="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 372 B |
@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import Analytics from '../components/Analytics/Analytics.astro';
|
import Analytics from '../components/Analytics/Analytics.astro';
|
||||||
import Authenticator from '../components/Authenticator/Authenticator.astro';
|
import Authenticator from '../components/Authenticator/Authenticator.astro';
|
||||||
|
import { CommandMenu } from '../components/CommandMenu/CommandMenu';
|
||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
import Navigation from '../components/Navigation/Navigation.astro';
|
import Navigation from '../components/Navigation/Navigation.astro';
|
||||||
import OpenSourceBanner from '../components/OpenSourceBanner.astro';
|
import OpenSourceBanner from '../components/OpenSourceBanner.astro';
|
||||||
@ -149,6 +150,7 @@ const gaPageIdentifier = Astro.url.pathname
|
|||||||
|
|
||||||
<Authenticator />
|
<Authenticator />
|
||||||
<PageProgress initialMessage={initialLoadingMessage} client:idle />
|
<PageProgress initialMessage={initialLoadingMessage} client:idle />
|
||||||
|
<CommandMenu client:idle />
|
||||||
<PageSponsor
|
<PageSponsor
|
||||||
gaPageIdentifier={briefTitle || gaPageIdentifier}
|
gaPageIdentifier={briefTitle || gaPageIdentifier}
|
||||||
client:load
|
client:load
|
||||||
|
@ -1,32 +0,0 @@
|
|||||||
---
|
|
||||||
import { getAllBestPractices } from '../lib/best-pratice';
|
|
||||||
import { getAllGuides } from '../lib/guide';
|
|
||||||
import { getRoadmapsByTag } from '../lib/roadmap';
|
|
||||||
import { getAllVideos } from '../lib/video';
|
|
||||||
|
|
||||||
const guides = await getAllGuides();
|
|
||||||
const videos = await getAllVideos();
|
|
||||||
const roadmaps = await getRoadmapsByTag('roadmap');
|
|
||||||
const bestPractices = await getAllBestPractices();
|
|
||||||
|
|
||||||
const formattedData = {
|
|
||||||
Roadmaps: roadmaps.map((roadmap) => ({
|
|
||||||
url: `/${roadmap.id}`,
|
|
||||||
title: roadmap.frontmatter.briefTitle,
|
|
||||||
})),
|
|
||||||
'Best Practices': bestPractices.map((bestPractice) => ({
|
|
||||||
url: `/${bestPractice.id}`,
|
|
||||||
title: bestPractice.frontmatter.briefTitle,
|
|
||||||
})),
|
|
||||||
Guides: guides.map((guide) => ({
|
|
||||||
url: `/${guide.id}`,
|
|
||||||
title: guide.frontmatter.title,
|
|
||||||
})),
|
|
||||||
Videos: videos.map((guide) => ({
|
|
||||||
url: `/${guide.id}`,
|
|
||||||
title: guide.frontmatter.title,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
---
|
|
||||||
|
|
||||||
{JSON.stringify(formattedData)}
|
|
36
src/pages/pages.json.ts
Normal file
36
src/pages/pages.json.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { getAllBestPractices } from '../lib/best-pratice';
|
||||||
|
import { getAllGuides } from '../lib/guide';
|
||||||
|
import { getRoadmapsByTag } from '../lib/roadmap';
|
||||||
|
import { getAllVideos } from '../lib/video';
|
||||||
|
|
||||||
|
export async function get() {
|
||||||
|
const guides = await getAllGuides();
|
||||||
|
const videos = await getAllVideos();
|
||||||
|
const roadmaps = await getRoadmapsByTag('roadmap');
|
||||||
|
const bestPractices = await getAllBestPractices();
|
||||||
|
|
||||||
|
return {
|
||||||
|
body: JSON.stringify([
|
||||||
|
...roadmaps.map((roadmap) => ({
|
||||||
|
url: `/${roadmap.id}`,
|
||||||
|
title: roadmap.frontmatter.briefTitle,
|
||||||
|
group: 'Roadmaps',
|
||||||
|
})),
|
||||||
|
...bestPractices.map((bestPractice) => ({
|
||||||
|
url: `/best-practices/${bestPractice.id}`,
|
||||||
|
title: bestPractice.frontmatter.briefTitle,
|
||||||
|
group: 'Best Practices',
|
||||||
|
})),
|
||||||
|
...guides.map((guide) => ({
|
||||||
|
url: `/guides/${guide.id}`,
|
||||||
|
title: guide.frontmatter.title,
|
||||||
|
group: 'Guides',
|
||||||
|
})),
|
||||||
|
...videos.map((guide) => ({
|
||||||
|
url: `/videos/${guide.id}`,
|
||||||
|
title: guide.frontmatter.title,
|
||||||
|
group: 'Videos',
|
||||||
|
})),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user