Update search widget

This commit is contained in:
Svilen Markov 2024-05-25 03:58:11 +01:00
parent 3f862f67ab
commit e1e7853c34
7 changed files with 334 additions and 56 deletions

View File

@ -11,6 +11,7 @@
- [Videos](#videos) - [Videos](#videos)
- [Hacker News](#hacker-news) - [Hacker News](#hacker-news)
- [Reddit](#reddit) - [Reddit](#reddit)
- [Search](#search-widget)
- [Weather](#weather) - [Weather](#weather)
- [Monitor](#monitor) - [Monitor](#monitor)
- [Releases](#releases) - [Releases](#releases)
@ -22,7 +23,6 @@
- [Twitch Channels](#twitch-channels) - [Twitch Channels](#twitch-channels)
- [Twitch Top Games](#twitch-top-games) - [Twitch Top Games](#twitch-top-games)
- [iframe](#iframe) - [iframe](#iframe)
- [Search](#search)
## Intro ## Intro
Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error. Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error.
@ -642,6 +642,80 @@ Can be used to specify an additional sort which will be applied on top of the al
The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts. The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
### Search Widget
Display a search bar that can be used to search for specific terms on various search engines.
Example:
```yaml
- type: search
search-engine: duckduckgo
bangs:
- title: YouTube
shortcut: "!yt"
url: https://www.youtube.com/results?search_query={QUERY}
```
Preview:
![](images/search-widget-preview.png)
#### Keyboard shortcuts
| Keys | Action | Condition |
| ---- | ------ | --------- |
| <kbd>S</kbd> | Focus the search bar | Not already focused on another input field |
| <kbd>Enter</kbd> | Perform search in the same tab | Search input is focused and not empty |
| <kbd>Ctrl</kbd> + <kbd>Enter</kbd> | Perform search in a new tab | Search input is focused and not empty |
| <kbd>Escape</kbd> | Leave focus | Search input is focused |
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| search-engine | string | no | duckduckgo |
| bangs | array | no | |
##### `search-engine`
Either a value from the table below or a URL to a custom search engine. Use `{QUERY}` to indicate where the query value gets placed.
| Name | URL |
| ---- | --- |
| duckduckgo | `https://duckduckgo.com/?q={QUERY}` |
| google | `https://www.google.com/search?q={QUERY}` |
##### `bangs`
What now? [Bangs](https://duckduckgo.com/bangs). They're shortcuts that allow you to use the same search box for many different sites. Assuming you have it configured, if for example you start your search input with `!yt` you'd be able to perform a search on YouTube:
![](images/search-widget-bangs-preview.png)
##### Properties for each bang
| Name | Type | Required |
| ---- | ---- | -------- |
| title | string | no |
| shortcut | string | yes |
| url | string | yes |
###### `title`
Optional title that will appear on the right side of the search bar when the query starts with the associated shortcut.
###### `shortcut`
Any value you wish to use as the shortcut for the search engine. It does not have to start with `!`.
> [!IMPORTANT]
>
> In YAML some characters have special meaning when placed in the beginning of a value. If your shortcut starts with `!` (and potentially some other special characters) you'll have to wrap the value in quotes:
> ```yaml
> shortcut: "!yt"
>```
###### `url`
The URL of the search engine. Use `{QUERY}` to indicate where the query value gets placed. Examples:
```yaml
url: https://www.reddit.com/search?q={QUERY}
url: https://store.steampowered.com/search/?term={QUERY}
url: https://www.amazon.com/s?k={QUERY}
```
### Weather ### Weather
Display weather information for a specific location. The data is provided by https://open-meteo.com/. Display weather information for a specific location. The data is provided by https://open-meteo.com/.
@ -1189,35 +1263,3 @@ The source of the iframe.
##### `height` ##### `height`
The height of the iframe. The minimum allowed height is 50. The height of the iframe. The minimum allowed height is 50.
### Search
Display a search bar that can be used to search for specific terms on various search engines.
Example:
```yaml
- type: search
search-url: https://www.google.com/search?q=
query: This is a default search
```
Preview:
![](images/search-widget-preview.png)
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| search-url | string | no | https://duckduckgo.com/?q= |
| query | string | no | |
##### `search-url`
The URL to use for the search. The query will be appended to the end of the URL. Some common examples:
- Google: `https://www.google.com/search?q=`
- DuckDuckGo: `https://duckduckgo.com/?q=`
- Bing: `https://www.bing.com/search?q=`
- Perplexity AI: `https://perplexity.ai/search?q=`
- ChatGPT (requires ChatGPT Plus subscription): `https://chatgpt.com/?model=gpt-4o&oai-dm=1&q=`
##### `query`
The default query to show in the search bar. If left blank the search bar will be empty.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -354,6 +354,23 @@ body {
border: 1px solid var(--color-negative); border: 1px solid var(--color-negative);
} }
kbd {
font: inherit;
padding: 0.1rem 0.8rem;
border-radius: var(--border-radius);
border: 2px solid var(--color-widget-background-highlight);
box-shadow: 0 2px 0 var(--color-widget-background-highlight);
user-select: none;
transition: transform .1s, box-shadow .1s;
font-size: var(--font-size-h5);
cursor: pointer;
}
kbd:active {
transform: translateY(2px);
box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
}
.content-bounds { .content-bounds {
max-width: 1600px; max-width: 1600px;
margin-inline: auto; margin-inline: auto;
@ -665,6 +682,85 @@ body {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
.search-icon {
width: 2.3rem;
}
.search-icon-container {
position: relative;
flex-shrink: 0;
}
/* gives a wider hit area for the 3 people that will notice the animation : ) */
.search-icon-container::before {
content: '';
position: absolute;
inset: -1rem;
}
.search-icon-container:hover > .search-icon {
animation: searchIconHover 2.9s forwards;
}
@keyframes searchIconHover {
0%, 39% { translate: 0 0; }
20% { scale: 1.3; }
40% { scale: 1; }
50% { translate: -30% 30%; }
70% { translate: 30% -30%; }
90% { translate: -30% -30%; }
100% { translate: 0 0; }
}
.search {
transition: border-color .2s;
position: relative;
}
.search:hover {
border-color: var(--color-text-subdue);
}
.search:focus-within {
border-color: var(--color-primary);
}
.search-input {
border: 0;
background: none;
width: 100%;
height: 6rem;
font: inherit;
outline: none;
}
.search-input::placeholder {
color: var(--color-text-base-muted);
opacity: 1;
}
.search-bangs { display: none; }
.search-bang {
border-radius: calc(var(--border-radius) * 2);
background: var(--color-widget-background-highlight);
padding: 0.3rem 1rem;
flex-shrink: 0;
font-size: var(--font-size-h5);
animation: searchBangsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
@keyframes searchBangsEntrance {
0% {
opacity: 0;
transform: translateX(-10px);
}
}
.search-bang:empty {
display: none;
}
.forum-post-list-item { .forum-post-list-item {
display: flex; display: flex;
gap: 1.2rem; gap: 1.2rem;

View File

@ -107,6 +107,103 @@ function updateRelativeTimeForElements(elements)
} }
} }
function setupSearchboxes() {
const searchWidgets = document.getElementsByClassName("search");
if (searchWidgets.length == 0) {
return;
}
for (let i = 0; i < searchWidgets.length; i++) {
const widget = searchWidgets[i];
const defaultSearchUrl = widget.dataset.defaultSearchUrl;
const inputElement = widget.getElementsByClassName("search-input")[0];
const bangElement = widget.getElementsByClassName("search-bang")[0];
const bangs = widget.querySelectorAll(".search-bangs > input");
const bangsMap = {};
const kbdElement = widget.getElementsByTagName("kbd")[0];
let currentBang = null;
for (let j = 0; j < bangs.length; j++) {
const bang = bangs[j];
bangsMap[bang.dataset.shortcut] = bang;
}
const handleKeyDown = (event) => {
if (event.key == "Escape") {
inputElement.blur();
return;
}
if (event.key == "Enter") {
const input = inputElement.value.trim();
let query;
let searchUrlTemplate;
if (currentBang != null) {
query = input.slice(currentBang.dataset.shortcut.length + 1);
searchUrlTemplate = currentBang.dataset.url;
} else {
query = input;
searchUrlTemplate = defaultSearchUrl;
}
if (query.length == 0) {
return;
}
const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
if (event.ctrlKey) {
window.open(url, '_blank').focus();
} else {
window.location.href = url;
}
return;
}
};
const changeCurrentBang = (bang) => {
currentBang = bang;
bangElement.textContent = bang != null ? bang.dataset.title : "";
}
const handleInput = (event) => {
const value = event.target.value.trimStart();
const words = value.split(" ");
if (words.length >= 2 && words[0] in bangsMap) {
changeCurrentBang(bangsMap[words[0]]);
return;
}
changeCurrentBang(null);
};
inputElement.addEventListener("focus", () => {
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("input", handleInput);
});
inputElement.addEventListener("blur", () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("input", handleInput);
});
document.addEventListener("keydown", (event) => {
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
if (event.key != "s") return;
inputElement.focus();
event.preventDefault();
});
kbdElement.addEventListener("mousedown", () => {
requestAnimationFrame(() => inputElement.focus());
});
}
}
function setupDynamicRelativeTime() { function setupDynamicRelativeTime() {
const elements = document.querySelectorAll("[data-dynamic-relative-time]"); const elements = document.querySelectorAll("[data-dynamic-relative-time]");
const updateInterval = 60 * 1000; const updateInterval = 60 * 1000;
@ -454,6 +551,7 @@ async function setupPage() {
try { try {
setupClocks() setupClocks()
setupCarousels(); setupCarousels();
setupSearchboxes();
setupCollapsibleLists(); setupCollapsibleLists();
setupCollapsibleGrids(); setupCollapsibleGrids();
setupDynamicRelativeTime(); setupDynamicRelativeTime();

View File

@ -1,18 +1,24 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
<!-- Search box -->
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }} {{ define "widget-content" }}
<form class="search-form" action="{{ .SearchURL }}" method="get"> <div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}">
<div class="search-input-container"> <div class="search-bangs">
<input type="text" class="search-input" value="{{ .Query }}" name="q" placeholder="Search..."> {{ range .Bangs }}
<button type="submit" class="search-button"> <input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" {{ end }}
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" </div>
class="icon icon-tabler icons-tabler-outline icon-tabler-search">
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <div class="search-icon-container">
<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" /> <svg class="search-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path d="M21 21l-6 -6" /> <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg> </svg>
</button> </div>
</div>
</form> <input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off">
<div class="search-bang"></div>
<kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>
</div>
{{ end }} {{ end }}

View File

@ -1,30 +1,66 @@
package widget package widget
import ( import (
"fmt"
"html/template" "html/template"
"strings"
"github.com/glanceapp/glance/internal/assets" "github.com/glanceapp/glance/internal/assets"
) )
type SearchBang struct {
Title string
Shortcut string
URL string
}
type Search struct { type Search struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
SearchURL string `yaml:"search-url"` cachedHTML template.HTML `yaml:"-"`
Query string `yaml:"query"` SearchEngine string `yaml:"search-engine"`
Bangs []SearchBang `yaml:"bangs"`
}
func convertSearchUrl(url string) string {
// Go's template is being stubborn and continues to escape the curlies in the
// URL regardless of what the type of the variable is so this is my way around it
return strings.ReplaceAll(url, "{QUERY}", "!QUERY!")
}
var searchEngines = map[string]string{
"duckduckgo": "https://duckduckgo.com/?q={QUERY}",
"google": "https://www.google.com/search?q={QUERY}",
} }
func (widget *Search) Initialize() error { func (widget *Search) Initialize() error {
widget.withTitle("Search").withError(nil) widget.withTitle("Search").withError(nil)
if widget.SearchURL == "" { if widget.SearchEngine == "" {
// set to the duckduckgo search engine widget.SearchEngine = "duckduckgo"
widget.SearchURL = "https://duckduckgo.com/?q="
} }
// if no query is provided, leave an empty string if url, ok := searchEngines[widget.SearchEngine]; ok {
widget.SearchEngine = url
}
widget.SearchEngine = convertSearchUrl(widget.SearchEngine)
for i := range widget.Bangs {
if widget.Bangs[i].Shortcut == "" {
return fmt.Errorf("Search bang %d has no shortcut", i+1)
}
if widget.Bangs[i].URL == "" {
return fmt.Errorf("Search bang %d has no URL", i+1)
}
widget.Bangs[i].URL = convertSearchUrl(widget.Bangs[i].URL)
}
widget.cachedHTML = widget.render(widget, assets.SearchTemplate)
return nil return nil
} }
func (widget *Search) Render() template.HTML { func (widget *Search) Render() template.HTML {
return widget.render(widget, assets.SearchTemplate) return widget.cachedHTML
} }