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>
pull/3776/head
Kamran Ahmed 2 years ago committed by GitHub
parent 83057d65cd
commit 51d986b86f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 201
      src/components/CommandMenu/CommandMenu.tsx
  2. 7
      src/components/Navigation/Navigation.astro
  3. 6
      src/components/Navigation/navigation.ts
  4. 11
      src/hooks/use-keydown.ts
  5. 2
      src/hooks/use-outside-click.ts
  6. 9
      src/icons/best-practices.svg
  7. 3
      src/icons/guide.svg
  8. 3
      src/icons/home.svg
  9. 1
      src/icons/roadmap.svg
  10. 3
      src/icons/user.svg
  11. 3
      src/icons/video.svg
  12. 2
      src/layouts/BaseLayout.astro
  13. 32
      src/pages/pages.json.astro
  14. 36
      src/pages/pages.json.ts

@ -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(() => {

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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)}

@ -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…
Cancel
Save