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
+1126
-602
No files matched your search
@@ -452,9 +452,13 @@ func TestContractPin_KoboSyncFromServer(t *testing.T) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// bookInfoKeys is the BookInfo JSON every card surface emits.
|
||||
// 2026-10-27 (search-progress batch): + viewer_percentage (nil = no
|
||||
// viewer data; only the search-unified adapter populates it today)
|
||||
// and + library_name (same deal). docs/contract_pins.md changelog.
|
||||
var bookInfoKeys = []string{
|
||||
"media_item_id", "title", "author", "cover_image_path",
|
||||
"has_conflict", "format_group", "badge",
|
||||
"viewer_percentage", "library_name",
|
||||
}
|
||||
|
||||
func collectionURL(setup *TestServerSetup, id string) string {
|
||||
@@ -667,6 +671,12 @@ func TestContractPin_SearchJSON(t *testing.T) {
|
||||
// (docs/contract_pins.md changelog).
|
||||
"subtitle", "explicit", "authors", "authors_search",
|
||||
"publishers", "publishers_search", "narrators_search",
|
||||
// 2026-10-27 (search-progress batch): + viewer_percentage —
|
||||
// the requester's reading_progress.percentage (LEFT JOIN on
|
||||
// the same user_id param the visibility join binds), null
|
||||
// when they never opened the book (docs/contract_pins.md
|
||||
// changelog).
|
||||
"viewer_percentage",
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.itemTitle), fx.libraryID)
|
||||
|
||||
@@ -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` |
|
||||
| | `POST /api/collections/preview` | bare `{items: BookInfo[]}` |
|
||||
| `SeriesJSON` | `GET /api/series/books?name=&library_id=` | `{name, library_id, books: BookInfo[], total}` |
|
||||
| `SearchJSON` | `GET /api/media-items/search?q=&library_id=` | top-level array of `SearchMediaItemsUnifiedRow` (47 keys) |
|
||||
| `SearchJSON` | `GET /api/media-items/search?q=&library_id=` | top-level array of `SearchMediaItemsUnifiedRow` (47 keys + viewer_percentage) |
|
||||
| `DashboardSections` | `GET /api/dashboard/sections` | `{sections[]: {id, collection_id, is_system, title, description, icon, items: BookInfo[], view_all_url, priority}}` |
|
||||
| `MediaListBothBranches` | `GET /api/media-items` (unscoped) | `{data: [38-key resolved map]}` (intentionally divergent from the scoped branch until the row-shape collapse batch) |
|
||||
| | `GET /api/media-items?library_id=&sort=` | `{data: [raw ListMediaItemsSortedRow, 70 sqlc keys]}` |
|
||||
| `SSRPages` | `/bookshelf`, `/collections`, `/collections/:id`, `/series`, `/series/detail`, `/tags/detail` | 200 + stable title/substring markers |
|
||||
|
||||
`BookInfo` (every card surface): `media_item_id, title, author,
|
||||
cover_image_path, has_conflict, format_group, badge`.
|
||||
cover_image_path, has_conflict, format_group, badge, viewer_percentage,
|
||||
library_name`.
|
||||
|
||||
## Changelog (additive keys per batch)
|
||||
|
||||
@@ -414,3 +415,20 @@ cover_image_path, has_conflict, format_group, badge`.
|
||||
BEFORE Rescan All — force rescan stamps books with the current
|
||||
setting, so bump-first + force = one pass of work; force-first +
|
||||
bump = the trickle re-derives everything again (wasted, not harmful).
|
||||
|
||||
## 2026-10-27 — Search-progress batch: viewer progress on cards
|
||||
- **`SearchJSON`**: `SearchMediaItemsUnifiedRow` + `viewer_percentage`
|
||||
(float 0..1, NULL when the requester never opened the book). The
|
||||
query LEFT JOINs `reading_progress` on the SAME `user_id` param the
|
||||
library-visibility join already binds — one requester param, no new
|
||||
wire inputs; the join is row-neutral (UNIQUE(media_item, user)).
|
||||
- **`BookInfo`** (CollectionsJSON / SeriesJSON / preview /
|
||||
DashboardSections / search cards): + `viewer_percentage` (JSON null
|
||||
when nil) and + `library_name` (empty on feeds that don't load it —
|
||||
only the search-unified adapter populates both today). Adapters
|
||||
other than BookCardsFromUnified keep emitting null/""; templ cards
|
||||
render the progress bar/completed check only when populated.
|
||||
- The header-search dropdown now renders the templ `SearchResults`
|
||||
partial (layout=dropdown on the same endpoint, HX-Request) instead
|
||||
of the retired innerHTML renderer in web/src/search.ts — same
|
||||
endpoint, same auth, content server-rendered.
|
||||
@@ -275,12 +275,24 @@ type Querier interface {
|
||||
// Series are grouped PER LIBRARY: the same series name in two libraries
|
||||
// (novels vs manga adaptations, LN splits) stays two entries. Visible
|
||||
// filter enforces per-user library visibility at the source.
|
||||
// Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
|
||||
// transaction share a single NOW() timestamp, leaving many groups tied on
|
||||
// MAX(created_at). Postgres does not guarantee tie order between
|
||||
// executions, so page boundaries could repeat or drop series. The GROUP BY
|
||||
// keys make the sort total and deterministic.
|
||||
//
|
||||
// Series-page sort/filter contract (extensible registry in
|
||||
// services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
|
||||
// it filters in WHERE (the group key — counts stay true); AuthorQuery
|
||||
// and Status filter via HAVING so COUNT(*) keeps reporting the whole
|
||||
// series. ViewerUserId scopes the per-user progress aggregates (the rp
|
||||
// join can't multiply rows: reading_progress is UNIQUE per
|
||||
// media_item+user). Sort is a BOUND PARAM compared inside the CASE
|
||||
// cascade — never interpolated — and the GROUP BY tiebreaker stays
|
||||
// REQUIRED for LIMIT/OFFSET determinism: bulk imports in one
|
||||
// transaction share a single NOW() timestamp, leaving many groups tied
|
||||
// on MAX(created_at), and Postgres does not guarantee tie order between
|
||||
// executions, so page boundaries could repeat or drop series.
|
||||
GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
|
||||
// Count counterpart of GetDistinctSeries: identical WHERE + JOIN +
|
||||
// HAVING so the pagination total matches what the page query returns
|
||||
// under the same filters (Sort is a page-ORDER concern, not a set
|
||||
// membership one, so it is absent here by design).
|
||||
GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error)
|
||||
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
||||
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
|
||||
|
||||
@@ -14693,11 +14693,17 @@ SELECT
|
||||
mi.opf_uuid,
|
||||
mi.hash_confidence,
|
||||
l.name as library_name,
|
||||
lt.name as library_type_name
|
||||
lt.name as library_type_name,
|
||||
-- Viewer's own progress (search-result badges): NULL when the
|
||||
-- requester never opened the book. Reuses the SAME requester param
|
||||
-- the visibility join binds; UNIQUE(media_item, user) keeps the
|
||||
-- join row-neutral.
|
||||
rp.percentage as viewer_percentage
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
@@ -14963,6 +14969,7 @@ type SearchMediaItemsUnifiedRow struct {
|
||||
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
||||
LibraryName string `db:"library_name" json:"library_name"`
|
||||
LibraryTypeName string `db:"library_type_name" json:"library_type_name"`
|
||||
ViewerPercentage pgtype.Float8 `db:"viewer_percentage" json:"viewer_percentage"`
|
||||
}
|
||||
|
||||
func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error) {
|
||||
@@ -15058,6 +15065,7 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt
|
||||
&i.HashConfidence,
|
||||
&i.LibraryName,
|
||||
&i.LibraryTypeName,
|
||||
&i.ViewerPercentage,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -734,11 +734,17 @@ SELECT
|
||||
mi.opf_uuid,
|
||||
mi.hash_confidence,
|
||||
l.name as library_name,
|
||||
lt.name as library_type_name
|
||||
lt.name as library_type_name,
|
||||
-- Viewer's own progress (search-result badges): NULL when the
|
||||
-- requester never opened the book. Reuses the SAME requester param
|
||||
-- the visibility join binds; UNIQUE(media_item, user) keeps the
|
||||
-- join row-neutral.
|
||||
rp.percentage as viewer_percentage
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// BookInfo is THE canonical wire+template card shape: every surface that
|
||||
// lists a book card as JSON (collections detail, series, preview,
|
||||
// dashboard sections, search) or renders a templ card (BookCard in
|
||||
// dashboard.templ, BooksGrid, BrowseDetail) carries exactly these seven
|
||||
// dashboard.templ, BooksGrid, BrowseDetail) carries exactly these nine
|
||||
// keys and no others — pinned by docs/contract_pins.md.
|
||||
//
|
||||
// H2 doctrine: this struct is constructed in EXACTLY ONE place —
|
||||
@@ -34,6 +34,15 @@ type BookInfo struct {
|
||||
// comic vs manga rides the LIBRARY type (format_group only knows
|
||||
// comic_archive — manga_type is unreliable across sources).
|
||||
Badge string `json:"badge"`
|
||||
// Viewer-scoped extras (2026-10-27 search-progress batch): nil
|
||||
// percentage = the requesting user never opened the book, so cards
|
||||
// render no progress bar and no completed badge. Adapters that
|
||||
// don't load viewer progress leave it nil; the search-unified
|
||||
// adapter is the only populated feed today. LibraryName rides the
|
||||
// same deal (unified rows carry it as a column; other feeds leave
|
||||
// it empty rather than pay a join).
|
||||
ViewerPercentage *float64 `json:"viewer_percentage"`
|
||||
LibraryName string `json:"library_name"`
|
||||
}
|
||||
|
||||
// LibraryTypeMap builds libraryID→library-type-name for badge resolution
|
||||
@@ -85,6 +94,8 @@ func buildBookCard(src cardSource) BookInfo {
|
||||
CoverImagePath: utils.ResolveMediaURL(src.LibraryID, src.CoverPath),
|
||||
FormatGroup: src.FormatGroup,
|
||||
Badge: MediaBadgeKind(src.FormatGroup, src.LibraryType),
|
||||
ViewerPercentage: src.ViewerProgress,
|
||||
LibraryName: src.LibraryName,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +113,10 @@ type cardSource struct {
|
||||
CoverPath pgtype.Text
|
||||
FormatGroup string
|
||||
LibraryType string
|
||||
// Viewer-scoped extras: nil progress = no data loaded for the
|
||||
// requesting user (see BookInfo.ViewerPercentage).
|
||||
ViewerProgress *float64
|
||||
LibraryName string
|
||||
}
|
||||
|
||||
// BookCardsFromMediaItems builds cards from full media rows
|
||||
@@ -167,10 +182,17 @@ func BookCardsFromCollectionItemRows(rows []database.GetCollectionItemsRow, libT
|
||||
|
||||
// BookCardsFromUnified builds cards from search-unified rows. Those
|
||||
// rows carry the library type name as a SQL column, so this adapter
|
||||
// needs no LibraryTypeMap query at all.
|
||||
// needs no LibraryTypeMap query at all — and the viewer's own
|
||||
// progress (the 2026-10-27 search-progress batch), which no other
|
||||
// feed loads.
|
||||
func BookCardsFromUnified(rows []database.SearchMediaItemsUnifiedRow) []BookInfo {
|
||||
cards := make([]BookInfo, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var viewer *float64
|
||||
if r.ViewerPercentage.Valid {
|
||||
pct := r.ViewerPercentage.Float64
|
||||
viewer = &pct
|
||||
}
|
||||
cards = append(cards, buildBookCard(cardSource{
|
||||
ID: uuid.UUID(r.ID.Bytes),
|
||||
Title: r.Title,
|
||||
@@ -179,6 +201,8 @@ func BookCardsFromUnified(rows []database.SearchMediaItemsUnifiedRow) []BookInfo
|
||||
CoverPath: r.CoverImagePath,
|
||||
FormatGroup: r.FormatGroup,
|
||||
LibraryType: r.LibraryTypeName,
|
||||
ViewerProgress: viewer,
|
||||
LibraryName: r.LibraryName,
|
||||
}))
|
||||
}
|
||||
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)
|
||||
// Render using BooksGrid template (or BookPickerGrid for collection picker)
|
||||
var buf bytes.Buffer
|
||||
if c.QueryParam("show_checkbox") == "true" {
|
||||
switch {
|
||||
case c.QueryParam("show_checkbox") == "true":
|
||||
err = templates.BookPickerGrid(bookInfoList).Render(c.Request().Context(), &buf)
|
||||
} else {
|
||||
case c.QueryParam("layout") == "dropdown":
|
||||
// Header-search dropdown: compact templ rows (rendering
|
||||
// ownership) carrying the viewer's progress/completed overlay.
|
||||
// Replaces the old innerHTML string renderer in search.ts.
|
||||
err = templates.SearchResults(bookInfoList, c.QueryParam("q")).Render(c.Request().Context(), &buf)
|
||||
default:
|
||||
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -154,6 +154,25 @@ templ BookCard(item handlers.BookInfo) {
|
||||
@Icon(MediaBadgeIcon(item.Badge), "h-4 w-4")
|
||||
</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 class="book-card-meta">
|
||||
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
|
||||
|
||||
+320
-282
@@ -465,7 +465,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -496,100 +496,138 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if item.ViewerPercentage != nil {
|
||||
if *item.ViewerPercentage >= CompletedProgressThreshold {
|
||||
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\">")
|
||||
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 = templ.ResolveAttributeValue(item.Title)
|
||||
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: 159, Col: 117}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 172, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
_, 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, 47, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\"></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</h3>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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, 52, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
|
||||
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: 163, Col: 100}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 179, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||
_, 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, 50, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</h3>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if item.Author != "" {
|
||||
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_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 {
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div></a><div class=\"book-card-action\">")
|
||||
var templ_7745c5c3_Var27 string
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 183, Col: 19}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, 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: 174, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</div></a><div class=\"book-card-action\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" title=\"Resolve progress conflict\">")
|
||||
if item.HasConflict {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 templ.SafeURL
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 191, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" class=\"book-card-action-btn\" aria-label=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue("Resolve progress conflict for " + item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 193, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" title=\"Resolve progress conflict\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -597,38 +635,38 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 templ.SafeURL
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
|
||||
var templ_7745c5c3_Var30 templ.SafeURL
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("Listen to " + item.Title)
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue("Listen to " + item.Title)
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -636,38 +674,38 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var31 templ.SafeURL
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
|
||||
var templ_7745c5c3_Var32 templ.SafeURL
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue("Read " + item.Title)
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue("Read " + item.Title)
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -675,12 +713,12 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -704,12 +742,12 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var33 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var33 == nil {
|
||||
templ_7745c5c3_Var33 = templ.NopComponent
|
||||
templ_7745c5c3_Var34 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var34 == nil {
|
||||
templ_7745c5c3_Var34 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -717,38 +755,38 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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=\"")
|
||||
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=\"")
|
||||
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_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 {
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -756,120 +794,120 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<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)\">")
|
||||
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_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 231, Col: 84}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 248, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</div></div><div class=\"flex items-center gap-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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, 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.ResolveAttributeValue(section.ID)
|
||||
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: 241, Col: 42}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 250, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
|
||||
_, 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, 77, "\" class=\"btn btn-secondary py-1 px-2.5 text-xs\" title=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if section.IsSystem {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</div></div><div class=\"flex items-center gap-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if section.IsSystem {
|
||||
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_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 {
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\">Restore</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !ContainsString(hiddenCollections, section.ID) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, 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.JoinStringErrs(itemsPerSection)
|
||||
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: 267, Col: 128}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 262, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
|
||||
_, 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, "</span></label> <input type=\"range\" min=\"10\" max=\"50\" step=\"5\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\">Restore</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !ContainsString(hiddenCollections, section.ID) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 274, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 286, Col: 128}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" class=\"w-full h-2 rounded-lg appearance-none cursor-pointer\" style=\"background-color: var(--border-strong);\" data-input-action=\"update-items-count\" target=\"items-count-display\"></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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -945,12 +983,12 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var42 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var42 == nil {
|
||||
templ_7745c5c3_Var42 = templ.NopComponent
|
||||
templ_7745c5c3_Var43 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var43 == nil {
|
||||
templ_7745c5c3_Var43 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -958,77 +996,77 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</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)\">")
|
||||
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_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d pages", week.WeekPages))
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(fmtReadingMinutes(week.WeekMinutes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 356, Col: 138}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 374, Col: 146}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var45 string
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d sessions", week.WeekSessions))
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d pages", week.WeekPages))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 357, Col: 144}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 375, Col: 138}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var46 string
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d books", week.WeekBooks))
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d sessions", week.WeekSessions))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 358, Col: 138}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 376, Col: 144}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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, 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", week.CurrentStreak))
|
||||
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: 360, Col: 146}
|
||||
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, 92, "-day streak</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if week.CurrentStreak > 0 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var48 string
|
||||
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.CurrentStreak))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 379, Col: 146}
|
||||
}
|
||||
_, 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, 97, "-day streak</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1037,166 +1075,166 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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)\">")
|
||||
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)\">")
|
||||
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_Var49 string
|
||||
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes))
|
||||
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(fmtReadingMinutes(week.WeekMinutes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 370, Col: 122}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 388, Col: 108}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</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)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var53 string
|
||||
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes))
|
||||
var templ_7745c5c3_Var50 string
|
||||
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 384, Col: 122}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 389, Col: 122}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</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))
|
||||
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)\">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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var54 string
|
||||
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekPages))
|
||||
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 388, Col: 106}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 403, Col: 122}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var55 string
|
||||
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekSessions))
|
||||
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekPages))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 392, Col: 109}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 407, Col: 106}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var56 string
|
||||
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekBooks))
|
||||
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekSessions))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 396, Col: 106}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 411, Col: 109}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "</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
|
||||
}
|
||||
}
|
||||
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 ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if week.CurrentStreak > 0 {
|
||||
if week.ActiveToday {
|
||||
var templ_7745c5c3_Var57 string
|
||||
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(" · 🔥 %d-day streak — keep it going today", week.CurrentStreak))
|
||||
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: 405, Col: 90}
|
||||
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
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var58 string
|
||||
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(" · 🔥 %d-day streak — read today to keep it", week.CurrentStreak))
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 407, Col: 92}
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if week.CurrentStreak > 0 {
|
||||
if week.ActiveToday {
|
||||
var templ_7745c5c3_Var58 string
|
||||
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(" · 🔥 %d-day streak — keep it going today", week.CurrentStreak))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 424, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var59 string
|
||||
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(" · 🔥 %d-day streak — read today to keep it", week.CurrentStreak))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 426, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1204,7 +1242,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "</a></div></div></details>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</a></div></div></details>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+23
-1
@@ -224,17 +224,39 @@ templ Header(user User, currentPath string) {
|
||||
>
|
||||
@Icon("menu", "h-5 w-5")
|
||||
</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);">
|
||||
@Icon("search", "h-4 w-4")
|
||||
</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>
|
||||
|
||||
@@ -717,7 +717,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -725,7 +725,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -770,7 +770,7 @@ func SidebarUserMenu(user User) templ.Component {
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 259, Col: 58}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 281, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -854,7 +854,7 @@ func SidebarSignIn(currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var45 string
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 307, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 329, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -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 { initializeSearch } from "./search";
|
||||
import {
|
||||
initializeTheme,
|
||||
changeTheme,
|
||||
@@ -137,7 +136,6 @@ Alpine.data("header", () => ({
|
||||
} catch {
|
||||
this.openPanel = window.location.pathname.startsWith("/admin") ? "admin" : null;
|
||||
}
|
||||
initializeSearch();
|
||||
this.initializeTheme();
|
||||
this.loadWoodPaneling();
|
||||
this.updateWoodPanelingIndicators();
|
||||
@@ -176,7 +174,6 @@ Alpine.data("header", () => ({
|
||||
this.openPanel = null;
|
||||
},
|
||||
logout,
|
||||
initializeSearch,
|
||||
initializeTheme,
|
||||
changeTheme,
|
||||
changeWoodPaneling,
|
||||
|
||||
@@ -19,7 +19,6 @@ web/src/markdown.ts:1
|
||||
web/src/dom.ts:1
|
||||
web/src/password_validation.ts:1
|
||||
web/src/queue.ts:3
|
||||
web/src/search.ts:4
|
||||
web/src/setup.ts:1
|
||||
web/src/stats.ts:9
|
||||
web/src/toast.ts:2
|
||||
|
||||
+90
-297
@@ -1,315 +1,108 @@
|
||||
import { Alpine } from "./alpine";
|
||||
|
||||
let searchInputTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
// Rendering ownership (AGENTS.md): the dropdown's CONTENT is templ —
|
||||
// the header input's hx-get swaps the SearchResults partial (titles,
|
||||
// authors, library names, progress/completed overlays all
|
||||
// server-rendered) into #search-results. This module owns only
|
||||
// client-local state: min-length gating, open/close, and keyboard
|
||||
// selection. It replaces the old renderer that built the dropdown from
|
||||
// innerHTML string templates — a violation of the rendering-ownership
|
||||
// doctrine this migration removes (2026-10-27 search-progress batch).
|
||||
|
||||
const SEARCH_MIN_CHARS = 2;
|
||||
|
||||
function initializeSearch(): void {
|
||||
const searchInput = document.getElementById("header-search") as HTMLInputElement | null;
|
||||
const mobileSearchInput = document.getElementById(
|
||||
"header-search-mobile",
|
||||
) as HTMLInputElement | null;
|
||||
interface HtmxAfterRequestEvent extends CustomEvent {
|
||||
detail: { successful: boolean };
|
||||
}
|
||||
|
||||
const wireInput = (input: HTMLInputElement) => {
|
||||
input.addEventListener("input", handleSearchInput);
|
||||
input.addEventListener("keydown", handleSearchKeydown);
|
||||
input.addEventListener("focus", () => {
|
||||
if (input.value.length >= SEARCH_MIN_CHARS) {
|
||||
performSearch(input.value);
|
||||
}
|
||||
Alpine.data("headerSearch", () => ({
|
||||
open: false,
|
||||
loading: false,
|
||||
selected: -1,
|
||||
lastQuery: "",
|
||||
|
||||
init() {
|
||||
// htmx dispatches request lifecycle events on the REQUESTING
|
||||
// element (the input), not the swap target.
|
||||
const input = this.$refs.input as HTMLInputElement | undefined;
|
||||
if (!input) return;
|
||||
input.addEventListener("htmx:beforeRequest", () => {
|
||||
this.loading = true;
|
||||
});
|
||||
};
|
||||
|
||||
if (searchInput) wireInput(searchInput);
|
||||
if (mobileSearchInput) wireInput(mobileSearchInput);
|
||||
|
||||
if (!searchInput && !mobileSearchInput) {
|
||||
console.warn("Search input not found");
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e: MouseEvent) => {
|
||||
const searchResults = document.getElementById("search-results");
|
||||
const searchInputEl = document.getElementById("header-search");
|
||||
const mobileSearchInputEl = document.getElementById("header-search-mobile");
|
||||
|
||||
if (
|
||||
searchResults &&
|
||||
!searchResults.contains(e.target as Node) &&
|
||||
e.target !== searchInputEl &&
|
||||
e.target !== mobileSearchInputEl
|
||||
) {
|
||||
hideSearchResults();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleSearchInput(e: Event): void {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const query = target.value.trim();
|
||||
|
||||
if (searchInputTimeout) {
|
||||
clearTimeout(searchInputTimeout);
|
||||
}
|
||||
|
||||
if (query.length < SEARCH_MIN_CHARS) {
|
||||
hideSearchResults();
|
||||
return;
|
||||
}
|
||||
|
||||
searchInputTimeout = setTimeout(() => {
|
||||
performSearch(query);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function handleSearchKeydown(e: KeyboardEvent): void {
|
||||
const searchResults = document.getElementById("search-results");
|
||||
if (!searchResults || searchResults.classList.contains("hidden")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = searchResults.querySelectorAll(".search-result-item");
|
||||
const currentIndex = parseInt(searchResults.dataset.selectedIndex || "-1");
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const nextIndex = Math.min(currentIndex + 1, items.length - 1);
|
||||
selectSearchResult(items, nextIndex);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const prevIndex = Math.max(currentIndex - 1, -1);
|
||||
selectSearchResult(items, prevIndex);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (currentIndex >= 0 && items[currentIndex]) {
|
||||
const link = items[currentIndex].querySelector("a");
|
||||
if (link) link.click();
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
hideSearchResults();
|
||||
}
|
||||
}
|
||||
|
||||
function selectSearchResult(items: NodeListOf<Element>, index: number): void {
|
||||
items.forEach((item, i) => {
|
||||
if (i === index) {
|
||||
item.classList.add("bg-opacity-80");
|
||||
input.addEventListener("htmx:afterRequest", (evt: Event) => {
|
||||
this.loading = false;
|
||||
const detail = (evt as HtmxAfterRequestEvent).detail;
|
||||
// htmx swaps only 2xx bodies: a failed request closes instead of
|
||||
// swapping an error page into the dropdown.
|
||||
if (detail && detail.successful) {
|
||||
this.open = true;
|
||||
this.selected = -1;
|
||||
this.lastQuery = this.query();
|
||||
} else {
|
||||
item.classList.remove("bg-opacity-80");
|
||||
this.open = false;
|
||||
}
|
||||
});
|
||||
|
||||
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: [] };
|
||||
|
||||
query(): string {
|
||||
const input = this.$refs.input as HTMLInputElement | undefined;
|
||||
return input ? input.value.trim() : "";
|
||||
},
|
||||
|
||||
onInput() {
|
||||
const q = this.query();
|
||||
if (q.length < SEARCH_MIN_CHARS) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data: { error?: string; results?: MediaItemSummary[] } | MediaItemSummary[]) => {
|
||||
hideSearchLoading();
|
||||
// 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");
|
||||
},
|
||||
|
||||
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);
|
||||
onFocus() {
|
||||
if (this.query().length < SEARCH_MIN_CHARS) return;
|
||||
if (this.lastQuery === this.query() && this.hasResults()) {
|
||||
this.open = true;
|
||||
} else {
|
||||
showNoResults(query, resultActiveId);
|
||||
window.htmx.trigger(this.$refs.input as HTMLElement, "search");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
hideSearchLoading();
|
||||
console.error("Search error:", error);
|
||||
showSearchError(getActiveSearchInput()?.id);
|
||||
},
|
||||
|
||||
hasResults(): boolean {
|
||||
const results = document.getElementById("search-results");
|
||||
return !!results && results.childElementCount > 0;
|
||||
},
|
||||
|
||||
resultItems(): HTMLElement[] {
|
||||
const results = document.getElementById("search-results");
|
||||
if (!results) return [];
|
||||
return Array.from(results.querySelectorAll<HTMLElement>(".search-result-item"));
|
||||
},
|
||||
|
||||
moveSelection(delta: number) {
|
||||
if (!this.open) return;
|
||||
const items = this.resultItems();
|
||||
if (items.length === 0) return;
|
||||
let next = this.selected + delta;
|
||||
if (next < -1) next = -1;
|
||||
if (next >= items.length) next = items.length - 1;
|
||||
this.selected = next;
|
||||
items.forEach((el, i) => {
|
||||
el.style.backgroundColor = i === next ? "var(--surface-hover)" : "";
|
||||
});
|
||||
}
|
||||
if (next >= 0) items[next].scrollIntoView({ block: "nearest" });
|
||||
},
|
||||
|
||||
function showSearchLoading(activeId?: string): void {
|
||||
createSearchResultsContainer(activeId);
|
||||
const searchResults = document.getElementById("search-results");
|
||||
if (!searchResults) return;
|
||||
openSelected() {
|
||||
if (!this.open || this.selected < 0) return;
|
||||
const link = this.resultItems()[this.selected]?.querySelector("a");
|
||||
if (link) (link as HTMLAnchorElement).click();
|
||||
},
|
||||
|
||||
searchResults.innerHTML = `
|
||||
<div class="p-4 text-center" style="color: var(--text-secondary)">
|
||||
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2" style="border-color: var(--accent)"></div>
|
||||
<p class="mt-2 text-sm">Searching...</p>
|
||||
</div>
|
||||
`;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function highlightMatch(text: string, query: string): string {
|
||||
if (!text) return "";
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
||||
return searchEscapeHtml(text).replace(
|
||||
regex,
|
||||
'<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">$1</mark>',
|
||||
);
|
||||
}
|
||||
|
||||
function searchEscapeHtml(text: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
export { initializeSearch };
|
||||
|
||||
Alpine.data("search", () => ({
|
||||
initializeSearch,
|
||||
close() {
|
||||
this.open = false;
|
||||
this.selected = -1;
|
||||
},
|
||||
}));
|
||||
Vendored
+4
-1
@@ -9,7 +9,8 @@
|
||||
// Source: internal/database/queries.sql.go:6123-6170 SearchMediaItemsRow
|
||||
// Endpoint: internal/handlers/media.go:SearchMediaItems()
|
||||
// Note: internal/handlers/search.go has an unused MediaItemSummary - ignore it
|
||||
// Used in: search.ts
|
||||
// Used in: search.ts (dropdown content is templ-rendered since the
|
||||
// 2026-10-27 search-progress batch; this type documents the JSON API)
|
||||
interface MediaItemSummary {
|
||||
id: string;
|
||||
library_id: string;
|
||||
@@ -57,6 +58,8 @@ interface MediaItemSummary {
|
||||
hash_confidence?: string;
|
||||
library_name: string;
|
||||
library_type_name: string;
|
||||
/** Requesting user's progress 0..1; null when never opened (2026-10-27). */
|
||||
viewer_percentage?: number | null;
|
||||
|
||||
// NEW: Reading direction fields for manga/comics
|
||||
manga_type?: "unknown" | "no" | "yes" | "yes_and_right_to_left";
|
||||
|
||||
Reference in new issue
Block a user