diff --git a/golink.go b/golink.go index a721d3d..6e336f4 100644 --- a/golink.go +++ b/golink.go @@ -25,10 +25,12 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "sync" texttemplate "text/template" "time" + "unicode" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -346,6 +348,30 @@ type homeData struct { User string } +type searchResult struct { + Short string `json:"short"` + Long string `json:"long"` + Owner string `json:"owner"` + LastEdit time.Time `json:"lastEdit"` + NumClicks int `json:"numClicks"` + Path string `json:"path"` + + score int +} + +type searchPageData struct { + Heading string + Query string + Results []searchResult + ShowExport bool +} + +type searchResponse struct { + Mode string `json:"mode"` + Query string `json:"query,omitempty"` + Results []searchResult `json:"results"` +} + // deleteData is the data used by deleteTmpl. type deleteData struct { Short string @@ -598,7 +624,11 @@ func serveAll(w http.ResponseWriter, _ *http.Request) { return links[i].Short < links[j].Short }) - searchTmpl.Execute(w, links) + searchTmpl.Execute(w, searchPageData{ + Heading: "All links", + Results: searchResultsFromLinks(links, clickStatsSnapshot()), + ShowExport: true, + }) } func serveHelp(w http.ResponseWriter, _ *http.Request) { @@ -751,25 +781,265 @@ func serveDetail(w http.ResponseWriter, r *http.Request) { detailTmpl.Execute(w, data) } -// serveSearch handles requests to /.search?q={query}, where {query} can currently only be -// the owner formated like "owner:". +// serveSearch handles requests to /.search?q={query}. Queries support both the +// legacy owner:email lookup and general link search for end users. func serveSearch(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query().Get("q") - owner, found := strings.CutPrefix(query, "owner:") - if !found { - http.Error(w, `search only supports "owner:"`, http.StatusBadRequest) - return - } - links, err := db.GetLinksByOwner(owner) + query := strings.TrimSpace(r.URL.Query().Get("q")) + limit := searchLimit(r, 200) + wantsJSON := strings.EqualFold(r.URL.Query().Get("format"), "json") || strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/json") + + data, resp, err := searchDataForQuery(query, clickStatsSnapshot(), limit) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - sort.Slice(links, func(i, j int) bool { - return links[i].Short < links[j].Short + if wantsJSON { + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + enc.Encode(resp) + return + } + + searchTmpl.Execute(w, data) +} + +func searchDataForQuery(query string, clicks ClickStats, limit int) (searchPageData, searchResponse, error) { + if owner, found := ownerSearchQuery(query); found { + links, err := db.GetLinksByOwner(owner) + if err != nil { + return searchPageData{}, searchResponse{}, err + } + sort.Slice(links, func(i, j int) bool { + return links[i].Short < links[j].Short + }) + results := searchResultsFromLinks(links, clicks) + if limit > 0 && len(results) > limit { + results = results[:limit] + } + return searchPageData{ + Heading: fmt.Sprintf("Results (%d total)", len(results)), + Query: query, + Results: results, + ShowExport: true, + }, searchResponse{ + Mode: "owner", + Query: query, + Results: results, + }, nil + } + + results, err := searchLinks(query, clicks, limit) + if err != nil { + return searchPageData{}, searchResponse{}, err + } + heading := fmt.Sprintf("Results (%d total)", len(results)) + if query == "" { + heading = fmt.Sprintf("Popular and recent links (%d total)", len(results)) + } + return searchPageData{ + Heading: heading, + Query: query, + Results: results, + ShowExport: true, + }, searchResponse{ + Mode: "search", + Query: query, + Results: results, + }, nil +} + +func clickStatsSnapshot() ClickStats { + stats.mu.Lock() + defer stats.mu.Unlock() + + clicks := make(ClickStats, len(stats.clicks)) + for short, count := range stats.clicks { + clicks[short] = count + } + return clicks +} + +func searchLimit(r *http.Request, defaultLimit int) int { + limit, err := strconv.Atoi(r.URL.Query().Get("limit")) + if err != nil || limit <= 0 { + return defaultLimit + } + if limit > 200 { + return 200 + } + return limit +} + +func ownerSearchQuery(query string) (string, bool) { + prefix := "owner:" + if len(query) < len(prefix) || !strings.EqualFold(query[:len(prefix)], prefix) { + return "", false + } + return strings.TrimSpace(query[len(prefix):]), true +} + +func searchLinks(query string, clicks ClickStats, limit int) ([]searchResult, error) { + links, err := db.LoadAll() + if err != nil { + return nil, err + } + + normalizedQuery := normalizeSearchText(query) + results := make([]searchResult, 0, len(links)) + for _, link := range links { + score := scoreLinkForSearch(link, normalizedQuery) + if normalizedQuery != "" && score == 0 { + continue + } + results = append(results, searchResult{ + Short: link.Short, + Long: link.Long, + Owner: link.Owner, + LastEdit: link.LastEdit, + NumClicks: clicks[link.Short], + Path: "/" + link.Short, + score: score, + }) + } + + sort.Slice(results, func(i, j int) bool { + if normalizedQuery != "" && results[i].score != results[j].score { + return results[i].score > results[j].score + } + if results[i].NumClicks != results[j].NumClicks { + return results[i].NumClicks > results[j].NumClicks + } + if !results[i].LastEdit.Equal(results[j].LastEdit) { + return results[i].LastEdit.After(results[j].LastEdit) + } + return results[i].Short < results[j].Short }) - searchTmpl.Execute(w, links) + + if limit > 0 && len(results) > limit { + results = results[:limit] + } + return results, nil +} + +func searchResultsFromLinks(links []*Link, clicks ClickStats) []searchResult { + results := make([]searchResult, 0, len(links)) + for _, link := range links { + results = append(results, searchResult{ + Short: link.Short, + Long: link.Long, + Owner: link.Owner, + LastEdit: link.LastEdit, + NumClicks: clicks[link.Short], + Path: "/" + link.Short, + }) + } + return results +} + +func scoreLinkForSearch(link *Link, query string) int { + if query == "" { + return 1 + } + + short := normalizeSearchText(link.Short) + long := normalizeSearchText(link.Long) + owner := normalizeSearchText(link.Owner) + queryTokens := strings.Fields(query) + + score := scoreSearchField(query, short, true, 1200, 900, 700, 400) + score += scoreSearchField(query, long, false, 200, 150, 120, 0) + score += scoreSearchField(query, owner, false, 140, 110, 90, 0) + + if len(queryTokens) > 1 { + for _, token := range queryTokens { + score += scoreSearchField(token, short, true, 120, 90, 70, 40) + score += scoreSearchField(token, long, false, 45, 35, 25, 0) + score += scoreSearchField(token, owner, false, 35, 25, 20, 0) + } + } + + return score +} + +func scoreSearchField(query, field string, allowFuzzy bool, exact, prefix, contains, fuzzy int) int { + if query == "" || field == "" { + return 0 + } + if field == query { + return exact + } + if strings.HasPrefix(field, query) { + return prefix + } + if strings.Contains(field, query) { + return contains + } + compactQuery := compactSearchText(query) + compactField := compactSearchText(field) + if compactQuery == "" || compactField == "" { + return 0 + } + if compactField == compactQuery { + return exact + } + if strings.HasPrefix(compactField, compactQuery) { + return prefix + } + if strings.Contains(compactField, compactQuery) { + return contains + } + if allowFuzzy { + return fuzzyScore(compactQuery, compactField, fuzzy) + } + return 0 +} + +func fuzzyScore(query, target string, base int) int { + if base == 0 || len(query) < 2 { + return 0 + } + queryRunes := []rune(query) + targetRunes := []rune(target) + q := 0 + last := -1 + penalty := 0 + for i, r := range targetRunes { + if q >= len(queryRunes) { + break + } + if r != queryRunes[q] { + continue + } + if last >= 0 { + penalty += i - last - 1 + } + last = i + q++ + } + if q != len(queryRunes) { + return 0 + } + score := base - penalty + if score < 1 { + return 1 + } + return score +} + +func normalizeSearchText(s string) string { + return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(s))), " ") +} + +func compactSearchText(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + } + } + return b.String() } type expandEnv struct { diff --git a/golink_test.go b/golink_test.go index 64d3b2b..48d9539 100644 --- a/golink_test.go +++ b/golink_test.go @@ -782,19 +782,30 @@ func TestServeSearch(t *testing.T) { if err != nil { t.Fatal(err) } + stats.clicks = nil + stats.dirty = nil links := []*Link{ - {Short: "alpha", Long: "http://alpha/", Owner: "foo@example.com"}, - {Short: "beta", Long: "http://beta/", Owner: "foo@example.com"}, - {Short: "gamma", Long: "http://gamma/", Owner: "bar@example.com"}, - {Short: "delta", Long: "http://delta/", Owner: "FOO@example.com"}, + {Short: "alpha", Long: "http://alpha/", Owner: "foo@example.com", LastEdit: time.Unix(10, 0).UTC()}, + {Short: "beta", Long: "http://docs.internal/beta-dashboard", Owner: "foo@example.com", LastEdit: time.Unix(20, 0).UTC()}, + {Short: "gamma", Long: "http://gamma/", Owner: "bar@example.com", LastEdit: time.Unix(30, 0).UTC()}, + {Short: "delta", Long: "http://delta/", Owner: "FOO@example.com", LastEdit: time.Unix(40, 0).UTC()}, + {Short: "deploy-runbook", Long: "https://wiki.example.com/deployment-runbook", Owner: "ops@example.com", LastEdit: time.Unix(50, 0).UTC()}, + {Short: "dk", Long: "https://docs.example.com/dev-kit", Owner: "dev@example.com", LastEdit: time.Unix(60, 0).UTC()}, } for _, link := range links { if err := db.Save(link); err != nil { t.Error(err) } } + stats.clicks = ClickStats{ + "beta": 12, + "alpha": 5, + "gamma": 1, + "dk": 7, + "deploy-runbook": 3, + } - tests := []struct { + htmlTests := []struct { name string owner string wantStatus int @@ -823,7 +834,7 @@ func TestServeSearch(t *testing.T) { }, } - for _, tt := range tests { + for _, tt := range htmlTests { t.Run(tt.name, func(t *testing.T) { testURL := "/.search?q=owner:" + url.QueryEscape(tt.owner) r := httptest.NewRequest("GET", testURL, nil) @@ -847,6 +858,109 @@ func TestServeSearch(t *testing.T) { } }) } + + t.Run("general search supports json", func(t *testing.T) { + r := httptest.NewRequest("GET", "/.search?q=deploy&format=json", nil) + w := httptest.NewRecorder() + serveHandler().ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("serveSearch(json) = %d; want %d", w.Code, http.StatusOK) + } + if got := w.Header().Get("Content-Type"); !strings.Contains(got, "application/json") { + t.Fatalf("Content-Type = %q; want application/json", got) + } + + var resp searchResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal json response: %v", err) + } + if resp.Mode != "search" { + t.Fatalf("Mode = %q; want search", resp.Mode) + } + if len(resp.Results) == 0 || resp.Results[0].Short != "deploy-runbook" { + t.Fatalf("unexpected results: %+v", resp.Results) + } + }) + + t.Run("general search ranks exact short match before popular partial match", func(t *testing.T) { + r := httptest.NewRequest("GET", "/.search?q=alpha&format=json", nil) + w := httptest.NewRecorder() + serveHandler().ServeHTTP(w, r) + + var resp searchResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal json response: %v", err) + } + if len(resp.Results) == 0 { + t.Fatal("expected results") + } + if resp.Results[0].Short != "alpha" { + t.Fatalf("first result = %q; want alpha", resp.Results[0].Short) + } + }) + + t.Run("general search fuzzy matches compact short names", func(t *testing.T) { + r := httptest.NewRequest("GET", "/.search?q=dr&format=json", nil) + w := httptest.NewRecorder() + serveHandler().ServeHTTP(w, r) + + var resp searchResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal json response: %v", err) + } + if len(resp.Results) == 0 || resp.Results[0].Short != "deploy-runbook" { + t.Fatalf("expected deploy-runbook fuzzy match first, got %+v", resp.Results) + } + }) + + t.Run("empty search returns popular links first", func(t *testing.T) { + r := httptest.NewRequest("GET", "/.search?format=json&limit=3", nil) + w := httptest.NewRecorder() + serveHandler().ServeHTTP(w, r) + + var resp searchResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal json response: %v", err) + } + got := make([]string, 0, len(resp.Results)) + for _, result := range resp.Results { + got = append(got, result.Short) + } + want := []string{"beta", "dk", "alpha"} + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("popular order mismatch (-want +got):\n%s", diff) + } + }) +} + +func TestServeHomeIncludesCommandPalette(t *testing.T) { + var err error + db, err = NewSQLiteDB(":memory:") + if err != nil { + t.Fatal(err) + } + oldCurrentUser := currentUser + currentUser = func(*http.Request) (user, error) { + return user{login: "foo@example.com"}, nil + } + t.Cleanup(func() { + currentUser = oldCurrentUser + }) + + r := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + serveHandler().ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("serveHome = %d; want %d", w.Code, http.StatusOK) + } + body := w.Body.String() + for _, want := range []string{"Search links", "Ctrl/Cmd + K", "command-palette.js", "data-command-palette"} { + if !strings.Contains(body, want) { + t.Fatalf("home page missing %q", want) + } + } } func TestParseAdvertiseTags(t *testing.T) { diff --git a/static/base.css b/static/base.css index 4543e6a..99c233a 100644 --- a/static/base.css +++ b/static/base.css @@ -1563,6 +1563,183 @@ select { text-decoration-line: underline; } +.hidden { + display: none; +} + +.command-palette-open { + overflow: hidden; +} + +.command-palette-surface { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; + padding: 1rem; + border: 1px solid rgba(238, 235, 234); + border-radius: 0.5rem; + background: rgba(249, 247, 246); +} + +.command-palette-trigger { + display: inline-flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border: 1px solid rgba(218, 214, 213); + border-radius: 0.5rem; + background: #fff; + box-shadow: 0 1px 2px rgba(31, 30, 30, 0.05); +} + +.command-palette-trigger:hover { + border-color: rgba(173, 199, 252); +} + +.command-palette-shortcut { + color: rgba(112, 110, 109); + font-size: 0.875rem; + white-space: nowrap; +} + +.command-palette { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 10vh 1rem 1rem; +} + +.command-palette-backdrop { + position: absolute; + inset: 0; + background: rgba(31, 30, 30, 0.45); +} + +.command-palette-dialog { + position: relative; + width: min(42rem, 100%); + max-height: 80vh; + overflow: hidden; + border: 1px solid rgba(218, 214, 213); + border-radius: 0.75rem; + background: #fff; + box-shadow: 0 18px 50px rgba(31, 30, 30, 0.28); +} + +.command-palette-header, +.command-palette-input-row, +.command-palette-footer { + padding: 1rem 1.25rem; +} + +.command-palette-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid rgba(238, 235, 234); +} + +.command-palette-close { + min-width: 3rem; + padding: 0.35rem 0.6rem; + border: 1px solid rgba(218, 214, 213); + border-radius: 0.375rem; + color: rgba(68, 67, 66); + font-size: 0.875rem; +} + +.command-palette-input-row { + border-bottom: 1px solid rgba(238, 235, 234); +} + +.command-palette-input { + width: 100%; + padding: 0.85rem 1rem; + border: 1px solid rgba(218, 214, 213); + border-radius: 0.5rem; +} + +.command-palette-status { + margin: 0; + padding: 0.75rem 1.25rem 0; + color: rgba(112, 110, 109); +} + +.command-palette-results { + margin: 0; + padding: 0.5rem; + max-height: 26rem; + overflow-y: auto; + list-style: none; +} + +.command-palette-result { + display: block; + padding: 0.9rem 1rem; + border-radius: 0.5rem; +} + +.command-palette-result:hover, +.command-palette-result.is-active { + background: rgba(240, 245, 255); +} + +.command-palette-result-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + font-weight: 700; +} + +.command-palette-result-badge { + flex-shrink: 0; + padding: 0.15rem 0.5rem; + border-radius: 9999px; + background: rgba(249, 247, 246); + color: rgba(68, 67, 66); + font-size: 0.75rem; + font-weight: 400; +} + +.command-palette-result-meta { + margin-top: 0.25rem; + color: rgba(112, 110, 109); + font-size: 0.875rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.command-palette-footer { + border-top: 1px solid rgba(238, 235, 234); +} + +@media (max-width: 640px) { + .command-palette-surface { + flex-direction: column; + align-items: stretch; + } + + .command-palette-trigger { + justify-content: space-between; + } + + .command-palette { + padding-top: 4vh; + } + + .command-palette-header { + flex-direction: column; + } +} + .disabled\:bg-gray-100:disabled { --tw-bg-opacity: 1; background-color: rgb(247 245 244 / var(--tw-bg-opacity)); @@ -1589,4 +1766,4 @@ select { .md\:max-w-\[40vw\] { max-width: 40vw; } -} \ No newline at end of file +} diff --git a/static/command-palette.js b/static/command-palette.js new file mode 100644 index 0000000..2d2fa8f --- /dev/null +++ b/static/command-palette.js @@ -0,0 +1,232 @@ +(function () { + const root = document.querySelector('[data-command-palette]'); + if (!root) { + return; + } + + const input = root.querySelector('[data-command-palette-input]'); + const resultsList = root.querySelector('[data-command-palette-results]'); + const loading = root.querySelector('[data-command-palette-loading]'); + const empty = root.querySelector('[data-command-palette-empty]'); + const openButtons = document.querySelectorAll('[data-command-palette-open]'); + const closeButtons = root.querySelectorAll('[data-command-palette-close]'); + + let isOpen = false; + let activeIndex = -1; + let results = []; + let controller = null; + let fetchTimer = null; + let requestId = 0; + + function setVisible(node, visible) { + node.classList.toggle('hidden', !visible); + } + + function openPalette() { + if (isOpen) { + input.focus(); + input.select(); + return; + } + isOpen = true; + root.classList.remove('hidden'); + root.setAttribute('aria-hidden', 'false'); + document.body.classList.add('command-palette-open'); + input.focus(); + input.select(); + loadResults(input.value.trim()); + } + + function closePalette() { + if (!isOpen) { + return; + } + isOpen = false; + root.classList.add('hidden'); + root.setAttribute('aria-hidden', 'true'); + document.body.classList.remove('command-palette-open'); + if (controller) { + controller.abort(); + controller = null; + } + } + + function renderResults() { + resultsList.innerHTML = ''; + results.forEach((result, index) => { + const item = document.createElement('li'); + item.setAttribute('role', 'option'); + item.setAttribute('id', 'command-palette-option-' + index); + item.setAttribute('aria-selected', index === activeIndex ? 'true' : 'false'); + + const link = document.createElement('a'); + link.href = result.path; + link.className = 'command-palette-result' + (index === activeIndex ? ' is-active' : ''); + link.dataset.index = String(index); + + const titleRow = document.createElement('div'); + titleRow.className = 'command-palette-result-title'; + titleRow.textContent = 'go/' + result.short; + if (typeof result.numClicks === 'number' && result.numClicks > 0) { + const badge = document.createElement('span'); + badge.className = 'command-palette-result-badge'; + badge.textContent = result.numClicks + ' click' + (result.numClicks === 1 ? '' : 's'); + titleRow.appendChild(badge); + } + + const meta = document.createElement('div'); + meta.className = 'command-palette-result-meta'; + meta.textContent = [result.long, result.owner].filter(Boolean).join(' • '); + + link.appendChild(titleRow); + link.appendChild(meta); + link.addEventListener('mousemove', function () { + if (activeIndex !== index) { + activeIndex = index; + syncActiveDescendant(); + renderResults(); + } + }); + item.appendChild(link); + resultsList.appendChild(item); + }); + + setVisible(empty, !loading.classList.contains('hidden') ? false : results.length === 0); + syncActiveDescendant(); + } + + function syncActiveDescendant() { + if (activeIndex >= 0 && activeIndex < results.length) { + input.setAttribute('aria-activedescendant', 'command-palette-option-' + activeIndex); + } else { + input.removeAttribute('aria-activedescendant'); + } + } + + async function loadResults(query) { + if (controller) { + controller.abort(); + } + controller = new AbortController(); + const currentRequest = ++requestId; + setVisible(loading, true); + setVisible(empty, false); + + const search = new URLSearchParams({ format: 'json', limit: '8' }); + if (query) { + search.set('q', query); + } + + try { + const response = await fetch('/.search?' + search.toString(), { + headers: { Accept: 'application/json' }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error('Search failed'); + } + const data = await response.json(); + if (currentRequest !== requestId) { + return; + } + results = Array.isArray(data.results) ? data.results : []; + activeIndex = results.length > 0 ? 0 : -1; + setVisible(loading, false); + renderResults(); + } catch (error) { + if (error && error.name === 'AbortError') { + return; + } + results = []; + activeIndex = -1; + setVisible(loading, false); + setVisible(empty, true); + resultsList.innerHTML = ''; + syncActiveDescendant(); + } + } + + function queueSearch() { + window.clearTimeout(fetchTimer); + fetchTimer = window.setTimeout(function () { + loadResults(input.value.trim()); + }, 120); + } + + function moveSelection(direction) { + if (!results.length) { + return; + } + activeIndex += direction; + if (activeIndex < 0) { + activeIndex = results.length - 1; + } else if (activeIndex >= results.length) { + activeIndex = 0; + } + renderResults(); + const active = resultsList.querySelector('[data-index="' + activeIndex + '"]'); + if (active) { + active.scrollIntoView({ block: 'nearest' }); + } + } + + function activateSelection() { + if (activeIndex < 0 || activeIndex >= results.length) { + return; + } + window.location.href = results[activeIndex].path; + } + + openButtons.forEach(function (button) { + button.addEventListener('click', openPalette); + }); + + closeButtons.forEach(function (button) { + button.addEventListener('click', closePalette); + }); + + input.addEventListener('input', queueSearch); + input.addEventListener('keydown', function (event) { + if (event.key === 'ArrowDown') { + event.preventDefault(); + moveSelection(1); + return; + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + moveSelection(-1); + return; + } + if (event.key === 'Enter') { + event.preventDefault(); + activateSelection(); + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + closePalette(); + } + }); + + root.addEventListener('click', function (event) { + const link = event.target.closest('.command-palette-result'); + if (!link) { + return; + } + event.preventDefault(); + window.location.href = link.getAttribute('href'); + }); + + document.addEventListener('keydown', function (event) { + const isModifier = event.ctrlKey || event.metaKey; + if (isModifier && event.key.toLowerCase() === 'k') { + event.preventDefault(); + openPalette(); + return; + } + if (event.key === 'Escape' && isOpen) { + event.preventDefault(); + closePalette(); + } + }); +})(); diff --git a/tmpl/base.html b/tmpl/base.html index 792bba8..5712004 100644 --- a/tmpl/base.html +++ b/tmpl/base.html @@ -38,4 +38,6 @@

{{go}}/

+ {{ block "scripts" .}}{{ end }} + diff --git a/tmpl/home.html b/tmpl/home.html index 7dea427..b794d09 100644 --- a/tmpl/home.html +++ b/tmpl/home.html @@ -1,4 +1,15 @@ {{ define "main" }} +
+
+

Search links fast

+

Open the command palette to jump to popular links or search by short name, destination, and owner.

+
+ +
+ {{ if .ReadOnly }}

{{go}} is running in read-only mode. Links can be resolved, but not created or updated.

{{ else }} @@ -46,3 +57,34 @@

Popular Links

See my links.

See all links.

{{ end }} + +{{ define "scripts" }} + + +{{ end }} diff --git a/tmpl/search.html b/tmpl/search.html index 65553b6..5df13f3 100644 --- a/tmpl/search.html +++ b/tmpl/search.html @@ -1,5 +1,11 @@ {{ define "main" }} -

Results ({{ len . }} total)

+

{{ .Heading }}

+ {{ if .Query }} +

Showing results for {{ .Query }}.

+ {{ end }} + {{ if not .Results }} +

No matching links found.

+ {{ else }} @@ -9,7 +15,7 @@

Results ({{ len . }} total)

- {{ range . }} + {{ range .Results }} {{ end }} + {{ if .ShowExport }} + {{ end }}
@@ -27,10 +33,13 @@

Results ({{ len . }} total)

Download all links in JSON Lines format.
+ {{ end }} {{ end }}