Просмотр исходного кода

feat(ui): add filter to search results by unread status

lclee3390 2 месяцев назад
Родитель
Сommit
df28028b24

+ 17 - 0
internal/storage/entry_pagination_builder.go

@@ -59,6 +59,23 @@ func (e *EntryPaginationBuilder) WithStatus(status string) {
 	}
 }
 
+// WithStatusOrEntryID adds a status condition that always includes a specific entry ID.
+func (e *EntryPaginationBuilder) WithStatusOrEntryID(status string, entryID int64) {
+	if status == "" {
+		return
+	}
+
+	if entryID == 0 {
+		e.WithStatus(status)
+		return
+	}
+
+	statusArg := len(e.args) + 1
+	entryArg := len(e.args) + 2
+	e.conditions = append(e.conditions, fmt.Sprintf("(e.status = $%d OR e.id = $%d)", statusArg, entryArg))
+	e.args = append(e.args, status, entryID)
+}
+
 func (e *EntryPaginationBuilder) WithTags(tags []string) {
 	if len(tags) > 0 {
 		for _, tag := range tags {

+ 42 - 0
internal/template/functions.go

@@ -10,6 +10,7 @@ import (
 	"math"
 	"net/mail"
 	"net/url"
+	"strconv"
 	"slices"
 	"strings"
 	"time"
@@ -105,6 +106,47 @@ func (f *funcMap) Map() template.FuncMap {
 		"subtract": func(a, b int) int {
 			return a - b
 		},
+		"queryString": func(params map[string]any) string {
+			if len(params) == 0 {
+				return ""
+			}
+
+			values := url.Values{}
+			for key, value := range params {
+				switch v := value.(type) {
+				case string:
+					if v != "" {
+						values.Set(key, v)
+					}
+				case int:
+					if v != 0 {
+						values.Set(key, strconv.Itoa(v))
+					}
+				case int64:
+					if v != 0 {
+						values.Set(key, strconv.FormatInt(v, 10))
+					}
+				case bool:
+					if v {
+						values.Set(key, "1")
+					}
+				default:
+					if value != nil {
+						str := fmt.Sprint(value)
+						if str != "" {
+							values.Set(key, str)
+						}
+					}
+				}
+			}
+
+			encoded := values.Encode()
+			if encoded == "" {
+				return ""
+			}
+
+			return "?" + encoded
+		},
 
 		// These functions are overridden at runtime after parsing.
 		"elapsed": func(timezone string, t time.Time) string {

+ 38 - 0
internal/template/functions_test.go

@@ -162,6 +162,44 @@ func TestFormatFileSize(t *testing.T) {
 	}
 }
 
+func TestQueryString(t *testing.T) {
+	params, err := dict("q", "ai", "unread", true, "offset", 20)
+	if err != nil {
+		t.Fatalf(`The dict should be valid: %v`, err)
+	}
+
+	got := (&funcMap{}).Map()["queryString"].(func(map[string]any) string)(params)
+	if got == "" {
+		t.Fatalf("Expected a query string, got an empty string")
+	}
+
+	if !strings.HasPrefix(got, "?") {
+		t.Fatalf(`Expected query string to start with "?", got %q`, got)
+	}
+
+	if !strings.Contains(got, "q=ai") {
+		t.Fatalf(`Expected query string to contain q=ai, got %q`, got)
+	}
+
+	if !strings.Contains(got, "unread=1") {
+		t.Fatalf(`Expected query string to contain unread=1, got %q`, got)
+	}
+
+	if !strings.Contains(got, "offset=20") {
+		t.Fatalf(`Expected query string to contain offset=20, got %q`, got)
+	}
+
+	empty, err := dict("q", "", "unread", false, "offset", 0)
+	if err != nil {
+		t.Fatalf(`The dict should be valid: %v`, err)
+	}
+
+	got = (&funcMap{}).Map()["queryString"].(func(map[string]any) string)(empty)
+	if got != "" {
+		t.Fatalf(`Expected empty query string, got %q`, got)
+	}
+}
+
 func TestCSPExternalFont(t *testing.T) {
 	want := []string{
 		`default-src 'none';`,

+ 4 - 4
internal/template/templates/common/pagination.html

@@ -3,7 +3,7 @@
     <div class="pagination-backward">
         <div class="pagination-first {{ if not .ShowFirst }}disabled{{end}}">
             {{ if .ShowFirst }}
-                <a href="{{ .Route }}{{ if gt .FirstOffset 0 }}?offset={{ .FirstOffset }}{{ if .SearchQuery }}&amp;q={{ .SearchQuery }}{{ end }}{{ else }}{{ if .SearchQuery }}?q={{ .SearchQuery }}{{ end }}{{ end }}" data-page="first">{{ t "pagination.first" }}</a>
+                <a href="{{ .Route }}{{ queryString (dict "offset" .FirstOffset "q" .SearchQuery "unread" .UnreadOnly) }}" data-page="first">{{ t "pagination.first" }}</a>
             {{ else }}
                 {{ t "pagination.first" }}
             {{ end }}
@@ -11,7 +11,7 @@
 
         <div class="pagination-prev {{ if not .ShowPrev }}disabled{{end}}">
             {{ if .ShowPrev }}
-                <a href="{{ .Route }}{{ if gt .PrevOffset 0 }}?offset={{ .PrevOffset }}{{ if .SearchQuery }}&amp;q={{ .SearchQuery }}{{ end }}{{ else }}{{ if .SearchQuery }}?q={{ .SearchQuery }}{{ end }}{{ end }}" data-page="previous" rel="prev">{{ t "pagination.previous" }}</a>
+                <a href="{{ .Route }}{{ queryString (dict "offset" .PrevOffset "q" .SearchQuery "unread" .UnreadOnly) }}" data-page="previous" rel="prev">{{ t "pagination.previous" }}</a>
             {{ else }}
                 {{ t "pagination.previous" }}
             {{ end }}
@@ -23,7 +23,7 @@
     <div class="pagination-forward">
         <div class="pagination-next {{ if not .ShowNext }}disabled{{end}}">
             {{ if .ShowNext }}
-                <a href="{{ .Route }}?offset={{ .NextOffset }}{{ if .SearchQuery }}&amp;q={{ .SearchQuery }}{{ end }}" data-page="next" rel="next">{{ t "pagination.next" }}</a>
+                <a href="{{ .Route }}{{ queryString (dict "offset" .NextOffset "q" .SearchQuery "unread" .UnreadOnly) }}" data-page="next" rel="next">{{ t "pagination.next" }}</a>
             {{ else }}
                 {{ t "pagination.next" }}
             {{ end }}
@@ -31,7 +31,7 @@
 
         <div class="pagination-last {{ if not .ShowLast }}disabled{{end}}">
             {{ if .ShowLast }}
-                <a href="{{ .Route }}?offset={{ .LastOffset }}{{ if .SearchQuery }}&amp;q={{ .SearchQuery }}{{ end }}" data-page="last" >{{ t "pagination.last" }}</a>
+                <a href="{{ .Route }}{{ queryString (dict "offset" .LastOffset "q" .SearchQuery "unread" .UnreadOnly) }}" data-page="last" >{{ t "pagination.last" }}</a>
             {{ else }}
                 {{ t "pagination.last" }}
             {{ end }}

+ 2 - 2
internal/template/templates/views/entry.html

@@ -4,7 +4,7 @@
 <div class="pagination">
     <div class="pagination-prev {{ if not .prevEntry }}disabled{{end}}">
         {{ if .prevEntry }}
-            <a href="{{ .prevEntryRoute }}{{ if .searchQuery }}?q={{ .searchQuery }}{{ end }}" title="{{ .prevEntry.Title }}" data-page="previous" rel="prev">{{ t "pagination.previous" }}</a>
+            <a href="{{ .prevEntryRoute }}{{ queryString (dict "q" .searchQuery "unread" .searchUnreadOnly) }}" title="{{ .prevEntry.Title }}" data-page="previous" rel="prev">{{ t "pagination.previous" }}</a>
         {{ else }}
             {{ t "pagination.previous" }}
         {{ end }}
@@ -14,7 +14,7 @@
 
     <div class="pagination-next {{ if not .nextEntry }}disabled{{end}}">
         {{ if .nextEntry }}
-            <a href="{{ .nextEntryRoute }}{{ if .searchQuery }}?q={{ .searchQuery }}{{ end }}" title="{{ .nextEntry.Title }}" data-page="next" rel="next">{{ t "pagination.next" }}</a>
+            <a href="{{ .nextEntryRoute }}{{ queryString (dict "q" .searchQuery "unread" .searchUnreadOnly) }}" title="{{ .nextEntry.Title }}" data-page="next" rel="next">{{ t "pagination.next" }}</a>
         {{ else }}
             {{ t "pagination.next" }}
         {{ end }}

+ 7 - 4
internal/template/templates/views/search.html

@@ -8,9 +8,12 @@
 
 {{ define "content"}}
 <search role="search">
-    <form action="{{ route "search" }}" aria-labelledby="search-input-label">
-        <input type="search" name="q" id="search-input" aria-label="{{ t "search.label" }}" placeholder="{{ t "search.placeholder" }}" {{ if $.searchQuery }}value="{{ .searchQuery }}"{{ else }}autofocus{{ end }} required>
-        <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.loading" }}">{{ t "search.submit" }}</button>
+    <form class="search-form" action="{{ route "search" }}" aria-labelledby="search-input-label">
+        <div class="search-input-row">
+            <input type="search" name="q" id="search-input" aria-label="{{ t "search.label" }}" placeholder="{{ t "search.placeholder" }}" {{ if $.searchQuery }}value="{{ .searchQuery }}"{{ else }}autofocus{{ end }} required>
+            <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.loading" }}">{{ t "search.submit" }}</button>
+        </div>
+        <label class="search-filter"><input type="checkbox" name="unread" value="1" {{ if $.searchUnreadOnly }}checked{{ end }}> {{ t "menu.show_only_unread_entries" }}</label>
     </form>
 </search>
 
@@ -30,7 +33,7 @@
             >
                 <header class="item-header" dir="auto">
                     <h2 id="entry-title-{{ .ID }}" class="item-title">
-                        <a href="{{ route "searchEntry" "entryID" .ID }}?q={{ $.searchQuery }}">
+                        <a href="{{ route "searchEntry" "entryID" .ID }}{{ queryString (dict "q" $.searchQuery "unread" $.searchUnreadOnly) }}">
                             {{ if ne .Feed.Icon.IconID 0 }}
                             <img src="{{ route "feedIcon" "externalIconID" .Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="{{ .Feed.Title }}">
                             {{ else }}

+ 10 - 0
internal/ui/entry_search.go

@@ -24,6 +24,7 @@ func (h *handler) showSearchEntryPage(w http.ResponseWriter, r *http.Request) {
 
 	entryID := request.RouteInt64Param(r, "entryID")
 	searchQuery := request.QueryStringParam(r, "q", "")
+	unreadOnly := request.QueryBoolParam(r, "unread", false)
 	builder := h.store.NewEntryQueryBuilder(user.ID)
 	builder.WithSearchQuery(searchQuery)
 	builder.WithEntryID(entryID)
@@ -57,6 +58,14 @@ func (h *handler) showSearchEntryPage(w http.ResponseWriter, r *http.Request) {
 
 	entryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)
 	entryPaginationBuilder.WithSearchQuery(searchQuery)
+	if unreadOnly {
+		if entry.Status == model.EntryStatusRead {
+			entryPaginationBuilder.WithStatusOrEntryID(model.EntryStatusUnread, entry.ID)
+		} else {
+			entryPaginationBuilder.WithStatus(model.EntryStatusUnread)
+		}
+	}
+
 	prevEntry, nextEntry, err := entryPaginationBuilder.Entries()
 	if err != nil {
 		html.ServerError(w, r, err)
@@ -76,6 +85,7 @@ func (h *handler) showSearchEntryPage(w http.ResponseWriter, r *http.Request) {
 	sess := session.New(h.store, request.SessionID(r))
 	view := view.New(h.tpl, r, sess)
 	view.Set("searchQuery", searchQuery)
+	view.Set("searchUnreadOnly", unreadOnly)
 	view.Set("entry", entry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("nextEntry", nextEntry)

+ 1 - 0
internal/ui/pagination.go

@@ -17,6 +17,7 @@ type pagination struct {
 	PrevOffset   int
 	FirstOffset  int
 	SearchQuery  string
+	UnreadOnly   bool
 }
 
 func getPagination(route string, total, offset, nbItemsPerPage int) pagination {

+ 6 - 0
internal/ui/search.go

@@ -22,6 +22,7 @@ func (h *handler) showSearchPage(w http.ResponseWriter, r *http.Request) {
 	}
 
 	searchQuery := request.QueryStringParam(r, "q", "")
+	unreadOnly := request.QueryBoolParam(r, "unread", false)
 	offset := request.QueryIntParam(r, "offset", 0)
 
 	var entries model.Entries
@@ -30,6 +31,9 @@ func (h *handler) showSearchPage(w http.ResponseWriter, r *http.Request) {
 	if searchQuery != "" {
 		builder := h.store.NewEntryQueryBuilder(user.ID)
 		builder.WithSearchQuery(searchQuery)
+		if unreadOnly {
+			builder.WithStatus(model.EntryStatusUnread)
+		}
 		builder.WithoutStatus(model.EntryStatusRemoved)
 		builder.WithOffset(offset)
 		builder.WithLimit(user.EntriesPerPage)
@@ -51,8 +55,10 @@ func (h *handler) showSearchPage(w http.ResponseWriter, r *http.Request) {
 	view := view.New(h.tpl, r, sess)
 	pagination := getPagination(route.Path(h.router, "search"), entriesCount, offset, user.EntriesPerPage)
 	pagination.SearchQuery = searchQuery
+	pagination.UnreadOnly = unreadOnly
 
 	view.Set("searchQuery", searchQuery)
+	view.Set("searchUnreadOnly", unreadOnly)
 	view.Set("entries", entries)
 	view.Set("total", entriesCount)
 	view.Set("pagination", pagination)

+ 33 - 1
internal/ui/static/css/common.css

@@ -462,6 +462,38 @@ input[type="checkbox"] {
     margin-bottom: 10px;
 }
 
+.search-form {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 6px;
+}
+
+.search-input-row {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+}
+
+.search-input-row input[type="search"] {
+    margin: 0;
+}
+
+.search-input-row .button {
+    margin: 0;
+}
+
+.search-filter {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    margin: 0;
+}
+
+.search-filter input[type="checkbox"] {
+    margin: 0;
+}
+
 textarea {
     width: 350px;
     color: var(--input-color);
@@ -1343,4 +1375,4 @@ footer .elevator {
 .pagination-top .elevator,
 .pagination-entry-top .elevator {
     display: none;
-}
+}