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
parent
83057d65cd
commit
51d986b86f
14 changed files with 284 additions and 35 deletions
@ -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> |
||||||
|
); |
||||||
|
} |
After Width: | Height: | Size: 491 B |
After Width: | Height: | Size: 525 B |
After Width: | Height: | Size: 436 B |
After Width: | Height: | Size: 343 B |
After Width: | Height: | Size: 346 B |
After Width: | Height: | Size: 372 B |
@ -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…
Reference in new issue