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