import { useTranslation } from "react-i18next"; import { useEffect, useState, useRef, useCallback, useContext } from "react"; import classNames from "classnames"; import ResolvedIcon from "./resolvedicon"; import { SettingsContext } from "utils/contexts/settings"; export default function QuickLaunch({servicesAndBookmarks, searchString, setSearchString, isOpen, close, searchProvider}) { const { t } = useTranslation(); const { settings } = useContext(SettingsContext); const { searchDescriptions, hideVisitURL } = settings?.quicklaunch ? settings.quicklaunch : { searchDescriptions: false, hideVisitURL: false }; const searchField = useRef(); const [results, setResults] = useState([]); const [currentItemIndex, setCurrentItemIndex] = useState(null); const [url, setUrl] = useState(null); function openCurrentItem(newWindow) { const result = results[currentItemIndex]; window.open(result.href, newWindow ? "_blank" : result.target ?? settings.target ?? "_blank", 'noreferrer'); } const closeAndReset = useCallback(() => { close(false); setTimeout(() => { setSearchString(""); setCurrentItemIndex(null); }, 200); // delay a little for animations }, [close, setSearchString, setCurrentItemIndex]); function handleSearchChange(event) { const rawSearchString = event.target.value.toLowerCase(); try { if (!/.+[.:].+/g.test(rawSearchString)) throw new Error(); // basic test for probably a url let urlString = rawSearchString; if (urlString.indexOf('http') !== 0) urlString = `https://${rawSearchString}`; setUrl(new URL(urlString)); // basic validation } catch (e) { setUrl(null); } setSearchString(rawSearchString); } function handleSearchKeyDown(event) { if (!isOpen) return; if (event.key === "Escape") { closeAndReset(); event.preventDefault(); } else if (event.key === "Enter" && results.length) { closeAndReset(); openCurrentItem(event.metaKey); } else if (event.key === "ArrowDown" && results[currentItemIndex + 1]) { setCurrentItemIndex(currentItemIndex + 1); event.preventDefault(); } else if (event.key === "ArrowUp" && currentItemIndex > 0) { setCurrentItemIndex(currentItemIndex - 1); event.preventDefault(); } } function handleItemHover(event) { setCurrentItemIndex(parseInt(event.target?.dataset?.index, 10)); } function handleItemClick(event) { closeAndReset(); openCurrentItem(event.metaKey); } useEffect(() => { if (searchString.length === 0) setResults([]); else { let newResults = servicesAndBookmarks.filter(r => { const nameMatch = r.name.toLowerCase().includes(searchString); let descriptionMatch; if (searchDescriptions) { descriptionMatch = r.description?.toLowerCase().includes(searchString) r.priority = nameMatch ? 2 * (+nameMatch) : +descriptionMatch; // eslint-disable-line no-param-reassign } return nameMatch || descriptionMatch; }); if (searchDescriptions) { newResults = newResults.sort((a, b) => b.priority - a.priority); } if (searchProvider) { newResults.push( { href: searchProvider.url + encodeURIComponent(searchString), name: `${searchProvider.name ?? t("quicklaunch.custom")} ${t("quicklaunch.search")} `, type: 'search', } ) } if (!hideVisitURL && url) { newResults.unshift( { href: url.toString(), name: `${t("quicklaunch.visit")} URL`, type: 'url', } ) } setResults(newResults); if (newResults.length) { setCurrentItemIndex(0); } } }, [searchString, servicesAndBookmarks, searchDescriptions, hideVisitURL, searchProvider, url, t]); const [hidden, setHidden] = useState(true); useEffect(() => { function handleBackdropClick(event) { if (event.target?.tagName === "DIV") closeAndReset(); } if (isOpen) { searchField.current.focus(); document.body.addEventListener('click', handleBackdropClick); setHidden(false); } else { document.body.removeEventListener('click', handleBackdropClick); searchField.current.blur(); setTimeout(() => { setHidden(true); }, 300); // disable on close } }, [isOpen, closeAndReset]); function highlightText(text) { const parts = text.split(new RegExp(`(${searchString})`, 'gi')); // eslint-disable-next-line react/no-array-index-key return <span>{parts.map((part, i) => part.toLowerCase() === searchString.toLowerCase() ? <span key={`${searchString}_${i}`} className="bg-theme-300/10">{part}</span> : part)}</span>; } return ( <div className={classNames( "relative z-20 ease-in-out duration-300 transition-opacity", hidden && !isOpen && "hidden", !hidden && isOpen && "opacity-100", !isOpen && "opacity-0", )} role="dialog" aria-modal="true"> <div className="fixed inset-0 bg-gray-500 bg-opacity-50" /> <div className="fixed inset-0 z-20 overflow-y-auto"> <div className="flex min-h-full min-w-full items-start justify-center text-center"> <dialog className="mt-[10%] min-w-[80%] max-w-[90%] md:min-w-[40%] rounded-md p-0 block font-medium text-theme-700 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-50 dark:bg-theme-800"> <input placeholder="Search" className={classNames( results.length > 0 && "rounded-t-md", results.length === 0 && "rounded-md", "w-full p-4 m-0 border-0 border-b border-slate-700 focus:border-slate-700 focus:outline-0 focus:ring-0 text-sm md:text-xl text-theme-700 dark:text-theme-200 bg-theme-60 dark:bg-theme-800" )} type="text" autoCorrect="false" ref={searchField} value={searchString} onChange={handleSearchChange} onKeyDown={handleSearchKeyDown} /> {results.length > 0 && <ul className="max-h-[60vh] overflow-y-auto m-2"> {results.map((r, i) => ( <li key={r.name}> <button type="button" data-index={i} onMouseEnter={handleItemHover} className={classNames( "flex flex-row w-full items-center justify-between rounded-md text-sm md:text-xl py-2 px-4 cursor-pointer text-theme-700 dark:text-theme-200", i === currentItemIndex && "bg-theme-300/50 dark:bg-theme-700/50", )} onClick={handleItemClick}> <div className="flex flex-row items-center mr-4 pointer-events-none"> {(r.icon || r.abbr) && <div className="w-5 text-xs mr-4"> {r.icon && <ResolvedIcon icon={r.icon} />} {r.abbr && r.abbr} </div>} <div className="flex flex-col md:flex-row text-left items-baseline mr-4 pointer-events-none"> <span className="mr-4">{r.name}</span> {r.description && <span className="text-xs text-theme-600 text-light"> {searchDescriptions && r.priority < 2 ? highlightText(r.description) : r.description} </span> } </div> </div> <div className="text-xs text-theme-600 font-bold pointer-events-none">{t(`quicklaunch.${r.type ? r.type.toLowerCase() : 'bookmark'}`)}</div> </button> </li> ))} </ul>} </dialog> </div> </div> </div> ); }