diff --git a/docs/configuration.md b/docs/configuration.md index 8fac540..6caedd7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,6 +11,7 @@ - [Videos](#videos) - [Hacker News](#hacker-news) - [Reddit](#reddit) + - [Search](#search-widget) - [Weather](#weather) - [Monitor](#monitor) - [Releases](#releases) @@ -22,7 +23,6 @@ - [Twitch Channels](#twitch-channels) - [Twitch Top Games](#twitch-top-games) - [iframe](#iframe) - - [Search](#search) ## 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. @@ -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. +### 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 | +| ---- | ------ | --------- | +| S | Focus the search bar | Not already focused on another input field | +| Enter | Perform search in the same tab | Search input is focused and not empty | +| Ctrl + Enter | Perform search in a new tab | Search input is focused and not empty | +| Escape | 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 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` 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. diff --git a/docs/images/search-widget-bangs-preview.png b/docs/images/search-widget-bangs-preview.png new file mode 100644 index 0000000..9490690 Binary files /dev/null and b/docs/images/search-widget-bangs-preview.png differ diff --git a/docs/images/search-widget-preview.png b/docs/images/search-widget-preview.png index c058401..9672a77 100644 Binary files a/docs/images/search-widget-preview.png and b/docs/images/search-widget-preview.png differ diff --git a/internal/assets/static/main.css b/internal/assets/static/main.css index b179b68..7f98e94 100644 --- a/internal/assets/static/main.css +++ b/internal/assets/static/main.css @@ -354,6 +354,23 @@ body { 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 { max-width: 1600px; margin-inline: auto; @@ -665,6 +682,85 @@ body { -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 { display: flex; gap: 1.2rem; diff --git a/internal/assets/static/main.js b/internal/assets/static/main.js index 146f781..3e10b96 100644 --- a/internal/assets/static/main.js +++ b/internal/assets/static/main.js @@ -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() { const elements = document.querySelectorAll("[data-dynamic-relative-time]"); const updateInterval = 60 * 1000; @@ -454,6 +551,7 @@ async function setupPage() { try { setupClocks() setupCarousels(); + setupSearchboxes(); setupCollapsibleLists(); setupCollapsibleGrids(); setupDynamicRelativeTime(); diff --git a/internal/assets/templates/search.html b/internal/assets/templates/search.html index 211ed0c..a0dfaeb 100644 --- a/internal/assets/templates/search.html +++ b/internal/assets/templates/search.html @@ -1,18 +1,24 @@ {{ template "widget-base.html" . }} - + +{{ define "widget-content-classes" }}widget-content-frameless{{ end }} + {{ define "widget-content" }} -
-
- - -
-
+ {{ end }} diff --git a/internal/widget/search.go b/internal/widget/search.go index 1786ee1..9cfd64e 100644 --- a/internal/widget/search.go +++ b/internal/widget/search.go @@ -1,30 +1,66 @@ package widget import ( + "fmt" "html/template" + "strings" "github.com/glanceapp/glance/internal/assets" ) +type SearchBang struct { + Title string + Shortcut string + URL string +} + type Search struct { - widgetBase `yaml:",inline"` - SearchURL string `yaml:"search-url"` - Query string `yaml:"query"` + widgetBase `yaml:",inline"` + cachedHTML template.HTML `yaml:"-"` + 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 { widget.withTitle("Search").withError(nil) - if widget.SearchURL == "" { - // set to the duckduckgo search engine - widget.SearchURL = "https://duckduckgo.com/?q=" + if widget.SearchEngine == "" { + widget.SearchEngine = "duckduckgo" } - // 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 } func (widget *Search) Render() template.HTML { - return widget.render(widget, assets.SearchTemplate) + return widget.cachedHTML }