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

---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
pull/2798/head
Florian Hye 3 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.
- `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
```

@ -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_

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

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

@ -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({
</div>
)}
<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 && (
<span className="text-xs text-theme-600 text-light">
{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 { 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 (
<ContainerForm options={options} callback={submitCallback} additionalClassNames="grow information-widget-search">
<ContainerForm options={options} additionalClassNames="grow information-widget-search">
<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" />
<input
type="text"
className="
overflow-hidden w-full h-full rounded-md
text-xs text-theme-900 dark:text-white
placeholder-theme-900 dark:placeholder-white/80
bg-white/50 dark:bg-white/10
focus:ring-theme-500 dark:focus:ring-white/50
focus:border-theme-500 dark:focus:border-white/50
border border-theme-300 dark:border-theme-200/50"
placeholder={t("search.placeholder")}
onChange={(s) => setQuery(s.currentTarget.value)}
required
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={options.focus}
/>
<Listbox
as="div"
value={selectedProvider}
onChange={onChangeProvider}
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"
<Combobox value={query} onChange={submitCallback}>
<Combobox.Input
type="text"
className="
overflow-hidden w-full h-full rounded-md
text-xs text-theme-900 dark:text-white
placeholder-theme-900 dark:placeholder-white/80
bg-white/50 dark:bg-white/10
focus:ring-theme-500 dark:focus:ring-white/50
focus:border-theme-500 dark:focus:border-white/50
border border-theme-300 dark:border-theme-200/50"
placeholder={t("search.placeholder")}
onChange={(event) => setQuery(event.target.value)}
required
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={options.focus}
/>
<Listbox
as="div"
value={selectedProvider}
onChange={onChangeProvider}
className="relative text-left"
disabled={availableProviderIds?.length === 1}
>
<Listbox.Options
className="absolute right-0 z-10 mt-1 origin-top-right rounded-md
bg-theme-100 dark:bg-theme-600 shadow-lg
ring-1 ring-black ring-opacity-5 focus:outline-none"
<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"
>
<div className="flex flex-col">
{availableProviderIds.map((providerId) => {
const p = searchProviders[providerId];
return (
<Listbox.Option key={providerId} value={p} as={Fragment}>
{({ active }) => (
<li
className={classNames(
"rounded-md cursor-pointer",
active ? "bg-theme-600/10 dark:bg-white/10 dark:text-gray-900" : "dark:text-gray-100",
)}
>
<p.icon className="h-4 w-4 mx-4 my-2" />
</li>
)}
</Listbox.Option>
);
})}
<Listbox.Options
className="absolute right-0 z-10 mt-1 origin-top-right rounded-md
bg-theme-100 dark:bg-theme-600 shadow-lg
ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div className="flex flex-col">
{availableProviderIds.map((providerId) => {
const p = searchProviders[providerId];
return (
<Listbox.Option key={providerId} value={p} as={Fragment}>
{({ active }) => (
<li
className={classNames(
"rounded-md cursor-pointer",
active ? "bg-theme-600/10 dark:bg-white/10 dark:text-gray-900" : "dark:text-gray-100",
)}
>
<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>
</Listbox.Options>
</Transition>
</Listbox>
</Combobox.Options>
)}
</Combobox>
</div>
</Raw>
</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
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";

Loading…
Cancel
Save