From d5af7eda639a0bb65ccddd22050818c15f2445c3 Mon Sep 17 00:00:00 2001 From: Florian Hye <31217036+Flo2410@users.noreply.github.com> Date: Thu, 1 Feb 2024 02:17:42 +0100 Subject: [PATCH] Feature: search suggestions for search and quick launch (#2775) --------- Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- docs/configs/settings.md | 2 + docs/widgets/info/search.md | 26 ++- public/locales/de/common.json | 3 +- public/locales/en/common.json | 3 +- src/components/quicklaunch.jsx | 78 +++++++- src/components/widgets/search/search.jsx | 220 +++++++++++++++-------- src/pages/api/search/searchSuggestion.js | 23 +++ src/pages/index.jsx | 6 +- 8 files changed, 269 insertions(+), 92 deletions(-) create mode 100644 src/pages/api/search/searchSuggestion.js diff --git a/docs/configs/settings.md b/docs/configs/settings.md index fdc5eff23..d3e9a837e 100644 --- a/docs/configs/settings.md +++ b/docs/configs/settings.md @@ -359,12 +359,14 @@ There are a few optional settings for the Quick Launch feature: - `searchDescriptions`: which lets you control whether item descriptions are included in searches. This is off by default. When enabled, results that match the item name will be placed above those that only match the description. - `hideInternetSearch`: disable automatically including the currently-selected web search (e.g. from the widget) as a Quick Launch option. This is false by default, enabling the feature. +- `showSearchSuggestions`: shows search suggestions for the internet search. This value will be inherited from the search widget if it is not specified. If it is not specified there either, it will default to false. - `hideVisitURL`: disable detecting and offering an option to open URLs. This is false by default, enabling the feature. ```yaml quicklaunch: searchDescriptions: true hideInternetSearch: true + showSearchSuggestions: true hideVisitURL: true ``` diff --git a/docs/widgets/info/search.md b/docs/widgets/info/search.md index a9851bb18..faae6c37d 100644 --- a/docs/widgets/info/search.md +++ b/docs/widgets/info/search.md @@ -9,6 +9,7 @@ You can add a search bar to your top widget area that can search using Google, D - search: provider: google # google, duckduckgo, bing, baidu, brave or custom focus: true # Optional, will set focus to the search bar on page load + showSearchSuggestions: true # Optional, will show search suggestions. Defaults to false target: _blank # One of _self, _blank, _parent or _top ``` @@ -17,8 +18,10 @@ or for a custom search: ```yaml - search: provider: custom - url: https://lougle.com/?q= + url: https://www.ecosia.org/search?q= target: _blank + suggestionUrl: https://ac.ecosia.org/autocomplete?type=list&q= # Optional + showSearchSuggestions: true # Optional ``` multiple providers is also supported via a dropdown (excluding custom): @@ -28,4 +31,25 @@ multiple providers is also supported via a dropdown (excluding custom): provider: [brave, google, duckduckgo] ``` +The response body for the URL provided with the `suggestionUrl` option should look like this: + +```json +[ + "home", + [ + "home depot", + "home depot near me", + "home equity loan", + "homeworkify", + "homedepot.com", + "homebase login", + "home depot credit card", + "home goods" + ] +] +``` + +The first entry of the array contains the search query, the second one is an array of the suggestions. +In the example above, the search query was **home**. + _Added in v0.1.6, updated in 0.6.0_ diff --git a/public/locales/de/common.json b/public/locales/de/common.json index 4d2a5b171..93d411107 100644 --- a/public/locales/de/common.json +++ b/public/locales/de/common.json @@ -419,7 +419,8 @@ "search": "Suchen", "custom": "Benutzerdefiniert", "visit": "Besuchen", - "url": "URL" + "url": "URL", + "searchsuggestion": "Vorschlag" }, "wmo": { "0-day": "sonnig", diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 596377d5b..5521fd0c3 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -419,7 +419,8 @@ "search": "Search", "custom": "Custom", "visit": "Visit", - "url": "URL" + "url": "URL", + "searchsuggestion": "Suggestion" }, "wmo": { "0-day": "Sunny", diff --git a/src/components/quicklaunch.jsx b/src/components/quicklaunch.jsx index 7fb1460aa..80d61ee03 100644 --- a/src/components/quicklaunch.jsx +++ b/src/components/quicklaunch.jsx @@ -15,16 +15,19 @@ export default function QuickLaunch({ searchProvider, }) { const { t } = useTranslation(); + const { settings } = useContext(SettingsContext); - const { searchDescriptions, hideVisitURL } = settings?.quicklaunch - ? settings.quicklaunch - : { searchDescriptions: false, hideVisitURL: false }; + 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]; @@ -36,8 +39,9 @@ export default function QuickLaunch({ setTimeout(() => { setSearchString(""); setCurrentItemIndex(null); + setSearchSuggestions([]); }, 200); // delay a little for animations - }, [close, setSearchString, setCurrentItemIndex]); + }, [close, setSearchString, setCurrentItemIndex, setSearchSuggestions]); function handleSearchChange(event) { const rawSearchString = event.target.value.toLowerCase(); @@ -90,6 +94,8 @@ export default function QuickLaunch({ } useEffect(() => { + const abortController = new AbortController(); + if (searchString.length === 0) setResults([]); else { let newResults = servicesAndBookmarks.filter((r) => { @@ -109,9 +115,43 @@ export default function QuickLaunch({ if (searchProvider) { newResults.push({ href: searchProvider.url + encodeURIComponent(searchString), - name: `${searchProvider.name ?? t("quicklaunch.custom")} ${t("quicklaunch.search")} `, + name: `${searchProvider.name ?? t("quicklaunch.custom")} ${t("quicklaunch.search")}`, type: "search", }); + + if (showSearchSuggestions && searchProvider.suggestionUrl) { + if (searchString.trim() !== searchSuggestions[0]) { + 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) { @@ -128,7 +168,21 @@ export default function QuickLaunch({ setCurrentItemIndex(0); } } - }, [searchString, servicesAndBookmarks, searchDescriptions, hideVisitURL, searchProvider, url, t]); + + return () => { + abortController.abort(); + }; + }, [ + searchString, + servicesAndBookmarks, + searchDescriptions, + hideVisitURL, + showSearchSuggestions, + searchSuggestions, + searchProvider, + url, + t, + ]); const [hidden, setHidden] = useState(true); useEffect(() => { @@ -219,7 +273,17 @@ export default function QuickLaunch({ )}
- {r.name} + {r.type !== "searchSuggestion" && {r.name}} + {r.type === "searchSuggestion" && ( +
+ + {r.name.indexOf(searchString) === 0 ? searchString : ""} + + + {r.name.indexOf(searchString) === 0 ? r.name.substring(searchString.length) : r.name} + +
+ )} {r.description && ( {searchDescriptions && r.priority < 2 ? highlightText(r.description) : r.description} diff --git a/src/components/widgets/search/search.jsx b/src/components/widgets/search/search.jsx index 0e4391321..6a634308a 100644 --- a/src/components/widgets/search/search.jsx +++ b/src/components/widgets/search/search.jsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, Fragment } from "react"; import { useTranslation } from "next-i18next"; import { FiSearch } from "react-icons/fi"; import { SiDuckduckgo, SiMicrosoftbing, SiGoogle, SiBaidu, SiBrave } from "react-icons/si"; -import { Listbox, Transition } from "@headlessui/react"; +import { Listbox, Transition, Combobox } from "@headlessui/react"; import classNames from "classnames"; import ContainerForm from "../widget/container_form"; @@ -12,26 +12,31 @@ export const searchProviders = { google: { name: "Google", url: "https://www.google.com/search?q=", + suggestionUrl: "https://www.google.com/complete/search?client=chrome&q=", icon: SiGoogle, }, duckduckgo: { name: "DuckDuckGo", url: "https://duckduckgo.com/?q=", + suggestionUrl: "https://duckduckgo.com/ac/?type=list&q=", icon: SiDuckduckgo, }, bing: { name: "Bing", url: "https://www.bing.com/search?q=", + suggestionUrl: "https://api.bing.com/osjson.aspx?query=", icon: SiMicrosoftbing, }, baidu: { name: "Baidu", url: "https://www.baidu.com/s?wd=", + suggestionUrl: "http://suggestion.baidu.com/su?&action=opensearch&ie=utf-8&wd=", icon: SiBaidu, }, brave: { name: "Brave", url: "https://search.brave.com/search?q=", + suggestionUrl: "https://search.brave.com/api/suggest?&rich=false&q=", icon: SiBrave, }, custom: { @@ -72,6 +77,7 @@ export default function Search({ options }) { const [selectedProvider, setSelectedProvider] = useState( searchProviders[availableProviderIds[0] ?? searchProviders.google], ); + const [searchSuggestions, setSearchSuggestions] = useState([]); useEffect(() => { const storedProvider = getStoredProvider(); @@ -82,9 +88,40 @@ export default function Search({ options }) { } }, [availableProviderIds]); + useEffect(() => { + const abortController = new AbortController(); + + if ( + options.showSearchSuggestions && + (selectedProvider.suggestionUrl || options.suggestionUrl) && // custom providers pass url via options + query.trim() !== searchSuggestions[0] + ) { + fetch(`/api/search/searchSuggestion?query=${encodeURIComponent(query)}&providerName=${selectedProvider.name}`, { + 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. + }); + } + + return () => { + abortController.abort(); + }; + }, [selectedProvider, options, query, searchSuggestions]); + const submitCallback = useCallback( - (event) => { - const q = encodeURIComponent(query); + (value) => { + const q = encodeURIComponent(value); const { url } = selectedProvider; if (url) { window.open(`${url}${q}`, options.target || "_blank"); @@ -92,11 +129,9 @@ export default function Search({ options }) { window.open(`${options.url}${q}`, options.target || "_blank"); } - event.preventDefault(); - event.target.reset(); setQuery(""); }, - [options.target, options.url, query, selectedProvider], + [selectedProvider, options.url, options.target], ); if (!availableProviderIds) { @@ -109,84 +144,111 @@ export default function Search({ options }) { }; return ( - + -
+
- setQuery(s.currentTarget.value)} - required - autoCapitalize="off" - autoCorrect="off" - autoComplete="off" - // eslint-disable-next-line jsx-a11y/no-autofocus - autoFocus={options.focus} - /> - -
- - - {t("search.search")} - -
- + setQuery(event.target.value)} + required + autoCapitalize="off" + autoCorrect="off" + autoComplete="off" + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus={options.focus} + /> + - + + + {t("search.search")} + +
+ -
- {availableProviderIds.map((providerId) => { - const p = searchProviders[providerId]; - return ( - - {({ active }) => ( -
  • - -
  • - )} -
    - ); - })} + +
    + {availableProviderIds.map((providerId) => { + const p = searchProviders[providerId]; + return ( + + {({ active }) => ( +
  • + +
  • + )} +
    + ); + })} +
    +
    + + + + {searchSuggestions[1]?.length > 0 && ( + +
    + + {searchSuggestions[1].map((suggestion) => ( + + {({ active }) => ( +
    + {suggestion.indexOf(query) === 0 ? query : ""} + + {suggestion.indexOf(query) === 0 ? suggestion.substring(query.length) : suggestion} + +
    + )} +
    + ))}
    - - - +
    + )} +
    diff --git a/src/pages/api/search/searchSuggestion.js b/src/pages/api/search/searchSuggestion.js new file mode 100644 index 000000000..c1c936c9d --- /dev/null +++ b/src/pages/api/search/searchSuggestion.js @@ -0,0 +1,23 @@ +import { searchProviders } from "components/widgets/search/search"; +import cachedFetch from "utils/proxy/cached-fetch"; +import { widgetsFromConfig } from "utils/config/widget-helpers"; + +export default async function handler(req, res) { + const { query, providerName } = req.query; + + const provider = Object.values(searchProviders).find(({ name }) => name === providerName); + + if (provider.name === "Custom") { + const widgets = await widgetsFromConfig(); + const searchWidget = widgets.find((w) => w.type === "search"); + + provider.url = searchWidget.options.url; + provider.suggestionUrl = searchWidget.options.suggestionUrl; + } + + if (!provider.suggestionUrl) { + return res.json([query, []]); // Responde with the same array format but with no suggestions. + } + + return res.send(await cachedFetch(`${provider.suggestionUrl}${encodeURIComponent(query)}`, 5)); +} diff --git a/src/pages/index.jsx b/src/pages/index.jsx index 928331173..ac03afe3c 100644 --- a/src/pages/index.jsx +++ b/src/pages/index.jsx @@ -211,12 +211,12 @@ function Home({ initialSettings }) { // 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 = { - url: searchWidget.options.url, - }; + searchProvider = searchWidget.options; } else { searchProvider = searchProviders[searchWidget.options?.provider]; } + // to pass to quicklaunch + searchProvider.showSearchSuggestions = searchWidget.options?.showSearchSuggestions; } const headerStyle = settings?.headerStyle || "underlined";