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 = false, hideVisitURL = false } = settings?.quicklaunch ?? {}; const showSearchSuggestions = !!( settings?.quicklaunch?.showSearchSuggestions ?? searchProvider?.showSearchSuggestions ); const searchField = useRef(); const [results, setResults] = useState([]); const [currentItemIndex, setCurrentItemIndex] = useState(null); const [url, setUrl] = useState(null); const [searchSuggestions, setSearchSuggestions] = useState([]); 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); setSearchSuggestions([]); }, 200); // delay a little for animations }, [close, setSearchString, setCurrentItemIndex, setSearchSuggestions]); 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); } function handleItemKeyDown(event) { if (!isOpen) return; // native button handles other keys if (event.key === "Escape") { closeAndReset(); event.preventDefault(); } } useEffect(() => { const abortController = new AbortController(); 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 (showSearchSuggestions && searchProvider.suggestionUrl) { if (searchString.trim() !== searchSuggestions[0]?.trim()) { fetch( `/api/search/searchSuggestion?query=${encodeURIComponent(searchString)}&providerName=${ searchProvider.name ?? "Custom" }`, { signal: abortController.signal }, ) .then(async (searchSuggestionResult) => { const newSearchSuggestions = await searchSuggestionResult.json(); if (newSearchSuggestions) { if (newSearchSuggestions[1].length > 4) { newSearchSuggestions[1] = newSearchSuggestions[1].splice(0, 4); } setSearchSuggestions(newSearchSuggestions); } }) .catch(() => { // If there is an error, just ignore it. There just will be no search suggestions. }); } if (searchSuggestions[1]) { newResults = newResults.concat( searchSuggestions[1].map((suggestion) => ({ href: searchProvider.url + encodeURIComponent(suggestion), name: suggestion, type: "searchSuggestion", })), ); } } } if (!hideVisitURL && url) { newResults.unshift({ href: url.toString(), name: `${t("quicklaunch.visit")} URL`, type: "url", }); } setResults(newResults); if (newResults.length) { setCurrentItemIndex(0); } } return () => { abortController.abort(); }; }, [ searchString, servicesAndBookmarks, searchDescriptions, hideVisitURL, showSearchSuggestions, searchSuggestions, 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")); return ( {parts.map((part, i) => part.toLowerCase() === searchString.toLowerCase() ? ( // eslint-disable-next-line react/no-array-index-key {part} ) : ( part ), )} ); } return (