Ver código fonte

Update search widget

Svilen Markov 1 ano atrás
pai
commit
e1e7853c34

+ 75 - 33
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 |
+| ---- | ------ | --------- |
+| <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
 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.

BIN
docs/images/search-widget-bangs-preview.png


BIN
docs/images/search-widget-preview.png


+ 96 - 0
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;

+ 98 - 0
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();

+ 21 - 15
internal/assets/templates/search.html

@@ -1,18 +1,24 @@
 {{ template "widget-base.html" . }}
-<!-- Search box  -->
+
+{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
+
 {{ define "widget-content" }}
-    <form class="search-form" action="{{ .SearchURL }}" method="get">
-      <div class="search-input-container">
-        <input type="text" class="search-input" value="{{ .Query }}" name="q" placeholder="Search...">
-        <button type="submit" class="search-button">
-          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
-            stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
-            class="icon icon-tabler icons-tabler-outline icon-tabler-search">
-            <path stroke="none" d="M0 0h24v24H0z" fill="none" />
-            <path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" />
-            <path d="M21 21l-6 -6" />
-          </svg>
-        </button>
-      </div>
-    </form>
+<div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}">
+    <div class="search-bangs">
+        {{ range .Bangs }}
+        <input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
+        {{ end }}
+    </div>
+
+    <div class="search-icon-container">
+        <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 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>
+    </div>
+
+    <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 }}

+ 44 - 8
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
 }