From 61e5f47ec0523a76d77494e97ca05f42e6a6a838 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 27 Oct 2026 15:11:49 -0400 Subject: [PATCH] feat(search): viewer progress + completed badges; dropdown to templ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search results now show where you are in a book: a completed check at the 0.999 threshold (same cut as the filter status arms), a partial progress bar along the cover while mid-read, nothing when you never opened it. Rendered in BookCard (any grid whose adapter loads viewer progress) and in the header-search dropdown rows. The progress rides a LEFT JOIN reading_progress on the SAME user_id param SearchMediaItemsUnified already binds for library visibility — one requester param, no new wire inputs, row-neutral join (the table is UNIQUE per media_item+user). SearchMediaItemsUnifiedRow and BookInfo gain the keys additively (viewer_percentage null when unknown, library_name empty on feeds that don't load it); contract_pins.md carries the changelog entry and the pin tests widen in lockstep. The header dropdown itself migrates off the innerHTML string renderer in search.ts (a rendering-ownership violation: a templ twin existed via the HX-Request branch). The input now hx-gets layout=dropdown and swaps the new templ SearchResults partial — titles/authors highlighted server-side (escape-first, safe by construction), rows with the same progress overlays — while a slim Alpine component owns only client-local state (min-length gating, open/close, keyboard selection, htmx lifecycle). web/src/rendering-allowlist.txt drops the search.ts baseline to zero accordingly. Integration test pins the per-user join (fresh user sees 1.0/0.42/null on the fixture; the shared admin sees null everywhere — read-only) and the dropdown partial's overlays/count/row count. --- cmd/server/tests/contract_pins_test.go | 10 + cmd/server/tests/search_progress_test.go | 129 +++++ docs/contract_pins.md | 22 +- internal/database/querier.go | 22 +- internal/database/queries.sql.go | 10 +- internal/database/queries/queries.sql | 8 +- internal/handlers/cards.go | 54 +- internal/router/search.go | 10 +- templates/dashboard.templ | 21 +- templates/dashboard_templ.go | 600 ++++++++++++----------- templates/header.templ | 24 +- templates/header_templ.go | 8 +- templates/search_results.templ | 127 +++++ templates/search_results_templ.go | 313 ++++++++++++ web/src/header.ts | 3 - web/src/rendering-allowlist.txt | 1 - web/src/search.ts | 389 ++++----------- web/src/types/api.d.ts | 5 +- 18 files changed, 1140 insertions(+), 616 deletions(-) create mode 100644 cmd/server/tests/search_progress_test.go create mode 100644 templates/search_results.templ create mode 100644 templates/search_results_templ.go 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 } - var templ_7745c5c3_Var23 string - templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 159, Col: 117} + if item.ViewerPercentage != nil { + if *item.ViewerPercentage >= CompletedProgressThreshold { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("check", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if *item.ViewerPercentage > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 179, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if item.Author != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "

") + 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, 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, " ") + 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, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "
") 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, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if week.CurrentStreak > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "") 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 = ` -
-
-

Searching...

-
- `; - 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";