Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1181,13 +1181,20 @@ widgets:
To register an app on Reddit, go to [this page](https://ssl.reddit.com/prefs/apps/).

### Search Widget
Display a search bar that can be used to search for specific terms on various search engines.
Display a search bar that can be used to search for specific terms on various search engines. Features include shortcuts for quick site navigation, search suggestions, and search bangs.

Example:

```yaml
- type: search
search-engine: duckduckgo
suggestions: true
shortcuts:
- title: Gmail
url: https://mail.google.com
alias: gm
- title: GitHub
url: https://github.com
bangs:
- title: YouTube
shortcut: "!yt"
Expand All @@ -1206,6 +1213,9 @@ Preview:
| <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 |
| <kbd>Up</kbd> | Insert the last search query since the page was opened into the input field | Search input is focused |
| <kbd>Down</kbd> / <kbd>Tab</kbd> | Navigate down in shortcuts/suggestions dropdown | Dropdown is visible |
| <kbd>Up</kbd> / <kbd>Shift</kbd> + <kbd>Tab</kbd> | Navigate up in shortcuts/suggestions dropdown | Dropdown is visible |
| <kbd>Enter</kbd> | Select highlighted shortcut or suggestion | Dropdown is visible and item is highlighted |

> [!TIP]
>
Expand All @@ -1219,6 +1229,9 @@ Preview:
| autofocus | boolean | no | false |
| target | string | no | _blank |
| placeholder | string | no | Type here to search… |
| suggestions | boolean | no | false |
| suggestion-engine | string | no | |
| shortcuts | array | no | |
| bangs | array | no | |

##### `search-engine`
Expand All @@ -1245,6 +1258,38 @@ The target to use when opening the search results in a new tab. Possible values
##### `placeholder`
When set, modifies the text displayed in the input field before typing.

##### `suggestions`
When set to `true`, enables search suggestions from the configured suggestion engine. Suggestions appear in a dropdown as you type.

##### `suggestion-engine`
The engine to use for search suggestions. Can be a preset value from the table below or a custom URL. If not specified and suggestions are enabled, defaults to the same value as `search-engine`. Use `{QUERY}` to indicate where the query value gets placed in custom URLs.

| Name | URL |
| ---- | --- |
| google | `https://suggestqueries.google.com/complete/search?output=firefox&q={QUERY}` |
| duckduckgo | `https://duckduckgo.com/ac/?q={QUERY}&type=list` |
| bing | `https://www.bing.com/osjson.aspx?query={QUERY}` |
| startpage | `https://startpage.com/suggestions?q={QUERY}&format=opensearch` |

##### `shortcuts`
An array of shortcuts to websites that appear in a dropdown as you type. Shortcuts are matched against the title and optional shortcut text using fuzzy matching.

##### Properties for each shortcut
| Name | Type | Required |
| ---- | ---- | -------- |
| title | string | yes |
| url | string | yes |
| alias | string | no |

###### `title`
The display name for the shortcut that will appear in the dropdown.

###### `url`
The URL to navigate to when the shortcut is selected.

###### `alias`
Optional short alias for the shortcut that can be typed to quickly find it. For example, "gm" for Gmail.

##### `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:

Expand Down
Binary file added glance
Binary file not shown.
121 changes: 121 additions & 0 deletions internal/glance/glance.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"path/filepath"
"slices"
"strconv"
Expand Down Expand Up @@ -401,6 +404,123 @@ func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("Page not found"))
}

type searchSuggestionsResponse struct {
Query string `json:"query"`
Suggestions []string `json:"suggestions"`
}

func (a *application) handleSearchSuggestionsRequest(w http.ResponseWriter, r *http.Request) {
if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) {
return
}

query := r.URL.Query().Get("query")
widgetIDStr := r.URL.Query().Get("widget_id")

if query == "" {
http.Error(w, "Missing query parameter", http.StatusBadRequest)
return
}

if widgetIDStr == "" {
http.Error(w, "Missing widget_id parameter", http.StatusBadRequest)
return
}

widgetID, err := strconv.ParseUint(widgetIDStr, 10, 64)
if err != nil {
http.Error(w, "Invalid widget_id parameter", http.StatusBadRequest)
return
}

widget, exists := a.widgetByID[widgetID]
if !exists {
http.Error(w, "Widget not found", http.StatusNotFound)
return
}

searchWidget, ok := widget.(*searchWidget)
if !ok {
http.Error(w, "Widget is not a search widget", http.StatusBadRequest)
return
}

if !searchWidget.Suggestions {
http.Error(w, "Widget does not have suggestions enabled", http.StatusBadRequest)
return
}

suggestions, err := a.fetchSearchSuggestions(query, searchWidget.SuggestionEngine)
if err != nil {
log.Printf("Error fetching search suggestions: %v", err)
// Set error on the widget to show the red dot indicator
searchWidget.withError(fmt.Errorf("suggestion service error: %v", err))
http.Error(w, "Failed to fetch suggestions", http.StatusInternalServerError)
return
}

// Clear any previous errors on successful response
searchWidget.withError(nil)

response := searchSuggestionsResponse{
Query: query,
Suggestions: suggestions,
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

func (a *application) fetchSearchSuggestions(query, engineURL string) ([]string, error) {
suggestionURL := strings.ReplaceAll(engineURL, "{QUERY}", url.QueryEscape(query))

client := &http.Client{
Timeout: 5 * time.Second,
}

resp, err := client.Get(suggestionURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch suggestions: %v", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("suggestion API returned status %d", resp.StatusCode)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %v", err)
}

return parseSuggestionResponse(body)
}

func parseSuggestionResponse(body []byte) ([]string, error) {
var suggestions []string

// Try to parse as standard OpenSearch format: [query, [suggestions...]]
// This works for all the supported engines (Google, DuckDuckGo, Bing, Startpage)
var response []any
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("failed to parse JSON response: %v", err)
}

if len(response) < 2 {
return suggestions, nil
}

if suggestionsList, ok := response[1].([]any); ok {
for _, item := range suggestionsList {
if suggestion, ok := item.(string); ok {
suggestions = append(suggestions, suggestion)
}
}
}

return suggestions, nil
}

func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) {
// TODO: this requires a rework of the widget update logic so that rather
// than locking the entire page we lock individual widgets
Expand Down Expand Up @@ -449,6 +569,7 @@ func (a *application) server() (func() error, func() error) {
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("POST /api/search/suggestions", a.handleSearchSuggestionsRequest)

if a.RequiresAuth {
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
Expand Down
124 changes: 124 additions & 0 deletions internal/glance/static/css/widget-search.css
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,130 @@
}

.search-bangs { display: none; }
.search-shortcuts { display: none; }

.search-input-container {
position: relative;
width: 100%;
}

.search-shortcuts-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--color-widget-background);
border: 1px solid var(--color-separator);
border-radius: var(--border-radius);
z-index: 1000;
max-height: 300px;
overflow-y: auto;
margin: 0.5rem 2em 0;
}

.search-shortcuts-dropdown.hidden {
display: none;
}

.search-shortcut-item {
padding: 0.8rem 1.2rem;
cursor: pointer;
border-bottom: 1px solid var(--color-separator);
transition: background-color 0.15s ease;
display: flex;
align-items: center;
gap: 0.75rem;
}

.search-shortcut-item:last-child {
border-bottom: none;
}

.search-shortcut-item:hover,
.search-shortcut-item.highlighted {
background: var(--color-widget-background-highlight);
color: var(--color-text-highlight);
}

.search-shortcut-item:hover .search-shortcut-title,
.search-shortcut-item.highlighted .search-shortcut-title,
.search-shortcut-item[data-type="suggestion"]:hover .search-shortcut-title,
.search-shortcut-item[data-type="suggestion"].highlighted .search-shortcut-title {
color: var(--color-text-highlight);
}

.search-shortcut-item:hover .search-shortcut-url,
.search-shortcut-item.highlighted .search-shortcut-url {
color: var(--color-text-highlight);
opacity: 0.8;
}

.search-shortcut-item:hover .search-shortcut-alias,
.search-shortcut-item.highlighted .search-shortcut-alias {
background: rgba(255, 255, 255, 0.2);
color: var(--color-text-highlight);
}

.search-shortcut-item.exact-match .search-shortcut-alias{
background: var(--color-primary);
color: var(--color-background);
}

.search-shortcut-item:hover .search-shortcut-icon,
.search-shortcut-item.highlighted .search-shortcut-icon {
stroke: var(--color-text-highlight);
}

.search-shortcut-item.exact-match {
background: var(--color-widget-background-highlight);
}

.search-shortcut-item.exact-match .search-shortcut-title {
color: var(--color-primary);
}

.search-shortcut-icon {
width: 1.2rem;
height: 1.2rem;
flex-shrink: 0;
stroke: var(--color-text-subdue);
}

.search-shortcut-content {
flex-grow: 1;
min-width: 0;
display: flex;
}

.search-shortcut-title {
font-weight: 500;
color: var(--color-text-highlight);
}

.search-shortcut-url {
font-size: 0.85em;
color: var(--color-text-subdue);
margin-top: 0.2rem;
margin-left: 1em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}

.search-shortcut-alias {
font-size: 0.8em;
color: var(--color-text-base);
background: var(--color-widget-background-highlight);
padding: 0.2rem 0.4rem;
border-radius: calc(var(--border-radius) * 0.5);
flex-shrink: 0;
}

.search-shortcut-item[data-type="suggestion"] .search-shortcut-title {
font-weight: normal;
color: var(--color-text-base);
}

.search-bang {
border-radius: calc(var(--border-radius) * 2);
Expand Down
Loading