about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorAlan Pearce2024-05-08 00:15:52 +0200
committerAlan Pearce2024-05-08 00:16:03 +0200
commit973345ad50f9b237714fcb364cf7f665b3909f9d (patch)
tree15225430bd5895b5140df0e301b0e6c3fb5758a8
parentf459e84ecf7307fe2eeb7fbaa5b0c50613ec04f4 (diff)
downloadsearchix-973345ad50f9b237714fcb364cf7f665b3909f9d.tar.lz
searchix-973345ad50f9b237714fcb364cf7f665b3909f9d.tar.zst
searchix-973345ad50f9b237714fcb364cf7f665b3909f9d.zip
feat: paginate search results
-rw-r--r--frontend/static/search.js66
-rw-r--r--frontend/static/style.css23
-rw-r--r--frontend/templates/blocks/options.gotmpl27
-rw-r--r--frontend/templates/blocks/search.gotmpl2
-rw-r--r--internal/search/search.go14
-rw-r--r--internal/server/server.go57
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" }}
-  <div id="results">
-    {{- with . }}
-      {{- range .Results }}
+  {{- if gt .Results.Total 0 }}
+    <section id="results">
+      {{- range .Results.Results }}
         <details id="{{ .Option }}">
           <summary>
             {{ .Option }}
@@ -53,9 +53,20 @@
             {{- end }}
           </dl>
         </details>
-      {{- else }}
-        Nothing found
       {{- end }}
-    {{- end }}
-  </div>
+      <footer>
+        <nav id="pagination">
+          {{- with .Prev }}
+            <a class="button" href="{{ . }}" rel="prev">Prev</a>
+          {{- end }}
+          {{- with .Next }}
+            <a class="button" href="{{ . }}" rel="next">Next</a>
+          {{- end }}
+        </nav>
+        <span> {{ .Results.Total }} results </span>
+      </footer>
+    </section>
+  {{- 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 @@
       <button>Search</button>
     </fieldset>
   </form>
-  {{- 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)