feat(ui): add filter to search results by unread status
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }}&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 }}&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 }}&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 }}&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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -17,6 +17,7 @@ type pagination struct {
|
||||
PrevOffset int
|
||||
FirstOffset int
|
||||
SearchQuery string
|
||||
UnreadOnly bool
|
||||
}
|
||||
|
||||
func getPagination(route string, total, offset, nbItemsPerPage int) pagination {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user