diff --git a/cmd/server/tests/contract_pins_test.go b/cmd/server/tests/contract_pins_test.go
index 12cb7a3a..17388157 100644
--- a/cmd/server/tests/contract_pins_test.go
+++ b/cmd/server/tests/contract_pins_test.go
@@ -452,9 +452,13 @@ func TestContractPin_KoboSyncFromServer(t *testing.T) {
// ---------------------------------------------------------------------------
// bookInfoKeys is the BookInfo JSON every card surface emits.
+// 2026-10-27 (search-progress batch): + viewer_percentage (nil = no
+// viewer data; only the search-unified adapter populates it today)
+// and + library_name (same deal). docs/contract_pins.md changelog.
var bookInfoKeys = []string{
"media_item_id", "title", "author", "cover_image_path",
"has_conflict", "format_group", "badge",
+ "viewer_percentage", "library_name",
}
func collectionURL(setup *TestServerSetup, id string) string {
@@ -667,6 +671,12 @@ func TestContractPin_SearchJSON(t *testing.T) {
// (docs/contract_pins.md changelog).
"subtitle", "explicit", "authors", "authors_search",
"publishers", "publishers_search", "narrators_search",
+ // 2026-10-27 (search-progress batch): + viewer_percentage —
+ // the requester's reading_progress.percentage (LEFT JOIN on
+ // the same user_id param the visibility join binds), null
+ // when they never opened the book (docs/contract_pins.md
+ // changelog).
+ "viewer_percentage",
}
url := fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.itemTitle), fx.libraryID)
diff --git a/cmd/server/tests/search_progress_test.go b/cmd/server/tests/search_progress_test.go
new file mode 100644
index 00000000..bfe10a6f
--- /dev/null
+++ b/cmd/server/tests/search_progress_test.go
@@ -0,0 +1,129 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestSearchViewerProgress pins the search-progress batch (2026-10-27):
+// the unified search row carries the REQUESTING user's reading progress
+// (viewer_percentage) via a LEFT JOIN on the same user_id param the
+// visibility join binds, the JSON endpoint serializes it, and the
+// layout=dropdown HX branch renders templ rows with the completed check
+// / partial bar. Fixture: three books in a test library; the FRESH
+// regular user finished one (1.0), is mid-read on another (0.42) and
+// never opened the third (NULL). The shared admin's search response is
+// read but never written — their progress stays untouched.
+func TestSearchViewerProgress(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration test in short mode")
+ }
+ setup := setupTestServer(t)
+ ctx := context.Background()
+
+ libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Search Progress Library", false)
+ viewerID := getRegularUserID(t, setup.DB)
+
+ books := []struct {
+ title string
+ percentage string // "" = no progress row at all
+ }{
+ {title: "Progress Probe Alpha", percentage: "1.0"},
+ {title: "Progress Probe Beta", percentage: "0.42"},
+ {title: "Progress Probe Gamma", percentage: ""},
+ }
+ itemIDs := map[string]string{}
+ t.Cleanup(func() {
+ _, _ = setup.DBPool.Exec(ctx, "DELETE FROM libraries WHERE id = $1", libID)
+ })
+
+ for _, b := range books {
+ var itemID string
+ err := setup.DBPool.QueryRow(ctx,
+ `INSERT INTO media_items (library_id, title, file_path, created_at, imported_at, updated_at)
+ VALUES ($1, $2, $3, '2026-02-01 00:00:00+00', '2026-02-01 00:00:00+00', '2026-02-01 00:00:00+00') RETURNING id`,
+ libID, b.title, fmt.Sprintf("/tmp/test-search-progress/%s.epub", b.title),
+ ).Scan(&itemID)
+ require.NoError(t, err)
+ itemIDs[b.title] = itemID
+ if b.percentage != "" {
+ _, err = setup.DBPool.Exec(ctx,
+ `INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at)
+ VALUES ($1, $2, $3::float, '2026-02-02 00:00:00+00')`,
+ itemID, viewerID, b.percentage,
+ )
+ require.NoError(t, err)
+ }
+ }
+
+ t.Run("JSON rows carry the viewer's own percentage", func(t *testing.T) {
+ rows := searchJSON(t, setup, setup.RegularToken, "Progress Probe", libID)
+ require.Len(t, rows, 3)
+ byTitle := map[string]map[string]interface{}{}
+ for _, row := range rows {
+ byTitle[row["title"].(string)] = row
+ }
+ assert.Equal(t, float64(1), byTitle["Progress Probe Alpha"]["viewer_percentage"])
+ assert.InDelta(t, 0.42, byTitle["Progress Probe Beta"]["viewer_percentage"], 0.0001)
+ require.Nil(t, byTitle["Progress Probe Gamma"]["viewer_percentage"], "never-opened book must be null")
+
+ // Per-user join: the shared admin (no progress on any fixture
+ // book) sees null across the board — the join binds the
+ // REQUESTER, not a global.
+ adminRows := searchJSON(t, setup, setup.Token, "Progress Probe", libID)
+ require.Len(t, adminRows, 3)
+ for _, row := range adminRows {
+ assert.Nil(t, row["viewer_percentage"], "admin must see null for %v", row["title"])
+ }
+ })
+
+ t.Run("dropdown partial renders progress overlays", func(t *testing.T) {
+ req, err := http.NewRequest("GET",
+ setup.Server.URL+"/api/media-items/search?layout=dropdown&q=Progress+Probe&library_id="+libID, nil)
+ require.NoError(t, err)
+ req.Header.Set("Cookie", "token="+setup.RegularToken)
+ req.Header.Set("HX-Request", "true")
+ resp, err := (&http.Client{}).Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ bodyBytes, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ body := string(bodyBytes)
+
+ assert.Contains(t, body, "3 results for", "count label")
+ // Completed book: the check overlay + title (completed ≥ 0.999).
+ assert.Contains(t, body, "Completed", "completed overlay")
+ // Partial book: the bar at 42%.
+ assert.Contains(t, body, "width: 42.0%", "partial progress bar")
+ // Server-rendered rows, one per hit.
+ assert.Equal(t, 3, strings.Count(body, `class="search-result-item`), "one row per result")
+ })
+}
+
+// searchJSON runs a search as the given token and decodes the row array
+// (library-scoped: the unified search is fuzzy, so an unscoped q would
+// sweep in live dev rows).
+func searchJSON(t *testing.T, setup *TestServerSetup, token, query, libraryID string) []map[string]interface{} {
+ t.Helper()
+ req, err := http.NewRequest("GET",
+ fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(query), libraryID), nil)
+ require.NoError(t, err)
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := (&http.Client{}).Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ var rows []map[string]interface{}
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
+ return rows
+}
diff --git a/docs/contract_pins.md b/docs/contract_pins.md
index b036a41c..bebef111 100644
--- a/docs/contract_pins.md
+++ b/docs/contract_pins.md
@@ -37,14 +37,15 @@ These are the response SHAPES (JSON key sets, not values) frozen by the
| `CollectionsJSON` | `GET /api/collections/:id` (all 4 branches) | envelope `id, user_id, name, description, color, icon, auto_assign_rules, view_settings, books[], book_count, is_smart, created_at`; books = `BookInfo` |
| | `POST /api/collections/preview` | bare `{items: BookInfo[]}` |
| `SeriesJSON` | `GET /api/series/books?name=&library_id=` | `{name, library_id, books: BookInfo[], total}` |
-| `SearchJSON` | `GET /api/media-items/search?q=&library_id=` | top-level array of `SearchMediaItemsUnifiedRow` (47 keys) |
+| `SearchJSON` | `GET /api/media-items/search?q=&library_id=` | top-level array of `SearchMediaItemsUnifiedRow` (47 keys + viewer_percentage) |
| `DashboardSections` | `GET /api/dashboard/sections` | `{sections[]: {id, collection_id, is_system, title, description, icon, items: BookInfo[], view_all_url, priority}}` |
| `MediaListBothBranches` | `GET /api/media-items` (unscoped) | `{data: [38-key resolved map]}` (intentionally divergent from the scoped branch until the row-shape collapse batch) |
| | `GET /api/media-items?library_id=&sort=` | `{data: [raw ListMediaItemsSortedRow, 70 sqlc keys]}` |
| `SSRPages` | `/bookshelf`, `/collections`, `/collections/:id`, `/series`, `/series/detail`, `/tags/detail` | 200 + stable title/substring markers |
`BookInfo` (every card surface): `media_item_id, title, author,
-cover_image_path, has_conflict, format_group, badge`.
+cover_image_path, has_conflict, format_group, badge, viewer_percentage,
+library_name`.
## Changelog (additive keys per batch)
@@ -414,3 +415,20 @@ cover_image_path, has_conflict, format_group, badge`.
BEFORE Rescan All — force rescan stamps books with the current
setting, so bump-first + force = one pass of work; force-first +
bump = the trickle re-derives everything again (wasted, not harmful).
+
+## 2026-10-27 — Search-progress batch: viewer progress on cards
+- **`SearchJSON`**: `SearchMediaItemsUnifiedRow` + `viewer_percentage`
+ (float 0..1, NULL when the requester never opened the book). The
+ query LEFT JOINs `reading_progress` on the SAME `user_id` param the
+ library-visibility join already binds — one requester param, no new
+ wire inputs; the join is row-neutral (UNIQUE(media_item, user)).
+- **`BookInfo`** (CollectionsJSON / SeriesJSON / preview /
+ DashboardSections / search cards): + `viewer_percentage` (JSON null
+ when nil) and + `library_name` (empty on feeds that don't load it —
+ only the search-unified adapter populates both today). Adapters
+ other than BookCardsFromUnified keep emitting null/""; templ cards
+ render the progress bar/completed check only when populated.
+- The header-search dropdown now renders the templ `SearchResults`
+ partial (layout=dropdown on the same endpoint, HX-Request) instead
+ of the retired innerHTML renderer in web/src/search.ts — same
+ endpoint, same auth, content server-rendered.
diff --git a/internal/database/querier.go b/internal/database/querier.go
index f7b20a31..fa000a96 100644
--- a/internal/database/querier.go
+++ b/internal/database/querier.go
@@ -275,12 +275,24 @@ type Querier interface {
// Series are grouped PER LIBRARY: the same series name in two libraries
// (novels vs manga adaptations, LN splits) stays two entries. Visible
// filter enforces per-user library visibility at the source.
- // Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
- // transaction share a single NOW() timestamp, leaving many groups tied on
- // MAX(created_at). Postgres does not guarantee tie order between
- // executions, so page boundaries could repeat or drop series. The GROUP BY
- // keys make the sort total and deterministic.
+ //
+ // Series-page sort/filter contract (extensible registry in
+ // services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
+ // it filters in WHERE (the group key — counts stay true); AuthorQuery
+ // and Status filter via HAVING so COUNT(*) keeps reporting the whole
+ // series. ViewerUserId scopes the per-user progress aggregates (the rp
+ // join can't multiply rows: reading_progress is UNIQUE per
+ // media_item+user). Sort is a BOUND PARAM compared inside the CASE
+ // cascade — never interpolated — and the GROUP BY tiebreaker stays
+ // REQUIRED for LIMIT/OFFSET determinism: bulk imports in one
+ // transaction share a single NOW() timestamp, leaving many groups tied
+ // on MAX(created_at), and Postgres does not guarantee tie order between
+ // executions, so page boundaries could repeat or drop series.
GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
+ // Count counterpart of GetDistinctSeries: identical WHERE + JOIN +
+ // HAVING so the pagination total matches what the page query returns
+ // under the same filters (Sort is a page-ORDER concern, not a set
+ // membership one, so it is absent here by design).
GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error)
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go
index 7ac051d9..7083c8d8 100644
--- a/internal/database/queries.sql.go
+++ b/internal/database/queries.sql.go
@@ -14693,11 +14693,17 @@ SELECT
mi.opf_uuid,
mi.hash_confidence,
l.name as library_name,
- lt.name as library_type_name
+ lt.name as library_type_name,
+ -- Viewer's own progress (search-result badges): NULL when the
+ -- requester never opened the book. Reuses the SAME requester param
+ -- the visibility join binds; UNIQUE(media_item, user) keeps the
+ -- join row-neutral.
+ rp.percentage as viewer_percentage
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
+LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
@@ -14963,6 +14969,7 @@ type SearchMediaItemsUnifiedRow struct {
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
LibraryName string `db:"library_name" json:"library_name"`
LibraryTypeName string `db:"library_type_name" json:"library_type_name"`
+ ViewerPercentage pgtype.Float8 `db:"viewer_percentage" json:"viewer_percentage"`
}
func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error) {
@@ -15058,6 +15065,7 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt
&i.HashConfidence,
&i.LibraryName,
&i.LibraryTypeName,
+ &i.ViewerPercentage,
); err != nil {
return nil, err
}
diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql
index 4ce21cd4..f6fce6e3 100644
--- a/internal/database/queries/queries.sql
+++ b/internal/database/queries/queries.sql
@@ -734,11 +734,17 @@ SELECT
mi.opf_uuid,
mi.hash_confidence,
l.name as library_name,
- lt.name as library_type_name
+ lt.name as library_type_name,
+ -- Viewer's own progress (search-result badges): NULL when the
+ -- requester never opened the book. Reuses the SAME requester param
+ -- the visibility join binds; UNIQUE(media_item, user) keeps the
+ -- join row-neutral.
+ rp.percentage as viewer_percentage
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
+LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
diff --git a/internal/handlers/cards.go b/internal/handlers/cards.go
index 42a2cb53..5cb7d8a9 100644
--- a/internal/handlers/cards.go
+++ b/internal/handlers/cards.go
@@ -13,7 +13,7 @@ import (
// BookInfo is THE canonical wire+template card shape: every surface that
// lists a book card as JSON (collections detail, series, preview,
// dashboard sections, search) or renders a templ card (BookCard in
-// dashboard.templ, BooksGrid, BrowseDetail) carries exactly these seven
+// dashboard.templ, BooksGrid, BrowseDetail) carries exactly these nine
// keys and no others — pinned by docs/contract_pins.md.
//
// H2 doctrine: this struct is constructed in EXACTLY ONE place —
@@ -34,6 +34,15 @@ type BookInfo struct {
// comic vs manga rides the LIBRARY type (format_group only knows
// comic_archive — manga_type is unreliable across sources).
Badge string `json:"badge"`
+ // Viewer-scoped extras (2026-10-27 search-progress batch): nil
+ // percentage = the requesting user never opened the book, so cards
+ // render no progress bar and no completed badge. Adapters that
+ // don't load viewer progress leave it nil; the search-unified
+ // adapter is the only populated feed today. LibraryName rides the
+ // same deal (unified rows carry it as a column; other feeds leave
+ // it empty rather than pay a join).
+ ViewerPercentage *float64 `json:"viewer_percentage"`
+ LibraryName string `json:"library_name"`
}
// LibraryTypeMap builds libraryID→library-type-name for badge resolution
@@ -79,12 +88,14 @@ func MediaBadgeKind(formatGroup, libraryTypeName string) string {
// adapters below normalize the sqlc row shapes into cardSource.
func buildBookCard(src cardSource) BookInfo {
return BookInfo{
- MediaItemID: src.ID.String(),
- Title: src.Title,
- Author: src.Author,
- CoverImagePath: utils.ResolveMediaURL(src.LibraryID, src.CoverPath),
- FormatGroup: src.FormatGroup,
- Badge: MediaBadgeKind(src.FormatGroup, src.LibraryType),
+ MediaItemID: src.ID.String(),
+ Title: src.Title,
+ Author: src.Author,
+ CoverImagePath: utils.ResolveMediaURL(src.LibraryID, src.CoverPath),
+ FormatGroup: src.FormatGroup,
+ Badge: MediaBadgeKind(src.FormatGroup, src.LibraryType),
+ ViewerPercentage: src.ViewerProgress,
+ LibraryName: src.LibraryName,
}
}
@@ -102,6 +113,10 @@ type cardSource struct {
CoverPath pgtype.Text
FormatGroup string
LibraryType string
+ // Viewer-scoped extras: nil progress = no data loaded for the
+ // requesting user (see BookInfo.ViewerPercentage).
+ ViewerProgress *float64
+ LibraryName string
}
// BookCardsFromMediaItems builds cards from full media rows
@@ -167,18 +182,27 @@ func BookCardsFromCollectionItemRows(rows []database.GetCollectionItemsRow, libT
// BookCardsFromUnified builds cards from search-unified rows. Those
// rows carry the library type name as a SQL column, so this adapter
-// needs no LibraryTypeMap query at all.
+// needs no LibraryTypeMap query at all — and the viewer's own
+// progress (the 2026-10-27 search-progress batch), which no other
+// feed loads.
func BookCardsFromUnified(rows []database.SearchMediaItemsUnifiedRow) []BookInfo {
cards := make([]BookInfo, 0, len(rows))
for _, r := range rows {
+ var viewer *float64
+ if r.ViewerPercentage.Valid {
+ pct := r.ViewerPercentage.Float64
+ viewer = &pct
+ }
cards = append(cards, buildBookCard(cardSource{
- ID: uuid.UUID(r.ID.Bytes),
- Title: r.Title,
- Author: utils.PgTextString(r.Author),
- LibraryID: r.LibraryID,
- CoverPath: r.CoverImagePath,
- FormatGroup: r.FormatGroup,
- LibraryType: r.LibraryTypeName,
+ ID: uuid.UUID(r.ID.Bytes),
+ Title: r.Title,
+ Author: utils.PgTextString(r.Author),
+ LibraryID: r.LibraryID,
+ CoverPath: r.CoverImagePath,
+ FormatGroup: r.FormatGroup,
+ LibraryType: r.LibraryTypeName,
+ ViewerProgress: viewer,
+ LibraryName: r.LibraryName,
}))
}
return cards
diff --git a/internal/router/search.go b/internal/router/search.go
index 1984573f..e36b73bf 100644
--- a/internal/router/search.go
+++ b/internal/router/search.go
@@ -90,9 +90,15 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList)
// Render using BooksGrid template (or BookPickerGrid for collection picker)
var buf bytes.Buffer
- if c.QueryParam("show_checkbox") == "true" {
+ switch {
+ case c.QueryParam("show_checkbox") == "true":
err = templates.BookPickerGrid(bookInfoList).Render(c.Request().Context(), &buf)
- } else {
+ case c.QueryParam("layout") == "dropdown":
+ // Header-search dropdown: compact templ rows (rendering
+ // ownership) carrying the viewer's progress/completed overlay.
+ // Replaces the old innerHTML string renderer in search.ts.
+ err = templates.SearchResults(bookInfoList, c.QueryParam("q")).Render(c.Request().Context(), &buf)
+ default:
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
}
if err != nil {
diff --git a/templates/dashboard.templ b/templates/dashboard.templ
index b045f7b6..cacef66d 100644
--- a/templates/dashboard.templ
+++ b/templates/dashboard.templ
@@ -144,7 +144,7 @@ templ BookCard(item handlers.BookInfo) {
/>
}
+ shelf default already reads as a book -->
if MediaBadgeIcon(item.Badge) != "" {
}
+
+ if item.ViewerPercentage != nil {
+ if *item.ViewerPercentage >= CompletedProgressThreshold {
+
+ @Icon("check", "h-4 w-4")
+
+ } else if *item.ViewerPercentage > 0 {
+
+ }
+ }
diff --git a/templates/dashboard_templ.go b/templates/dashboard_templ.go
index ea0cd6f9..c8ef9ad6 100644
--- a/templates/dashboard_templ.go
+++ b/templates/dashboard_templ.go
@@ -465,7 +465,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -496,100 +496,138 @@ func BookCard(item handlers.BookInfo) templ.Component {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if item.HasConflict {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
")
+ var templ_7745c5c3_Var28 templ.SafeURL
+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 191, Col: 40}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" class=\"book-card-action-btn\" aria-label=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var29 string
+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue("Resolve progress conflict for " + item.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 193, Col: 63}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" title=\"Resolve progress conflict\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -597,38 +635,38 @@ func BookCard(item handlers.BookInfo) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if item.FormatGroup == "audiobook" {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" title=\"Listen\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -636,38 +674,38 @@ func BookCard(item handlers.BookInfo) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\" title=\"Read\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -675,12 +713,12 @@ func BookCard(item handlers.BookInfo) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -704,12 +742,12 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var33 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var33 == nil {
- templ_7745c5c3_Var33 = templ.NopComponent
+ templ_7745c5c3_Var34 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var34 == nil {
+ templ_7745c5c3_Var34 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "Customize Dashboard Drag to reorder, toggle to show or hide.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "Customize Dashboard Drag to reorder, toggle to show or hide.
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -717,38 +755,38 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, section := range sections {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" data-is-system=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var36 string
+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 243, Col: 58}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" draggable=\"true\">
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -756,120 +794,120 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var36 string
- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 229, Col: 43}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 231, Col: 84}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 248, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var38 string
+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 250, Col: 84}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if section.IsSystem {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "System ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "System ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if section.IsSystem {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
Restore ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" class=\"btn btn-secondary py-1 px-2.5 text-xs\" title=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var40 string
+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue("Restore " + section.Title + " to defaults")
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 262, Col: 60}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\">Restore ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, ">
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "
Items per Collection: ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var40 string
- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 267, Col: 128}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " Items per Collection: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 274, Col: 28}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 286, Col: 128}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" class=\"w-full h-2 rounded-lg appearance-none cursor-pointer\" style=\"background-color: var(--border-strong);\" data-input-action=\"update-items-count\" target=\"items-count-display\">
Cancel Save Changes
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, " Cancel Save Changes
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -945,12 +983,12 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var42 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var42 == nil {
- templ_7745c5c3_Var42 = templ.NopComponent
+ templ_7745c5c3_Var43 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var43 == nil {
+ templ_7745c5c3_Var43 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -958,77 +996,77 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, " This Week ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var43 string
- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(fmtReadingMinutes(week.WeekMinutes))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 355, Col: 146}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, " This Week ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 string
- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d pages", week.WeekPages))
+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(fmtReadingMinutes(week.WeekMinutes))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 356, Col: 138}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 374, Col: 146}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var45 string
- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d sessions", week.WeekSessions))
+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d pages", week.WeekPages))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 357, Col: 144}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 375, Col: 138}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var46 string
- templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d books", week.WeekBooks))
+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d sessions", week.WeekSessions))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 358, Col: 138}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 376, Col: 144}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var47 string
+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d books", week.WeekBooks))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 377, Col: 138}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if week.CurrentStreak > 0 {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "🔥 ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "🔥 ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var47 string
- templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.CurrentStreak))
+ var templ_7745c5c3_Var48 string
+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.CurrentStreak))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 360, Col: 146}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 379, Col: 146}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "-day streak ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "-day streak ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1037,166 +1075,166 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if activeLibType == "audiobooks" {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "
Listening Time
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var48 string
- templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(fmtReadingMinutes(week.WeekMinutes))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 369, Col: 108}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "
Listening Time
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var49 string
- templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes))
+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(fmtReadingMinutes(week.WeekMinutes))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 370, Col: 122}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 388, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "
Sessions
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var50 string
- templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekSessions))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 374, Col: 109}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "
Books Touched
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var51 string
- templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekBooks))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 378, Col: 106}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- } else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "
Reading Time
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var52 string
- templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(fmtReadingMinutes(week.WeekMinutes))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 383, Col: 108}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var53 string
- templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes))
+ var templ_7745c5c3_Var50 string
+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 384, Col: 122}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 389, Col: 122}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "
Sessions
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var51 string
+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekSessions))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 393, Col: 109}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "
Books Touched
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var52 string
+ templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekBooks))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 397, Col: 106}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "
Reading Time
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var53 string
+ templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(fmtReadingMinutes(week.WeekMinutes))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 402, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "
Pages
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var54 string
- templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekPages))
+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 388, Col: 106}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 403, Col: 122}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "
Sessions
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "
Pages
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var55 string
- templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekSessions))
+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekPages))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 392, Col: 109}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 407, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "
Books Touched
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "
Sessions
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var56 string
- templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekBooks))
+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekSessions))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 396, Col: 106}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 411, Col: 109}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "
Books Touched
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var57 string
+ templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekBooks))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 415, Col: 106}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "
Synced from your reading devices ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "
Synced from your reading devices ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if week.CurrentStreak > 0 {
if week.ActiveToday {
- var templ_7745c5c3_Var57 string
- templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(" · 🔥 %d-day streak — keep it going today", week.CurrentStreak))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 405, Col: 90}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- } else {
var templ_7745c5c3_Var58 string
- templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(" · 🔥 %d-day streak — read today to keep it", week.CurrentStreak))
+ templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(" · 🔥 %d-day streak — keep it going today", week.CurrentStreak))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 407, Col: 92}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 424, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+ } else {
+ var templ_7745c5c3_Var59 string
+ templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(" · 🔥 %d-day streak — read today to keep it", week.CurrentStreak))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 426, Col: 92}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "
Full stats")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, " Full stats")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1204,7 +1242,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/templates/header.templ b/templates/header.templ
index 25fce8b3..61397a36 100644
--- a/templates/header.templ
+++ b/templates/header.templ
@@ -224,17 +224,39 @@ templ Header(user User, currentPath string) {
>
@Icon("menu", "h-5 w-5")
-
+
@Icon("search", "h-4 w-4")
+
diff --git a/templates/header_templ.go b/templates/header_templ.go
index bef8e9c5..dfaabf24 100644
--- a/templates/header_templ.go
+++ b/templates/header_templ.go
@@ -717,7 +717,7 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -725,7 +725,7 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -770,7 +770,7 @@ func SidebarUserMenu(user User) templ.Component {
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 259, Col: 58}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 281, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
@@ -854,7 +854,7 @@ func SidebarSignIn(currentPath string) templ.Component {
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 307, Col: 60}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 329, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
if templ_7745c5c3_Err != nil {
diff --git a/templates/search_results.templ b/templates/search_results.templ
new file mode 100644
index 00000000..d2bac2ef
--- /dev/null
+++ b/templates/search_results.templ
@@ -0,0 +1,127 @@
+package templates
+
+import (
+ "fmt"
+ "html"
+ "regexp"
+
+ "bookhoard/internal/handlers"
+)
+
+// CompletedProgressThreshold is the viewer_percentage at which a card
+// shows the completed check instead of a partial-progress bar — the
+// SAME 0.999 cut the filter_query.go status arms and the series-page
+// status filter use.
+const CompletedProgressThreshold = 0.999
+
+// progressFillCSS renders the partial-progress bar width (gotcha #8:
+// quote-free style values only — templ double-escapes quotes into
+// literal text the CSS parser never sees).
+func progressFillCSS(pct float64) string {
+ return fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", pct*100)
+}
+
+// highlightQueryHTML escapes text FIRST and only then wraps
+// case-insensitive query matches in — the returned HTML is
+// safe by construction (escaped payload, marker tags we wrote), which
+// is what lets the search partial emit it via templ.Raw.
+func highlightQueryHTML(text, query string) string {
+ if text == "" || query == "" {
+ return html.EscapeString(text)
+ }
+ pattern := regexp.MustCompile(regexp.QuoteMeta(html.EscapeString(query)))
+ markOpen := ``
+ return pattern.ReplaceAllString(html.EscapeString(text), markOpen+"$0 ")
+}
+
+// searchCoverURL falls back to the placeholder the way BookCard does —
+// no inline onerror (CSP: hash-mode refuses native handler attributes).
+func searchCoverURL(item handlers.BookInfo) string {
+ if item.CoverImagePath != "" {
+ return item.CoverImagePath
+ }
+ return "/static/placeholder-book.svg"
+}
+
+// SearchResults is the header-search dropdown partial: compact result
+// rows swapped into #search-results by the htmx request the header
+// input fires (layout=dropdown on /api/media-items/search). Content is
+// server-rendered (rendering-ownership doctrine); web/src/search.ts
+// only owns open/close/keyboard state.
+templ SearchResults(items []handlers.BookInfo, query string) {
+
+
+ { resultCountLabel(len(items), query) }
+
+
+ if len(items) == 0 {
+
+
🔍
+
No results found
+
Try different keywords
+
+ } else {
+
+ for _, item := range items {
+ @SearchResultRow(item, query)
+ }
+
+
+
+ Press ↑↓ to navigate,
+ Enter to select
+
+
+ }
+}
+
+templ SearchResultRow(item handlers.BookInfo, query string) {
+
+}
+
+// SearchProgressOverlay is the cover overlay shared by the dropdown
+// rows and BookCard: a completed check at/above the 0.999 threshold,
+// a partial fill bar along the cover bottom while mid-read, nothing
+// when the viewer never opened the book.
+templ SearchProgressOverlay(item handlers.BookInfo) {
+ if item.ViewerPercentage != nil {
+ if *item.ViewerPercentage >= CompletedProgressThreshold {
+
+ @Icon("check", "h-3 w-3")
+
+ } else if *item.ViewerPercentage > 0 {
+
+ }
+ }
+}
+
+func resultCountLabel(n int, query string) string {
+ if n == 1 {
+ return fmt.Sprintf("1 result for “%s”", query)
+ }
+ return fmt.Sprintf("%d results for “%s”", n, query)
+}
diff --git a/templates/search_results_templ.go b/templates/search_results_templ.go
new file mode 100644
index 00000000..4ec2d026
--- /dev/null
+++ b/templates/search_results_templ.go
@@ -0,0 +1,313 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.1020
+package templates
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "fmt"
+ "html"
+ "regexp"
+
+ "bookhoard/internal/handlers"
+)
+
+// CompletedProgressThreshold is the viewer_percentage at which a card
+// shows the completed check instead of a partial-progress bar — the
+// SAME 0.999 cut the filter_query.go status arms and the series-page
+// status filter use.
+const CompletedProgressThreshold = 0.999
+
+// progressFillCSS renders the partial-progress bar width (gotcha #8:
+// quote-free style values only — templ double-escapes quotes into
+// literal text the CSS parser never sees).
+func progressFillCSS(pct float64) string {
+ return fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", pct*100)
+}
+
+// highlightQueryHTML escapes text FIRST and only then wraps
+// case-insensitive query matches in — the returned HTML is
+// safe by construction (escaped payload, marker tags we wrote), which
+// is what lets the search partial emit it via templ.Raw.
+func highlightQueryHTML(text, query string) string {
+ if text == "" || query == "" {
+ return html.EscapeString(text)
+ }
+ pattern := regexp.MustCompile(regexp.QuoteMeta(html.EscapeString(query)))
+ markOpen := ``
+ return pattern.ReplaceAllString(html.EscapeString(text), markOpen+"$0 ")
+}
+
+// searchCoverURL falls back to the placeholder the way BookCard does —
+// no inline onerror (CSP: hash-mode refuses native handler attributes).
+func searchCoverURL(item handlers.BookInfo) string {
+ if item.CoverImagePath != "" {
+ return item.CoverImagePath
+ }
+ return "/static/placeholder-book.svg"
+}
+
+// SearchResults is the header-search dropdown partial: compact result
+// rows swapped into #search-results by the htmx request the header
+// input fires (layout=dropdown on /api/media-items/search). Content is
+// server-rendered (rendering-ownership doctrine); web/src/search.ts
+// only owns open/close/keyboard state.
+func SearchResults(items []handlers.BookInfo, query string) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(resultCountLabel(len(items), query))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/search_results.templ`, Line: 54, Col: 40}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(items) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "🔍
No results found
Try different keywords
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, item := range items {
+ templ_7745c5c3_Err = SearchResultRow(item, query).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
Press ↑↓ to navigate, Enter to select
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+}
+
+func SearchResultRow(item handlers.BookInfo, query string) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var3 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var3 == nil {
+ templ_7745c5c3_Var3 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = SearchProgressOverlay(item).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.Raw(highlightQueryHTML(item.Title, query)).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if item.Author != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.Raw(highlightQueryHTML(item.Author, query)).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if item.LibraryName != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.LibraryName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/search_results.templ`, Line: 92, Col: 85}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// SearchProgressOverlay is the cover overlay shared by the dropdown
+// rows and BookCard: a completed check at/above the 0.999 threshold,
+// a partial fill bar along the cover bottom while mid-read, nothing
+// when the viewer never opened the book.
+func SearchProgressOverlay(item handlers.BookInfo) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var8 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var8 == nil {
+ templ_7745c5c3_Var8 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ if item.ViewerPercentage != nil {
+ if *item.ViewerPercentage >= CompletedProgressThreshold {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("check", "h-3 w-3").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if *item.ViewerPercentage > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ }
+ return nil
+ })
+}
+
+func resultCountLabel(n int, query string) string {
+ if n == 1 {
+ return fmt.Sprintf("1 result for “%s”", query)
+ }
+ return fmt.Sprintf("%d results for “%s”", n, query)
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/web/src/header.ts b/web/src/header.ts
index 3babcbd9..c18d54b0 100644
--- a/web/src/header.ts
+++ b/web/src/header.ts
@@ -1,5 +1,4 @@
import { Alpine } from "./alpine";
-import { initializeSearch } from "./search";
import {
initializeTheme,
changeTheme,
@@ -137,7 +136,6 @@ Alpine.data("header", () => ({
} catch {
this.openPanel = window.location.pathname.startsWith("/admin") ? "admin" : null;
}
- initializeSearch();
this.initializeTheme();
this.loadWoodPaneling();
this.updateWoodPanelingIndicators();
@@ -176,7 +174,6 @@ Alpine.data("header", () => ({
this.openPanel = null;
},
logout,
- initializeSearch,
initializeTheme,
changeTheme,
changeWoodPaneling,
diff --git a/web/src/rendering-allowlist.txt b/web/src/rendering-allowlist.txt
index 3aac0430..8f0e435f 100644
--- a/web/src/rendering-allowlist.txt
+++ b/web/src/rendering-allowlist.txt
@@ -19,7 +19,6 @@ web/src/markdown.ts:1
web/src/dom.ts:1
web/src/password_validation.ts:1
web/src/queue.ts:3
-web/src/search.ts:4
web/src/setup.ts:1
web/src/stats.ts:9
web/src/toast.ts:2
diff --git a/web/src/search.ts b/web/src/search.ts
index d285b3d8..ed596910 100644
--- a/web/src/search.ts
+++ b/web/src/search.ts
@@ -1,315 +1,108 @@
import { Alpine } from "./alpine";
-let searchInputTimeout: ReturnType | null = null;
-const SEARCH_DEBOUNCE_MS = 300;
+// Rendering ownership (AGENTS.md): the dropdown's CONTENT is templ —
+// the header input's hx-get swaps the SearchResults partial (titles,
+// authors, library names, progress/completed overlays all
+// server-rendered) into #search-results. This module owns only
+// client-local state: min-length gating, open/close, and keyboard
+// selection. It replaces the old renderer that built the dropdown from
+// innerHTML string templates — a violation of the rendering-ownership
+// doctrine this migration removes (2026-10-27 search-progress batch).
+
const SEARCH_MIN_CHARS = 2;
-function initializeSearch(): void {
- const searchInput = document.getElementById("header-search") as HTMLInputElement | null;
- const mobileSearchInput = document.getElementById(
- "header-search-mobile",
- ) as HTMLInputElement | null;
+interface HtmxAfterRequestEvent extends CustomEvent {
+ detail: { successful: boolean };
+}
- const wireInput = (input: HTMLInputElement) => {
- input.addEventListener("input", handleSearchInput);
- input.addEventListener("keydown", handleSearchKeydown);
- input.addEventListener("focus", () => {
- if (input.value.length >= SEARCH_MIN_CHARS) {
- performSearch(input.value);
- }
+Alpine.data("headerSearch", () => ({
+ open: false,
+ loading: false,
+ selected: -1,
+ lastQuery: "",
+
+ init() {
+ // htmx dispatches request lifecycle events on the REQUESTING
+ // element (the input), not the swap target.
+ const input = this.$refs.input as HTMLInputElement | undefined;
+ if (!input) return;
+ input.addEventListener("htmx:beforeRequest", () => {
+ this.loading = true;
});
- };
-
- if (searchInput) wireInput(searchInput);
- if (mobileSearchInput) wireInput(mobileSearchInput);
-
- if (!searchInput && !mobileSearchInput) {
- console.warn("Search input not found");
- }
-
- document.addEventListener("click", (e: MouseEvent) => {
- const searchResults = document.getElementById("search-results");
- const searchInputEl = document.getElementById("header-search");
- const mobileSearchInputEl = document.getElementById("header-search-mobile");
-
- if (
- searchResults &&
- !searchResults.contains(e.target as Node) &&
- e.target !== searchInputEl &&
- e.target !== mobileSearchInputEl
- ) {
- hideSearchResults();
- }
- });
-}
-
-function handleSearchInput(e: Event): void {
- const target = e.target as HTMLInputElement;
- const query = target.value.trim();
-
- if (searchInputTimeout) {
- clearTimeout(searchInputTimeout);
- }
-
- if (query.length < SEARCH_MIN_CHARS) {
- hideSearchResults();
- return;
- }
-
- searchInputTimeout = setTimeout(() => {
- performSearch(query);
- }, SEARCH_DEBOUNCE_MS);
-}
-
-function handleSearchKeydown(e: KeyboardEvent): void {
- const searchResults = document.getElementById("search-results");
- if (!searchResults || searchResults.classList.contains("hidden")) {
- return;
- }
-
- const items = searchResults.querySelectorAll(".search-result-item");
- const currentIndex = parseInt(searchResults.dataset.selectedIndex || "-1");
-
- if (e.key === "ArrowDown") {
- e.preventDefault();
- const nextIndex = Math.min(currentIndex + 1, items.length - 1);
- selectSearchResult(items, nextIndex);
- } else if (e.key === "ArrowUp") {
- e.preventDefault();
- const prevIndex = Math.max(currentIndex - 1, -1);
- selectSearchResult(items, prevIndex);
- } else if (e.key === "Enter") {
- e.preventDefault();
- if (currentIndex >= 0 && items[currentIndex]) {
- const link = items[currentIndex].querySelector("a");
- if (link) link.click();
- }
- } else if (e.key === "Escape") {
- hideSearchResults();
- }
-}
-
-function selectSearchResult(items: NodeListOf, index: number): void {
- items.forEach((item, i) => {
- if (i === index) {
- item.classList.add("bg-opacity-80");
- } else {
- item.classList.remove("bg-opacity-80");
- }
- });
-
- const searchResults = document.getElementById("search-results");
- if (searchResults) {
- searchResults.dataset.selectedIndex = index.toString();
- }
-}
-
-function getActiveSearchInput(): HTMLInputElement | null {
- return document.activeElement?.id === "header-search-mobile"
- ? (document.getElementById("header-search-mobile") as HTMLInputElement)
- : (document.getElementById("header-search") as HTMLInputElement);
-}
-
-function performSearch(query: string): void {
- const token = localStorage.getItem("token");
- if (!token) {
- console.warn("No authentication token found");
- return;
- }
-
- const activeInput = getActiveSearchInput();
- const activeId = activeInput?.id || "header-search";
-
- showSearchLoading(activeId);
-
- fetch(`/api/media-items/search?q=${encodeURIComponent(query)}`, {
- headers: {
- Authorization: `Bearer ${token}`,
- "Content-Type": "application/json",
- },
- })
- .then((response) => {
- if (response.status === 404) {
- return { error: "no results found", results: [] };
- }
- return response.json();
- })
- .then((data: { error?: string; results?: MediaItemSummary[] } | MediaItemSummary[]) => {
- hideSearchLoading();
-
- const resultActiveId = getActiveSearchInput()?.id;
-
- if (data && "error" in data && data.error === "no results found") {
- showNoResults(query, resultActiveId);
- } else if (Array.isArray(data) && data.length > 0) {
- showSearchResults(data, query, resultActiveId);
- } else if (Array.isArray(data)) {
- showNoResults(query, resultActiveId);
+ input.addEventListener("htmx:afterRequest", (evt: Event) => {
+ this.loading = false;
+ const detail = (evt as HtmxAfterRequestEvent).detail;
+ // htmx swaps only 2xx bodies: a failed request closes instead of
+ // swapping an error page into the dropdown.
+ if (detail && detail.successful) {
+ this.open = true;
+ this.selected = -1;
+ this.lastQuery = this.query();
} else {
- showNoResults(query, resultActiveId);
+ this.open = false;
}
- })
- .catch((error) => {
- hideSearchLoading();
- console.error("Search error:", error);
- showSearchError(getActiveSearchInput()?.id);
});
-}
+ },
-function showSearchLoading(activeId?: string): void {
- createSearchResultsContainer(activeId);
- const searchResults = document.getElementById("search-results");
- if (!searchResults) return;
+ query(): string {
+ const input = this.$refs.input as HTMLInputElement | undefined;
+ return input ? input.value.trim() : "";
+ },
- searchResults.innerHTML = `
-
- `;
- searchResults.classList.remove("hidden");
-}
-
-function hideSearchLoading(): void {}
-
-function showSearchResults(results: MediaItemSummary[], query: string, activeId?: string): void {
- createSearchResultsContainer(activeId);
- const searchResults = document.getElementById("search-results");
- if (!searchResults) return;
-
- searchResults.dataset.selectedIndex = "-1";
-
- let html = `
-
-
- ${results.length} result${results.length !== 1 ? "s" : ""} for "${searchEscapeHtml(query)}"
-
-
-
- `;
-
- results.forEach((item, index) => {
- const titleHtml = highlightMatch(item.title, query);
- const authorHtml = item.author ? highlightMatch(item.author, query) : "";
- const coverUrl = item.cover_image_path || "/static/placeholder-book.svg";
-
- html += `
-
- `;
- });
-
- html += `
-
-
-
- Press ↑↓ to navigate,
- Enter to select
-
-
- `;
-
- searchResults.innerHTML = html;
- searchResults.classList.remove("hidden");
-}
-
-function showNoResults(query: string, activeId?: string): void {
- createSearchResultsContainer(activeId);
- const searchResults = document.getElementById("search-results");
- if (!searchResults) return;
-
- searchResults.innerHTML = `
-
-
🔍
-
No results found for "${searchEscapeHtml(query)}"
-
Try different keywords
-
- `;
- searchResults.classList.remove("hidden");
-}
-
-function showSearchError(activeId?: string): void {
- createSearchResultsContainer(activeId);
- const searchResults = document.getElementById("search-results");
- if (!searchResults) return;
-
- searchResults.innerHTML = `
-
-
⚠️
-
Search error
-
Please try again
-
- `;
- searchResults.classList.remove("hidden");
-}
-
-function hideSearchResults(): void {
- const searchResults = document.getElementById("search-results");
- if (searchResults) {
- searchResults.classList.add("hidden");
- }
-}
-
-function createSearchResultsContainer(activeId?: string): void {
- let searchResults = document.getElementById("search-results");
- if (!searchResults) {
- searchResults = document.createElement("div");
- searchResults.id = "search-results";
- searchResults.className =
- "hidden absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border";
- searchResults.style.cssText =
- "background-color: var(--bg-secondary); border-color: var(--border)";
-
- const searchInput = document.getElementById(activeId || "header-search");
- if (searchInput) {
- const searchContainer = searchInput.closest(".relative");
- if (searchContainer) {
- searchContainer.appendChild(searchResults);
- }
+ onInput() {
+ const q = this.query();
+ if (q.length < SEARCH_MIN_CHARS) {
+ this.close();
+ return;
}
- }
-}
+ // htmx owns the request (its `search` trigger carries the 300ms
+ // delay); this side only gates the minimum length. No trigger
+ // filter expressions — CSP has no 'unsafe-eval'.
+ window.htmx.trigger(this.$refs.input as HTMLElement, "search");
+ },
-function highlightMatch(text: string, query: string): string {
- if (!text) return "";
- const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const regex = new RegExp(`(${escapedQuery})`, "gi");
- return searchEscapeHtml(text).replace(
- regex,
- '$1 ',
- );
-}
+ onFocus() {
+ if (this.query().length < SEARCH_MIN_CHARS) return;
+ if (this.lastQuery === this.query() && this.hasResults()) {
+ this.open = true;
+ } else {
+ window.htmx.trigger(this.$refs.input as HTMLElement, "search");
+ }
+ },
-function searchEscapeHtml(text: string): string {
- const div = document.createElement("div");
- div.textContent = text;
- return div.innerHTML;
-}
+ hasResults(): boolean {
+ const results = document.getElementById("search-results");
+ return !!results && results.childElementCount > 0;
+ },
-export { initializeSearch };
+ resultItems(): HTMLElement[] {
+ const results = document.getElementById("search-results");
+ if (!results) return [];
+ return Array.from(results.querySelectorAll(".search-result-item"));
+ },
-Alpine.data("search", () => ({
- initializeSearch,
+ moveSelection(delta: number) {
+ if (!this.open) return;
+ const items = this.resultItems();
+ if (items.length === 0) return;
+ let next = this.selected + delta;
+ if (next < -1) next = -1;
+ if (next >= items.length) next = items.length - 1;
+ this.selected = next;
+ items.forEach((el, i) => {
+ el.style.backgroundColor = i === next ? "var(--surface-hover)" : "";
+ });
+ if (next >= 0) items[next].scrollIntoView({ block: "nearest" });
+ },
+
+ openSelected() {
+ if (!this.open || this.selected < 0) return;
+ const link = this.resultItems()[this.selected]?.querySelector("a");
+ if (link) (link as HTMLAnchorElement).click();
+ },
+
+ close() {
+ this.open = false;
+ this.selected = -1;
+ },
}));
diff --git a/web/src/types/api.d.ts b/web/src/types/api.d.ts
index bd40c9a8..98ce54a3 100644
--- a/web/src/types/api.d.ts
+++ b/web/src/types/api.d.ts
@@ -9,7 +9,8 @@
// Source: internal/database/queries.sql.go:6123-6170 SearchMediaItemsRow
// Endpoint: internal/handlers/media.go:SearchMediaItems()
// Note: internal/handlers/search.go has an unused MediaItemSummary - ignore it
-// Used in: search.ts
+// Used in: search.ts (dropdown content is templ-rendered since the
+// 2026-10-27 search-progress batch; this type documents the JSON API)
interface MediaItemSummary {
id: string;
library_id: string;
@@ -57,6 +58,8 @@ interface MediaItemSummary {
hash_confidence?: string;
library_name: string;
library_type_name: string;
+ /** Requesting user's progress 0..1; null when never opened (2026-10-27). */
+ viewer_percentage?: number | null;
// NEW: Reading direction fields for manga/comics
manga_type?: "unknown" | "no" | "yes" | "yes_and_right_to_left";