diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 69d88305c..9724db9da 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -218,5 +218,9 @@ "cpu": "CPU", "mem": "MEM", "wait": "Please wait" + }, + "homepagesearch": { + "bookmark": "Bookmark", + "service": "Service" } } diff --git a/src/components/quicklaunch.jsx b/src/components/quicklaunch.jsx new file mode 100644 index 000000000..58d15cef2 --- /dev/null +++ b/src/components/quicklaunch.jsx @@ -0,0 +1,156 @@ +import { useTranslation } from "react-i18next"; +import { useEffect, useState, useRef, useCallback, useContext } from "react"; +import classNames from "classnames"; + +import { resolveIcon } from "./services/item"; + +import { SettingsContext } from "utils/contexts/settings"; + +export default function QuickLaunch({servicesAndBookmarks, searchString, setSearchString, isOpen, close, searchDescriptions}) { + const { t } = useTranslation(); + const { settings } = useContext(SettingsContext); + + const searchField = useRef(); + + const [results, setResults] = useState([]); + const [currentItemIndex, setCurrentItemIndex] = useState(null); + + function openCurrentItem(newWindow) { + const result = results[currentItemIndex]; + window.open(result.href, newWindow ? "_blank" : result.target ?? settings.target ?? "_blank"); + } + + const closeAndReset = useCallback(() => { + close(false); + setTimeout(() => { + setSearchString(""); + setCurrentItemIndex(null); + }, 200); // delay a little for animations + }, [close, setSearchString, setCurrentItemIndex]); + + function handleSearchChange(event) { + setSearchString(event.target.value.toLowerCase()) + } + + function handleSearchKeyDown(event) { + if (event.key === "Escape") { + closeAndReset(); + } 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); + } + + setResults(newResults); + + if (newResults.length) { + setCurrentItemIndex(0); + } + } + }, [searchString, servicesAndBookmarks, searchDescriptions]); + + + 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); + setTimeout(() => { + setHidden(true); + }, 300); // disable on close + } + + }, [isOpen, closeAndReset]); + + function highlightText(text) { + const parts = text.split(new RegExp(`(${searchString})`, 'gi')); + return {parts.map(part => part.toLowerCase() === searchString.toLowerCase() ? {part} : part)}; + } + + return ( +