From 2c58a04716e9738a55707f5a188aad51ebbe2fe9 Mon Sep 17 00:00:00 2001 From: Kyle Moore Date: Tue, 27 May 2025 16:36:43 -0400 Subject: [PATCH 01/12] add shortcuts --- internal/glance/static/css/widget-search.css | 69 +++++++ internal/glance/static/js/page.js | 198 ++++++++++++++++++- internal/glance/templates/search.html | 14 +- internal/glance/widget-search.go | 35 +++- 4 files changed, 303 insertions(+), 13 deletions(-) diff --git a/internal/glance/static/css/widget-search.css b/internal/glance/static/css/widget-search.css index ebf5cbbb..4a9668d2 100644 --- a/internal/glance/static/css/widget-search.css +++ b/internal/glance/static/css/widget-search.css @@ -57,6 +57,75 @@ } .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: 200px; + 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; +} + +.search-shortcut-item:last-child { + border-bottom: none; +} + +.search-shortcut-item:hover, +.search-shortcut-item.highlighted { + background: var(--color-widget-background-highlight); +} + +.search-shortcut-item.exact-match { + background: var(--color-primary); + color: var(--color-text-highlight); +} + +.search-shortcut-item.exact-match:hover { + background: var(--color-primary); +} + +.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; +} + +.search-shortcut-shortcut { + 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); + margin-left: 0.5rem; +} .search-bang { border-radius: calc(var(--border-radius) * 2); diff --git a/internal/glance/static/js/page.js b/internal/glance/static/js/page.js index 56e9c2ef..2f5472ab 100644 --- a/internal/glance/static/js/page.js +++ b/internal/glance/static/js/page.js @@ -102,6 +102,35 @@ function setupSearchBoxes() { return; } + // Simple fuzzy matching function + function fuzzyMatch(text, query) { + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + + // Exact match gets highest priority + if (lowerText === lowerQuery) return { score: 1000, type: 'exact' }; + if (lowerText.includes(lowerQuery)) return { score: 500, type: 'contains' }; + + // Simple fuzzy matching + let score = 0; + let textIndex = 0; + + for (let i = 0; i < lowerQuery.length; i++) { + const char = lowerQuery[i]; + const foundIndex = lowerText.indexOf(char, textIndex); + + if (foundIndex === -1) return { score: 0, type: 'none' }; + + // Bonus for consecutive characters + if (foundIndex === textIndex) score += 10; + else score += 1; + + textIndex = foundIndex + 1; + } + + return { score, type: 'fuzzy' }; + } + for (let i = 0; i < searchWidgets.length; i++) { const widget = searchWidgets[i]; const defaultSearchUrl = widget.dataset.defaultSearchUrl; @@ -109,25 +138,159 @@ function setupSearchBoxes() { const newTab = widget.dataset.newTab === "true"; const inputElement = widget.getElementsByClassName("search-input")[0]; const bangElement = widget.getElementsByClassName("search-bang")[0]; + const dropdownElement = widget.getElementsByClassName("search-shortcuts-dropdown")[0]; + const shortcutsListElement = widget.getElementsByClassName("search-shortcuts-list")[0]; const bangs = widget.querySelectorAll(".search-bangs > input"); + const shortcuts = widget.querySelectorAll(".search-shortcuts > input"); const bangsMap = {}; + const shortcutsArray = []; const kbdElement = widget.getElementsByTagName("kbd")[0]; let currentBang = null; let lastQuery = ""; + let highlightedIndex = -1; + let filteredShortcuts = []; for (let j = 0; j < bangs.length; j++) { const bang = bangs[j]; bangsMap[bang.dataset.shortcut] = bang; } + for (let j = 0; j < shortcuts.length; j++) { + const shortcut = shortcuts[j]; + shortcutsArray.push({ + title: shortcut.dataset.title, + url: shortcut.dataset.url, + shortcut: shortcut.dataset.shortcut + }); + } + + function hideDropdown() { + dropdownElement.classList.add("hidden"); + highlightedIndex = -1; + } + + function showDropdown() { + if (filteredShortcuts.length > 0) { + dropdownElement.classList.remove("hidden"); + } + } + + function updateDropdown(query) { + if (!query || shortcutsArray.length === 0) { + hideDropdown(); + return; + } + + // Filter and score shortcuts + const matches = shortcutsArray.map(shortcut => { + const titleMatch = fuzzyMatch(shortcut.title, query); + const shortcutMatch = fuzzyMatch(shortcut.shortcut, query); + const bestMatch = titleMatch.score > shortcutMatch.score ? titleMatch : shortcutMatch; + + return { + ...shortcut, + score: bestMatch.score, + matchType: bestMatch.type, + isExact: titleMatch.type === 'exact' || shortcutMatch.type === 'exact' + }; + }).filter(item => item.score > 0) + .sort((a, b) => b.score - a.score); + + filteredShortcuts = matches; + highlightedIndex = -1; + + if (matches.length === 0) { + hideDropdown(); + return; + } + + // Render dropdown items + shortcutsListElement.innerHTML = matches.map((item, index) => ` +
+
+ ${item.title} + ${item.shortcut} +
+
${item.url}
+
+ `).join(''); + + // Add click event listeners + shortcutsListElement.querySelectorAll('.search-shortcut-item').forEach((item, index) => { + item.addEventListener('click', () => { + navigateToShortcut(matches[index]); + }); + }); + + showDropdown(); + } + + function navigateToShortcut(shortcut) { + if (newTab) { + window.open(shortcut.url, target).focus(); + } else { + window.location.href = shortcut.url; + } + inputElement.value = ""; + hideDropdown(); + } + + function highlightItem(index) { + const items = shortcutsListElement.querySelectorAll('.search-shortcut-item'); + items.forEach(item => item.classList.remove('highlighted')); + + if (index >= 0 && index < items.length) { + items[index].classList.add('highlighted'); + highlightedIndex = index; + } else { + highlightedIndex = -1; + } + } + const handleKeyDown = (event) => { if (event.key == "Escape") { + hideDropdown(); inputElement.blur(); return; } + // Handle dropdown navigation + if (!dropdownElement.classList.contains("hidden")) { + if (event.key === "ArrowDown") { + event.preventDefault(); + const newIndex = Math.min(highlightedIndex + 1, filteredShortcuts.length - 1); + highlightItem(newIndex); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + const newIndex = Math.max(highlightedIndex - 1, -1); + highlightItem(newIndex); + return; + } + + if (event.key === "Enter" && highlightedIndex >= 0) { + event.preventDefault(); + navigateToShortcut(filteredShortcuts[highlightedIndex]); + return; + } + } + if (event.key == "Enter") { const input = inputElement.value.trim(); + + // Check for exact shortcut match first + const exactMatch = shortcutsArray.find(s => + s.title.toLowerCase() === input.toLowerCase() || + s.shortcut.toLowerCase() === input.toLowerCase() + ); + + if (exactMatch) { + navigateToShortcut(exactMatch); + return; + } + let query; let searchUrlTemplate; @@ -138,6 +301,7 @@ function setupSearchBoxes() { query = input; searchUrlTemplate = defaultSearchUrl; } + if (query.length == 0 && currentBang == null) { return; } @@ -152,11 +316,11 @@ function setupSearchBoxes() { lastQuery = query; inputElement.value = ""; - + hideDropdown(); return; } - if (event.key == "ArrowUp" && lastQuery.length > 0) { + if (event.key == "ArrowUp" && lastQuery.length > 0 && dropdownElement.classList.contains("hidden")) { inputElement.value = lastQuery; return; } @@ -169,27 +333,51 @@ function setupSearchBoxes() { const handleInput = (event) => { const value = event.target.value.trim(); + + // Check for bangs first if (value in bangsMap) { changeCurrentBang(bangsMap[value]); + hideDropdown(); return; } const words = value.split(" "); if (words.length >= 2 && words[0] in bangsMap) { changeCurrentBang(bangsMap[words[0]]); + hideDropdown(); return; } changeCurrentBang(null); + + // Update shortcuts dropdown + updateDropdown(value); }; + // Close dropdown when clicking outside + document.addEventListener('click', (event) => { + if (!widget.contains(event.target)) { + hideDropdown(); + } + }); + inputElement.addEventListener("focus", () => { document.addEventListener("keydown", handleKeyDown); document.addEventListener("input", handleInput); + if (inputElement.value.trim()) { + updateDropdown(inputElement.value.trim()); + } }); - inputElement.addEventListener("blur", () => { - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("input", handleInput); + + inputElement.addEventListener("blur", (event) => { + // Delay hiding dropdown to allow for clicks + setTimeout(() => { + if (!widget.contains(document.activeElement)) { + hideDropdown(); + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("input", handleInput); + } + }, 150); }); document.addEventListener("keydown", (event) => { diff --git a/internal/glance/templates/search.html b/internal/glance/templates/search.html index ae981c63..82b908f7 100644 --- a/internal/glance/templates/search.html +++ b/internal/glance/templates/search.html @@ -10,13 +10,25 @@ {{ end }} +
+ {{ range .Shortcuts }} + + {{ end }} +
+
- +
+ +
+ +
S diff --git a/internal/glance/widget-search.go b/internal/glance/widget-search.go index 300361d9..53ec091f 100644 --- a/internal/glance/widget-search.go +++ b/internal/glance/widget-search.go @@ -14,15 +14,22 @@ type SearchBang struct { URL string } +type SearchShortcut struct { + Title string `yaml:"title"` + URL string `yaml:"url"` + Shortcut string `yaml:"shortcut"` +} + type searchWidget struct { widgetBase `yaml:",inline"` - cachedHTML template.HTML `yaml:"-"` - SearchEngine string `yaml:"search-engine"` - Bangs []SearchBang `yaml:"bangs"` - NewTab bool `yaml:"new-tab"` - Target string `yaml:"target"` - Autofocus bool `yaml:"autofocus"` - Placeholder string `yaml:"placeholder"` + cachedHTML template.HTML `yaml:"-"` + SearchEngine string `yaml:"search-engine"` + Bangs []SearchBang `yaml:"bangs"` + Shortcuts []SearchShortcut `yaml:"shortcuts"` + NewTab bool `yaml:"new-tab"` + Target string `yaml:"target"` + Autofocus bool `yaml:"autofocus"` + Placeholder string `yaml:"placeholder"` } func convertSearchUrl(url string) string { @@ -69,6 +76,20 @@ func (widget *searchWidget) initialize() error { widget.Bangs[i].URL = convertSearchUrl(widget.Bangs[i].URL) } + for i := range widget.Shortcuts { + if widget.Shortcuts[i].Title == "" { + return fmt.Errorf("search shortcut #%d has no title", i+1) + } + + if widget.Shortcuts[i].URL == "" { + return fmt.Errorf("search shortcut #%d has no URL", i+1) + } + + if widget.Shortcuts[i].Shortcut == "" { + return fmt.Errorf("search shortcut #%d has no shortcut", i+1) + } + } + widget.cachedHTML = widget.renderTemplate(widget, searchWidgetTemplate) return nil } From 3cf4e189906dbe6dbbfa175c03817b5a4d82f71d Mon Sep 17 00:00:00 2001 From: Kyle Moore Date: Tue, 27 May 2025 19:49:25 -0400 Subject: [PATCH 02/12] search suggestions --- internal/glance/glance.go | 123 +++++++++++++ internal/glance/static/css/widget-search.css | 55 +++++- internal/glance/static/js/page.js | 179 ++++++++++++++----- internal/glance/templates/search.html | 2 +- internal/glance/widget-search.go | 30 ++-- 5 files changed, 324 insertions(+), 65 deletions(-) diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 28771fa5..6a880dbb 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -4,9 +4,12 @@ import ( "bytes" "context" "encoding/base64" + "encoding/json" "fmt" + "io" "log" "net/http" + "net/url" "path/filepath" "slices" "strconv" @@ -401,6 +404,125 @@ 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") + engine := r.URL.Query().Get("suggestion_engine") + + if query == "" { + http.Error(w, "Missing query parameter", http.StatusBadRequest) + return + } + + if engine == "" { + http.Error(w, "Missing suggestion_engine parameter", http.StatusBadRequest) + return + } + + suggestions, err := a.fetchSearchSuggestions(query, engine) + if err != nil { + log.Printf("Error fetching search suggestions: %v", err) + http.Error(w, "Failed to fetch suggestions", http.StatusInternalServerError) + return + } + + response := searchSuggestionsResponse{ + Query: query, + Suggestions: suggestions, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func (a *application) fetchSearchSuggestions(query, engine string) ([]string, error) { + var suggestionURL string + + switch engine { + case "google": + suggestionURL = fmt.Sprintf("http://suggestqueries.google.com/complete/search?output=firefox&q=%s", url.QueryEscape(query)) + case "duckduckgo": + suggestionURL = fmt.Sprintf("https://duckduckgo.com/ac/?q=%s&type=list", url.QueryEscape(query)) + case "bing": + suggestionURL = fmt.Sprintf("https://www.bing.com/osjson.aspx?query=%s", url.QueryEscape(query)) + case "startpage": + suggestionURL = fmt.Sprintf("https://startpage.com/suggestions?q=%s&format=opensearch", url.QueryEscape(query)) + default: + return nil, fmt.Errorf("unsupported search engine: %s", engine) + } + + resp, err := http.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, engine) +} + +func parseSuggestionResponse(body []byte, engine string) ([]string, error) { + var suggestions []string + + switch engine { + case "google", "startpage": + // Firefox format: [query, [suggestions...]] + var response []interface{} + 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].([]interface{}); ok { + for _, item := range suggestionsList { + if suggestion, ok := item.(string); ok { + suggestions = append(suggestions, suggestion) + } + } + } + + case "duckduckgo", "bing": + // Standard OpenSearch format: [query, [suggestions...]] + var response []interface{} + 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].([]interface{}); 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 @@ -449,6 +571,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) diff --git a/internal/glance/static/css/widget-search.css b/internal/glance/static/css/widget-search.css index 4a9668d2..b82aa482 100644 --- a/internal/glance/static/css/widget-search.css +++ b/internal/glance/static/css/widget-search.css @@ -73,7 +73,7 @@ border: 1px solid var(--color-separator); border-radius: var(--border-radius); z-index: 1000; - max-height: 200px; + max-height: 300px; overflow-y: auto; margin: 0.5rem 2em 0; } @@ -87,6 +87,9 @@ 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 { @@ -95,16 +98,50 @@ .search-shortcut-item:hover, .search-shortcut-item.highlighted { - background: var(--color-widget-background-highlight); + background: var(--color-primary); + color: var(--color-text-subdue); +} + +.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-subdue); +} + +.search-shortcut-item:hover .search-shortcut-url, +.search-shortcut-item.highlighted .search-shortcut-url { + color: var(--color-text-subdue); + opacity: 0.8; +} + +.search-shortcut-item:hover .search-shortcut-shortcut, +.search-shortcut-item.highlighted .search-shortcut-shortcut { + background: rgba(255, 255, 255, 0.2); + color: var(--color-text-subdue); +} + +.search-shortcut-item:hover .search-shortcut-icon, +.search-shortcut-item.highlighted .search-shortcut-icon { + stroke: var(--color-text-subdue); } .search-shortcut-item.exact-match { background: var(--color-primary); - color: var(--color-text-highlight); + color: var(--color-text-subdue); } -.search-shortcut-item.exact-match:hover { - background: 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 { @@ -116,6 +153,7 @@ font-size: 0.85em; color: var(--color-text-subdue); margin-top: 0.2rem; + margin-left: 1em; } .search-shortcut-shortcut { @@ -124,7 +162,12 @@ background: var(--color-widget-background-highlight); padding: 0.2rem 0.4rem; border-radius: calc(var(--border-radius) * 0.5); - margin-left: 0.5rem; + flex-shrink: 0; +} + +.search-shortcut-item[data-type="suggestion"] .search-shortcut-title { + font-weight: normal; + color: var(--color-text-base); } .search-bang { diff --git a/internal/glance/static/js/page.js b/internal/glance/static/js/page.js index 2f5472ab..f467cd2c 100644 --- a/internal/glance/static/js/page.js +++ b/internal/glance/static/js/page.js @@ -106,28 +106,28 @@ function setupSearchBoxes() { function fuzzyMatch(text, query) { const lowerText = text.toLowerCase(); const lowerQuery = query.toLowerCase(); - + // Exact match gets highest priority if (lowerText === lowerQuery) return { score: 1000, type: 'exact' }; if (lowerText.includes(lowerQuery)) return { score: 500, type: 'contains' }; - + // Simple fuzzy matching let score = 0; let textIndex = 0; - + for (let i = 0; i < lowerQuery.length; i++) { const char = lowerQuery[i]; const foundIndex = lowerText.indexOf(char, textIndex); - + if (foundIndex === -1) return { score: 0, type: 'none' }; - + // Bonus for consecutive characters if (foundIndex === textIndex) score += 10; else score += 1; - + textIndex = foundIndex + 1; } - + return { score, type: 'fuzzy' }; } @@ -136,6 +136,8 @@ function setupSearchBoxes() { const defaultSearchUrl = widget.dataset.defaultSearchUrl; const target = widget.dataset.target || "_blank"; const newTab = widget.dataset.newTab === "true"; + const suggestionsEnabled = widget.dataset.suggestionsEnabled === "true"; + const suggestionEngine = widget.dataset.suggestionEngine; const inputElement = widget.getElementsByClassName("search-input")[0]; const bangElement = widget.getElementsByClassName("search-bang")[0]; const dropdownElement = widget.getElementsByClassName("search-shortcuts-dropdown")[0]; @@ -148,7 +150,8 @@ function setupSearchBoxes() { let currentBang = null; let lastQuery = ""; let highlightedIndex = -1; - let filteredShortcuts = []; + let filteredResults = []; + let currentSuggestions = []; for (let j = 0; j < bangs.length; j++) { const bang = bangs[j]; @@ -160,7 +163,7 @@ function setupSearchBoxes() { shortcutsArray.push({ title: shortcut.dataset.title, url: shortcut.dataset.url, - shortcut: shortcut.dataset.shortcut + shortcut: shortcut.dataset.shortcut || "" }); } @@ -170,25 +173,26 @@ function setupSearchBoxes() { } function showDropdown() { - if (filteredShortcuts.length > 0) { + if (filteredResults.length > 0) { dropdownElement.classList.remove("hidden"); } } function updateDropdown(query) { - if (!query || shortcutsArray.length === 0) { + if (!query) { hideDropdown(); return; } // Filter and score shortcuts - const matches = shortcutsArray.map(shortcut => { + const shortcutMatches = shortcutsArray.map(shortcut => { const titleMatch = fuzzyMatch(shortcut.title, query); - const shortcutMatch = fuzzyMatch(shortcut.shortcut, query); + const shortcutMatch = shortcut.shortcut ? fuzzyMatch(shortcut.shortcut, query) : { score: 0, type: 'none' }; const bestMatch = titleMatch.score > shortcutMatch.score ? titleMatch : shortcutMatch; - + return { ...shortcut, + type: 'shortcut', score: bestMatch.score, matchType: bestMatch.type, isExact: titleMatch.type === 'exact' || shortcutMatch.type === 'exact' @@ -196,33 +200,106 @@ function setupSearchBoxes() { }).filter(item => item.score > 0) .sort((a, b) => b.score - a.score); - filteredShortcuts = matches; + // Start with shortcuts + filteredResults = [...shortcutMatches]; highlightedIndex = -1; - if (matches.length === 0) { + // Fetch search suggestions if enabled and no bang is active + if (suggestionsEnabled && suggestionEngine && currentBang === null) { + fetchSuggestions(query).then(suggestions => { + currentSuggestions = suggestions.map(suggestion => ({ + type: 'suggestion', + title: suggestion, + url: null, + score: 1 // Lower priority than shortcuts + })); + + // Combine shortcuts and suggestions + filteredResults = [...shortcutMatches, ...currentSuggestions]; + renderDropdown(); + showDropdown(); + }); + } + + renderDropdown(); + if (filteredResults.length > 0) { + showDropdown(); + } else { hideDropdown(); - return; } + } - // Render dropdown items - shortcutsListElement.innerHTML = matches.map((item, index) => ` -
-
- ${item.title} - ${item.shortcut} -
-
${item.url}
-
- `).join(''); + function renderDropdown() { + const shortcutIcon = ` + + `; + + const suggestionIcon = ` + + `; + + shortcutsListElement.innerHTML = filteredResults.map((item, index) => { + if (item.type === 'shortcut') { + return ` +
+ ${shortcutIcon} +
+
${item.title}
+
${item.url}
+
+ ${item.shortcut ? `
${item.shortcut}
` : ''} +
+ `; + } else { + return ` +
+ ${suggestionIcon} +
${item.title}
+
+ `; + } + }).join(''); // Add click event listeners shortcutsListElement.querySelectorAll('.search-shortcut-item').forEach((item, index) => { item.addEventListener('click', () => { - navigateToShortcut(matches[index]); + const result = filteredResults[index]; + if (result.type === 'shortcut') { + navigateToShortcut(result); + } else { + performSearch(result.title); + } }); }); + } + + async function fetchSuggestions(query) { + try { + const response = await fetch(`/api/search/suggestions?query=${encodeURIComponent(query)}&suggestion_engine=${encodeURIComponent(suggestionEngine)}`, { + method: 'POST' + }); + + if (!response.ok) { + return []; + } + + const data = await response.json(); + return data.suggestions || []; + } catch (error) { + console.error('Failed to fetch suggestions:', error); + return []; + } + } - showDropdown(); + function performSearch(query) { + const url = defaultSearchUrl.replace("!QUERY!", encodeURIComponent(query)); + if (newTab) { + window.open(url, target).focus(); + } else { + window.location.href = url; + } + inputElement.value = ""; + hideDropdown(); } function navigateToShortcut(shortcut) { @@ -238,10 +315,17 @@ function setupSearchBoxes() { function highlightItem(index) { const items = shortcutsListElement.querySelectorAll('.search-shortcut-item'); items.forEach(item => item.classList.remove('highlighted')); - + if (index >= 0 && index < items.length) { items[index].classList.add('highlighted'); highlightedIndex = index; + + // Scroll the highlighted item into view + items[index].scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest' + }); } else { highlightedIndex = -1; } @@ -256,36 +340,41 @@ function setupSearchBoxes() { // Handle dropdown navigation if (!dropdownElement.classList.contains("hidden")) { - if (event.key === "ArrowDown") { + if (event.key === "ArrowDown" || (event.key === "Tab" && !event.shiftKey)) { event.preventDefault(); - const newIndex = Math.min(highlightedIndex + 1, filteredShortcuts.length - 1); + const newIndex = Math.min(highlightedIndex + 1, filteredResults.length - 1); highlightItem(newIndex); return; } - - if (event.key === "ArrowUp") { + + if (event.key === "ArrowUp" || (event.key === "Tab" && event.shiftKey)) { event.preventDefault(); const newIndex = Math.max(highlightedIndex - 1, -1); highlightItem(newIndex); return; } - + if (event.key === "Enter" && highlightedIndex >= 0) { event.preventDefault(); - navigateToShortcut(filteredShortcuts[highlightedIndex]); + const result = filteredResults[highlightedIndex]; + if (result.type === 'shortcut') { + navigateToShortcut(result); + } else { + performSearch(result.title); + } return; } } if (event.key == "Enter") { const input = inputElement.value.trim(); - + // Check for exact shortcut match first - const exactMatch = shortcutsArray.find(s => - s.title.toLowerCase() === input.toLowerCase() || - s.shortcut.toLowerCase() === input.toLowerCase() + const exactMatch = shortcutsArray.find(s => + s.title.toLowerCase() === input.toLowerCase() || + (s.shortcut && s.shortcut.toLowerCase() === input.toLowerCase()) ); - + if (exactMatch) { navigateToShortcut(exactMatch); return; @@ -301,7 +390,7 @@ function setupSearchBoxes() { query = input; searchUrlTemplate = defaultSearchUrl; } - + if (query.length == 0 && currentBang == null) { return; } @@ -333,7 +422,7 @@ function setupSearchBoxes() { const handleInput = (event) => { const value = event.target.value.trim(); - + // Check for bangs first if (value in bangsMap) { changeCurrentBang(bangsMap[value]); @@ -349,7 +438,7 @@ function setupSearchBoxes() { } changeCurrentBang(null); - + // Update shortcuts dropdown updateDropdown(value); }; @@ -368,7 +457,7 @@ function setupSearchBoxes() { updateDropdown(inputElement.value.trim()); } }); - + inputElement.addEventListener("blur", (event) => { // Delay hiding dropdown to allow for clicks setTimeout(() => { diff --git a/internal/glance/templates/search.html b/internal/glance/templates/search.html index 82b908f7..f6424c98 100644 --- a/internal/glance/templates/search.html +++ b/internal/glance/templates/search.html @@ -3,7 +3,7 @@ {{ define "widget-content-classes" }}widget-content-frameless{{ end }} {{ define "widget-content" }} -