From 973345ad50f9b237714fcb364cf7f665b3909f9d Mon Sep 17 00:00:00 2001 From: Alan Pearce Date: Wed, 8 May 2024 00:15:52 +0200 Subject: feat: paginate search results --- frontend/static/search.js | 66 ++++++++++++++++++++++++-------- frontend/static/style.css | 23 ++++++++++- frontend/templates/blocks/options.gotmpl | 27 +++++++++---- frontend/templates/blocks/search.gotmpl | 2 +- internal/search/search.go | 14 ++++--- internal/server/server.go | 57 ++++++++++++++++++++++++--- 6 files changed, 151 insertions(+), 38 deletions(-) diff --git a/frontend/static/search.js b/frontend/static/search.js index 60fa30d..2282558 100644 --- a/frontend/static/search.js +++ b/frontend/static/search.js @@ -1,12 +1,14 @@ const search = document.getElementById("search"); let results = document.getElementById("results"); +let pagination = document.getElementById("pagination"); const range = new Range(); range.setStartAfter(search); range.setEndAfter(search.parentNode.lastChild); let state = history.state || { - url: new URL(location).toJSON(), + url: new URL(location).toString(), + fragment: range.cloneContents().innerHTML || "", opened: [], }; @@ -35,44 +37,69 @@ function addToggleEventListeners(results) { details.addEventListener("toggle", detailsToggled, { passive: true }), ); } -search.addEventListener("submit", function (ev) { - const url = new URL(this.action); - url.search = new URLSearchParams(new FormData(this)).toString(); + +function paginationLinkClicked(ev) { + const url = new URL(ev.target.href); + getResults(url); + ev.preventDefault(); +} + +function addPaginationEventListeners() { + pagination.addEventListener("click", paginationLinkClicked); +} + +function renderFragmentHTML(html) { + const fragment = range.createContextualFragment( + escapePolicy !== null ? escapePolicy.createHTML(html) : html, + ); + results = fragment.querySelector("#results"); + pagination = fragment.querySelector("#pagination"); + range.deleteContents(); + range.insertNode(fragment); + addToggleEventListeners(results); + addPaginationEventListeners(pagination); +} + +function getResults(url) { fetch(url, { headers: { fetch: "true", }, }) .then(async function (res) { - state.url = url.toJSON(); - state.opened = []; if (res.ok) { - history.pushState(state, null, url); return res.text(); } else { throw new Error(`${res.status} ${res.statusText}: ${await res.text()}`); } }) .then(function (html) { - const fragment = range.createContextualFragment( - escapePolicy !== null ? escapePolicy.createHTML(html) : html, - ); - const results = fragment.firstElementChild; - range.deleteContents(); - range.insertNode(results); - addToggleEventListeners(results); + state.url = url.toJSON(); + state.opened = []; + state.fragment = html; + history.pushState(state, null, url); + return renderFragmentHTML(html); }) .catch(function (error) { range.deleteContents(); range.insertNode(new Text(error.message)); console.error("fetch failed", error); }); +} + +search.addEventListener("submit", function (ev) { + const url = new URL(this.action); + url.search = new URLSearchParams(new FormData(this)).toString(); + getResults(url); ev.preventDefault(); }); if (results !== null) { addToggleEventListeners(results); } +if (pagination !== null) { + addPaginationEventListeners(pagination); +} if (state.opened.length > 0) { state.opened.forEach((id) => @@ -83,8 +110,13 @@ if (state.opened.length > 0) { } addEventListener("popstate", function (ev) { - if (ev.state == null || ev.state.url.pathname.startsWith("/search/")) { - range.deleteContents(); - search.reset(); + if (ev.state != null) { + url = new URL(ev.state.url); + if (!url.pathname.endsWith("/search") && ev.state.fragment !== null) { + renderFragmentHTML(ev.state.fragment); + return; + } } + range.deleteContents(); + search.reset(); }); diff --git a/frontend/static/style.css b/frontend/static/style.css index 375883e..dabf39c 100644 --- a/frontend/static/style.css +++ b/frontend/static/style.css @@ -41,7 +41,7 @@ body > header { } form { - margin-top: 1.5rem; + margin: 1.5rem 0; } fieldset { @@ -49,6 +49,13 @@ fieldset { column-gap: 1ex; border: none; padding: unset; + margin: unset; +} + +section { + border-top: none; + margin: unset; + padding: unset; } input[type="search"] { @@ -83,3 +90,17 @@ pre { pre:has(> code) { background: var(--bg); } + +section { + nav { + display: flex; + justify-content: space-between; + align-items: baseline; + a[rel="next"] { + margin-left: auto; + } + } + footer { + text-align: center; + } +} diff --git a/frontend/templates/blocks/options.gotmpl b/frontend/templates/blocks/options.gotmpl index e39d60c..88553b2 100644 --- a/frontend/templates/blocks/options.gotmpl +++ b/frontend/templates/blocks/options.gotmpl @@ -1,8 +1,8 @@ -{{- template "results" .Results -}} +{{- template "results" . -}} {{- define "results" }} -
- {{- with . }} - {{- range .Results }} + {{- if gt .Results.Total 0 }} +
+ {{- range .Results.Results }}
{{ .Option }} @@ -53,9 +53,20 @@ {{- end }}
- {{- else }} - Nothing found {{- end }} - {{- end }} -
+ + + {{- else }} + Nothing found + {{- end }} {{- end }} diff --git a/frontend/templates/blocks/search.gotmpl b/frontend/templates/blocks/search.gotmpl index a4f0ee5..0be63c1 100644 --- a/frontend/templates/blocks/search.gotmpl +++ b/frontend/templates/blocks/search.gotmpl @@ -9,7 +9,7 @@ - {{- with .Results }} + {{- if .Results }} {{ block "results" . }}{{ end }} {{- end }} {{- end }} diff --git a/internal/search/search.go b/internal/search/search.go index b449512..83a8365 100644 --- a/internal/search/search.go +++ b/internal/search/search.go @@ -15,7 +15,7 @@ import ( "github.com/pkg/errors" ) -const maxResults = 10 +const ResultsPerPage = 20 type Result[T options.NixOption] struct { *bleve.SearchResult @@ -93,9 +93,14 @@ func New[T options.NixOption](kind string) (*Index[T], error) { }, nil } -func (index *Index[T]) Search(ctx context.Context, keyword string) (*Result[T], error) { +func (index *Index[T]) Search(ctx context.Context, keyword string, from uint64) (*Result[T], error) { query := bleve.NewMatchQuery(keyword) search := bleve.NewSearchRequest(query) + search.Size = ResultsPerPage + + if from != 0 { + search.From = int(from) + } bleveResult, err := index.index.SearchInContext(ctx, search) select { @@ -106,13 +111,10 @@ func (index *Index[T]) Search(ctx context.Context, keyword string) (*Result[T], return nil, errors.WithMessage(err, "failed to execute search query") } - results := make([]T, min(maxResults, bleveResult.Total)) + results := make([]T, min(ResultsPerPage, bleveResult.Total)) for i, result := range bleveResult.Hits { doc, _ := index.docs.Load(result.ID) results[i] = doc.(T) - if i > maxResults { - break - } } return &Result[T]{ diff --git a/internal/server/server.go b/internal/server/server.go index 9649ad2..db40e6f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -9,9 +9,11 @@ import ( "log/slog" "net" "net/http" + "net/url" "os" "path" "slices" + "strconv" "time" cfg "searchix/internal/config" @@ -63,8 +65,11 @@ type TemplateData struct { type ResultData[T options.NixOption] struct { TemplateData - Query string - Results *search.Result[T] + Query string + ResultsPerPage int + Results *search.Result[T] + Prev string + Next string } func applyDevModeOverrides(config *cfg.Config) { @@ -158,7 +163,16 @@ func New(runtimeConfig *Config) (*Server, error) { return } - results, err := index[source].Search(ctx, r.URL.Query().Get("query")) + qs := r.URL.Query().Get("query") + pg := r.URL.Query().Get("page") + var page uint64 = 1 + if pg != "" { + page, err = strconv.ParseUint(pg, 10, 64) + if err != nil || page == 0 { + http.Error(w, "Bad query string", http.StatusBadRequest) + } + } + results, err := index[source].Search(ctx, qs, (page-1)*search.ResultsPerPage) if err != nil { if err == context.DeadlineExceeded { http.Error(w, "Search timed out", http.StatusInternalServerError) @@ -173,9 +187,42 @@ func New(runtimeConfig *Config) (*Server, error) { LiveReload: jsSnippet, Source: source, }, - Query: r.URL.Query().Get("query"), - Results: results, + ResultsPerPage: search.ResultsPerPage, + Query: qs, + Results: results, } + + hits := uint64(len(results.Hits)) + if results.Total > hits { + q, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + http.Error(w, "Query string error", http.StatusBadRequest) + + return + } + + if page*search.ResultsPerPage > results.Total { + http.Error(w, "Not found", http.StatusNotFound) + + return + } + + if page*search.ResultsPerPage < results.Total { + q.Set("page", strconv.FormatUint(page+1, 10)) + tdata.Next = "results?" + q.Encode() + } + + if page > 1 { + p := page - 1 + if p == 1 { + q.Del("page") + } else { + q.Set("page", strconv.FormatUint(p, 10)) + } + tdata.Prev = "results?" + q.Encode() + } + } + if r.Header.Get("Fetch") == "true" { w.Header().Add("Content-Type", "text/html; charset=utf-8") err = templates["options"].ExecuteTemplate(w, "options.gotmpl", tdata) -- cgit 1.4.1