瀏覽代碼

Update search widget

Svilen Markov 1 年之前
父節點
當前提交
e1e7853c34

+ 75 - 33
docs/configuration.md

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

二進制
docs/images/search-widget-bangs-preview.png


二進制
docs/images/search-widget-preview.png


+ 96 - 0
internal/assets/static/main.css

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

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

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

@@ -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-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 }}
 {{ end }}

+ 44 - 8
internal/widget/search.go

@@ -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"`
-	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 {
 func (widget *Search) Initialize() error {
 	widget.withTitle("Search").withError(nil)
 	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
 	return nil
 }
 }
 
 
 func (widget *Search) Render() template.HTML {
 func (widget *Search) Render() template.HTML {
-	return widget.render(widget, assets.SearchTemplate)
+	return widget.cachedHTML
 }
 }