Feature: search suggestions for search and quick launch (#2775)

---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
pull/2798/head
Florian Hye 10 months ago committed by GitHub
parent f0635db51d
commit d5af7eda63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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. - `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. - `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. - `hideVisitURL`: disable detecting and offering an option to open URLs. This is false by default, enabling the feature.
```yaml ```yaml
quicklaunch: quicklaunch:
searchDescriptions: true searchDescriptions: true
hideInternetSearch: true hideInternetSearch: true
showSearchSuggestions: true
hideVisitURL: true hideVisitURL: true
``` ```

@ -9,6 +9,7 @@ You can add a search bar to your top widget area that can search using Google, D
- search: - search:
provider: google # google, duckduckgo, bing, baidu, brave or custom provider: google # google, duckduckgo, bing, baidu, brave or custom
focus: true # Optional, will set focus to the search bar on page load 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 target: _blank # One of _self, _blank, _parent or _top
``` ```
@ -17,8 +18,10 @@ or for a custom search:
```yaml ```yaml
- search: - search:
provider: custom provider: custom
url: https://lougle.com/?q= url: https://www.ecosia.org/search?q=
target: _blank 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): 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] 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_ _Added in v0.1.6, updated in 0.6.0_

@ -419,7 +419,8 @@
"search": "Suchen", "search": "Suchen",
"custom": "Benutzerdefiniert", "custom": "Benutzerdefiniert",
"visit": "Besuchen", "visit": "Besuchen",
"url": "URL" "url": "URL",
"searchsuggestion": "Vorschlag"
}, },
"wmo": { "wmo": {
"0-day": "sonnig", "0-day": "sonnig",

@ -419,7 +419,8 @@
"search": "Search", "search": "Search",
"custom": "Custom", "custom": "Custom",
"visit": "Visit", "visit": "Visit",
"url": "URL" "url": "URL",
"searchsuggestion": "Suggestion"
}, },
"wmo": { "wmo": {
"0-day": "Sunny", "0-day": "Sunny",

@ -15,16 +15,19 @@ export default function QuickLaunch({
searchProvider, searchProvider,
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { settings } = useContext(SettingsContext); const { settings } = useContext(SettingsContext);
const { searchDescriptions, hideVisitURL } = settings?.quicklaunch const { searchDescriptions = false, hideVisitURL = false } = settings?.quicklaunch ?? {};
? settings.quicklaunch const showSearchSuggestions = !!(
: { searchDescriptions: false, hideVisitURL: false }; settings?.quicklaunch?.showSearchSuggestions ?? searchProvider.showSearchSuggestions
);
const searchField = useRef(); const searchField = useRef();
const [results, setResults] = useState([]); const [results, setResults] = useState([]);
const [currentItemIndex, setCurrentItemIndex] = useState(null); const [currentItemIndex, setCurrentItemIndex] = useState(null);
const [url, setUrl] = useState(null); const [url, setUrl] = useState(null);
const [searchSuggestions, setSearchSuggestions] = useState([]);
function openCurrentItem(newWindow) { function openCurrentItem(newWindow) {
const result = results[currentItemIndex]; const result = results[currentItemIndex];
@ -36,8 +39,9 @@ export default function QuickLaunch({
setTimeout(() => { setTimeout(() => {
setSearchString(""); setSearchString("");
setCurrentItemIndex(null); setCurrentItemIndex(null);
setSearchSuggestions([]);
}, 200); // delay a little for animations }, 200); // delay a little for animations
}, [close, setSearchString, setCurrentItemIndex]); }, [close, setSearchString, setCurrentItemIndex, setSearchSuggestions]);
function handleSearchChange(event) { function handleSearchChange(event) {
const rawSearchString = event.target.value.toLowerCase(); const rawSearchString = event.target.value.toLowerCase();
@ -90,6 +94,8 @@ export default function QuickLaunch({
} }
useEffect(() => { useEffect(() => {
const abortController = new AbortController();
if (searchString.length === 0) setResults([]); if (searchString.length === 0) setResults([]);
else { else {
let newResults = servicesAndBookmarks.filter((r) => { let newResults = servicesAndBookmarks.filter((r) => {
@ -109,9 +115,43 @@ export default function QuickLaunch({
if (searchProvider) { if (searchProvider) {
newResults.push({ newResults.push({
href: searchProvider.url + encodeURIComponent(searchString), 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", 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) { if (!hideVisitURL && url) {
@ -128,7 +168,21 @@ export default function QuickLaunch({
setCurrentItemIndex(0); 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); const [hidden, setHidden] = useState(true);
useEffect(() => { useEffect(() => {
@ -219,7 +273,17 @@ export default function QuickLaunch({
</div> </div>
)} )}
<div className="flex flex-col md:flex-row text-left items-baseline mr-4 pointer-events-none"> <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.type !== "searchSuggestion" && <span className="mr-4">{r.name}</span>}
{r.type === "searchSuggestion" && (
<div class="flex-nowrap">
<span className="whitespace-pre">
{r.name.indexOf(searchString) === 0 ? searchString : ""}
</span>
<span className="whitespace-pre opacity-50">
{r.name.indexOf(searchString) === 0 ? r.name.substring(searchString.length) : r.name}
</span>
</div>
)}
{r.description && ( {r.description && (
<span className="text-xs text-theme-600 text-light"> <span className="text-xs text-theme-600 text-light">
{searchDescriptions && r.priority < 2 ? highlightText(r.description) : r.description} {searchDescriptions && r.priority < 2 ? highlightText(r.description) : r.description}

@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, Fragment } from "react";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { FiSearch } from "react-icons/fi"; import { FiSearch } from "react-icons/fi";
import { SiDuckduckgo, SiMicrosoftbing, SiGoogle, SiBaidu, SiBrave } from "react-icons/si"; 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 classNames from "classnames";
import ContainerForm from "../widget/container_form"; import ContainerForm from "../widget/container_form";
@ -12,26 +12,31 @@ export const searchProviders = {
google: { google: {
name: "Google", name: "Google",
url: "https://www.google.com/search?q=", url: "https://www.google.com/search?q=",
suggestionUrl: "https://www.google.com/complete/search?client=chrome&q=",
icon: SiGoogle, icon: SiGoogle,
}, },
duckduckgo: { duckduckgo: {
name: "DuckDuckGo", name: "DuckDuckGo",
url: "https://duckduckgo.com/?q=", url: "https://duckduckgo.com/?q=",
suggestionUrl: "https://duckduckgo.com/ac/?type=list&q=",
icon: SiDuckduckgo, icon: SiDuckduckgo,
}, },
bing: { bing: {
name: "Bing", name: "Bing",
url: "https://www.bing.com/search?q=", url: "https://www.bing.com/search?q=",
suggestionUrl: "https://api.bing.com/osjson.aspx?query=",
icon: SiMicrosoftbing, icon: SiMicrosoftbing,
}, },
baidu: { baidu: {
name: "Baidu", name: "Baidu",
url: "https://www.baidu.com/s?wd=", url: "https://www.baidu.com/s?wd=",
suggestionUrl: "http://suggestion.baidu.com/su?&action=opensearch&ie=utf-8&wd=",
icon: SiBaidu, icon: SiBaidu,
}, },
brave: { brave: {
name: "Brave", name: "Brave",
url: "https://search.brave.com/search?q=", url: "https://search.brave.com/search?q=",
suggestionUrl: "https://search.brave.com/api/suggest?&rich=false&q=",
icon: SiBrave, icon: SiBrave,
}, },
custom: { custom: {
@ -72,6 +77,7 @@ export default function Search({ options }) {
const [selectedProvider, setSelectedProvider] = useState( const [selectedProvider, setSelectedProvider] = useState(
searchProviders[availableProviderIds[0] ?? searchProviders.google], searchProviders[availableProviderIds[0] ?? searchProviders.google],
); );
const [searchSuggestions, setSearchSuggestions] = useState([]);
useEffect(() => { useEffect(() => {
const storedProvider = getStoredProvider(); const storedProvider = getStoredProvider();
@ -82,9 +88,40 @@ export default function Search({ options }) {
} }
}, [availableProviderIds]); }, [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( const submitCallback = useCallback(
(event) => { (value) => {
const q = encodeURIComponent(query); const q = encodeURIComponent(value);
const { url } = selectedProvider; const { url } = selectedProvider;
if (url) { if (url) {
window.open(`${url}${q}`, options.target || "_blank"); window.open(`${url}${q}`, options.target || "_blank");
@ -92,11 +129,9 @@ export default function Search({ options }) {
window.open(`${options.url}${q}`, options.target || "_blank"); window.open(`${options.url}${q}`, options.target || "_blank");
} }
event.preventDefault();
event.target.reset();
setQuery(""); setQuery("");
}, },
[options.target, options.url, query, selectedProvider], [selectedProvider, options.url, options.target],
); );
if (!availableProviderIds) { if (!availableProviderIds) {
@ -109,84 +144,111 @@ export default function Search({ options }) {
}; };
return ( return (
<ContainerForm options={options} callback={submitCallback} additionalClassNames="grow information-widget-search"> <ContainerForm options={options} additionalClassNames="grow information-widget-search">
<Raw> <Raw>
<div className="flex-col relative h-8 my-4 min-w-fit"> <div className="flex-col relative h-8 my-4 min-w-fit z-20">
<div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none w-full text-theme-800 dark:text-white" /> <div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none w-full text-theme-800 dark:text-white" />
<input <Combobox value={query} onChange={submitCallback}>
type="text" <Combobox.Input
className=" type="text"
overflow-hidden w-full h-full rounded-md className="
text-xs text-theme-900 dark:text-white overflow-hidden w-full h-full rounded-md
placeholder-theme-900 dark:placeholder-white/80 text-xs text-theme-900 dark:text-white
bg-white/50 dark:bg-white/10 placeholder-theme-900 dark:placeholder-white/80
focus:ring-theme-500 dark:focus:ring-white/50 bg-white/50 dark:bg-white/10
focus:border-theme-500 dark:focus:border-white/50 focus:ring-theme-500 dark:focus:ring-white/50
border border-theme-300 dark:border-theme-200/50" focus:border-theme-500 dark:focus:border-white/50
placeholder={t("search.placeholder")} border border-theme-300 dark:border-theme-200/50"
onChange={(s) => setQuery(s.currentTarget.value)} placeholder={t("search.placeholder")}
required onChange={(event) => setQuery(event.target.value)}
autoCapitalize="off" required
autoCorrect="off" autoCapitalize="off"
autoComplete="off" autoCorrect="off"
// eslint-disable-next-line jsx-a11y/no-autofocus autoComplete="off"
autoFocus={options.focus} // eslint-disable-next-line jsx-a11y/no-autofocus
/> autoFocus={options.focus}
<Listbox />
as="div" <Listbox
value={selectedProvider} as="div"
onChange={onChangeProvider} value={selectedProvider}
className="relative text-left" onChange={onChangeProvider}
disabled={availableProviderIds?.length === 1} className="relative text-left"
> disabled={availableProviderIds?.length === 1}
<div>
<Listbox.Button
className="
absolute right-0.5 bottom-0.5 rounded-r-md px-4 py-2 border-1
text-white font-medium text-sm
bg-theme-600/40 dark:bg-white/10
focus:ring-theme-500 dark:focus:ring-white/50"
>
<selectedProvider.icon className="text-white w-3 h-3" />
<span className="sr-only">{t("search.search")}</span>
</Listbox.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
> >
<Listbox.Options <div>
className="absolute right-0 z-10 mt-1 origin-top-right rounded-md <Listbox.Button
bg-theme-100 dark:bg-theme-600 shadow-lg className="
ring-1 ring-black ring-opacity-5 focus:outline-none" absolute right-0.5 bottom-0.5 rounded-r-md px-4 py-2 border-1
text-white font-medium text-sm
bg-theme-600/40 dark:bg-white/10
focus:ring-theme-500 dark:focus:ring-white/50"
>
<selectedProvider.icon className="text-white w-3 h-3" />
<span className="sr-only">{t("search.search")}</span>
</Listbox.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
> >
<div className="flex flex-col"> <Listbox.Options
{availableProviderIds.map((providerId) => { className="absolute right-0 z-10 mt-1 origin-top-right rounded-md
const p = searchProviders[providerId]; bg-theme-100 dark:bg-theme-600 shadow-lg
return ( ring-1 ring-black ring-opacity-5 focus:outline-none"
<Listbox.Option key={providerId} value={p} as={Fragment}> >
{({ active }) => ( <div className="flex flex-col">
<li {availableProviderIds.map((providerId) => {
className={classNames( const p = searchProviders[providerId];
"rounded-md cursor-pointer", return (
active ? "bg-theme-600/10 dark:bg-white/10 dark:text-gray-900" : "dark:text-gray-100", <Listbox.Option key={providerId} value={p} as={Fragment}>
)} {({ active }) => (
> <li
<p.icon className="h-4 w-4 mx-4 my-2" /> className={classNames(
</li> "rounded-md cursor-pointer",
)} active ? "bg-theme-600/10 dark:bg-white/10 dark:text-gray-900" : "dark:text-gray-100",
</Listbox.Option> )}
); >
})} <p.icon className="h-4 w-4 mx-4 my-2" />
</li>
)}
</Listbox.Option>
);
})}
</div>
</Listbox.Options>
</Transition>
</Listbox>
{searchSuggestions[1]?.length > 0 && (
<Combobox.Options className="mt-1 rounded-md bg-theme-50 dark:bg-theme-800 border border-theme-300 dark:border-theme-200/30 cursor-pointer shadow-lg">
<div className="p-1 bg-white/50 dark:bg-white/10 text-theme-900/90 dark:text-white/90 text-xs">
<Combobox.Option key={query} value={query} />
{searchSuggestions[1].map((suggestion) => (
<Combobox.Option key={suggestion} value={suggestion} className="flex w-full">
{({ active }) => (
<div
className={classNames(
"px-2 py-1 rounded-md w-full flex-nowrap",
active ? "bg-theme-300/20 dark:bg-white/10" : "",
)}
>
<span className="whitespace-pre">{suggestion.indexOf(query) === 0 ? query : ""}</span>
<span className="mr-4 whitespace-pre opacity-50">
{suggestion.indexOf(query) === 0 ? suggestion.substring(query.length) : suggestion}
</span>
</div>
)}
</Combobox.Option>
))}
</div> </div>
</Listbox.Options> </Combobox.Options>
</Transition> )}
</Listbox> </Combobox>
</div> </div>
</Raw> </Raw>
</ContainerForm> </ContainerForm>

@ -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));
}

@ -211,12 +211,12 @@ function Home({ initialSettings }) {
// if search provider is a list, try to retrieve from localstorage, fall back to the first // if search provider is a list, try to retrieve from localstorage, fall back to the first
searchProvider = getStoredProvider() ?? searchProviders[searchWidget.options.provider[0]]; searchProvider = getStoredProvider() ?? searchProviders[searchWidget.options.provider[0]];
} else if (searchWidget.options?.provider === "custom") { } else if (searchWidget.options?.provider === "custom") {
searchProvider = { searchProvider = searchWidget.options;
url: searchWidget.options.url,
};
} else { } else {
searchProvider = searchProviders[searchWidget.options?.provider]; searchProvider = searchProviders[searchWidget.options?.provider];
} }
// to pass to quicklaunch
searchProvider.showSearchSuggestions = searchWidget.options?.showSearchSuggestions;
} }
const headerStyle = settings?.headerStyle || "underlined"; const headerStyle = settings?.headerStyle || "underlined";

Loading…
Cancel
Save