import classNames from "classnames"; import { useTranslation } from "next-i18next/pages"; import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { FiSearch } from "react-icons/fi"; import useSWR from "swr"; import { SettingsContext } from "utils/contexts/settings"; import ResolvedIcon from "./resolvedicon"; import { getStoredProvider, searchProviders } from "./widgets/search/search"; const MOBILE_BUTTON_POSITIONS = { "top-left": "top-4 left-4", "top-right": "top-4 right-4", "bottom-left": "bottom-4 left-4", "bottom-right": "bottom-4 right-4", }; function parseUrl(searchString) { if (!/.+[.:].+/.test(searchString)) return null; // basic test for probably a url try { return new URL(searchString.toLowerCase().startsWith("http") ? searchString : `https://${searchString}`); } catch { return null; } } function getSearchResults({ hideVisitURL, searchDescriptions, searchProvider, searchString, searchSuggestions, servicesAndBookmarks, t, url, }) { if (searchString.trim().length === 0) return []; const results = servicesAndBookmarks.flatMap((result) => { const nameMatch = result.name.toLowerCase().includes(searchString); const descriptionMatch = searchDescriptions && result.description?.toLowerCase().includes(searchString); if (!nameMatch && !descriptionMatch) return []; return [{ ...result, ...(searchDescriptions && { priority: nameMatch ? 2 : +descriptionMatch }) }]; }); if (searchDescriptions) { results.sort((a, b) => b.priority - a.priority); } if (searchProvider) { results.push({ href: searchProvider.url + encodeURIComponent(searchString), name: `${searchProvider.name ?? t("quicklaunch.custom")} ${t("quicklaunch.search")}`, type: "search", }); if (searchProvider.showSearchSuggestions && searchProvider.suggestionUrl && searchSuggestions[1]) { results.push( ...searchSuggestions[1].map((suggestion) => ({ href: searchProvider.url + encodeURIComponent(suggestion), name: suggestion, type: "searchSuggestion", })), ); } } if (!hideVisitURL && url) { results.unshift({ href: url.toString(), name: `${t("quicklaunch.visit")} URL`, type: "url", }); } return results; } export default function QuickLaunch({ servicesAndBookmarks, searchString, setSearchString, isOpen, setSearching }) { const { t } = useTranslation(); const { settings } = useContext(SettingsContext); const { searchDescriptions = false, hideVisitURL = false } = settings?.quicklaunch ?? {}; const searchField = useRef(); const [currentItemIndex, setCurrentItemIndex] = useState(null); const url = parseUrl(searchString); const [searchSuggestions, setSearchSuggestions] = useState([]); const { data: widgets } = useSWR("/api/widgets"); const searchWidget = Object.values(widgets).find((w) => w.type === "search"); let searchProvider; if (settings?.quicklaunch?.provider === "custom" && settings?.quicklaunch?.url?.length > 0) { searchProvider = settings.quicklaunch; } else if (settings?.quicklaunch?.provider && settings?.quicklaunch?.provider !== "custom") { searchProvider = searchProviders[settings.quicklaunch.provider]; } else if (searchWidget) { // If there is no search provider in quick launch settings, try to get it from the search widget if (Array.isArray(searchWidget.options?.provider)) { // If search provider is a list, try to retrieve from localstorage, fall back to the first searchProvider = getStoredProvider() ?? searchProviders[searchWidget.options.provider[0]]; } else if (searchWidget.options?.provider === "custom") { searchProvider = searchWidget.options; } else { searchProvider = searchProviders[searchWidget.options?.provider]; } } if (searchProvider) { searchProvider.showSearchSuggestions = !!( settings?.quicklaunch?.showSearchSuggestions ?? searchWidget?.options?.showSearchSuggestions ?? false ); } let mobileButtonPosition = settings.quicklaunch?.mobileButtonPosition ? MOBILE_BUTTON_POSITIONS[settings.quicklaunch.mobileButtonPosition] : null; function openCurrentItem(newWindow) { const result = results[activeItemIndex]; window.open( result.href, newWindow ? "_blank" : (result.target ?? searchProvider?.target ?? settings.target ?? "_blank"), "noreferrer", ); } const closeAndReset = useCallback(() => { setSearching(false); setTimeout(() => { setSearchString(""); setCurrentItemIndex(null); setSearchSuggestions([]); }, 200); // delay a little for animations }, [setSearching, setSearchString, setCurrentItemIndex, setSearchSuggestions]); function handleSearchChange(event) { const rawSearchString = event.target.value; setCurrentItemIndex(null); // urls keep their casing, everything else is lowercased for matching setSearchString(parseUrl(rawSearchString) ? rawSearchString : rawSearchString.toLowerCase()); } 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[activeItemIndex + 1]) { setCurrentItemIndex(activeItemIndex + 1); event.preventDefault(); } else if (event.key === "ArrowUp" && activeItemIndex > 0) { setCurrentItemIndex(activeItemIndex - 1); event.preventDefault(); } else if ( event.key === "ArrowRight" && results[activeItemIndex] && results[activeItemIndex].type === "searchSuggestion" ) { setCurrentItemIndex(null); setSearchString(results[activeItemIndex].name); } } 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(() => { if ( searchString.trim().length === 0 || !searchProvider?.showSearchSuggestions || !searchProvider.suggestionUrl || searchString.trim() === searchSuggestions[0]?.trim() ) { return undefined; } const abortController = new AbortController(); fetch( `/api/search/searchSuggestion?query=${encodeURIComponent(searchString)}&providerName=${ searchProvider.name ?? "Custom" }`, { signal: abortController.signal }, ) .then(async (searchSuggestionResult) => { const newSearchSuggestions = await searchSuggestionResult.json(); if (newSearchSuggestions) { setCurrentItemIndex(null); setSearchSuggestions([newSearchSuggestions[0], newSearchSuggestions[1].slice(0, 4)]); } }) .catch(() => { // If there is an error, just ignore it. There just will be no search suggestions. }); return () => abortController.abort(); }, [searchProvider, searchString, searchSuggestions]); const results = getSearchResults({ hideVisitURL, searchDescriptions, searchProvider, searchString, searchSuggestions, servicesAndBookmarks, t, url, }); const activeItemIndex = currentItemIndex ?? (results.length ? 0 : null); useEffect(() => { function handleBackdropClick(event) { if (event.target?.tagName === "DIV") closeAndReset(); } if (isOpen) { searchField.current.focus(); document.body.addEventListener("click", handleBackdropClick); } else { searchField.current.blur(); } return () => document.body.removeEventListener("click", handleBackdropClick); }, [isOpen, closeAndReset]); function highlightText(text) { const parts = text.split(new RegExp(`(${searchString})`, "gi")); return ( {parts.map((part, i) => part.toLowerCase() === searchString.toLowerCase() ? ( {part} ) : ( part ), )} ); } return ( <>