feat(search): viewer progress + completed badges; dropdown to templ
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.
This commit is contained in:
1 parent
d1f9ad3b25
commit
61e5f47ec0
18 files changed
+1140
-616
No files matched your search
@@ -452,9 +452,13 @@ func TestContractPin_KoboSyncFromServer(t *testing.T) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// bookInfoKeys is the BookInfo JSON every card surface emits.
|
// 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{
|
var bookInfoKeys = []string{
|
||||||
"media_item_id", "title", "author", "cover_image_path",
|
"media_item_id", "title", "author", "cover_image_path",
|
||||||
"has_conflict", "format_group", "badge",
|
"has_conflict", "format_group", "badge",
|
||||||
|
"viewer_percentage", "library_name",
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectionURL(setup *TestServerSetup, id string) string {
|
func collectionURL(setup *TestServerSetup, id string) string {
|
||||||
@@ -667,6 +671,12 @@ func TestContractPin_SearchJSON(t *testing.T) {
|
|||||||
// (docs/contract_pins.md changelog).
|
// (docs/contract_pins.md changelog).
|
||||||
"subtitle", "explicit", "authors", "authors_search",
|
"subtitle", "explicit", "authors", "authors_search",
|
||||||
"publishers", "publishers_search", "narrators_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)
|
url := fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.itemTitle), fx.libraryID)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+20
-2
@@ -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` |
|
| `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[]}` |
|
| | `POST /api/collections/preview` | bare `{items: BookInfo[]}` |
|
||||||
| `SeriesJSON` | `GET /api/series/books?name=&library_id=` | `{name, library_id, books: BookInfo[], total}` |
|
| `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}}` |
|
| `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) |
|
| `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]}` |
|
| | `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 |
|
| `SSRPages` | `/bookshelf`, `/collections`, `/collections/:id`, `/series`, `/series/detail`, `/tags/detail` | 200 + stable title/substring markers |
|
||||||
|
|
||||||
`BookInfo` (every card surface): `media_item_id, title, author,
|
`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)
|
## 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
|
BEFORE Rescan All — force rescan stamps books with the current
|
||||||
setting, so bump-first + force = one pass of work; force-first +
|
setting, so bump-first + force = one pass of work; force-first +
|
||||||
bump = the trickle re-derives everything again (wasted, not harmful).
|
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.
|
||||||
@@ -275,12 +275,24 @@ type Querier interface {
|
|||||||
// Series are grouped PER LIBRARY: the same series name in two libraries
|
// Series are grouped PER LIBRARY: the same series name in two libraries
|
||||||
// (novels vs manga adaptations, LN splits) stays two entries. Visible
|
// (novels vs manga adaptations, LN splits) stays two entries. Visible
|
||||||
// filter enforces per-user library visibility at the source.
|
// 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
|
// Series-page sort/filter contract (extensible registry in
|
||||||
// MAX(created_at). Postgres does not guarantee tie order between
|
// services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
|
||||||
// executions, so page boundaries could repeat or drop series. The GROUP BY
|
// it filters in WHERE (the group key — counts stay true); AuthorQuery
|
||||||
// keys make the sort total and deterministic.
|
// 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)
|
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)
|
GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error)
|
||||||
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
||||||
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
|
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
|
||||||
|
|||||||
@@ -14693,11 +14693,17 @@ SELECT
|
|||||||
mi.opf_uuid,
|
mi.opf_uuid,
|
||||||
mi.hash_confidence,
|
mi.hash_confidence,
|
||||||
l.name as library_name,
|
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
|
FROM media_items mi
|
||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
JOIN library_types lt ON l.library_type_id = lt.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 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
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
@@ -14963,6 +14969,7 @@ type SearchMediaItemsUnifiedRow struct {
|
|||||||
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
||||||
LibraryName string `db:"library_name" json:"library_name"`
|
LibraryName string `db:"library_name" json:"library_name"`
|
||||||
LibraryTypeName string `db:"library_type_name" json:"library_type_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) {
|
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.HashConfidence,
|
||||||
&i.LibraryName,
|
&i.LibraryName,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
|
&i.ViewerPercentage,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -734,11 +734,17 @@ SELECT
|
|||||||
mi.opf_uuid,
|
mi.opf_uuid,
|
||||||
mi.hash_confidence,
|
mi.hash_confidence,
|
||||||
l.name as library_name,
|
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
|
FROM media_items mi
|
||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
JOIN library_types lt ON l.library_type_id = lt.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 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
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
|
|||||||
+39
-15
@@ -13,7 +13,7 @@ import (
|
|||||||
// BookInfo is THE canonical wire+template card shape: every surface that
|
// BookInfo is THE canonical wire+template card shape: every surface that
|
||||||
// lists a book card as JSON (collections detail, series, preview,
|
// lists a book card as JSON (collections detail, series, preview,
|
||||||
// dashboard sections, search) or renders a templ card (BookCard in
|
// 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.
|
// keys and no others — pinned by docs/contract_pins.md.
|
||||||
//
|
//
|
||||||
// H2 doctrine: this struct is constructed in EXACTLY ONE place —
|
// 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 vs manga rides the LIBRARY type (format_group only knows
|
||||||
// comic_archive — manga_type is unreliable across sources).
|
// comic_archive — manga_type is unreliable across sources).
|
||||||
Badge string `json:"badge"`
|
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
|
// 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.
|
// adapters below normalize the sqlc row shapes into cardSource.
|
||||||
func buildBookCard(src cardSource) BookInfo {
|
func buildBookCard(src cardSource) BookInfo {
|
||||||
return BookInfo{
|
return BookInfo{
|
||||||
MediaItemID: src.ID.String(),
|
MediaItemID: src.ID.String(),
|
||||||
Title: src.Title,
|
Title: src.Title,
|
||||||
Author: src.Author,
|
Author: src.Author,
|
||||||
CoverImagePath: utils.ResolveMediaURL(src.LibraryID, src.CoverPath),
|
CoverImagePath: utils.ResolveMediaURL(src.LibraryID, src.CoverPath),
|
||||||
FormatGroup: src.FormatGroup,
|
FormatGroup: src.FormatGroup,
|
||||||
Badge: MediaBadgeKind(src.FormatGroup, src.LibraryType),
|
Badge: MediaBadgeKind(src.FormatGroup, src.LibraryType),
|
||||||
|
ViewerPercentage: src.ViewerProgress,
|
||||||
|
LibraryName: src.LibraryName,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +113,10 @@ type cardSource struct {
|
|||||||
CoverPath pgtype.Text
|
CoverPath pgtype.Text
|
||||||
FormatGroup string
|
FormatGroup string
|
||||||
LibraryType 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
|
// 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
|
// BookCardsFromUnified builds cards from search-unified rows. Those
|
||||||
// rows carry the library type name as a SQL column, so this adapter
|
// 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 {
|
func BookCardsFromUnified(rows []database.SearchMediaItemsUnifiedRow) []BookInfo {
|
||||||
cards := make([]BookInfo, 0, len(rows))
|
cards := make([]BookInfo, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
|
var viewer *float64
|
||||||
|
if r.ViewerPercentage.Valid {
|
||||||
|
pct := r.ViewerPercentage.Float64
|
||||||
|
viewer = &pct
|
||||||
|
}
|
||||||
cards = append(cards, buildBookCard(cardSource{
|
cards = append(cards, buildBookCard(cardSource{
|
||||||
ID: uuid.UUID(r.ID.Bytes),
|
ID: uuid.UUID(r.ID.Bytes),
|
||||||
Title: r.Title,
|
Title: r.Title,
|
||||||
Author: utils.PgTextString(r.Author),
|
Author: utils.PgTextString(r.Author),
|
||||||
LibraryID: r.LibraryID,
|
LibraryID: r.LibraryID,
|
||||||
CoverPath: r.CoverImagePath,
|
CoverPath: r.CoverImagePath,
|
||||||
FormatGroup: r.FormatGroup,
|
FormatGroup: r.FormatGroup,
|
||||||
LibraryType: r.LibraryTypeName,
|
LibraryType: r.LibraryTypeName,
|
||||||
|
ViewerProgress: viewer,
|
||||||
|
LibraryName: r.LibraryName,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
return cards
|
return cards
|
||||||
|
|||||||
@@ -90,9 +90,15 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
|
|||||||
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList)
|
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList)
|
||||||
// Render using BooksGrid template (or BookPickerGrid for collection picker)
|
// Render using BooksGrid template (or BookPickerGrid for collection picker)
|
||||||
var buf bytes.Buffer
|
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)
|
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)
|
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ templ BookCard(item handlers.BookInfo) {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
<!-- Media-type identifier (A2c): books stay unbadged — the
|
<!-- Media-type identifier (A2c): books stay unbadged — the
|
||||||
shelf default already reads as a book -->
|
shelf default already reads as a book -->
|
||||||
if MediaBadgeIcon(item.Badge) != "" {
|
if MediaBadgeIcon(item.Badge) != "" {
|
||||||
<span
|
<span
|
||||||
class="absolute top-2 right-2 z-10 flex items-center justify-center h-7 w-7 rounded-full shadow-sm"
|
class="absolute top-2 right-2 z-10 flex items-center justify-center h-7 w-7 rounded-full shadow-sm"
|
||||||
@@ -154,6 +154,25 @@ templ BookCard(item handlers.BookInfo) {
|
|||||||
@Icon(MediaBadgeIcon(item.Badge), "h-4 w-4")
|
@Icon(MediaBadgeIcon(item.Badge), "h-4 w-4")
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
<!-- Viewer progress (2026-10-27 search-progress batch):
|
||||||
|
completed check top-left, partial bar along the cover
|
||||||
|
bottom. Renders only when the feeding adapter loaded
|
||||||
|
the requester's progress (search today). -->
|
||||||
|
if item.ViewerPercentage != nil {
|
||||||
|
if *item.ViewerPercentage >= CompletedProgressThreshold {
|
||||||
|
<span
|
||||||
|
class="absolute top-2 left-2 z-10 flex items-center justify-center h-7 w-7 rounded-full shadow-sm"
|
||||||
|
style="background-color: color-mix(in srgb, var(--bg-primary) 78%, transparent); color: var(--accent); backdrop-filter: blur(3px);"
|
||||||
|
title="Completed"
|
||||||
|
>
|
||||||
|
@Icon("check", "h-4 w-4")
|
||||||
|
</span>
|
||||||
|
} else if *item.ViewerPercentage > 0 {
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 z-10 h-1" style="background-color: color-mix(in srgb, var(--bg-primary) 55%, transparent);">
|
||||||
|
<div class="h-full" style={ progressFillCSS(*item.ViewerPercentage) }></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="book-card-meta">
|
<div class="book-card-meta">
|
||||||
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
|
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
|
||||||
|
|||||||
+319
-281
@@ -465,7 +465,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<!-- Media-type identifier (A2c): books stay unbadged — the\n\t\t\t\t shelf default already reads as a book -->")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<!-- Media-type identifier (A2c): books stay unbadged — the\n\t\t\t shelf default already reads as a book -->")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -496,100 +496,138 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</div><div class=\"book-card-meta\"><h3 class=\"font-semibold text-sm leading-snug line-clamp-2\" style=\"color: var(--text-primary)\" title=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<!-- Viewer progress (2026-10-27 search-progress batch):\n\t\t\t\t completed check top-left, partial bar along the cover\n\t\t\t\t bottom. Renders only when the feeding adapter loaded\n\t\t\t\t the requester's progress (search today). -->")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var23 string
|
if item.ViewerPercentage != nil {
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
if *item.ViewerPercentage >= CompletedProgressThreshold {
|
||||||
if templ_7745c5c3_Err != nil {
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<span class=\"absolute top-2 left-2 z-10 flex items-center justify-center h-7 w-7 rounded-full shadow-sm\" style=\"background-color: color-mix(in srgb, var(--bg-primary) 78%, transparent); color: var(--accent); backdrop-filter: blur(3px);\" title=\"Completed\">")
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 159, Col: 117}
|
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, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else if *item.ViewerPercentage > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"absolute bottom-0 left-0 right-0 z-10 h-1\" style=\"background-color: color-mix(in srgb, var(--bg-primary) 55%, transparent);\"><div class=\"h-full\" style=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var23 string
|
||||||
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(progressFillCSS(*item.ViewerPercentage))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 172, Col: 74}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\"></div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div><div class=\"book-card-meta\"><h3 class=\"font-semibold text-sm leading-snug line-clamp-2\" style=\"color: var(--text-primary)\" title=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var24 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 160, Col: 17}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 178, Col: 117}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</h3>")
|
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, "</h3>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if item.Author != "" {
|
if item.Author != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<p class=\"text-xs mt-0.5 line-clamp-1\" style=\"color: var(--text-secondary)\" title=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<p class=\"text-xs mt-0.5 line-clamp-1\" style=\"color: var(--text-secondary)\" title=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var25 string
|
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 163, Col: 100}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var26 string
|
var templ_7745c5c3_Var26 string
|
||||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 164, Col: 19}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 182, Col: 100}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
var templ_7745c5c3_Var27 string
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div></a><div class=\"book-card-action\">")
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if item.HasConflict {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<a href=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 183, Col: 19}
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var27 templ.SafeURL
|
|
||||||
templ_7745c5c3_Var27, 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: 172, Col: 40}
|
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" class=\"book-card-action-btn\" aria-label=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var28 string
|
}
|
||||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("Resolve progress conflict for " + item.Title)
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</div></a><div class=\"book-card-action\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 174, Col: 63}
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
if item.HasConflict {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" title=\"Resolve progress conflict\">")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -597,38 +635,38 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else if item.FormatGroup == "audiobook" {
|
} else if item.FormatGroup == "audiobook" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var29 templ.SafeURL
|
var templ_7745c5c3_Var30 templ.SafeURL
|
||||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 181, Col: 42}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 200, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" class=\"book-card-action-btn\" aria-label=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" class=\"book-card-action-btn\" aria-label=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var30 string
|
var templ_7745c5c3_Var31 string
|
||||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("Listen to " + item.Title)
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue("Listen to " + item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 183, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 202, Col: 43}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" title=\"Listen\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" title=\"Listen\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -636,38 +674,38 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var31 templ.SafeURL
|
var templ_7745c5c3_Var32 templ.SafeURL
|
||||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
|
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 190, Col: 42}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 209, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" class=\"book-card-action-btn\" aria-label=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" class=\"book-card-action-btn\" aria-label=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var32 string
|
var templ_7745c5c3_Var33 string
|
||||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue("Read " + item.Title)
|
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue("Read " + item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 192, Col: 38}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 211, Col: 38}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" title=\"Read\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\" title=\"Read\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -675,12 +713,12 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -704,12 +742,12 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var33 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var34 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var33 == nil {
|
if templ_7745c5c3_Var34 == nil {
|
||||||
templ_7745c5c3_Var33 = templ.NopComponent
|
templ_7745c5c3_Var34 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<div id=\"dashboard-settings-modal\" class=\"hidden fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-2xl shadow-2xl\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Customize Dashboard</h2><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">Drag to reorder, toggle to show or hide.</p><p class=\"text-xs mt-1 font-medium\" id=\"dashboard-scope-label\" style=\"color: var(--accent)\"></p></div><button data-action=\"close-dashboard-settings\" class=\"icon-btn\" aria-label=\"Close\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<div id=\"dashboard-settings-modal\" class=\"hidden fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-2xl shadow-2xl\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Customize Dashboard</h2><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">Drag to reorder, toggle to show or hide.</p><p class=\"text-xs mt-1 font-medium\" id=\"dashboard-scope-label\" style=\"color: var(--accent)\"></p></div><button data-action=\"close-dashboard-settings\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -717,38 +755,38 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</button></div><div id=\"collection-list\" class=\"space-y-2 mb-6\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</button></div><div id=\"collection-list\" class=\"space-y-2 mb-6\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, section := range sections {
|
for _, section := range sections {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div class=\"collection-item flex items-center justify-between p-3 rounded-xl cursor-move select-none card\" data-collection-id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<div class=\"collection-item flex items-center justify-between p-3 rounded-xl cursor-move select-none card\" data-collection-id=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var34 string
|
|
||||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 223, Col: 37}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" data-is-system=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var35 string
|
var templ_7745c5c3_Var35 string
|
||||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
|
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 224, Col: 58}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 242, Col: 37}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" draggable=\"true\"><div class=\"flex items-center gap-3\">")
|
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\"><div class=\"flex items-center gap-3\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -756,120 +794,120 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<span class=\"text-xl\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span class=\"text-xl\">")
|
||||||
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, "</span><div class=\"flex items-center gap-2\"><span class=\"font-medium\" style=\"color: var(--text-primary)\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var37 string
|
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 {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span><div class=\"flex items-center gap-2\"><span class=\"font-medium\" style=\"color: var(--text-primary)\">")
|
||||||
|
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, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if section.IsSystem {
|
if section.IsSystem {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">System</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">System</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</div></div><div class=\"flex items-center gap-3\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</div></div><div class=\"flex items-center gap-3\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if section.IsSystem {
|
if section.IsSystem {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<button data-action=\"restore-system-collection\" data-collection-name=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<button data-action=\"restore-system-collection\" data-collection-name=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var38 string
|
|
||||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 241, Col: 42}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" class=\"btn btn-secondary py-1 px-2.5 text-xs\" title=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var39 string
|
var templ_7745c5c3_Var39 string
|
||||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue("Restore " + section.Title + " to defaults")
|
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 243, Col: 60}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 260, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\">Restore</button> ")
|
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</button> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<label class=\"relative inline-flex items-center cursor-pointer\"><input type=\"checkbox\" class=\"sr-only peer\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<label class=\"relative inline-flex items-center cursor-pointer\"><input type=\"checkbox\" class=\"sr-only peer\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if !ContainsString(hiddenCollections, section.ID) {
|
if !ContainsString(hiddenCollections, section.ID) {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, " checked")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, " checked")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "><div class=\"w-11 h-6 rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all\" style=\"background-color: var(--border-strong);\"></div></label></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "><div class=\"w-11 h-6 rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all\" style=\"background-color: var(--border-strong);\"></div></label></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</div><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Items per Collection: <span id=\"items-count-display\" class=\"font-bold\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</div><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Items per Collection: <span id=\"items-count-display\" class=\"font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
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, "</span></label> <input type=\"range\" min=\"10\" max=\"50\" step=\"5\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var41 string
|
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 {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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\"></div><div class=\"flex justify-end gap-3\"><button data-action=\"close-dashboard-settings\" class=\"btn btn-secondary\">Cancel</button> <button data-action=\"save-dashboard-settings\" class=\"btn btn-primary\">Save Changes</button></div></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span></label> <input type=\"range\" min=\"10\" max=\"50\" step=\"5\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var42 string
|
||||||
|
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 293, Col: 28}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" 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\"></div><div class=\"flex justify-end gap-3\"><button data-action=\"close-dashboard-settings\" class=\"btn btn-secondary\">Cancel</button> <button data-action=\"save-dashboard-settings\" class=\"btn btn-primary\">Save Changes</button></div></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -945,12 +983,12 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var42 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var43 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var42 == nil {
|
if templ_7745c5c3_Var43 == nil {
|
||||||
templ_7745c5c3_Var42 = templ.NopComponent
|
templ_7745c5c3_Var43 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<details id=\"week-widget\" class=\"group sm:relative\" hx-get=\"/dashboard/week-widget\" hx-trigger=\"week-refresh from:body\" hx-include=\"#library-select\" hx-swap=\"outerHTML\"><summary class=\"flex items-center gap-2 cursor-pointer select-none list-none rounded-lg px-2 py-1 hover:bg-surface-hover [&::-webkit-details-marker]:hidden\" aria-label=\"Reading stats for this week\"><span class=\"grid place-items-center h-6 w-6 rounded-md shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<details id=\"week-widget\" class=\"group sm:relative\" hx-get=\"/dashboard/week-widget\" hx-trigger=\"week-refresh from:body\" hx-include=\"#library-select\" hx-swap=\"outerHTML\"><summary class=\"flex items-center gap-2 cursor-pointer select-none list-none rounded-lg px-2 py-1 hover:bg-surface-hover [&::-webkit-details-marker]:hidden\" aria-label=\"Reading stats for this week\"><span class=\"grid place-items-center h-6 w-6 rounded-md shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -958,77 +996,77 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</span> <span class=\"text-sm font-bold tracking-tight whitespace-nowrap hidden sm:inline\" style=\"color: var(--text-primary)\">This Week</span> <span class=\"text-sm font-semibold whitespace-nowrap hidden sm:inline\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</span> <span class=\"text-sm font-bold tracking-tight whitespace-nowrap hidden sm:inline\" style=\"color: var(--text-primary)\">This Week</span> <span class=\"text-sm font-semibold whitespace-nowrap hidden sm:inline\" style=\"color: var(--text-primary)\">")
|
||||||
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, "</span> <span class=\"text-xs whitespace-nowrap hidden md:inline\" style=\"color: var(--text-secondary)\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var44 string
|
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 {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span> <span class=\"text-xs whitespace-nowrap hidden lg:inline\" style=\"color: var(--text-secondary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</span> <span class=\"text-xs whitespace-nowrap hidden md:inline\" style=\"color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var45 string
|
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 {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</span> <span class=\"text-xs whitespace-nowrap hidden xl:inline\" style=\"color: var(--text-secondary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</span> <span class=\"text-xs whitespace-nowrap hidden lg:inline\" style=\"color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var46 string
|
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 {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</span> <span class=\"text-xs whitespace-nowrap hidden xl:inline\" style=\"color: var(--text-secondary)\">")
|
||||||
|
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, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if week.CurrentStreak > 0 {
|
if week.CurrentStreak > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<span class=\"text-xs font-medium whitespace-nowrap hidden lg:inline\" style=\"color: var(--accent)\">🔥 ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<span class=\"text-xs font-medium whitespace-nowrap hidden lg:inline\" style=\"color: var(--accent)\">🔥 ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var47 string
|
var templ_7745c5c3_Var48 string
|
||||||
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.CurrentStreak))
|
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.CurrentStreak))
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "-day streak</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "-day streak</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -1037,166 +1075,166 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</summary><div class=\"card absolute top-full left-4 right-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 mt-2 sm:w-[30rem] max-w-[calc(100vw-2rem)] z-40 p-4 md:px-6 md:py-5\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"grid grid-cols-2 lg:grid-cols-4 gap-4\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</summary><div class=\"card absolute top-full left-4 right-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 mt-2 sm:w-[30rem] max-w-[calc(100vw-2rem)] z-40 p-4 md:px-6 md:py-5\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"grid grid-cols-2 lg:grid-cols-4 gap-4\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if activeLibType == "audiobooks" {
|
if activeLibType == "audiobooks" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<div><p class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Listening Time</p><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<div><p class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Listening Time</p><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
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, "</p><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var49 string
|
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 {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</p></div><div><p class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Sessions</p><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
|
||||||
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, "</p></div><div><p class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Books Touched</p><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
|
||||||
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, "</p></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<div><p class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Reading Time</p><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
|
||||||
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, "</p><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</p><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var53 string
|
var templ_7745c5c3_Var50 string
|
||||||
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes))
|
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes))
|
||||||
if templ_7745c5c3_Err != nil {
|
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, "</p></div><div><p class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Sessions</p><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
|
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, "</p></div><div><p class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Books Touched</p><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
|
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, "</p></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<div><p class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Reading Time</p><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</p></div><div><p class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Pages</p><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</p><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var54 string
|
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 {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</p></div><div><p class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Sessions</p><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</p></div><div><p class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Pages</p><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var55 string
|
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 {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</p></div><div><p class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Books Touched</p><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "</p></div><div><p class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Sessions</p><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var56 string
|
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 {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "</p></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "</p></div><div><p class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Books Touched</p><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
|
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, "</p></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</div><div class=\"flex items-end justify-between gap-4 mt-3 pt-3 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);\"><p class=\"text-xs\" style=\"color: var(--text-secondary)\">Synced from your reading devices ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "</div><div class=\"flex items-end justify-between gap-4 mt-3 pt-3 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);\"><p class=\"text-xs\" style=\"color: var(--text-secondary)\">Synced from your reading devices ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if week.CurrentStreak > 0 {
|
if week.CurrentStreak > 0 {
|
||||||
if week.ActiveToday {
|
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
|
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 {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, "</p><a href=\"/stats\" class=\"inline-flex items-center gap-1 text-xs font-medium hover:underline shrink-0 whitespace-nowrap\" style=\"color: var(--accent);\">Full stats")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "</p><a href=\"/stats\" class=\"inline-flex items-center gap-1 text-xs font-medium hover:underline shrink-0 whitespace-nowrap\" style=\"color: var(--accent);\">Full stats")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -1204,7 +1242,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "</a></div></div></details>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</a></div></div></details>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -224,17 +224,39 @@ templ Header(user User, currentPath string) {
|
|||||||
>
|
>
|
||||||
@Icon("menu", "h-5 w-5")
|
@Icon("menu", "h-5 w-5")
|
||||||
</button>
|
</button>
|
||||||
<div class="relative flex-1 max-w-xl">
|
<div class="relative flex-1 max-w-xl" x-data="headerSearch" @click.outside="close()">
|
||||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
|
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
|
||||||
@Icon("search", "h-4 w-4")
|
@Icon("search", "h-4 w-4")
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="header-search"
|
id="header-search"
|
||||||
|
name="q"
|
||||||
placeholder="Search your library…"
|
placeholder="Search your library…"
|
||||||
class="input pl-10"
|
class="input pl-10"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
x-ref="input"
|
||||||
|
role="combobox"
|
||||||
|
:aria-expanded="open ? 'true' : 'false'"
|
||||||
|
aria-label="Search your library"
|
||||||
|
@input="onInput()"
|
||||||
|
@focus="onFocus()"
|
||||||
|
@keydown.arrow-down.prevent="moveSelection(1)"
|
||||||
|
@keydown.arrow-up.prevent="moveSelection(-1)"
|
||||||
|
@keydown.enter.prevent="openSelected()"
|
||||||
|
@keydown.escape="close()"
|
||||||
|
hx-get="/api/media-items/search?layout=dropdown"
|
||||||
|
hx-trigger="search delay:300ms"
|
||||||
|
hx-target="#search-results"
|
||||||
|
hx-swap="innerHTML"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
id="search-results"
|
||||||
|
x-show="open"
|
||||||
|
x-cloak
|
||||||
|
class="absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border overflow-hidden"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border)"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -717,7 +717,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</button><div class=\"relative flex-1 max-w-xl\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</button><div class=\"relative flex-1 max-w-xl\" x-data=\"headerSearch\" @click.outside=\"close()\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -725,7 +725,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span> <input type=\"text\" id=\"header-search\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\"></div></div></header><!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL) --><script type=\"module\" src=\"/static/main.js?v=7\"></script></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span> <input type=\"text\" id=\"header-search\" name=\"q\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\" x-ref=\"input\" role=\"combobox\" :aria-expanded=\"open ? 'true' : 'false'\" aria-label=\"Search your library\" @input=\"onInput()\" @focus=\"onFocus()\" @keydown.arrow-down.prevent=\"moveSelection(1)\" @keydown.arrow-up.prevent=\"moveSelection(-1)\" @keydown.enter.prevent=\"openSelected()\" @keydown.escape=\"close()\" hx-get=\"/api/media-items/search?layout=dropdown\" hx-trigger=\"search delay:300ms\" hx-target=\"#search-results\" hx-swap=\"innerHTML\"><div id=\"search-results\" x-show=\"open\" x-cloak class=\"absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border overflow-hidden\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"></div></div></div></header><!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL) --><script type=\"module\" src=\"/static/main.js?v=7\"></script></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -770,7 +770,7 @@ func SidebarUserMenu(user User) templ.Component {
|
|||||||
var templ_7745c5c3_Var43 string
|
var templ_7745c5c3_Var43 string
|
||||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -854,7 +854,7 @@ func SidebarSignIn(currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var45 string
|
var templ_7745c5c3_Var45 string
|
||||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -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 <mark> — 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 := `<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">`
|
||||||
|
return pattern.ReplaceAllString(html.EscapeString(text), markOpen+"$0</mark>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
<div class="p-3 border-b" style="border-color: var(--border)">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">
|
||||||
|
{ resultCountLabel(len(items), query) }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
if len(items) == 0 {
|
||||||
|
<div class="p-4 text-center">
|
||||||
|
<div class="text-4xl mb-2">🔍</div>
|
||||||
|
<p class="text-sm" style="color: var(--text-primary)">No results found</p>
|
||||||
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Try different keywords</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="max-h-96 overflow-y-auto">
|
||||||
|
for _, item := range items {
|
||||||
|
@SearchResultRow(item, query)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="p-2 border-t text-center" style="border-color: var(--border)">
|
||||||
|
<p class="text-xs" style="color: var(--text-secondary)">
|
||||||
|
Press <kbd class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary)">↑↓</kbd> to navigate,
|
||||||
|
<kbd class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary)">Enter</kbd> to select
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ SearchResultRow(item handlers.BookInfo, query string) {
|
||||||
|
<div class="search-result-item p-3 border-b transition-colors cursor-pointer" style="border-color: var(--border)">
|
||||||
|
<a href={ "/media/" + item.MediaItemID } class="block">
|
||||||
|
<div class="flex items-start space-x-3">
|
||||||
|
<div class="relative w-10 h-14 flex-shrink-0 rounded overflow-hidden bg-gradient-to-br from-gray-700 to-gray-900">
|
||||||
|
<img src={ searchCoverURL(item) } alt={ item.Title } class="w-full h-full object-cover" loading="lazy"/>
|
||||||
|
@SearchProgressOverlay(item)
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="text-sm font-medium truncate" style="color: var(--text-primary)">@templ.Raw(highlightQueryHTML(item.Title, query))</h4>
|
||||||
|
if item.Author != "" {
|
||||||
|
<p class="text-xs truncate" style="color: var(--text-secondary)">@templ.Raw(highlightQueryHTML(item.Author, query))</p>
|
||||||
|
}
|
||||||
|
if item.LibraryName != "" {
|
||||||
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">{ item.LibraryName }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 z-10 flex items-center justify-center h-4 w-4 rounded-full shadow-sm"
|
||||||
|
style="background-color: var(--accent); color: var(--bg-primary);"
|
||||||
|
title="Completed"
|
||||||
|
>
|
||||||
|
@Icon("check", "h-3 w-3")
|
||||||
|
</span>
|
||||||
|
} else if *item.ViewerPercentage > 0 {
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 z-10 h-1" style="background-color: color-mix(in srgb, var(--bg-primary) 60%, transparent);">
|
||||||
|
<div class="h-full" style={ progressFillCSS(*item.ViewerPercentage) }></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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 <mark> — 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 := `<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">`
|
||||||
|
return pattern.ReplaceAllString(html.EscapeString(text), markOpen+"$0</mark>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, "<div class=\"p-3 border-b\" style=\"border-color: var(--border)\"><p class=\"text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">")
|
||||||
|
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, "</p></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"p-4 text-center\"><div class=\"text-4xl mb-2\">🔍</div><p class=\"text-sm\" style=\"color: var(--text-primary)\">No results found</p><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Try different keywords</p></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"max-h-96 overflow-y-auto\">")
|
||||||
|
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, "</div><div class=\"p-2 border-t text-center\" style=\"border-color: var(--border)\"><p class=\"text-xs\" style=\"color: var(--text-secondary)\">Press <kbd class=\"px-1 py-0.5 rounded\" style=\"background-color: var(--bg-primary)\">↑↓</kbd> to navigate, <kbd class=\"px-1 py-0.5 rounded\" style=\"background-color: var(--bg-primary)\">Enter</kbd> to select</p></div>")
|
||||||
|
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, "<div class=\"search-result-item p-3 border-b transition-colors cursor-pointer\" style=\"border-color: var(--border)\"><a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/search_results.templ`, Line: 80, Col: 40}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" class=\"block\"><div class=\"flex items-start space-x-3\"><div class=\"relative w-10 h-14 flex-shrink-0 rounded overflow-hidden bg-gradient-to-br from-gray-700 to-gray-900\"><img src=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(searchCoverURL(item))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/search_results.templ`, Line: 83, Col: 36}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" alt=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 string
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/search_results.templ`, Line: 83, Col: 55}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" class=\"w-full h-full object-cover\" loading=\"lazy\">")
|
||||||
|
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, "</div><div class=\"flex-1 min-w-0\"><h4 class=\"text-sm font-medium truncate\" style=\"color: var(--text-primary)\">")
|
||||||
|
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, "</h4>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if item.Author != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<p class=\"text-xs truncate\" style=\"color: var(--text-secondary)\">")
|
||||||
|
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, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item.LibraryName != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">")
|
||||||
|
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, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div></a></div>")
|
||||||
|
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, "<span class=\"absolute top-0.5 left-0.5 z-10 flex items-center justify-center h-4 w-4 rounded-full shadow-sm\" style=\"background-color: var(--accent); color: var(--bg-primary);\" title=\"Completed\">")
|
||||||
|
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, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else if *item.ViewerPercentage > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"absolute bottom-0 left-0 right-0 z-10 h-1\" style=\"background-color: color-mix(in srgb, var(--bg-primary) 60%, transparent);\"><div class=\"h-full\" style=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(progressFillCSS(*item.ViewerPercentage))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/search_results.templ`, Line: 116, Col: 71}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"></div></div>")
|
||||||
|
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
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Alpine } from "./alpine";
|
import { Alpine } from "./alpine";
|
||||||
import { initializeSearch } from "./search";
|
|
||||||
import {
|
import {
|
||||||
initializeTheme,
|
initializeTheme,
|
||||||
changeTheme,
|
changeTheme,
|
||||||
@@ -137,7 +136,6 @@ Alpine.data("header", () => ({
|
|||||||
} catch {
|
} catch {
|
||||||
this.openPanel = window.location.pathname.startsWith("/admin") ? "admin" : null;
|
this.openPanel = window.location.pathname.startsWith("/admin") ? "admin" : null;
|
||||||
}
|
}
|
||||||
initializeSearch();
|
|
||||||
this.initializeTheme();
|
this.initializeTheme();
|
||||||
this.loadWoodPaneling();
|
this.loadWoodPaneling();
|
||||||
this.updateWoodPanelingIndicators();
|
this.updateWoodPanelingIndicators();
|
||||||
@@ -176,7 +174,6 @@ Alpine.data("header", () => ({
|
|||||||
this.openPanel = null;
|
this.openPanel = null;
|
||||||
},
|
},
|
||||||
logout,
|
logout,
|
||||||
initializeSearch,
|
|
||||||
initializeTheme,
|
initializeTheme,
|
||||||
changeTheme,
|
changeTheme,
|
||||||
changeWoodPaneling,
|
changeWoodPaneling,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ web/src/markdown.ts:1
|
|||||||
web/src/dom.ts:1
|
web/src/dom.ts:1
|
||||||
web/src/password_validation.ts:1
|
web/src/password_validation.ts:1
|
||||||
web/src/queue.ts:3
|
web/src/queue.ts:3
|
||||||
web/src/search.ts:4
|
|
||||||
web/src/setup.ts:1
|
web/src/setup.ts:1
|
||||||
web/src/stats.ts:9
|
web/src/stats.ts:9
|
||||||
web/src/toast.ts:2
|
web/src/toast.ts:2
|
||||||
|
|||||||
+91
-298
@@ -1,315 +1,108 @@
|
|||||||
import { Alpine } from "./alpine";
|
import { Alpine } from "./alpine";
|
||||||
|
|
||||||
let searchInputTimeout: ReturnType<typeof setTimeout> | null = null;
|
// Rendering ownership (AGENTS.md): the dropdown's CONTENT is templ —
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
// 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;
|
const SEARCH_MIN_CHARS = 2;
|
||||||
|
|
||||||
function initializeSearch(): void {
|
interface HtmxAfterRequestEvent extends CustomEvent {
|
||||||
const searchInput = document.getElementById("header-search") as HTMLInputElement | null;
|
detail: { successful: boolean };
|
||||||
const mobileSearchInput = document.getElementById(
|
}
|
||||||
"header-search-mobile",
|
|
||||||
) as HTMLInputElement | null;
|
|
||||||
|
|
||||||
const wireInput = (input: HTMLInputElement) => {
|
Alpine.data("headerSearch", () => ({
|
||||||
input.addEventListener("input", handleSearchInput);
|
open: false,
|
||||||
input.addEventListener("keydown", handleSearchKeydown);
|
loading: false,
|
||||||
input.addEventListener("focus", () => {
|
selected: -1,
|
||||||
if (input.value.length >= SEARCH_MIN_CHARS) {
|
lastQuery: "",
|
||||||
performSearch(input.value);
|
|
||||||
}
|
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;
|
||||||
});
|
});
|
||||||
};
|
input.addEventListener("htmx:afterRequest", (evt: Event) => {
|
||||||
|
this.loading = false;
|
||||||
if (searchInput) wireInput(searchInput);
|
const detail = (evt as HtmxAfterRequestEvent).detail;
|
||||||
if (mobileSearchInput) wireInput(mobileSearchInput);
|
// htmx swaps only 2xx bodies: a failed request closes instead of
|
||||||
|
// swapping an error page into the dropdown.
|
||||||
if (!searchInput && !mobileSearchInput) {
|
if (detail && detail.successful) {
|
||||||
console.warn("Search input not found");
|
this.open = true;
|
||||||
}
|
this.selected = -1;
|
||||||
|
this.lastQuery = this.query();
|
||||||
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<Element>, 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);
|
|
||||||
} else {
|
} else {
|
||||||
showNoResults(query, resultActiveId);
|
this.open = false;
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
hideSearchLoading();
|
|
||||||
console.error("Search error:", error);
|
|
||||||
showSearchError(getActiveSearchInput()?.id);
|
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
|
||||||
function showSearchLoading(activeId?: string): void {
|
query(): string {
|
||||||
createSearchResultsContainer(activeId);
|
const input = this.$refs.input as HTMLInputElement | undefined;
|
||||||
const searchResults = document.getElementById("search-results");
|
return input ? input.value.trim() : "";
|
||||||
if (!searchResults) return;
|
},
|
||||||
|
|
||||||
searchResults.innerHTML = `
|
onInput() {
|
||||||
<div class="p-4 text-center" style="color: var(--text-secondary)">
|
const q = this.query();
|
||||||
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2" style="border-color: var(--accent)"></div>
|
if (q.length < SEARCH_MIN_CHARS) {
|
||||||
<p class="mt-2 text-sm">Searching...</p>
|
this.close();
|
||||||
</div>
|
return;
|
||||||
`;
|
|
||||||
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 = `
|
|
||||||
<div class="p-3 border-b" style="border-color: var(--border)">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">
|
|
||||||
${results.length} result${results.length !== 1 ? "s" : ""} for "${searchEscapeHtml(query)}"
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="max-h-96 overflow-y-auto">
|
|
||||||
`;
|
|
||||||
|
|
||||||
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 += `
|
|
||||||
<div class="search-result-item p-3 border-b hover:bg-opacity-50 transition-colors cursor-pointer"
|
|
||||||
style="border-color: var(--border); background-color: var(--bg-secondary)"
|
|
||||||
data-index="${index}">
|
|
||||||
<a href="/media/${item.id}" class="block">
|
|
||||||
<div class="flex items-start space-x-3">
|
|
||||||
<div class="w-10 h-14 flex-shrink-0 rounded overflow-hidden bg-gradient-to-br from-gray-700 to-gray-900">
|
|
||||||
<img src="${coverUrl}"
|
|
||||||
alt="${searchEscapeHtml(item.title)}"
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
loading="lazy"
|
|
||||||
onerror="this.src='/static/placeholder-book.svg'">
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<h4 class="text-sm font-medium truncate" style="color: var(--text-primary)">
|
|
||||||
${titleHtml}
|
|
||||||
</h4>
|
|
||||||
${authorHtml ? `<p class="text-xs truncate" style="color: var(--text-secondary)">${authorHtml}</p>` : ""}
|
|
||||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">
|
|
||||||
${searchEscapeHtml(item.library_name)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
html += `
|
|
||||||
</div>
|
|
||||||
<div class="p-2 border-t text-center" style="border-color: var(--border)">
|
|
||||||
<p class="text-xs" style="color: var(--text-secondary)">
|
|
||||||
Press <kbd class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary)">↑↓</kbd> to navigate,
|
|
||||||
<kbd class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary)">Enter</kbd> to select
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
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 = `
|
|
||||||
<div class="p-4 text-center">
|
|
||||||
<div class="text-4xl mb-2">🔍</div>
|
|
||||||
<p class="text-sm" style="color: var(--text-primary)">No results found for "${searchEscapeHtml(query)}"</p>
|
|
||||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Try different keywords</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
searchResults.classList.remove("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
function showSearchError(activeId?: string): void {
|
|
||||||
createSearchResultsContainer(activeId);
|
|
||||||
const searchResults = document.getElementById("search-results");
|
|
||||||
if (!searchResults) return;
|
|
||||||
|
|
||||||
searchResults.innerHTML = `
|
|
||||||
<div class="p-4 text-center">
|
|
||||||
<div class="text-4xl mb-2">⚠️</div>
|
|
||||||
<p class="text-sm" style="color: var(--text-primary)">Search error</p>
|
|
||||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Please try again</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
// 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 {
|
onFocus() {
|
||||||
if (!text) return "";
|
if (this.query().length < SEARCH_MIN_CHARS) return;
|
||||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
if (this.lastQuery === this.query() && this.hasResults()) {
|
||||||
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
this.open = true;
|
||||||
return searchEscapeHtml(text).replace(
|
} else {
|
||||||
regex,
|
window.htmx.trigger(this.$refs.input as HTMLElement, "search");
|
||||||
'<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">$1</mark>',
|
}
|
||||||
);
|
},
|
||||||
}
|
|
||||||
|
|
||||||
function searchEscapeHtml(text: string): string {
|
hasResults(): boolean {
|
||||||
const div = document.createElement("div");
|
const results = document.getElementById("search-results");
|
||||||
div.textContent = text;
|
return !!results && results.childElementCount > 0;
|
||||||
return div.innerHTML;
|
},
|
||||||
}
|
|
||||||
|
|
||||||
export { initializeSearch };
|
resultItems(): HTMLElement[] {
|
||||||
|
const results = document.getElementById("search-results");
|
||||||
|
if (!results) return [];
|
||||||
|
return Array.from(results.querySelectorAll<HTMLElement>(".search-result-item"));
|
||||||
|
},
|
||||||
|
|
||||||
Alpine.data("search", () => ({
|
moveSelection(delta: number) {
|
||||||
initializeSearch,
|
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;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
Vendored
+4
-1
@@ -9,7 +9,8 @@
|
|||||||
// Source: internal/database/queries.sql.go:6123-6170 SearchMediaItemsRow
|
// Source: internal/database/queries.sql.go:6123-6170 SearchMediaItemsRow
|
||||||
// Endpoint: internal/handlers/media.go:SearchMediaItems()
|
// Endpoint: internal/handlers/media.go:SearchMediaItems()
|
||||||
// Note: internal/handlers/search.go has an unused MediaItemSummary - ignore it
|
// 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 {
|
interface MediaItemSummary {
|
||||||
id: string;
|
id: string;
|
||||||
library_id: string;
|
library_id: string;
|
||||||
@@ -57,6 +58,8 @@ interface MediaItemSummary {
|
|||||||
hash_confidence?: string;
|
hash_confidence?: string;
|
||||||
library_name: string;
|
library_name: string;
|
||||||
library_type_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
|
// NEW: Reading direction fields for manga/comics
|
||||||
manga_type?: "unknown" | "no" | "yes" | "yes_and_right_to_left";
|
manga_type?: "unknown" | "no" | "yes" | "yes_and_right_to_left";
|
||||||
|
|||||||
Reference in new issue
Block a user