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

This commit is contained in:
lclee3390
2026-01-08 12:13:56 +08:00
committed by GitHub
parent 6439d352d2
commit df28028b24
10 changed files with 160 additions and 11 deletions
@@ -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
View File
@@ -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
View File
@@ -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';`,
@@ -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
View File
@@ -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 }}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}