diff --git a/src/components/CommandMenu/CommandMenu.tsx b/src/components/CommandMenu/CommandMenu.tsx new file mode 100644 index 000000000..638d577a8 --- /dev/null +++ b/src/components/CommandMenu/CommandMenu.tsx @@ -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(null); + const modalRef = useRef(null); + const [isActive, setIsActive] = useState(false); + const [allPages, setAllPages] = useState([]); + const [searchResults, setSearchResults] = useState(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(`/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 ( +
+
+
+ { + 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; + } + } + }} + /> + +
+
+ {searchResults.length === 0 && ( +
+ No results found +
+ )} + + {searchResults.map((page, counter) => { + const prevPage = searchResults[counter - 1]; + const groupChanged = prevPage && prevPage.group !== page.group; + + return ( + <> + {groupChanged && ( +
+ )} + setActiveCounter(counter)} + href={page.url} + > + {!page.icon && ( + {page.group} + )} + {page.icon && ( + + )} + {page.title} + + + ); + })} +
+
+
+
+
+ ); +} diff --git a/src/components/Navigation/Navigation.astro b/src/components/Navigation/Navigation.astro index aed4b36d1..8eae92a3f 100644 --- a/src/components/Navigation/Navigation.astro +++ b/src/components/Navigation/Navigation.astro @@ -7,7 +7,6 @@ import AccountDropdown from './AccountDropdown.astro';