Compare commits

..
11 Commits
Author SHA1 Message Date
John O'Keefe d74db03cf3 feat(sync): unlinked-books queue — ingest, page route, dashboard banner
Release / build-and-push (push) Successful in 3m7s
The identification queue for device files the sync cannot match is now
live end-to-end. Previously the whole feature was dark: unmatched device
files were silently dropped (losing their reading progress), the queue
table sat empty by construction, and the page template had no route.

- Ingest: the KOReader sync loop, on an unresolvable file, upserts
  unlinked_books (one row per device + content id; re-syncs bump
  last_seen_at and refresh device-reported metadata) instead of
  dropping the file. Schema gains the (device_id, content_id) unique
  index the upsert needs.
- Page: GET /unlinked-books routes the existing page template, listing
  the caller's own unresolved rows (ListUnresolvedUnlinkedBooksForUser
  — devices.user_id scoping; no cross-user leakage).
- Dashboard banner: a warning card above the shelves, rendered only
  when the caller's queue is non-empty (hidden-unless-needed), linking
  to the page.
- Resolve flow unchanged and already gated: MatchingHandler.LinkBook
  verifies device ownership and target-item visibility (403s) and
  creates the device file alias, so the next sync matches by path.
  SyncHandler.LinkBook and Handler.LinkBook are unrouted dead handlers
  — triage candidates, not part of the live flow.
2026-10-18 16:45:48 -04:00
John O'Keefe 9e831085ef fix(ui): derive Library Type dropdowns from library_types — audiobooks was missing
The audiobook library type shipped backend-complete (LibraryTypeAudiobooks
constant, allowed extensions, MIME map, schema seed row, GET
/api/libraries/types) but neither create-library form offered it: the
admin Create Library modal and the setup wizard's add-library step both
hardcoded their <option> lists to the original three types
(ebooks/comics/manga), so an audiobook library could not be created from
the UI.

Both dropdowns now render from the same DB-backed library_types table
the create API resolves types against — a future type appears without
template edits (the exact drift the rendering-ownership rule warns
about):

- templates/utils.go: OrderedLibraryTypes pins the historical dropdown
  order (ebooks, comics, manga, audiobooks) and appends unknown future
  types by name; LibraryTypeDisplayName renders labels with a
  capitalize-the-name fallback for types added without a label
- templates/admin_library.templ: new LibraryTypeOptions fragment used
  by the modal's select; AdminLibrary takes the types list
- templates/setup.templ: Setup takes the types list, same fragment
- internal/router/helpers.go: libraryTypeOptionsFor fetches via
  LibraryService.GetLibraryTypes, falling back to the built-in type
  names on DB failure so the forms stay usable (CreateLibrary still
  resolves the name against the DB, so the fallback is never
  authoritative)
- internal/router/frontend.go, internal/router/setup.go: pass the list
  into the two pages

Tests:
- templates/utils_test.go: pure unit tests for ordering + labels
- cmd/server/tests/admin_library_page_test.go: SSR regression —
  /admin/library must offer all four library types, audiobooks exactly
  once (options come from the DB loop, not a hardcode)
2026-10-18 16:38:03 -04:00
John O'Keefe 327fc2c0f9 feat(matching): match results as a scoped templ partial + enriched matches
- BookMatch now carries title/author/cover (the fields the old client
  renderer always assumed but the API never sent — cards would have
  shown 'undefined'); filled during the family-lens pass in QueryBooks
- new partial route GET /unlinked-books/matches renders the scoped
  match cascade server-side; searchMatches swaps it via htmx.ajax
  instead of rebuilding cards from JSON
- NOTE: the unlinked-books surface is currently dark end-to-end — the
  templ page has no route, the live linking.ts renderer targets a
  container no template has, and its Link button calls a
  window.showMatchModal that does not exist. This commit ships the
  scoped backend + partial; wiring the UI is a product decision.
2026-10-18 16:00:04 -04:00
John O'Keefe a31da89df5 feat(ui): View Transitions crossfade for library switches
Where the browser supports it (Chrome 111+, Safari 18+, Firefox 139+),
switchWithTransition now uses the native View Transitions API: the old
shelves are snapshotted, the fetch + swap happen behind the dim, and the
two snapshots crossfade — no blank phase, no manual opacity
choreography. The dim + spinner is hidden inside the update so it blends
away as part of the transition. Browsers without the API keep the
existing dim + fade sequence unchanged. Zero dependencies — the
crossfade is the browser's own.
2026-10-18 16:00:04 -04:00
John O'Keefe a11b4a9cc4 fix(ui): library-switch overlay was fully opaque — the 'new page' wipe
The switch overlay intended a 50% dim (bg-opacity-50) but set its
background with an inline style — Tailwind's bg-opacity variable does
not apply to inline styles, so the overlay rendered fully opaque. Every
library switch wiped the material area to a solid sheet (chrome stays
visible: the sidebar sits at z-60 above the overlay's z-50) and
hard-cut back when the overlay dropped — reading exactly as a new page
instead of a fade. The dashboard showed it worst because its switch
holds the overlay longest (sections fetch + awaited week-widget refresh
behind it).

- overlay background is now theme-aware translucent
  (color-mix 55% bg-primary over transparent); the dead bg-opacity-50
  class is gone
- the dim lifts before the fade-in starts, so the new content's fade
  plays fully visible rather than being hard-cut mid-transition
2026-10-18 13:16:35 -04:00
John O'Keefe 040c10ac76 feat(matching): family-lens scoping for book matching and linking
/api/books/match returned media metadata from ALL libraries regardless
of visibility: the resolver lookups (GetMediaItemBySHA256 and friends)
are global, so match results leaked hidden-library titles, covers and
ids to any authenticated user, and LinkBook accepted a link target from
a hidden library. Both now enforce the family-lens rule:

- QueryBooks post-filters the cascade by the requester's visible-library
  set (absent-from-results — a hidden library's book simply never
  matches), before determineAction so the suggested action reflects the
  filtered set. BookQueryRequest gains a server-side-only
  VisibleLibraryIDs field; nil keeps legacy callers unfiltered.
- LinkBook verifies the device belongs to the caller
  (DeviceExistsForUser) and the target item's library is visible —
  403 otherwise. Linking was previously unscoped on both axes.
- GetUnlinkedBooks likewise verifies device ownership (its service is
  still a stub, but the route no longer answers for other users'
  devices).

Also: the collections books-grid partial uses a light loader —
requested library validated against the visible set directly instead of
the page-only library resolution (folder reachability, counts,
dropdown payload), removing the extra queries that made library
switching hold the spinner longer.

And the dashboard library switch now folds the This Week widget's
htmx refresh behind the spinner (refreshWeekWidget awaits the widget's
swap, with a safety timeout) instead of letting it pop in after the
fade-in — the two-stage update introduced with the widget's
library-scoped re-fetch.

Guardrail: the rendering lint now counts all innerHTML assignments (not
just template-literal ones) — the old pattern undercounted 20 files at
80 sites, including .join("") builders like renderDashboardCollections.
Baseline rebalanced accordingly.
2026-10-18 13:10:57 -04:00
John O'Keefe 4a68fe7cab fix(conflicts): the status filter, select-all, and bulk actions actually work
They never did: filterConflicts/toggleAllConflicts/updateBulkActions
were referenced in the templ but defined nowhere, and the bulk buttons
called bulkResolve()/bulkDismiss() with no arguments against functions
expecting (strategy, conflictIds).

- conflict cards carry data-status; the status dropdown filters them
  client-side (pure client state — allowed to be JS), applied on page
  init and re-applied after every partial refresh, with a
  'no conflicts match this filter' note when everything is filtered out
- select-all toggles only the visible cards; the bulk bar shows the
  selected count and resolve/dismiss now gather the checked ids and the
  chosen strategy (the bar's own buttons use resolveSelected()/
  dismissSelected() wrappers)
- dead onchange= globals became Alpine @change handlers on the server-
  rendered controls; partial swaps re-bind via Alpine.initTree (already
  in refreshConflicts) and re-apply filter + bulk state

Visible change: the dropdown's stated default (Unresolved Only) now
actually applies on load, so resolved conflicts are hidden until the
filter is changed.
2026-10-18 12:42:50 -04:00
John O'Keefe cb4e892b10 test(templates): pin the rendering partials — standalone render, OOB markers, empty state 2026-10-18 12:30:55 -04:00
John O'Keefe 506a04b55d chore(web): library-admin inline script moves to admin.ts; rendering guardrail
The library admin page carried an inline <script> block of global
functions (toggleLibraryPanel, openEditModal, openDeleteModal,
openFolderBrowser plus the delete-modal close listener). They move into
the admin Alpine component as typed methods; the page body gains
x-data="admin" and the call sites switch onclick= -> @click.
Behavior unchanged.

Guardrail: make lint-rendering (wired into make test) fails when a
web/src file builds more HTML template literals than its recorded
baseline (web/src/rendering-allowlist.txt, seeded at 18 sites across 9
files post-twin-deletion) or when a .templ file gains an inline script
block. theme_flash_guard.templ is the only sanctioned inline script;
web/src/reader is a sanctioned island.

theme.ts: Array.from(classList) — DOMTokenList spread needs
lib.dom.iterable, which the tsconfig does not include.
2026-10-18 12:28:50 -04:00
John O'Keefe f1844591b4 feat(web): templ partials replace the last UI twins (collections grid, conflicts list)
Both pages had a templ render (initial load) and a TypeScript twin
(innerHTML rebuild on refresh) of the same fragment. The twins are
deleted; the server-rendered markup is now the only markup.

- collections: the library switcher swaps in the new books-grid partial
  (GET /collections/:id/books-grid) instead of re-rendering cards from
  the /api/collections JSON. Book loading is extracted into
  Config.loadCollectionBooks, shared verbatim by the page and the
  partial (smart / dashboard-query / manual branches, family-lens
  scoping via resolveLibrary + UserVisibleLibraryIDs) so the two cannot
  drift.
- conflicts: resolve/dismiss refreshes swap the new list partial
  (GET /conflicts/list) with the stats bar as an out-of-band swap,
  instead of the TS twin — which rendered a degraded card (no
  checkboxes, no timestamps), pointed at a window.showResolveModal that
  does not exist, and updated stats via element ids the SSR never had.
- shims.d.ts: window.htmx typing (ajax/process) for TS callers.

Visibility note: the books-grid partial inherits its family-lens
scoping from the shared loader (identical to the detail page); a
dedicated hidden-library pin test for the partial route is still worth
adding in the next test pass.
2026-10-18 12:28:25 -04:00
John O'Keefe 1fdf306f2d docs(agents): rendering-ownership rule — templ renders, JS decides what shows
Codifies the standing rule discussed for the templ/JS split: content
derived from server data is rendered by templ and refreshed via templ
partials (htmx.ajax), pure client-local state may render in TS/Alpine,
web/src is TypeScript-only, and inline <script> in templ files requires
a pre-paint justification (theme_flash_guard.templ only). Lists the
sanctioned JS islands (reader, Chart.js, toasts, password validation).

Also refreshes gotcha #1: the 'harmlessly masked' aside is stale — the
quoted-attribute sweep fixed every occurrence; the note now points at
templates.JSONArg for free text.
2026-10-18 12:28:25 -04:00
39 changed files with 3330 additions and 1805 deletions

No files matched your search

+33 -3
View File
@@ -4,6 +4,34 @@ Standing rules for any agent or developer working in this repo.
Project-wide context lives in the sibling repos' AGENTS.md files
(../bookhoard-app/AGENTS.md is the Android client's).
## Rendering ownership (templ vs TypeScript) — ALWAYS apply
The point of templ + htmx + Alpine is: the server renders the UI, and
JavaScript is kept minimal. The rule that keeps it that way:
> **JavaScript decides what shows. templ decides what it looks like.**
- Content derived from **server data** (DB rows, search results, match
lists, conflict cards, any list/card/badge/table) is rendered by
templ. When it changes client-side, fetch a **templ partial** from an
SSR route and swap it in (`htmx.ajax(...)`), never rebuild it from a
JSON payload with `innerHTML` string templates.
- **Pure functions of client-local state** (open/closed panels, tabs,
password strength from a typed value, toasts) may render in
TS/Alpine — no server round trip needed.
- `web/src` is **TypeScript only** — no new `.js` source files.
Vendored third-party and vite build output under `web/static` are
exempt.
- Inline `<script>` in a `.templ` file needs a reason a bundled module
cannot do the job (pre-paint execution). Currently sanctioned:
`theme_flash_guard.templ` only.
Sanctioned JS islands (engines, not UI renderers — exempt from the
above): the reader (foliate-js + `web/src/reader/`), Chart.js graphs in
stats, toasts, password validation. Anything else that renders app UI
in TypeScript is a defect; if a templ twin of it exists, one of the two
must be deleted.
## Library visibility (family-lens rule) — ALWAYS apply
Per-user library visibility (`library_visibility` table, default-ALLOW:
@@ -96,9 +124,11 @@ of a server bug: stop and flag the user.
values** — it emits the literal text. `x-init="f('{ col.ID }')"`
shipped a request to `/api/collections/{ collection.ID }` found by
the request logs; the whole attribute value must BE an expression:
`x-init={ "f('" + col.ID + "')" }`. Note
`class="theme-{ user.Theme }"` has always been literal (harmlessly
masked by the JS theme engine).
`x-init={ "f('" + col.ID + "')" }`. (Every occurrence of this bug —
including `class="theme-{ user.Theme }"` on 22 page bodies and 26
attribute interpolations — was swept and fixed in 2026-10; when
adding attributes with dynamic values, the whole value is an
expression, and free text goes through `templates.JSONArg`.)
2. **SettingsRegistry caches**: writes must go through the settings
API (Reload on save). Direct SQL writes to system_settings leave
the cache stale until the next API-path write — never "preview"
+26 -1
View File
@@ -47,9 +47,34 @@ help:
@echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)"
# Run unit tests locally (fast, no containers)
test:
test: lint-rendering
go test ./internal/... -v -short
# Rendering-ownership guard (AGENTS.md → Rendering ownership): templ renders
# UI markup, JS orchestrates. Fails when a web/src file builds more HTML
# template literals than its recorded baseline (web/src/rendering-allowlist.txt),
# or when a .templ file gains an inline <script> block (theme_flash_guard.templ
# is the only sanctioned one). Shrink baselines as fragments move to templ
# partials; grow one only with a written reason.
lint-rendering:
@violations=""; \
for f in $$(grep -rlE 'innerHTML[[:space:]]*=[[:space:]]*' web/src --include='*.ts' | grep -v 'web/src/reader/'); do \
count=$$(grep -E 'innerHTML[[:space:]]*=[[:space:]]*' $$f | grep -cvE 'innerHTML[[:space:]]*=[[:space:]]*""[[:space:]]*$$'); \
allowed=$$(grep -F "$$f:" web/src/rendering-allowlist.txt | cut -d: -f2); \
allowed=$${allowed:-0}; \
if [ $$count -gt $$allowed ]; then \
violations="$$violations $$f ($$count > $$allowed)"; \
fi; \
done; \
for f in $$(grep -rln '<script>' templates --include='*.templ'); do \
case $$f in *theme_flash_guard*) continue;; esac; \
violations="$$violations $$f (inline script)"; \
done; \
if [ -n "$$violations" ]; then \
echo "rendering-ownership violations:$$violations"; exit 1; \
fi; \
echo "rendering ownership OK"
# Run integration tests in containers (matches production environment)
test-integration:
@echo "Building test containers..."
@@ -0,0 +1,36 @@
package main
import (
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The Library Type dropdown on /admin/library (and the setup wizard, which
// shares LibraryTypeOptions) must render from the DB-backed library_types
// table. Regression: audiobooks shipped backend-complete (constant, allowed
// extensions, MIME map, schema seed row, /api/libraries/types) while the
// dropdowns hardcoded the original three types, so an audiobook library
// could not be created from the UI.
func TestAdminLibraryPageDropdownCoversAllLibraryTypes(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
code, html := ssrGet(t, setup.Server.URL+"/admin/library", setup.Token)
require.Equal(t, http.StatusOK, code)
for _, typ := range []string{"ebooks", "comics", "manga", "audiobooks"} {
assert.Contains(t, html, `value="`+typ+`"`,
"library type %q missing from the create-library dropdown", typ)
}
assert.Contains(t, html, "Audiobooks")
// Options come from the DB loop, not the deleted hardcode: exactly one
// audiobooks option per page.
assert.Equal(t, 1, strings.Count(html, `value="audiobooks"`))
}
+2
View File
@@ -1353,6 +1353,8 @@ CREATE TABLE IF NOT EXISTS unlinked_books (
CREATE INDEX IF NOT EXISTS idx_unlinked_books_device ON unlinked_books(device_id);
CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_id);
-- One queue row per (device, document): the sync ingest upserts on this pair.
CREATE UNIQUE INDEX IF NOT EXISTS idx_unlinked_books_device_content ON unlinked_books(device_id, content_id);
CREATE INDEX IF NOT EXISTS idx_unlinked_books_resolved ON unlinked_books(resolved);
-- Create saved_filters for users to be able to filter different parts of the application
+7
View File
@@ -48,6 +48,8 @@ type Querier interface {
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
// Count unlinked books for a device
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
// Banner count: unresolved queue rows across the caller's own devices.
CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error)
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
// COLLECTIONS QUERIES
@@ -469,6 +471,8 @@ type Querier interface {
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
// List unresolved unlinked books with pagination
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
// Page list: unresolved queue rows for the caller's own devices.
ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error)
ListUsers(ctx context.Context) ([]ListUsersRow, error)
// First consecutive scan that cannot find the file on disk.
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
@@ -607,6 +611,9 @@ type Querier interface {
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
// Ingest: insert or refresh the queue row for a device file that could not
// be identified. One row per (device, content_id); re-syncs bump last_seen.
UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error)
}
var _ Querier = (*Queries)(nil)
+129
View File
@@ -332,6 +332,21 @@ func (q *Queries) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID)
return count, err
}
const CountUnresolvedUnlinkedBooksForUser = `-- name: CountUnresolvedUnlinkedBooksForUser :one
SELECT COUNT(*) as count
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false
`
// Banner count: unresolved queue rows across the caller's own devices.
func (q *Queries) CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, CountUnresolvedUnlinkedBooksForUser, userID)
var count int64
err := row.Scan(&count)
return count, err
}
const CountUserDevices = `-- name: CountUserDevices :one
SELECT COUNT(*) FROM devices WHERE user_id = $1
`
@@ -11365,6 +11380,69 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
return items, nil
}
const ListUnresolvedUnlinkedBooksForUser = `-- name: ListUnresolvedUnlinkedBooksForUser :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false
ORDER BY ub.last_seen_at DESC
`
type ListUnresolvedUnlinkedBooksForUserRow struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
ContentID string `db:"content_id" json:"content_id"`
FilePath pgtype.Text `db:"file_path" json:"file_path"`
Title pgtype.Text `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
// Page list: unresolved queue rows for the caller's own devices.
func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error) {
rows, err := q.db.Query(ctx, ListUnresolvedUnlinkedBooksForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListUnresolvedUnlinkedBooksForUserRow{}
for rows.Next() {
var i ListUnresolvedUnlinkedBooksForUserRow
if err := rows.Scan(
&i.ID,
&i.DeviceID,
&i.ContentID,
&i.FilePath,
&i.Title,
&i.Author,
&i.ConfidenceScore,
&i.Resolved,
&i.MediaItemID,
&i.ResolvedAt,
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.DeviceName,
&i.DeviceType,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListUsers = `-- name: ListUsers :many
SELECT
u.id,
@@ -15382,3 +15460,54 @@ func (q *Queries) UpsertSystemSetting(ctx context.Context, arg UpsertSystemSetti
)
return i, err
}
const UpsertUnlinkedBook = `-- name: UpsertUnlinkedBook :one
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (device_id, content_id) DO UPDATE
SET file_path = EXCLUDED.file_path,
title = EXCLUDED.title,
author = EXCLUDED.author,
confidence_score = EXCLUDED.confidence_score,
last_seen_at = NOW()
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
`
type UpsertUnlinkedBookParams struct {
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
ContentID string `db:"content_id" json:"content_id"`
FilePath pgtype.Text `db:"file_path" json:"file_path"`
Title pgtype.Text `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
}
// Ingest: insert or refresh the queue row for a device file that could not
// be identified. One row per (device, content_id); re-syncs bump last_seen.
func (q *Queries) UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error) {
row := q.db.QueryRow(ctx, UpsertUnlinkedBook,
arg.DeviceID,
arg.ContentID,
arg.FilePath,
arg.Title,
arg.Author,
arg.ConfidenceScore,
)
var i UnlinkedBooks
err := row.Scan(
&i.ID,
&i.DeviceID,
&i.ContentID,
&i.FilePath,
&i.Title,
&i.Author,
&i.ConfidenceScore,
&i.Resolved,
&i.MediaItemID,
&i.ResolvedAt,
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
)
return i, err
}
+28
View File
@@ -2619,6 +2619,34 @@ WHERE ub.resolved = false
ORDER BY ub.last_seen_at DESC
LIMIT $1 OFFSET $2;
-- Ingest: insert or refresh the queue row for a device file that could not
-- be identified. One row per (device, content_id); re-syncs bump last_seen.
-- name: UpsertUnlinkedBook :one
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (device_id, content_id) DO UPDATE
SET file_path = EXCLUDED.file_path,
title = EXCLUDED.title,
author = EXCLUDED.author,
confidence_score = EXCLUDED.confidence_score,
last_seen_at = NOW()
RETURNING *;
-- Banner count: unresolved queue rows across the caller's own devices.
-- name: CountUnresolvedUnlinkedBooksForUser :one
SELECT COUNT(*) as count
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false;
-- Page list: unresolved queue rows for the caller's own devices.
-- name: ListUnresolvedUnlinkedBooksForUser :many
SELECT ub.*, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false
ORDER BY ub.last_seen_at DESC;
-- ============================================
-- CAROUSEL-STYLE DASHBOARD
-- ============================================
+94 -5
View File
@@ -80,6 +80,48 @@ func (h *Handler) LinkBook(c *echo.Context) error {
})
}
// Ownership: the device must belong to the caller.
user, ok := c.Get("user").(database.Users)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "user not authenticated",
})
}
deviceOwned, err := h.db.DeviceExistsForUser(c.Request().Context(), database.DeviceExistsForUserParams{
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
UserID: user.ID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to verify device",
})
}
if !deviceOwned {
return c.JSON(http.StatusForbidden, map[string]string{
"error": "Device not found",
})
}
// Family-lens: the link target must sit in a library the caller can
// see — a hidden library's book can never be linked.
visibleSet, visErr := h.visibleLibrarySetFor(c)
if visErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to resolve visible libraries",
})
}
item, itemErr := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: req.MediaItemID, Valid: true})
if itemErr != nil {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "Media item not found",
})
}
if !item.LibraryID.Valid || !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
return c.JSON(http.StatusForbidden, map[string]string{
"error": "Book is not available to you",
})
}
alias, err := matchingService.LinkBook(c.Request().Context(), deviceID, &req)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
@@ -112,6 +154,27 @@ func (h *Handler) GetUnlinkedBooks(c *echo.Context) error {
})
}
user, ok := c.Get("user").(database.Users)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "user not authenticated",
})
}
deviceOwned, devErr := h.db.DeviceExistsForUser(c.Request().Context(), database.DeviceExistsForUserParams{
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
UserID: user.ID,
})
if devErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to verify device",
})
}
if !deviceOwned {
return c.JSON(http.StatusForbidden, map[string]string{
"error": "Device not found",
})
}
unlinked, err := matchingService.GetUnlinkedBooks(c.Request().Context(), deviceID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
@@ -509,10 +572,35 @@ func (h *Handler) DeleteDeviceFileAlias(c *echo.Context) error {
})
}
// visibleLibrarySetFor resolves the requesting user's visible-library set
// (family-lens) from the request context identity.
func (h *Handler) visibleLibrarySetFor(c *echo.Context) (map[string]bool, error) {
user, ok := c.Get("user").(database.Users)
if !ok {
return nil, echo.NewHTTPError(http.StatusUnauthorized, "user not authenticated")
}
set, _, err := services.NewLibraryService(h.db).VisibleLibrarySet(
c.Request().Context(), user.ID)
return set, err
}
// GetBookMatches handles GET /api/books/match
func (h *Handler) GetBookMatches(c *echo.Context) error {
matchingService := h.getMatchingService()
// Family-lens: restrict matches to libraries the requester can see
// (absent-from-results — a hidden library's book simply never matches).
visibleSet, visErr := h.visibleLibrarySetFor(c)
if visErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to resolve visible libraries",
})
}
visibleIDs := make([]string, 0, len(visibleSet))
for id := range visibleSet {
visibleIDs = append(visibleIDs, id)
}
// Get query parameters
identifiers := c.QueryParams()["identifier"]
sha256 := c.QueryParam("sha256")
@@ -532,11 +620,12 @@ func (h *Handler) GetBookMatches(c *echo.Context) error {
}
req := &services.BookQueryRequest{
Identifiers: identifiers,
SHA256: sha256,
Title: title,
Author: author,
FileSize: fileSize,
Identifiers: identifiers,
SHA256: sha256,
Title: title,
Author: author,
FileSize: fileSize,
VisibleLibraryIDs: visibleIDs,
}
response, err := matchingService.QueryBooks(c.Request().Context(), req)
+43 -1
View File
@@ -451,6 +451,10 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
for _, book := range req.Books {
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
if !mediaItemID.Valid {
// Unidentified file: queue it for manual identification
// (unlinked-books page) instead of silently dropping the
// file's progress. One row per (device, content id).
h.queueUnlinkedBook(c, device.ID, book)
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
Synced: false,
@@ -498,9 +502,47 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
})
}
func (h *KOReaderHandler) resolveBookToMediaItem(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, book KOReaderBookProgress) (pgtype.UUID, float64) {
// queueUnlinkedBook records a device file that could not be identified, so
// the user can resolve it on the unlinked-books page. One row per
// (device, content id); re-syncs refresh the row instead of duplicating it.
// Best-effort: queueing failures never fail the sync response.
func (h *KOReaderHandler) queueUnlinkedBook(c *echo.Context, deviceID pgtype.UUID, book KOReaderBookProgress) {
ctx := c.Request().Context()
contentID := book.UUID
if contentID == "" {
contentID = book.SHA256
}
if contentID == "" {
// Nothing stable to identify the file by — it could never resolve.
return
}
var title pgtype.Text
if book.Title != "" {
title = pgtype.Text{String: book.Title, Valid: true}
}
var author pgtype.Text
if len(book.Authors) > 0 && book.Authors[0] != "" {
author = pgtype.Text{String: book.Authors[0], Valid: true}
}
var filePath pgtype.Text
if book.FilePath != "" {
filePath = pgtype.Text{String: book.FilePath, Valid: true}
}
_, _ = h.db.UpsertUnlinkedBook(ctx, database.UpsertUnlinkedBookParams{
DeviceID: deviceID,
ContentID: contentID,
FilePath: filePath,
Title: title,
Author: author,
ConfidenceScore: pgtype.Float8{Float64: 0, Valid: true},
})
}
func (h *KOReaderHandler) resolveBookToMediaItem(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, book KOReaderBookProgress) (pgtype.UUID, float64) { ctx := c.Request().Context()
// Priority 1: UUID provided (highest confidence - 1.0)
if book.UUID != "" {
mediaUUID, err := uuid.Parse(book.UUID)
+166 -91
View File
@@ -475,8 +475,18 @@ func registerFrontendRoutes(cfg *Config) {
}
}
userUUID, userUUIDErr := uuid.Parse(user.ID)
if userUUIDErr != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
unlinkedCount, countErr := cfg.Queries.CountUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if countErr != nil {
log.Printf("CountUnresolvedUnlinkedBooksForUser failed: %v", countErr)
unlinkedCount = 0
}
var buf bytes.Buffer
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, activeLibType, errorMsg).Render(c.Request().Context(), &buf)
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, activeLibType, errorMsg, int(unlinkedCount)).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -660,18 +670,19 @@ func registerFrontendRoutes(cfg *Config) {
})
// Collection detail page (works for both system and user collections)
// Collection detail page. Book loading lives in loadCollectionBooks —
// shared verbatim with the books-grid partial below so the SSR page and
// the partial can never drift (rendering-ownership rule).
frontendProtected.GET("/collections/:id", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
// Parse collection ID from URL
collectionID := c.Param("id")
collUUID, err := uuid.Parse(collectionID)
if err != nil {
return renderErrorPage(c, "Invalid collection ID", "invalid_id")
}
// Fetch collection details
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if err != nil {
if err.Error() == "no rows in result set" {
@@ -679,106 +690,146 @@ func registerFrontendRoutes(cfg *Config) {
}
return renderErrorPage(c, "Error loading collection", "collection_load_error")
}
// Fetch books in collection
userUUID, _ := uuid.Parse(user.ID)
var books []handlers.BookInfo
libRes := resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
var libScope pgtype.UUID
if !libRes.IsAll {
if parsed, parseErr := uuid.Parse(libraryID); parseErr == nil {
libScope = pgtype.UUID{Bytes: parsed, Valid: true}
}
}
colService := cfg.CollectionHandler.CollectionService()
isSmart := colService.IsSmartCollection(collection.AutoAssignRules)
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
if visErr != nil {
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
visibleIDs = nil
}
libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
if isSmart {
// Smart (dynamic) collection: membership is evaluated live —
// visible libraries, live items only, any enabled rule matches.
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{
LibraryID: libScope,
VisibleIDs: visibleIDs,
})
if evalErr != nil {
return renderErrorPage(c, "Error loading books", "books_load_error")
}
books = handlers.BookCardsFromMediaItems(matches, libTypes)
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
dashboardSvc := services.NewDashboardService(cfg.Queries)
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libRes.LibUUID, 1000, []string{}, []string{})
if secErr != nil {
return renderErrorPage(c, "Error loading books", "books_load_error")
}
for _, section := range sections {
if section.CollectionID.String() == collectionID {
books = handlers.BookCardsFromMediaItems(section.Items, libTypes)
break
}
}
} else {
if libraryID != "" && !libRes.IsAll {
libUUID, parseErr := uuid.Parse(libraryID)
if parseErr != nil {
return renderErrorPage(c, "Invalid library ID", "invalid_library_id")
}
collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
database.GetCollectionItemsForDashboardParams{
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Limit: pgtype.Int4{Int32: 1000, Valid: true},
})
if collErr != nil {
books = handlers.BookCardsFromCollectionDashRows(nil, libTypes)
} else {
// The query filters excluded rows in SQL.
books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes)
}
} else {
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if collErr != nil {
collItems = nil
}
books = handlers.BookCardsFromCollectionItemRows(collItems, libTypes)
}
}
// Build collection data
colData := templates.CollectionData{
ID: collectionID,
Name: collection.Name,
Description: collection.Description.String,
Color: collection.Color.String,
Icon: collection.Icon.String,
IsSystem: collection.IsSystemCollection.Bool,
IsSmart: isSmart,
BookCount: len(books),
books, libRes, isSmart, errCode := cfg.loadCollectionBooks(c, user, collection, collectionID)
if errCode != "" {
msg := map[string]string{
"books_load_error": "Error loading books",
"invalid_library_id": "Invalid library ID",
"invalid_id": "Invalid collection ID",
}[errCode]
return renderErrorPage(c, msg, errCode)
}
colData := collectionDataFromRow(collection, collectionID, isSmart, len(books))
var buf bytes.Buffer
err = templates.CollectionDetail(user, colData, books, libraryID, libRes.Libraries).Render(c.Request().Context(), &buf)
err = templates.CollectionDetail(user, colData, books, libRes.LibraryID, libRes.Libraries).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Books-grid partial: the collection detail page's #books-container
// content. The library switcher swaps this in (htmx.ajax) instead of
// re-rendering the grid client-side from JSON. Family-lens scoped via
// loadCollectionBooksLite — the requested library must be in the
// caller's visible set, without the page-only library resolution.
frontendProtected.GET("/collections/:id/books-grid", func(c *echo.Context) error {
user, ok := c.Get("user").(database.Users)
if !ok {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
userUUID, uuidErr := uuid.Parse(user.ID.String())
if uuidErr != nil {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
collectionID := c.Param("id")
collUUID, parseErr := uuid.Parse(collectionID)
if parseErr != nil {
return c.HTML(http.StatusBadRequest, "<div>Invalid collection ID</div>")
}
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if err != nil {
return c.HTML(http.StatusNotFound, "<div>Collection not found</div>")
}
books, isSmart, errCode := cfg.loadCollectionBooksLite(c, userUUID, collection, collectionID)
if errCode == "library_forbidden" {
return c.HTML(http.StatusForbidden, "<div>Library not available</div>")
}
if errCode != "" {
return c.HTML(http.StatusBadRequest, "<div>Error loading books</div>")
}
colData := collectionDataFromRow(collection, collectionID, isSmart, len(books))
var buf bytes.Buffer
if renderErr := templates.CollectionBooksGrid(colData, books).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
})
// Week-widget fragment: the collapsible "This Week" strip re-fetches
// itself here (htmx) whenever the library switcher dispatches
// week-refresh, carrying the current #library-select value. Same
// resolution rules as the dashboard page (family-lens safe).
// Unlinked-books page: the identification queue for device files the
// sync could not match (personal data — the caller's own devices only).
frontendProtected.GET("/unlinked-books", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
userUUID, userUUIDErr := uuid.Parse(user.ID)
if userUUIDErr != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
rows, listErr := cfg.Queries.ListUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if listErr != nil {
log.Printf("ListUnresolvedUnlinkedBooksForUser failed: %v", listErr)
rows = []database.ListUnresolvedUnlinkedBooksForUserRow{}
}
books := make([]templates.UnlinkedBookData, 0, len(rows))
for _, r := range rows {
books = append(books, templates.UnlinkedBookData{
ProgressID: uuid.UUID(r.ID.Bytes).String(),
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
DeviceType: r.DeviceType,
DeviceName: r.DeviceName,
FilePath: utils.PgTextString(r.FilePath),
SHA256: r.ContentID,
TitleFromDevice: utils.PgTextString(r.Title),
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, user.Timezone),
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
var buf bytes.Buffer
if renderErr := templates.UnlinkedBooks(user, books).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
})
// Unlinked-book match results partial: the match cascade for one device
// file, rendered server-side and scoped to the requester's visible
// libraries (absent-from-results) — rendering-ownership: templ renders,
// JS only swaps. NOTE: nothing routes to the unlinked-books page yet;
// this endpoint is ready for whichever UI wires it up.
frontendProtected.GET("/unlinked-books/matches", func(c *echo.Context) error {
user, ok := c.Get("user").(database.Users)
if !ok {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
userUUID, uuidErr := uuid.Parse(user.ID.String())
if uuidErr != nil {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
set, _, setErr := cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if setErr != nil {
return c.HTML(http.StatusInternalServerError, "<div>Failed to resolve visible libraries</div>")
}
visibleIDs := make([]string, 0, len(set))
for id := range set {
visibleIDs = append(visibleIDs, id)
}
req := &services.BookQueryRequest{
SHA256: c.QueryParam("sha256"),
Title: c.QueryParam("title"),
VisibleLibraryIDs: visibleIDs,
}
resp, matchErr := services.NewBookMatchingService(cfg.Queries).QueryBooks(c.Request().Context(), req)
if matchErr != nil {
return c.HTML(http.StatusInternalServerError, "<div>Failed to search</div>")
}
var buf bytes.Buffer
if renderErr := templates.UnlinkedMatchCards(c.QueryParam("progress_id"), resp.Matches).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
})
frontendProtected.GET("/dashboard/week-widget", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
@@ -945,6 +996,29 @@ func registerFrontendRoutes(cfg *Config) {
}
return c.HTML(http.StatusOK, buf.String())
})
// Conflicts list partial: fresh list plus the stats bar as an
// out-of-band swap. The client refreshes through this instead of
// re-rendering conflict cards from JSON (rendering-ownership rule:
// templ renders, JS only swaps).
frontendProtected.GET("/conflicts/list", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
conflictsData, total, unresolved, dataErr := cfg.ConflictHandler.GetConflictsData(c)
if dataErr != nil {
log.Printf("GetConflictsData failed: %v", dataErr)
conflictsData = []handlers.ConflictDetailResponse{}
total = 0
unresolved = 0
}
var buf bytes.Buffer
if renderErr := templates.ConflictsRefreshFragment(user, conflictsData, total, unresolved).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
})
// Analytics page → /stats (the rebuilt reading-statistics page;
// nobody bookmarks orphan pages twice).
@@ -1118,7 +1192,8 @@ func registerFrontendRoutes(cfg *Config) {
var buf bytes.Buffer
archivedCount, _ := cfg.Queries.CountArchivedMediaItems(c.Request().Context())
err = templates.AdminLibrary(user, libData, userData, archivedCount).Render(c.Request().Context(), &buf)
libraryTypes := libraryTypeOptionsFor(c.Request().Context(), cfg)
err = templates.AdminLibrary(user, libData, userData, archivedCount, libraryTypes).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
+169
View File
@@ -10,6 +10,8 @@ import (
"time"
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/services"
"bookhoard/internal/utils"
"bookhoard/templates"
@@ -257,3 +259,170 @@ func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgty
}
return cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), uuidToPGType(userU))
}
// collectionDataFromRow builds the template collection payload shared by
// the collection detail page and its books-grid partial.
func collectionDataFromRow(collection database.Collections, collectionID string, isSmart bool, bookCount int) templates.CollectionData {
return templates.CollectionData{
ID: collectionID,
Name: collection.Name,
Description: collection.Description.String,
Color: collection.Color.String,
Icon: collection.Icon.String,
IsSystem: collection.IsSystemCollection.Bool,
IsSmart: isSmart,
BookCount: bookCount,
}
}
// collectionBooksForScope runs the per-scope book loading shared by the
// detail page and the books-grid partial: smart evaluation, a dashboard
// query section, or manual membership. Callers resolve the scope:
// libUUID/libScope/isAll/scoped per their own resolution rules.
func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, collUUID pgtype.UUID, collection database.Collections, collectionID string, libUUID pgtype.UUID, libScope pgtype.UUID, scoped bool, visibleIDs []pgtype.UUID) (books []handlers.BookInfo, isSmart bool, errCode string) {
colService := cfg.CollectionHandler.CollectionService()
isSmart = colService.IsSmartCollection(collection.AutoAssignRules)
libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
if isSmart {
// Smart (dynamic) collection: membership is evaluated live —
// visible libraries, live items only, any enabled rule matches.
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{
LibraryID: libScope,
VisibleIDs: visibleIDs,
})
if evalErr != nil {
return nil, isSmart, "books_load_error"
}
books = handlers.BookCardsFromMediaItems(matches, libTypes)
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
dashboardSvc := services.NewDashboardService(cfg.Queries)
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
if secErr != nil {
return nil, isSmart, "books_load_error"
}
for _, section := range sections {
if section.CollectionID.String() == collectionID {
books = handlers.BookCardsFromMediaItems(section.Items, libTypes)
break
}
}
} else {
if scoped && libUUID.Valid {
collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
database.GetCollectionItemsForDashboardParams{
CollectionID: collUUID,
LibraryID: libUUID,
Limit: pgtype.Int4{Int32: 1000, Valid: true},
})
if collErr != nil {
books = handlers.BookCardsFromCollectionDashRows(nil, libTypes)
} else {
// The query filters excluded rows in SQL.
books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes)
}
} else {
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), collUUID)
if collErr != nil {
collItems = nil
}
books = handlers.BookCardsFromCollectionItemRows(collItems, libTypes)
}
}
return books, isSmart, ""
}
// loadCollectionBooks resolves the books shown for a collection detail
// page: full library resolution (visible-library payload, folder
// reachability, counts) because the page render needs it. The books-grid
// partial uses the lighter loadCollectionBooksLite instead.
// errCode is "" on success, otherwise a renderErrorPage code.
func (cfg *Config) loadCollectionBooks(c *echo.Context, user templates.User, collection database.Collections, collectionID string) (books []handlers.BookInfo, libRes LibraryResolution, isSmart bool, errCode string) {
collUUID, parseErr := uuid.Parse(collectionID)
if parseErr != nil {
return nil, libRes, isSmart, "invalid_id"
}
userUUID, _ := uuid.Parse(user.ID)
libRes = resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
var libScope pgtype.UUID
if !libRes.IsAll {
if parsed, parseErr := uuid.Parse(libraryID); parseErr == nil {
libScope = pgtype.UUID{Bytes: parsed, Valid: true}
}
}
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
if visErr != nil {
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
visibleIDs = nil
}
scoped := libraryID != "" && !libRes.IsAll
books, isSmart, errCode = cfg.collectionBooksForScope(c, userUUID, pgtype.UUID{Bytes: collUUID, Valid: true}, collection, collectionID, libRes.LibUUID, libScope, scoped, visibleIDs)
return books, libRes, isSmart, errCode
}
// loadCollectionBooksLite is the books-grid partial's loader. The switcher
// refresh only needs "is the requested library visible, and its books" —
// so it validates the requested library against the caller's visible set
// directly (family-lens: out-of-set is rejected) and skips the page-only
// library resolution (folder reachability, counts, dropdown payload).
func (cfg *Config) loadCollectionBooksLite(c *echo.Context, userUUID uuid.UUID, collection database.Collections, collectionID string) (books []handlers.BookInfo, isSmart bool, errCode string) {
collUUID, parseErr := uuid.Parse(collectionID)
if parseErr != nil {
return nil, isSmart, "invalid_id"
}
libraryID := c.QueryParam("library_id")
var libUUID pgtype.UUID
if libraryID != "" {
parsed, parseErr := uuid.Parse(libraryID)
if parseErr != nil {
return nil, isSmart, "invalid_library_id"
}
set, _, setErr := cfg.LibraryService.VisibleLibrarySet(
c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if setErr != nil {
return nil, isSmart, "books_load_error"
}
if !set[libraryID] {
return nil, isSmart, "library_forbidden"
}
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
if visErr != nil {
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
visibleIDs = nil
}
return cfg.collectionBooksForScope(c, userUUID, pgtype.UUID{Bytes: collUUID, Valid: true}, collection, collectionID,
libUUID, libUUID, libraryID != "", visibleIDs)
}
// libraryTypeOptionsFor loads the library_types rows for the create-library
// dropdowns (admin page + setup wizard), ordered for display. On DB failure
// it falls back to the built-in type names so the form stays usable —
// CreateLibrary resolves the name against the DB, so this is never
// authoritative.
func libraryTypeOptionsFor(ctx context.Context, cfg *Config) []database.LibraryTypes {
types, err := cfg.LibraryService.GetLibraryTypes(ctx)
if err != nil {
log.Printf("libraryTypeOptionsFor: GetLibraryTypes failed, using built-in type names: %v", err)
for _, name := range []string{
services.LibraryTypeEbooks,
services.LibraryTypeComics,
services.LibraryTypeManga,
services.LibraryTypeAudiobooks,
} {
types = append(types, database.LibraryTypes{Name: name})
}
}
return templates.OrderedLibraryTypes(types)
}
+2 -1
View File
@@ -81,7 +81,8 @@ func registerSetupRoutes(cfg *Config) {
return c.Redirect(http.StatusFound, "/")
}
var buf bytes.Buffer
if err := templates.Setup().Render(c.Request().Context(), &buf); err != nil {
libraryTypes := libraryTypeOptionsFor(c.Request().Context(), cfg)
if err := templates.Setup(libraryTypes).Render(c.Request().Context(), &buf); err != nil {
log.Printf("Failed to render setup template: %v", err)
return c.HTML(http.StatusInternalServerError, "Failed to render setup page")
}
+41 -1
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"math"
"bookhoard/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
@@ -16,6 +18,10 @@ type BookMatch struct {
BookhoardUUID uuid.UUID `json:"bookhoard_uuid"`
Confidence float64 `json:"confidence"`
MatchMethod string `json:"match_method"`
// Display fields, filled during the family-lens pass in QueryBooks.
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
}
// BookQueryRequest represents a book query from a device
@@ -24,7 +30,11 @@ type BookQueryRequest struct {
SHA256 string `json:"sha256"`
Title string `json:"title"`
Author string `json:"author"`
FileSize int64 `json:"file_size"`
// VisibleLibraryIDs scopes results to the requesting user's visible
// libraries (family-lens, absent-from-results). Set server-side by the
// handler — never from client input. nil keeps legacy callers unfiltered.
VisibleLibraryIDs []string `json:"-"`
FileSize int64 `json:"file_size"`
}
// BookQueryResponse represents the response to a book query
@@ -106,6 +116,36 @@ func (s *BookMatchingService) QueryBooks(ctx context.Context, req *BookQueryRequ
}
}
// Family-lens: matches may only surface items from libraries the
// requesting user can see — a hidden library's book simply never
// matches (absent-from-results). Runs before determineAction so the
// suggested action reflects the filtered set. nil (unset) keeps
// legacy callers unfiltered.
// Enrich + scope in one pass over the cascade hits: display fields come
// from the resolved item, and when scoped the family-lens filter drops
// matches from libraries the requester cannot see
// (absent-from-results).
scoped := req.VisibleLibraryIDs != nil
allowed := make(map[string]bool, len(req.VisibleLibraryIDs))
for _, id := range req.VisibleLibraryIDs {
allowed[id] = true
}
filtered := matches[:0]
for _, m := range matches {
item, err := s.db.GetMediaItem(ctx, pgtype.UUID{Bytes: m.MediaItemID, Valid: true})
if err != nil || !item.LibraryID.Valid {
continue // vanished between cascade and filter — drop
}
m.Title = item.Title
m.Author = utils.PgTextString(item.Author)
m.CoverImagePath = utils.PgTextString(item.CoverImagePath)
if scoped && !allowed[uuid.UUID(item.LibraryID.Bytes).String()] {
continue
}
filtered = append(filtered, m)
}
matches = filtered
// Determine action
action := s.determineAction(matches)
+19 -56
View File
@@ -1,6 +1,8 @@
package templates
templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCount int64) {
import "bookhoard/internal/database"
templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCount int64, libraryTypes []database.LibraryTypes) {
<!DOCTYPE html>
<html lang="en">
<head>
@@ -11,7 +13,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body class={ "theme-" + user.Theme }>
<body x-data="admin" class={ "theme-" + user.Theme }>
@Header(user, "/admin/library")
<main class="p-8">
<div class="max-w-4xl">
@@ -88,9 +90,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
<div class="mb-6">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Type</label>
<select name="type" class="input" required>
<option value="ebooks">Ebooks</option>
<option value="comics">Comics</option>
<option value="manga">Manga</option>
@LibraryTypeOptions(libraryTypes)
</select>
</div>
<div class="flex justify-end space-x-3">
@@ -173,57 +173,20 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
</div>
</div>
</div>
<script>
function toggleLibraryPanel(btn) {
var libId = btn.dataset.libId;
var panel = document.getElementById('library-panel-' + libId);
if (panel.innerHTML.trim() !== '') {
panel.innerHTML = '';
} else {
htmx.ajax('GET', '/admin/library/' + libId + '/panel', {
target: '#library-panel-' + libId,
swap: 'innerHTML'
});
}
}
function openEditModal(id, name, description) {
document.getElementById('edit-library-form').setAttribute('hx-put', '/admin/library/' + id);
document.getElementById('edit-library-name').value = name;
document.getElementById('edit-library-desc').value = description;
htmx.process(document.getElementById('edit-library-form'));
document.getElementById('edit-library-modal').classList.remove('hidden');
}
function openDeleteModal(id, name) {
const btn = document.getElementById('delete-library-confirm');
btn.setAttribute('hx-delete', '/admin/library/' + id);
btn.setAttribute('hx-target', '#libraries-container');
btn.setAttribute('hx-swap', 'innerHTML');
document.getElementById('delete-library-name').textContent = name;
htmx.process(btn);
document.getElementById('delete-library-modal').classList.remove('hidden');
}
function openFolderBrowser(targetInputId, libraryId) {
const content = document.getElementById('folder-browser-content');
content.setAttribute('hx-get', '/admin/library/browse');
content.setAttribute('hx-vals', '{"target_input": "' + targetInputId + '", "library_id": "' + libraryId + '"}');
content.setAttribute('hx-trigger', 'load');
htmx.process(content);
document.getElementById('folder-browser-modal').classList.remove('hidden');
}
// Close the delete-library modal after a successful delete: the
// htmx swap already removes the deleted row from the libraries
// list, but the modal lives outside the swapped container and
// would otherwise stay open.
document.getElementById('delete-library-confirm').addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail.successful) {
document.getElementById('delete-library-modal').classList.add('hidden');
}
});
</script>
</body>
</html>
}
// LibraryTypeOptions renders the <option> list for a library-type <select>
// from the DB-backed library_types rows, so new types show up without
// template edits.
templ LibraryTypeOptions(types []database.LibraryTypes) {
for _, t := range types {
<option value={ t.Name }>{ LibraryTypeDisplayName(t.Name) }</option>
}
}
templ LibraryList(user User, libraries []LibraryData, users []User) {
if len(libraries) == 0 {
<div class="card text-center py-16">
@@ -273,7 +236,7 @@ templ LibraryList(user User, libraries []LibraryData, users []User) {
</button>
<button
data-lib-id={ library.ID }
onclick="toggleLibraryPanel(this)"
@click="toggleLibraryPanel($el)"
class="btn btn-secondary text-xs px-3 py-1.5"
>
@Icon("chevron-down", "h-4 w-4")
@@ -331,7 +294,7 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
type="button"
data-library-id={ libraryID }
data-target-input={ "folder-input-" + libraryID }
onclick="openFolderBrowser(this.dataset.targetInput, this.dataset.libraryId)"
@click="openFolderBrowser($el.dataset.targetInput, $el.dataset.libraryId)"
class="btn btn-secondary text-sm shrink-0"
>
@Icon("folder", "h-4 w-4")
@@ -392,7 +355,7 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
data-edit-id={ libraryID }
data-edit-name={ library.Name }
data-edit-desc={ library.Description }
onclick="openEditModal(this.dataset.editId, this.dataset.editName, this.dataset.editDesc)"
@click="openEditModal($el.dataset.editId, $el.dataset.editName, $el.dataset.editDesc)"
class="btn btn-secondary text-sm"
>
@Icon("edit", "h-4 w-4")
@@ -401,7 +364,7 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
<button
data-delete-id={ libraryID }
data-delete-name={ library.Name }
onclick="openDeleteModal(this.dataset.deleteId, this.dataset.deleteName)"
@click="openDeleteModal($el.dataset.deleteId, $el.dataset.deleteName)"
class="btn btn-danger text-sm"
>
@Icon("trash", "h-4 w-4")
File diff suppressed because it is too large. Load diff
+71 -62
View File
@@ -14,6 +14,76 @@ func pluralizeBooks(n int) string {
return "books"
}
// CollectionBooksGrid renders the content of #books-container: the
// collection's book cards, or the empty state. Shared by the collection
// detail page and the /collections/:id/books-grid partial route (the
// library switcher refreshes the grid through it — rendering ownership:
// templ renders, JS only swaps).
templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo) {
if len(books) == 0 {
<div id="empty-state" class="col-span-full card text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
}
for _, book := range books {
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
<div class="flex gap-4">
if !collection.IsSystem && !collection.IsSmart {
<div class="flex-shrink-0 pt-1">
<label class="flex items-center cursor-pointer p-2 -m-2">
<input
type="checkbox"
class="w-5 h-5"
:checked={ "selectedBooks.includes(" + JSONArg(book.MediaItemID) + ")" }
@change={ "toggleSelection(" + JSONArg(book.MediaItemID) + ")" }
/>
</label>
</div>
}
<div class="flex-1 min-w-0">
<a href={ "/media/" + book.MediaItemID }>
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">
{ book.Title }
</h3>
</a>
if book.Author != "" {
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">
by { book.Author }
</p>
}
</div>
<div class="flex-shrink-0 w-16 sm:w-20">
<a href={ "/media/" + book.MediaItemID }>
if book.CoverImagePath != "" {
<img
src={ book.CoverImagePath }
alt="Cover"
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
onerror="this.src='/static/placeholder-book.svg'"
/>
} else {
<img
src="/static/placeholder-book.svg"
alt="Cover"
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
/>
}
</a>
</div>
</div>
if !collection.IsSystem && !collection.IsSmart {
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button
@click={ "requestRemoveBook(" + JSONArg(book.MediaItemID) + ")" }
class="btn btn-secondary w-full"
>
@Icon("trash", "h-4 w-4")
<span>Remove from Collection</span>
</button>
</div>
}
</div>
}
}
templ Collection(user User, collections []CollectionData, errorMessage string) {
<!DOCTYPE html>
<html lang="en">
@@ -210,68 +280,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
</div>
</div>
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
if len(books) == 0 {
<div id="empty-state" class="col-span-full card text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
}
for _, book := range books {
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
<div class="flex gap-4">
if !collection.IsSystem && !collection.IsSmart {
<div class="flex-shrink-0 pt-1">
<label class="flex items-center cursor-pointer p-2 -m-2">
<input
type="checkbox"
class="w-5 h-5"
:checked={ "selectedBooks.includes(" + JSONArg(book.MediaItemID) + ")" }
@change={ "toggleSelection(" + JSONArg(book.MediaItemID) + ")" }
/>
</label>
</div>
}
<div class="flex-1 min-w-0">
<a href={ "/media/" + book.MediaItemID }>
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">
{ book.Title }
</h3>
</a>
if book.Author != "" {
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">
by { book.Author }
</p>
}
</div>
<div class="flex-shrink-0 w-16 sm:w-20">
<a href={ "/media/" + book.MediaItemID }>
if book.CoverImagePath != "" {
<img
src={ book.CoverImagePath }
alt="Cover"
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
onerror="this.src='/static/placeholder-book.svg'"
/>
} else {
<img
src="/static/placeholder-book.svg"
alt="Cover"
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
/>
}
</a>
</div>
</div>
if !collection.IsSystem && !collection.IsSmart {
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button
@click={ "requestRemoveBook(" + JSONArg(book.MediaItemID) + ")" }
class="btn btn-secondary w-full"
>
@Icon("trash", "h-4 w-4")
<span>Remove from Collection</span>
</button>
</div>
}
</div>
}
@CollectionBooksGrid(collection, books)
</div>
</div>
if !collection.IsSystem && !collection.IsSmart {
File diff suppressed because it is too large. Load diff
+88 -60
View File
@@ -2,6 +2,89 @@ package templates
import "bookhoard/internal/handlers"
// ConflictsListContent renders the inner content of #conflicts-list:
// bulk-select controls, the empty state, or the conflict cards. Shared by
// the conflicts page and the /conflicts/list partial route.
templ ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse) {
<div id="select-all-container" class="mb-4 hidden">
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" @change="toggleAllConflicts()"/>
<span class="text-sm font-semibold" style="color: var(--text-primary);">Select All</span>
</label>
</div>
if len(conflicts) == 0 {
<div class="card text-center py-16">
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
@Icon("check-circle", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
</div>
}
for _, conflict := range conflicts {
<div class="conflict-item card p-6" data-conflict-id={ conflict.ID } data-status={ conflict.ResolutionStatus } style="border-left-width: 4px; border-left-color: var(--status-warning);">
<div class="flex justify-between items-start mb-4 gap-4">
<div class="flex items-start space-x-4">
<input
type="checkbox"
class="conflict-checkbox w-5 h-5 rounded mt-1"
data-conflict-id={ conflict.ID }
@change="updateBulkActions()"
/>
<div class="flex items-start gap-3">
<span style="color: var(--status-warning);">
@Icon("alert", "h-5 w-5")
</span>
<div>
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3>
<p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p>
</div>
</div>
</div>
<button @click={ "showResolveModal(" + JSONArg(conflict.ID) + ")" } class="btn btn-primary">Resolve</button>
</div>
<div class="text-sm" style="color: var(--text-secondary)">
Created: { FormatInTimezone(conflict.CreatedAt, user.Timezone) }
if conflict.ResolvedBy != "" {
| Resolved by: { conflict.ResolvedBy }
}
</div>
</div>
}
<p id="filter-empty" class="hidden text-center p-4" style="color: var(--text-secondary)">
No conflicts match this filter.
</p>
}
// ConflictStatsBar renders the total/unresolved/resolved stat cards.
templ ConflictStatsBar(total int, unresolved int) {
<div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--text-primary);">{ total }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Total</div>
</div>
<div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--status-warning);">{ unresolved }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Unresolved</div>
</div>
<div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--status-success);">{ total - unresolved }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Resolved</div>
</div>
}
// ConflictsRefreshFragment is the /conflicts/list partial response: a
// fresh list plus the stats bar as an out-of-band swap. The client swaps
// this instead of re-rendering conflict cards from JSON (rendering
// ownership: templ renders, JS only swaps).
templ ConflictsRefreshFragment(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int) {
<div id="conflicts-list" class="space-y-4">
@ConflictsListContent(user, conflicts)
</div>
<div id="stats-bar" class="mt-4 grid grid-cols-3 gap-4 max-w-lg" hx-swap-oob="true">
@ConflictStatsBar(total, unresolved)
</div>
}
templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage string) {
<!DOCTYPE html>
<html lang="en">
@@ -30,7 +113,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
<div class="flex space-x-3">
<select
id="status-filter"
onchange="filterConflicts()"
@change="filterConflicts()"
class="input"
>
<option value="unresolved">Unresolved Only</option>
@@ -41,18 +124,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
</div>
</div>
<div id="stats-bar" class="mt-4 grid grid-cols-3 gap-4 max-w-lg">
<div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--text-primary);">{ total }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Total</div>
</div>
<div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--status-warning);">{ unresolved }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Unresolved</div>
</div>
<div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--status-success);">{ total - unresolved }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Resolved</div>
</div>
@ConflictStatsBar(total, unresolved)
</div>
<div id="bulk-actions" class="mt-4 hidden">
<div class="card p-4 flex items-center justify-between gap-4 flex-wrap">
@@ -69,11 +141,11 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
</select>
</div>
<div class="flex space-x-2">
<button @click="bulkResolve()" class="btn btn-primary text-sm">
<button @click="resolveSelected()" class="btn btn-primary text-sm">
@Icon("check", "h-4 w-4")
Resolve Selected
</button>
<button @click="bulkDismiss()" class="btn btn-danger text-sm">
<button @click="dismissSelected()" class="btn btn-danger text-sm">
Dismiss Selected
</button>
</div>
@@ -92,51 +164,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
</div>
<div id="conflicts-list" class="space-y-4">
<div id="select-all-container" class="mb-4 hidden">
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" onchange="toggleAllConflicts()"/>
<span class="text-sm font-semibold" style="color: var(--text-primary);">Select All</span>
</label>
</div>
if len(conflicts) == 0 {
<div class="card text-center py-16">
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
@Icon("check-circle", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
</div>
}
for _, conflict := range conflicts {
<div class="conflict-item card p-6" data-conflict-id={ conflict.ID } style="border-left-width: 4px; border-left-color: var(--status-warning);">
<div class="flex justify-between items-start mb-4 gap-4">
<div class="flex items-start space-x-4">
<input
type="checkbox"
class="conflict-checkbox w-5 h-5 rounded mt-1"
data-conflict-id={ conflict.ID }
onchange="updateBulkActions()"
/>
<div class="flex items-start gap-3">
<span style="color: var(--status-warning);">
@Icon("alert", "h-5 w-5")
</span>
<div>
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3>
<p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p>
</div>
</div>
</div>
<button @click={ "showResolveModal(" + JSONArg(conflict.ID) + ")" } class="btn btn-primary">Resolve</button>
</div>
<div class="text-sm" style="color: var(--text-secondary)">
Created: { FormatInTimezone(conflict.CreatedAt, user.Timezone) }
if conflict.ResolvedBy != "" {
| Resolved by: { conflict.ResolvedBy }
}
</div>
</div>
}
@ConflictsListContent(user, conflicts)
</div>
<div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-4xl my-8" style="box-shadow: var(--shadow-pop);">
+278 -138
View File
@@ -10,7 +10,10 @@ import templruntime "github.com/a-h/templ/runtime"
import "bookhoard/internal/handlers"
func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage string) templ.Component {
// ConflictsListContent renders the inner content of #conflicts-list:
// bulk-select controls, the empty state, or the conflict cards. Shared by
// the conflicts page and the /conflicts/list partial route.
func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse) 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 {
@@ -31,105 +34,12 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Sync Conflicts - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 = []any{"theme-" + user.Theme}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body x-data=\"conflicts\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Header(user, "/conflicts").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex justify-between items-center gap-4 flex-wrap\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: color-mix(in srgb, var(--status-warning) 16%, transparent); color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Sync Conflicts</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Resolve conflicts when reading progress differs across devices</p></div><div class=\"flex space-x-3\"><select id=\"status-filter\" onchange=\"filterConflicts()\" class=\"input\"><option value=\"unresolved\">Unresolved Only</option> <option value=\"all\">All Conflicts</option> <option value=\"resolved\">Resolved Only</option></select> <button @click=\"dismissAllResolved()\" class=\"btn btn-secondary\">Dismiss All Resolved</button></div></div><div id=\"stats-bar\" class=\"mt-4 grid grid-cols-3 gap-4 max-w-lg\"><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(total)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 45, Col: 82}
}
_, 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, 6, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Total</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 49, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Unresolved</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 53, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Resolved</div></div></div><div id=\"bulk-actions\" class=\"mt-4 hidden\"><div class=\"card p-4 flex items-center justify-between gap-4 flex-wrap\"><div class=\"flex items-center space-x-4\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\"><span id=\"selected-count\">0</span> conflicts selected</span> <select id=\"bulk-strategy\" class=\"input w-auto\"><option value=\"most_recent\">Most Recent</option> <option value=\"highest_progress\">Highest Progress</option></select></div><div class=\"flex space-x-2\"><button @click=\"bulkResolve()\" class=\"btn btn-primary text-sm\">")
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, 9, "Resolve Selected</button> <button @click=\"bulkDismiss()\" class=\"btn btn-danger text-sm\">Dismiss Selected</button></div></div></div></div><div id=\"loading\" class=\"hidden text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading conflicts...</p></div><div id=\"empty-state\" class=\"hidden card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check-circle", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Conflicts</h3><p style=\"color: var(--text-secondary)\">Your devices are in sync! No conflicts to resolve.</p></div><div id=\"conflicts-list\" class=\"space-y-4\"><div id=\"select-all-container\" class=\"mb-4 hidden\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"checkbox\" id=\"select-all-conflicts\" class=\"w-5 h-5 rounded\" onchange=\"toggleAllConflicts()\"> <span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Select All</span></label></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"select-all-container\" class=\"mb-4 hidden\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"checkbox\" id=\"select-all-conflicts\" class=\"w-5 h-5 rounded\" @change=\"toggleAllConflicts()\"> <span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Select All</span></label></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(conflicts) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -137,39 +47,52 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Conflicts</h3><p style=\"color: var(--text-secondary)\">Your devices are in sync! No conflicts to resolve.</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Conflicts</h3><p style=\"color: var(--text-secondary)\">Your devices are in sync! No conflicts to resolve.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, conflict := range conflicts {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"conflict-item card p-6\" data-conflict-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"conflict-item card p-6\" data-conflict-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 111, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 25, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" style=\"border-left-width: 4px; border-left-color: var(--status-warning);\"><div class=\"flex justify-between items-start mb-4 gap-4\"><div class=\"flex items-start space-x-4\"><input type=\"checkbox\" class=\"conflict-checkbox w-5 h-5 rounded mt-1\" data-conflict-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" data-status=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ResolutionStatus)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 117, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 25, Col: 114}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" onchange=\"updateBulkActions()\"><div class=\"flex items-start gap-3\"><span style=\"color: var(--status-warning);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" style=\"border-left-width: 4px; border-left-color: var(--status-warning);\"><div class=\"flex justify-between items-start mb-4 gap-4\"><div class=\"flex items-start space-x-4\"><input type=\"checkbox\" class=\"conflict-checkbox w-5 h-5 rounded mt-1\" data-conflict-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 31, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" @change=\"updateBulkActions()\"><div class=\"flex items-start gap-3\"><span style=\"color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -177,83 +100,300 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span><div><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><div><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle)
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 125, Col: 105}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 39, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Type: ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Type: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 126, Col: 96}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 40, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p></div></div></div><button @click=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</p></div></div></div><button @click=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("showResolveModal(" + JSONArg(conflict.ID) + ")")
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("showResolveModal(" + JSONArg(conflict.ID) + ")")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 130, Col: 73}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 44, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"btn btn-primary\">Resolve</button></div><div class=\"text-sm\" style=\"color: var(--text-secondary)\">Created: ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" class=\"btn btn-primary\">Resolve</button></div><div class=\"text-sm\" style=\"color: var(--text-secondary)\">Created: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 133, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 47, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if conflict.ResolvedBy != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "| Resolved by: ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "| Resolved by: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 135, Col: 45}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 49, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
_, 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, 22, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div><div id=\"conflict-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-4xl my-8\" 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)\">Resolve Conflict</h2><p class=\"text-sm\" id=\"conflict-book-title\" style=\"color: var(--text-secondary);\">Book Title</p></div><button @click=\"hideResolveModal()\" class=\"icon-btn\" aria-label=\"Close\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p id=\"filter-empty\" class=\"hidden text-center p-4\" style=\"color: var(--text-secondary)\">No conflicts match this filter.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// ConflictStatsBar renders the total/unresolved/resolved stat cards.
func ConflictStatsBar(total int, unresolved int) 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_Var10 := templ.GetChildren(ctx)
if templ_7745c5c3_Var10 == nil {
templ_7745c5c3_Var10 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(total)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 62, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Total</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 66, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Unresolved</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 70, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Resolved</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// ConflictsRefreshFragment is the /conflicts/list partial response: a
// fresh list plus the stats bar as an out-of-band swap. The client swaps
// this instead of re-rendering conflict cards from JSON (rendering
// ownership: templ renders, JS only swaps).
func ConflictsRefreshFragment(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int) 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_Var14 := templ.GetChildren(ctx)
if templ_7745c5c3_Var14 == nil {
templ_7745c5c3_Var14 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div id=\"conflicts-list\" class=\"space-y-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ConflictsListContent(user, conflicts).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div><div id=\"stats-bar\" class=\"mt-4 grid grid-cols-3 gap-4 max-w-lg\" hx-swap-oob=\"true\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ConflictStatsBar(total, unresolved).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage 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_Var15 := templ.GetChildren(ctx)
if templ_7745c5c3_Var15 == nil {
templ_7745c5c3_Var15 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Sync Conflicts - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 = []any{"theme-" + user.Theme}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<body x-data=\"conflicts\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var16).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Header(user, "/conflicts").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex justify-between items-center gap-4 flex-wrap\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: color-mix(in srgb, var(--status-warning) 16%, transparent); color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Sync Conflicts</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Resolve conflicts when reading progress differs across devices</p></div><div class=\"flex space-x-3\"><select id=\"status-filter\" @change=\"filterConflicts()\" class=\"input\"><option value=\"unresolved\">Unresolved Only</option> <option value=\"all\">All Conflicts</option> <option value=\"resolved\">Resolved Only</option></select> <button @click=\"dismissAllResolved()\" class=\"btn btn-secondary\">Dismiss All Resolved</button></div></div><div id=\"stats-bar\" class=\"mt-4 grid grid-cols-3 gap-4 max-w-lg\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ConflictStatsBar(total, unresolved).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><div id=\"bulk-actions\" class=\"mt-4 hidden\"><div class=\"card p-4 flex items-center justify-between gap-4 flex-wrap\"><div class=\"flex items-center space-x-4\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\"><span id=\"selected-count\">0</span> conflicts selected</span> <select id=\"bulk-strategy\" class=\"input w-auto\"><option value=\"most_recent\">Most Recent</option> <option value=\"highest_progress\">Highest Progress</option></select></div><div class=\"flex space-x-2\"><button @click=\"resolveSelected()\" class=\"btn btn-primary text-sm\">")
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, 29, "Resolve Selected</button> <button @click=\"dismissSelected()\" class=\"btn btn-danger text-sm\">Dismiss Selected</button></div></div></div></div><div id=\"loading\" class=\"hidden text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading conflicts...</p></div><div id=\"empty-state\" class=\"hidden card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check-circle", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Conflicts</h3><p style=\"color: var(--text-secondary)\">Your devices are in sync! No conflicts to resolve.</p></div><div id=\"conflicts-list\" class=\"space-y-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ConflictsListContent(user, conflicts).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div><div id=\"conflict-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-4xl my-8\" 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)\">Resolve Conflict</h2><p class=\"text-sm\" id=\"conflict-book-title\" style=\"color: var(--text-secondary);\">Book Title</p></div><button @click=\"hideResolveModal()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -261,7 +401,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</button></div><form id=\"conflict-resolution-form\" @submit=\"handleResolveSubmit($event)\"><input type=\"hidden\" id=\"conflict-id\"><div id=\"conflict-details\" class=\"mb-6\"></div><div class=\"card p-4 mb-6\"><h3 class=\"font-semibold mb-4\" style=\"color: var(--text-primary)\">Resolution Options</h3><div class=\"space-y-3 mb-6\"><label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"koreader\" class=\"w-4 h-4\" required><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep KOReader Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from your KOReader device</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"kobo\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep Kobo Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from your Kobo device</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"web\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep Web Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from the web interface</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"manual\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Manual Override</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Specify a custom progress value</p></div></label></div><div id=\"manual-override\" class=\"hidden mb-4 p-4 rounded-lg\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary);\">Manual Progress</h4><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Percentage (0-100)</label> <input type=\"number\" id=\"manual-percentage\" min=\"0\" max=\"100\" step=\"0.01\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Page Number</label> <input type=\"number\" id=\"manual-page\" min=\"1\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">EPUB CFI</label> <input type=\"text\" id=\"manual-epubcfi\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Chapter</label> <input type=\"number\" id=\"manual-chapter\" min=\"1\" class=\"input\"></div></div></div><div class=\"mb-6\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"apply-to-all\" class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary);\">Apply this resolution to all future conflicts</span></label></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Resolution Reason (optional)</label> <input type=\"text\" id=\"resolution-reason\" class=\"input\" placeholder=\"e.g., Device was offline, using most recent progress\"></div></div><div class=\"flex justify-end space-x-3 flex-wrap\"><button type=\"button\" @click=\"hideResolveModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"deleteConflict()\" class=\"btn btn-danger\">Dismiss</button> <button type=\"submit\" class=\"btn btn-primary\">Resolve Conflict</button></div></form></div></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</button></div><form id=\"conflict-resolution-form\" @submit=\"handleResolveSubmit($event)\"><input type=\"hidden\" id=\"conflict-id\"><div id=\"conflict-details\" class=\"mb-6\"></div><div class=\"card p-4 mb-6\"><h3 class=\"font-semibold mb-4\" style=\"color: var(--text-primary)\">Resolution Options</h3><div class=\"space-y-3 mb-6\"><label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"koreader\" class=\"w-4 h-4\" required><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep KOReader Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from your KOReader device</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"kobo\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep Kobo Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from your Kobo device</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"web\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep Web Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from the web interface</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"manual\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Manual Override</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Specify a custom progress value</p></div></label></div><div id=\"manual-override\" class=\"hidden mb-4 p-4 rounded-lg\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary);\">Manual Progress</h4><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Percentage (0-100)</label> <input type=\"number\" id=\"manual-percentage\" min=\"0\" max=\"100\" step=\"0.01\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Page Number</label> <input type=\"number\" id=\"manual-page\" min=\"1\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">EPUB CFI</label> <input type=\"text\" id=\"manual-epubcfi\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Chapter</label> <input type=\"number\" id=\"manual-chapter\" min=\"1\" class=\"input\"></div></div></div><div class=\"mb-6\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"apply-to-all\" class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary);\">Apply this resolution to all future conflicts</span></label></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Resolution Reason (optional)</label> <input type=\"text\" id=\"resolution-reason\" class=\"input\" placeholder=\"e.g., Device was offline, using most recent progress\"></div></div><div class=\"flex justify-end space-x-3 flex-wrap\"><button type=\"button\" @click=\"hideResolveModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"deleteConflict()\" class=\"btn btn-danger\">Dismiss</button> <button type=\"submit\" class=\"btn btn-primary\">Resolve Conflict</button></div></form></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -269,7 +409,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+19 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt"
)
templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, activeLibType string, errorMessage string) {
templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, activeLibType string, errorMessage string, unlinkedCount int) {
<!DOCTYPE html>
<html lang="en">
<head>
@@ -23,6 +23,24 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
re-render wipes that container's innerHTML, and anything
server-rendered inside it dies on the first library
switch. The widget re-fetches itself via htmx instead. -->
if unlinkedCount > 0 {
<div class="mx-auto container px-4 sm:px-6 lg:px-8 pt-4">
<a
href="/unlinked-books"
class="card p-4 flex items-center gap-3 transition-colors hover:bg-surface-hover"
style="border-left-width: 4px; border-left-color: var(--status-warning);"
>
<span class="grid place-items-center h-8 w-8 rounded-lg shrink-0" style="background-color: color-mix(in srgb, var(--status-warning) 16%, transparent); color: var(--status-warning);">
@Icon("alert", "h-4 w-4")
</span>
<span class="flex-1 text-sm" style="color: var(--text-primary)">
<span class="font-semibold">{ fmt.Sprintf("%d", unlinkedCount) }</span>
{ pluralizeBooks(unlinkedCount) } on your devices could not be identified — reading progress for them is paused until they are linked.
</span>
<span class="text-sm font-medium shrink-0" style="color: var(--accent)">Review →</span>
</a>
</div>
}
if week != nil {
<div class="w-full px-4 sm:px-6 lg:px-8 pt-6">
@DashboardWeekWidget(*week, activeLibType)
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -43,8 +43,8 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title stri
</div>
<div
id="loading-spinner"
class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
style="background-color: var(--bg-primary);"
class="hidden fixed inset-0 flex items-center justify-center z-50"
style="background-color: color-mix(in srgb, var(--bg-primary) 55%, transparent);"
>
<div class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
</div>
+1 -1
View File
@@ -275,7 +275,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50\" style=\"background-color: var(--bg-primary);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 flex items-center justify-center z-50\" style=\"background-color: color-mix(in srgb, var(--bg-primary) 55%, transparent);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+93
View File
@@ -0,0 +1,93 @@
package templates
import (
"context"
"strings"
"testing"
"bookhoard/internal/handlers"
)
// The rendering-ownership partials must render standalone (they are
// fetched by htmx.ajax at runtime) and must never leak templ literal
// expressions into their markup.
func TestRenderingPartialsRender(t *testing.T) {
var b strings.Builder
col := CollectionData{ID: "c1", Name: "Test Shelf", IsSmart: false}
books := []handlers.BookInfo{{MediaItemID: "m1", Title: "A Book", Author: "An Author"}}
if err := CollectionBooksGrid(col, books).Render(context.Background(), &b); err != nil {
t.Fatalf("CollectionBooksGrid: %v", err)
}
out := b.String()
for _, want := range []string{"A Book", `data-media-id="m1"`, "Remove from Collection"} {
if !strings.Contains(out, want) {
t.Errorf("books grid missing %q", want)
}
}
// Empty collection renders the empty state, not a literal loop.
b.Reset()
if err := CollectionBooksGrid(col, nil).Render(context.Background(), &b); err != nil {
t.Fatalf("CollectionBooksGrid empty: %v", err)
}
if !strings.Contains(b.String(), "No books in this collection yet.") {
t.Error("books grid empty state missing")
}
b.Reset()
user := User{ID: "u1", Timezone: "UTC"}
conflicts := []handlers.ConflictDetailResponse{{ID: "cf1", MediaItemTitle: "War and Peace", ConflictType: "progress"}}
if err := ConflictsRefreshFragment(user, conflicts, 3, 1).Render(context.Background(), &b); err != nil {
t.Fatalf("ConflictsRefreshFragment: %v", err)
}
out = b.String()
for _, want := range []string{`id="conflicts-list"`, `id="stats-bar"`, "hx-swap-oob", "Resolve"} {
if !strings.Contains(out, want) {
t.Errorf("conflicts fragment missing %q", want)
}
}
}
// The unlinked-books queue banner renders only when the queue is
// non-empty (hidden-unless-needed).
func TestDashboardUnlinkedBanner(t *testing.T) {
render := func(count int) string {
var b strings.Builder
err := Dashboard(User{ID: "u1"}, nil, nil, nil, "", nil, 0, nil, "", "", count).Render(context.Background(), &b)
if err != nil {
t.Fatalf("render: %v", err)
}
return b.String()
}
if out := render(0); strings.Contains(out, "/unlinked-books") {
t.Error("banner rendered with empty queue")
}
out := render(2)
if !strings.Contains(out, "/unlinked-books") || !strings.Contains(out, "could not be identified") {
t.Error("banner missing with non-empty queue")
}
}
// The unlinked-books page renders queue rows standalone.
func TestUnlinkedBooksPageRender(t *testing.T) {
var b strings.Builder
user := User{ID: "u1", Timezone: "UTC"}
books := []UnlinkedBookData{{
ProgressID: "ub1",
DeviceName: "Kobo Clara",
DeviceType: "kobo",
FilePath: "/mnt/onboard/book.epub",
SHA256: "abc123",
TitleFromDevice: "Some Sideloaded Book",
LastSyncTime: "2026-10-18 12:00",
}}
if err := UnlinkedBooks(user, books).Render(context.Background(), &b); err != nil {
t.Fatalf("render: %v", err)
}
out := b.String()
for _, want := range []string{"Some Sideloaded Book", "abc123", `data-progress-id="ub1"`, "searchMatches"} {
if !strings.Contains(out, want) {
t.Errorf("unlinked page missing %q", want)
}
}
}
+4 -4
View File
@@ -1,6 +1,8 @@
package templates
templ Setup() {
import "bookhoard/internal/database"
templ Setup(libraryTypes []database.LibraryTypes) {
<!DOCTYPE html>
<html lang="en">
<head>
@@ -211,9 +213,7 @@ templ Setup() {
x-model="newLibrary.type"
class="input mt-2"
>
<option value="ebooks">Ebooks</option>
<option value="comics">Comics</option>
<option value="manga">Manga</option>
@LibraryTypeOptions(libraryTypes)
</select>
</div>
<div>
+24 -14
View File
@@ -8,7 +8,9 @@ package templates
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func Setup() templ.Component {
import "bookhoard/internal/database"
func Setup(libraryTypes []database.LibraryTypes) 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 {
@@ -85,7 +87,15 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</button></div></template></div><form @submit.prevent=\"addLibrary()\" class=\"space-y-4 card p-4\" style=\"background-color: var(--bg-primary);\"><div><label for=\"lib-name\" class=\"block text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Library Name</label> <input type=\"text\" id=\"lib-name\" x-model=\"newLibrary.name\" placeholder=\"My Ebook Library\" class=\"input mt-2\" required></div><div><label for=\"lib-type\" class=\"block text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Library Type</label> <select id=\"lib-type\" x-model=\"newLibrary.type\" class=\"input mt-2\"><option value=\"ebooks\">Ebooks</option> <option value=\"comics\">Comics</option> <option value=\"manga\">Manga</option></select></div><div><label for=\"lib-desc\" class=\"block text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Description (optional)</label> <input type=\"text\" id=\"lib-desc\" x-model=\"newLibrary.description\" placeholder=\"Optional\" class=\"input mt-2\"></div><button type=\"submit\" class=\"btn btn-secondary w-full\" :disabled=\"loading || !newLibrary.name.trim()\" x-text=\"loading ? 'Creating…' : '+ Add This Library'\"></button></form><div class=\"flex justify-between mt-6\"><button type=\"button\" @click=\"currentStep = 1\" class=\"btn btn-secondary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</button></div></template></div><form @submit.prevent=\"addLibrary()\" class=\"space-y-4 card p-4\" style=\"background-color: var(--bg-primary);\"><div><label for=\"lib-name\" class=\"block text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Library Name</label> <input type=\"text\" id=\"lib-name\" x-model=\"newLibrary.name\" placeholder=\"My Ebook Library\" class=\"input mt-2\" required></div><div><label for=\"lib-type\" class=\"block text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Library Type</label> <select id=\"lib-type\" x-model=\"newLibrary.type\" class=\"input mt-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = LibraryTypeOptions(libraryTypes).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</select></div><div><label for=\"lib-desc\" class=\"block text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Description (optional)</label> <input type=\"text\" id=\"lib-desc\" x-model=\"newLibrary.description\" placeholder=\"Optional\" class=\"input mt-2\"></div><button type=\"submit\" class=\"btn btn-secondary w-full\" :disabled=\"loading || !newLibrary.name.trim()\" x-text=\"loading ? 'Creating…' : '+ Add This Library'\"></button></form><div class=\"flex justify-between mt-6\"><button type=\"button\" @click=\"currentStep = 1\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -93,7 +103,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Back</button> <button type=\"button\" @click=\"currentStep = 3\" class=\"btn btn-primary\"><span x-text=\"libraries.length > 0 ? 'Next: Configure Folders' : 'Skip – Continue'\"></span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Back</button> <button type=\"button\" @click=\"currentStep = 3\" class=\"btn btn-primary\"><span x-text=\"libraries.length > 0 ? 'Next: Configure Folders' : 'Skip – Continue'\"></span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -101,7 +111,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</button></div></div><!-- ==================== STEP 3: Configure Folders ==================== --><div x-show=\"currentStep === 3\" class=\"card p-6\"><h2 class=\"text-2xl font-bold mb-2\" style=\"color: var(--text-primary)\">Configure Library Folders</h2><p class=\"mb-6\" style=\"color: var(--text-secondary)\">Add the filesystem folders where your media files are stored.</p><div x-show=\"libraries.length === 0\" class=\"text-center py-8\" style=\"color: var(--text-secondary)\"><p>No libraries created. You can add folders later from the admin panel.</p></div><div class=\"space-y-6\"><template x-for=\"lib in libraries\" :key=\"lib.id\"><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><div class=\"flex items-center gap-2 mb-3\"><span class=\"grid place-items-center h-8 w-8 rounded-lg\" style=\"background-color: var(--accent-muted); color: var(--accent);\"><span class=\"inline-flex\" x-show=\"lib.type === 'ebooks'\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</button></div></div><!-- ==================== STEP 3: Configure Folders ==================== --><div x-show=\"currentStep === 3\" class=\"card p-6\"><h2 class=\"text-2xl font-bold mb-2\" style=\"color: var(--text-primary)\">Configure Library Folders</h2><p class=\"mb-6\" style=\"color: var(--text-secondary)\">Add the filesystem folders where your media files are stored.</p><div x-show=\"libraries.length === 0\" class=\"text-center py-8\" style=\"color: var(--text-secondary)\"><p>No libraries created. You can add folders later from the admin panel.</p></div><div class=\"space-y-6\"><template x-for=\"lib in libraries\" :key=\"lib.id\"><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><div class=\"flex items-center gap-2 mb-3\"><span class=\"grid place-items-center h-8 w-8 rounded-lg\" style=\"background-color: var(--accent-muted); color: var(--accent);\"><span class=\"inline-flex\" x-show=\"lib.type === 'ebooks'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -109,7 +119,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span> <span class=\"inline-flex\" x-show=\"lib.type === 'comics'\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span> <span class=\"inline-flex\" x-show=\"lib.type === 'comics'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -117,7 +127,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span> <span class=\"inline-flex\" x-show=\"lib.type === 'manga'\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span> <span class=\"inline-flex\" x-show=\"lib.type === 'manga'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -125,7 +135,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span></span><h4 class=\"font-semibold\" style=\"color: var(--text-primary)\" x-text=\"lib.name\"></h4></div><div class=\"space-y-1 mb-3\" x-show=\"lib.folders && lib.folders.length > 0\"><template x-for=\"(folder, fIdx) in (lib.folders || [])\" :key=\"folder\"><div class=\"flex items-center justify-between p-2 rounded-lg text-sm\" style=\"background-color: var(--bg-secondary);\"><span class=\"flex items-center gap-2 truncate\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span></span><h4 class=\"font-semibold\" style=\"color: var(--text-primary)\" x-text=\"lib.name\"></h4></div><div class=\"space-y-1 mb-3\" x-show=\"lib.folders && lib.folders.length > 0\"><template x-for=\"(folder, fIdx) in (lib.folders || [])\" :key=\"folder\"><div class=\"flex items-center justify-between p-2 rounded-lg text-sm\" style=\"background-color: var(--bg-secondary);\"><span class=\"flex items-center gap-2 truncate\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -133,7 +143,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"truncate\" x-text=\"folder\"></span></span> <button type=\"button\" @click=\"removeFolder(lib.id, folder, fIdx)\" class=\"icon-btn shrink-0\" style=\"color: var(--status-danger);\" aria-label=\"Remove folder\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<span class=\"truncate\" x-text=\"folder\"></span></span> <button type=\"button\" @click=\"removeFolder(lib.id, folder, fIdx)\" class=\"icon-btn shrink-0\" style=\"color: var(--status-danger);\" aria-label=\"Remove folder\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -141,7 +151,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</button></div></template></div><div class=\"flex gap-2\"><input type=\"text\" :placeholder=\"'/path/to/' + lib.type\" :id=\"'folderInput-' + lib.id\" class=\"input flex-1 text-sm\"> <button type=\"button\" @click=\"showFolderBrowser('folderInput-' + lib.id)\" class=\"btn btn-secondary px-3 text-xs\">Browse</button> <button type=\"button\" @click=\"addFolderToLibrary(lib)\" class=\"btn btn-primary px-3 text-xs\">Add</button></div></div></template></div><div class=\"flex justify-between mt-6\"><button type=\"button\" @click=\"currentStep = 2\" class=\"btn btn-secondary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button></div></template></div><div class=\"flex gap-2\"><input type=\"text\" :placeholder=\"'/path/to/' + lib.type\" :id=\"'folderInput-' + lib.id\" class=\"input flex-1 text-sm\"> <button type=\"button\" @click=\"showFolderBrowser('folderInput-' + lib.id)\" class=\"btn btn-secondary px-3 text-xs\">Browse</button> <button type=\"button\" @click=\"addFolderToLibrary(lib)\" class=\"btn btn-primary px-3 text-xs\">Add</button></div></div></template></div><div class=\"flex justify-between mt-6\"><button type=\"button\" @click=\"currentStep = 2\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -149,7 +159,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "Back</button> <button type=\"button\" @click=\"currentStep = 4\" class=\"btn btn-primary\">Next: Scan")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Back</button> <button type=\"button\" @click=\"currentStep = 4\" class=\"btn btn-primary\">Next: Scan")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -157,7 +167,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</button></div></div><!-- ==================== STEP 4: Scan & Finish ==================== --><div x-show=\"currentStep === 4\" class=\"card p-6\"><h2 class=\"text-2xl font-bold mb-2\" style=\"color: var(--text-primary)\">Scan & Finish</h2><p class=\"mb-6\" style=\"color: var(--text-secondary)\">Review your configuration and start the initial scan.</p><div class=\"space-y-3 mb-6\"><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><p class=\"text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Server URL</p><p style=\"color: var(--text-primary)\" x-text=\"admin.baseUrl\"></p></div><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><p class=\"text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Administrator</p><p style=\"color: var(--text-primary)\" x-text=\"admin.username + ' (' + admin.email + ')'\"></p></div><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><p class=\"text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Libraries (<span x-text=\"libraries.length\"></span>)</p><template x-for=\"lib in libraries\" :key=\"lib.id\"><div class=\"flex items-start justify-between text-sm py-1\"><div class=\"flex items-center gap-2\"><span style=\"color: var(--text-primary)\" x-text=\"lib.name\"></span> <span class=\"chip\" x-text=\"lib.type\"></span></div><div class=\"text-right\" style=\"color: var(--text-secondary)\"><span x-text=\"(lib.folders || []).length + ' folder(s)'\"></span></div></div></template></div></div><div x-show=\"scanStarted\" class=\"mb-6 space-y-3\"><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Scan Progress</h3><div id=\"library-progress-list\" class=\"space-y-3\"><template x-for=\"job in scanJobs\" :key=\"job.jobId\"><div class=\"card p-3\" style=\"background-color: var(--bg-primary);\"><div class=\"flex justify-between items-center mb-2\"><span class=\"font-medium\" style=\"color: var(--text-primary)\" x-text=\"job.libraryName\"></span> <span class=\"text-sm\" style=\"color: var(--text-secondary)\" x-text=\"job.statusText\"></span></div><div class=\"w-full rounded-full h-2\" style=\"background-color: var(--bg-secondary)\"><div class=\"h-2 rounded-full transition-all duration-500\" :style=\"'width: ' + job.progress + '%; background-color: var(--accent);'\"></div></div></div></template></div><div x-show=\"scanComplete\" class=\"card p-4 text-sm\" style=\"border-color: var(--status-success); background-color: color-mix(in srgb, var(--status-success) 12%, transparent); color: var(--status-success);\">Initial scan complete! Your libraries are ready to use.</div></div><div class=\"flex justify-between mt-6\"><button type=\"button\" @click=\"currentStep = 3\" class=\"btn btn-secondary\" :disabled=\"loading\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</button></div></div><!-- ==================== STEP 4: Scan & Finish ==================== --><div x-show=\"currentStep === 4\" class=\"card p-6\"><h2 class=\"text-2xl font-bold mb-2\" style=\"color: var(--text-primary)\">Scan & Finish</h2><p class=\"mb-6\" style=\"color: var(--text-secondary)\">Review your configuration and start the initial scan.</p><div class=\"space-y-3 mb-6\"><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><p class=\"text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Server URL</p><p style=\"color: var(--text-primary)\" x-text=\"admin.baseUrl\"></p></div><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><p class=\"text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Administrator</p><p style=\"color: var(--text-primary)\" x-text=\"admin.username + ' (' + admin.email + ')'\"></p></div><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><p class=\"text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Libraries (<span x-text=\"libraries.length\"></span>)</p><template x-for=\"lib in libraries\" :key=\"lib.id\"><div class=\"flex items-start justify-between text-sm py-1\"><div class=\"flex items-center gap-2\"><span style=\"color: var(--text-primary)\" x-text=\"lib.name\"></span> <span class=\"chip\" x-text=\"lib.type\"></span></div><div class=\"text-right\" style=\"color: var(--text-secondary)\"><span x-text=\"(lib.folders || []).length + ' folder(s)'\"></span></div></div></template></div></div><div x-show=\"scanStarted\" class=\"mb-6 space-y-3\"><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Scan Progress</h3><div id=\"library-progress-list\" class=\"space-y-3\"><template x-for=\"job in scanJobs\" :key=\"job.jobId\"><div class=\"card p-3\" style=\"background-color: var(--bg-primary);\"><div class=\"flex justify-between items-center mb-2\"><span class=\"font-medium\" style=\"color: var(--text-primary)\" x-text=\"job.libraryName\"></span> <span class=\"text-sm\" style=\"color: var(--text-secondary)\" x-text=\"job.statusText\"></span></div><div class=\"w-full rounded-full h-2\" style=\"background-color: var(--bg-secondary)\"><div class=\"h-2 rounded-full transition-all duration-500\" :style=\"'width: ' + job.progress + '%; background-color: var(--accent);'\"></div></div></div></template></div><div x-show=\"scanComplete\" class=\"card p-4 text-sm\" style=\"border-color: var(--status-success); background-color: color-mix(in srgb, var(--status-success) 12%, transparent); color: var(--status-success);\">Initial scan complete! Your libraries are ready to use.</div></div><div class=\"flex justify-between mt-6\"><button type=\"button\" @click=\"currentStep = 3\" class=\"btn btn-secondary\" :disabled=\"loading\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -165,7 +175,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "Back</button><div class=\"flex gap-3\"><button x-show=\"!scanStarted\" type=\"button\" @click=\"startScan()\" class=\"btn btn-primary\" :disabled=\"loading\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Back</button><div class=\"flex gap-3\"><button x-show=\"!scanStarted\" type=\"button\" @click=\"startScan()\" class=\"btn btn-primary\" :disabled=\"loading\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -173,7 +183,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Start Scan</button> <button x-show=\"scanStarted && !scanComplete\" type=\"button\" @click=\"finishSetup()\" class=\"btn btn-secondary\" :disabled=\"loading\">Skip to Dashboard</button> <button x-show=\"scanComplete\" type=\"button\" @click=\"finishSetup()\" class=\"btn btn-primary\" :disabled=\"loading\" x-text=\"loading ? 'Finishing…' : 'Go to Dashboard'\"></button></div></div></div></div><div id=\"folder-browser-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Browse Folders</h2><button type=\"button\" @click=\"hideFolderBrowser()\" class=\"icon-btn\" aria-label=\"Close\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Start Scan</button> <button x-show=\"scanStarted && !scanComplete\" type=\"button\" @click=\"finishSetup()\" class=\"btn btn-secondary\" :disabled=\"loading\">Skip to Dashboard</button> <button x-show=\"scanComplete\" type=\"button\" @click=\"finishSetup()\" class=\"btn btn-primary\" :disabled=\"loading\" x-text=\"loading ? 'Finishing…' : 'Go to Dashboard'\"></button></div></div></div></div><div id=\"folder-browser-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Browse Folders</h2><button type=\"button\" @click=\"hideFolderBrowser()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -181,7 +191,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</button></div><div id=\"folder-browser-content\"></div></div></div></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</button></div><div id=\"folder-browser-content\"></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+48
View File
@@ -1,5 +1,53 @@
package templates
import (
"fmt"
"bookhoard/internal/services"
)
// UnlinkedMatchCards renders the match candidates for one device file's
// "find matches" reveal — the /unlinked-books/matches partial response.
// Cards are clicked through the delegated data-action handler (rendering
// ownership: templ renders, JS only swaps and handles the click).
templ UnlinkedMatchCards(progressId string, matches []services.BookMatch) {
if len(matches) == 0 {
<p class="text-sm text-center p-4" style="color: var(--text-secondary)">No matches found. Try manual linking.</p>
} else {
for _, match := range matches {
<div class="card p-4 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow"
style="background-color: var(--bg-primary); border-color: var(--border);"
data-action="auto-link"
data-progress-id={ progressId }
data-media-item-id={ match.MediaItemID.String() }
data-confidence={ fmt.Sprintf("%v", match.Confidence) }
>
<div class="flex gap-4">
<img
src={ match.CoverImagePath }
alt="Cover"
class="w-16 h-24 object-cover rounded"
onerror="this.src='/static/placeholder-book.svg'"
/>
<div>
<h5 class="font-semibold" style="color: var(--text-primary)">{ match.Title }</h5>
if match.Author != "" {
<p class="text-sm" style="color: var(--text-secondary)">by { match.Author }</p>
}
<div class="mt-2 flex items-center gap-2">
<span class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: var(--bg-primary);">
{ fmt.Sprintf("%.0f%% confidence", match.Confidence*100) }
</span>
<span class="text-xs" style="color: var(--text-secondary)">{ match.MatchMethod }</span>
</div>
</div>
</div>
</div>
}
}
}
templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
<!DOCTYPE html>
<html lang="en">
+262 -100
View File
@@ -8,7 +8,17 @@ package templates
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component {
import (
"fmt"
"bookhoard/internal/services"
)
// UnlinkedMatchCards renders the match candidates for one device file's
// "find matches" reveal — the /unlinked-books/matches partial response.
// Cards are clicked through the delegated data-action handler (rendering
// ownership: templ renders, JS only swaps and handles the click).
func UnlinkedMatchCards(progressId string, matches []services.BookMatch) 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 {
@@ -29,29 +39,181 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Unlinked Books - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if len(matches) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<p class=\"text-sm text-center p-4\" style=\"color: var(--text-secondary)\">No matches found. Try manual linking.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
for _, match := range matches {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"card p-4 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow\" style=\"background-color: var(--bg-primary); border-color: var(--border);\" data-action=\"auto-link\" data-progress-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(progressId)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 22, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" data-media-item-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(match.MediaItemID.String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 23, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" data-confidence=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", match.Confidence))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 24, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"><div class=\"flex gap-4\"><img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(match.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 28, Col: 32}
}
_, 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, 6, "\" alt=\"Cover\" class=\"w-16 h-24 object-cover rounded\" onerror=\"this.src='/static/placeholder-book.svg'\"><div><h5 class=\"font-semibold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(match.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 34, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</h5>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if match.Author != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<p class=\"text-sm\" style=\"color: var(--text-secondary)\">by ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(match.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 36, Col: 80}
}
_, 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, 9, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"mt-2 flex items-center gap-2\"><span class=\"text-xs px-2 py-1 rounded\" style=\"background-color: var(--accent); color: var(--bg-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%% confidence", match.Confidence*100))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 40, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(match.MatchMethod)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 42, Col: 85}
}
_, 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, 12, "</span></div></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
return nil
})
}
func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) 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_Var10 := templ.GetChildren(ctx)
if templ_7745c5c3_Var10 == nil {
templ_7745c5c3_Var10 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Unlinked Books - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 = []any{"theme-" + user.Theme}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
var templ_7745c5c3_Var11 = []any{"theme-" + user.Theme}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<body class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var11).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" x-data=\"unlinkedBooks\" x-init=\"setupEventDelegation()\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" x-data=\"unlinkedBooks\" x-init=\"setupEventDelegation()\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -59,7 +221,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -67,12 +229,12 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Unlinked Books</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Resolve books that couldn't be automatically matched between devices</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Unlinked Books</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Resolve books that couldn't be automatically matched between devices</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(unlinkedBooks) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"card p-4 mb-6 flex flex-wrap justify-between items-center gap-3\"><div class=\"flex items-center gap-4\"><input type=\"checkbox\" id=\"select-all-unlinked\" @change=\"toggleAllUnlinked()\" class=\"w-5 h-5\"> <label for=\"select-all-unlinked\" class=\"cursor-pointer text-sm font-medium\" style=\"color: var(--text-primary)\">Select All</label> <span id=\"selected-count\" class=\"text-sm\" style=\"color: var(--text-secondary)\">0 selected</span></div><div class=\"flex gap-2 flex-wrap\"><button @click=\"bulkAutoLink()\" class=\"btn btn-secondary text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"card p-4 mb-6 flex flex-wrap justify-between items-center gap-3\"><div class=\"flex items-center gap-4\"><input type=\"checkbox\" id=\"select-all-unlinked\" @change=\"toggleAllUnlinked()\" class=\"w-5 h-5\"> <label for=\"select-all-unlinked\" class=\"cursor-pointer text-sm font-medium\" style=\"color: var(--text-primary)\">Select All</label> <span id=\"selected-count\" class=\"text-sm\" style=\"color: var(--text-secondary)\">0 selected</span></div><div class=\"flex gap-2 flex-wrap\"><button @click=\"bulkAutoLink()\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -80,7 +242,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "Auto-Link Selected (High Confidence)</button> <button @click=\"bulkGetSuggestions()\" class=\"btn btn-secondary text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Auto-Link Selected (High Confidence)</button> <button @click=\"bulkGetSuggestions()\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -88,7 +250,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "Get Suggestions for Selected</button> <button @click=\"showBulkManualLink()\" class=\"btn btn-primary text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Get Suggestions for Selected</button> <button @click=\"showBulkManualLink()\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -96,17 +258,17 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Bulk Manual Link</button></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "Bulk Manual Link</button></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div id=\"unlinked-container\" class=\"space-y-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div id=\"unlinked-container\" class=\"space-y-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(unlinkedBooks) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -114,39 +276,39 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">All Books Linked!</h3><p style=\"color: var(--text-secondary)\">There are no unlinked books that need manual resolution.</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">All Books Linked!</h3><p style=\"color: var(--text-secondary)\">There are no unlinked books that need manual resolution.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, book := range unlinkedBooks {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"card p-6\"><div class=\"flex gap-6\"><div class=\"flex items-start pt-2\"><input type=\"checkbox\" class=\"unlinked-checkbox w-5 h-5\" data-progress-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"card p-6\"><div class=\"flex gap-6\"><div class=\"flex items-start pt-2\"><input type=\"checkbox\" class=\"unlinked-checkbox w-5 h-5\" data-progress-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.ProgressID)
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.ProgressID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 63, Col: 100}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 111, Col: 100}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" data-title=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" data-title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.TitleFromDevice)
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.TitleFromDevice)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 63, Col: 136}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 111, Col: 136}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" onchange=\"updateSelectedCount()\"></div><div class=\"flex-1 min-w-0\"><div class=\"flex items-center gap-3 mb-4\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" onchange=\"updateSelectedCount()\"></div><div class=\"flex-1 min-w-0\"><div class=\"flex items-center gap-3 mb-4\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -166,46 +328,46 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span><div class=\"min-w-0\"><h3 class=\"font-semibold text-lg truncate\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span><div class=\"min-w-0\"><h3 class=\"font-semibold text-lg truncate\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice)
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 77, Col: 111}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 125, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Device: ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Device: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName)
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 79, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 127, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " (")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceType)
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 79, Col: 58}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 127, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ")</p></div></div><div class=\"space-y-2 text-sm mb-4\"><div class=\"flex items-center gap-2 flex-wrap\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">File Path:</span> <code class=\"chip font-mono\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ")</p></div></div><div class=\"space-y-2 text-sm mb-4\"><div class=\"flex items-center gap-2 flex-wrap\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">File Path:</span> <code class=\"chip font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -213,101 +375,101 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(book.FilePath)
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.FilePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 88, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 136, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</code></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</code></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.SHA256 != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"flex items-center gap-2 flex-wrap\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">SHA-256:</span> <code class=\"chip font-mono text-xs\" title=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"flex items-center gap-2 flex-wrap\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">SHA-256:</span> <code class=\"chip font-mono text-xs\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.SHA256)
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.SHA256)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 94, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 142, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256)
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 94, Col: 84}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 142, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</code></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</code></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"flex items-center gap-2\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Last Synced:</span> <span style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<div class=\"flex items-center gap-2\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Last Synced:</span> <span style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastSyncTime)
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastSyncTime)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 99, Col: 71}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 147, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.ConfidenceScore > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"flex items-center gap-2\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Auto-Link Confidence:</span> <span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"flex items-center gap-2\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Auto-Link Confidence:</span> <span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(book.ConfidenceScore)
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(book.ConfidenceScore)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 105, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 153, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "% confidence</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "% confidence</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><div class=\"mb-4 flex gap-2 flex-wrap\"><button @click=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div><div class=\"mb-4 flex gap-2 flex-wrap\"><button @click=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("searchMatches(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.SHA256) + ", " + JSONArg(book.TitleFromDevice) + ")")
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("searchMatches(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.SHA256) + ", " + JSONArg(book.TitleFromDevice) + ")")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 112, Col: 138}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 160, Col: 138}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"btn btn-secondary text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -315,20 +477,20 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Find Potential Matches</button> <button @click=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "Find Potential Matches</button> <button @click=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("showManualLinkModal(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.TitleFromDevice) + ")")
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("showManualLinkModal(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.TitleFromDevice) + ")")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 119, Col: 114}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 167, Col: 114}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
_, 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, 31, "\" class=\"btn btn-primary text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -336,38 +498,38 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "Manually Link</button></div><div id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "Manually Link</button></div><div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-" + book.ProgressID)
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-" + book.ProgressID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 126, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 174, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" class=\"hidden\"><div class=\"border-t pt-4 mt-2\" style=\"border-color: var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Potential Matches</h4><div id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" class=\"hidden\"><div class=\"border-t pt-4 mt-2\" style=\"border-color: var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Potential Matches</h4><div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-list-" + book.ProgressID)
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-list-" + book.ProgressID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 129, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 177, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
_, 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, 34, "\" class=\"space-y-3\"><p style=\"color: var(--text-secondary)\">Click \"Find Potential Matches\" to search</p></div></div></div></div></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" class=\"space-y-3\"><p style=\"color: var(--text-secondary)\">Click \"Find Potential Matches\" to search</p></div></div></div></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div></main><div id=\"manual-link-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Manually Link Book</h2><button @click=\"hideManualLinkModal()\" class=\"icon-btn\" aria-label=\"Close\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div></main><div id=\"manual-link-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Manually Link Book</h2><button @click=\"hideManualLinkModal()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -375,7 +537,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</button></div><input type=\"hidden\" id=\"link-progress-id\"> <input type=\"hidden\" id=\"link-book-sha256\"><div class=\"mb-4\"><label for=\"link-book-title\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Book Title from Device</label> <input type=\"text\" id=\"link-book-title\" readonly class=\"input\" style=\"color: var(--text-secondary);\"></div><div class=\"mb-4\"><label for=\"link-search-input\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Search for Book to Link</label><div class=\"flex gap-2\"><input type=\"text\" id=\"link-search-input\" placeholder=\"Search by title, author…\" class=\"input flex-1\"> <button type=\"button\" @click=\"searchBooksForLink()\" class=\"btn btn-primary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</button></div><input type=\"hidden\" id=\"link-progress-id\"> <input type=\"hidden\" id=\"link-book-sha256\"><div class=\"mb-4\"><label for=\"link-book-title\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Book Title from Device</label> <input type=\"text\" id=\"link-book-title\" readonly class=\"input\" style=\"color: var(--text-secondary);\"></div><div class=\"mb-4\"><label for=\"link-search-input\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Search for Book to Link</label><div class=\"flex gap-2\"><input type=\"text\" id=\"link-search-input\" placeholder=\"Search by title, author…\" class=\"input flex-1\"> <button type=\"button\" @click=\"searchBooksForLink()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -383,7 +545,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "Search</button></div></div><div id=\"link-search-results\" class=\"mb-4 max-h-48 overflow-y-auto space-y-2\"><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Search for books to link</p></div><div class=\"mb-4\"><label for=\"link-confidence\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Confidence Score</label> <input type=\"number\" id=\"link-confidence\" min=\"0\" max=\"1\" step=\"0.1\" value=\"1.0\" class=\"input\"><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">1.0 = manual override (highest confidence)</p></div><div class=\"flex justify-end gap-3\"><button type=\"button\" @click=\"hideManualLinkModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"confirmManualLink()\" class=\"btn btn-primary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "Search</button></div></div><div id=\"link-search-results\" class=\"mb-4 max-h-48 overflow-y-auto space-y-2\"><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Search for books to link</p></div><div class=\"mb-4\"><label for=\"link-confidence\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Confidence Score</label> <input type=\"number\" id=\"link-confidence\" min=\"0\" max=\"1\" step=\"0.1\" value=\"1.0\" class=\"input\"><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">1.0 = manual override (highest confidence)</p></div><div class=\"flex justify-end gap-3\"><button type=\"button\" @click=\"hideManualLinkModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"confirmManualLink()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -391,7 +553,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "Link Book</button></div></div></div></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "Link Book</button></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+58
View File
@@ -409,3 +409,61 @@ func badgeLabel(badge string) string {
}
return ""
}
// Library type dropdown helpers. The option list comes from the DB-backed
// library_types table (the same source the create API resolves types
// against), so a new type appears in the UI without template edits.
// libraryTypeDisplayOrder pins the long-standing library types to their
// historical dropdown order.
var libraryTypeDisplayOrder = map[string]int{
"ebooks": 0,
"comics": 1,
"manga": 2,
"audiobooks": 3,
}
const libraryTypeRankUnknown = 1 << 30
func libraryTypeRank(name string) int {
if rank, ok := libraryTypeDisplayOrder[name]; ok {
return rank
}
return libraryTypeRankUnknown
}
// OrderedLibraryTypes sorts library types for a create-library dropdown:
// the original types keep their UI order, any newer types follow by name.
func OrderedLibraryTypes(types []database.LibraryTypes) []database.LibraryTypes {
sorted := make([]database.LibraryTypes, len(types))
copy(sorted, types)
sort.SliceStable(sorted, func(i, j int) bool {
ri, rj := libraryTypeRank(sorted[i].Name), libraryTypeRank(sorted[j].Name)
if ri != rj {
return ri < rj
}
return sorted[i].Name < sorted[j].Name
})
return sorted
}
// LibraryTypeDisplayName renders a library_types.name for the UI
// ("audiobooks" → "Audiobooks"); unknown names just get their first letter
// capitalized so a future type still reads sensibly.
func LibraryTypeDisplayName(name string) string {
switch name {
case "ebooks":
return "Ebooks"
case "comics":
return "Comics"
case "manga":
return "Manga"
case "audiobooks":
return "Audiobooks"
}
if name == "" {
return name
}
r := []rune(name)
return strings.ToUpper(string(r[0])) + string(r[1:])
}
+42 -1
View File
@@ -1,6 +1,10 @@
package templates
import "testing"
import (
"testing"
"bookhoard/internal/database"
)
func TestGroupTunableSettings(t *testing.T) {
entries := []SettingEntry{
@@ -54,3 +58,40 @@ func TestThemeCheckClass(t *testing.T) {
t.Errorf("inactive theme should be hidden, got %q", got)
}
}
// The library-type dropdown renders from DB rows; ordering and labels are
// presentation policy (OrderedLibraryTypes / LibraryTypeDisplayName).
func TestOrderedLibraryTypes(t *testing.T) {
// The DB query returns ORDER BY name; the dropdown must not reshuffle
// the historical UI order, and unknown (future) types append by name.
types := []database.LibraryTypes{
{Name: "audiobooks"}, {Name: "comics"}, {Name: "ebooks"},
{Name: "manga"}, {Name: "videos"},
}
got := OrderedLibraryTypes(types)
want := []string{"ebooks", "comics", "manga", "audiobooks", "videos"}
if len(got) != len(want) {
t.Fatalf("got %d types, want %d", len(got), len(want))
}
for i, name := range want {
if got[i].Name != name {
t.Errorf("position %d = %q, want %q", i, got[i].Name, name)
}
}
}
func TestLibraryTypeDisplayName(t *testing.T) {
cases := map[string]string{
"ebooks": "Ebooks",
"comics": "Comics",
"manga": "Manga",
"audiobooks": "Audiobooks",
"videos": "Videos", // unknown type: capitalized fallback
"": "",
}
for name, want := range cases {
if got := LibraryTypeDisplayName(name); got != want {
t.Errorf("LibraryTypeDisplayName(%q) = %q, want %q", name, got, want)
}
}
}
+76
View File
@@ -450,10 +450,86 @@ export {
stopScanStatusPolling,
};
// Library admin page helpers (rendering-ownership: these orchestrate htmx
// swaps and modal visibility on server-rendered DOM; they render nothing).
function toggleLibraryPanel(btn: HTMLElement): void {
const libId = btn.dataset.libId || "";
const panel = document.getElementById(`library-panel-${libId}`);
if (!panel) return;
if (panel.innerHTML.trim() !== "") {
panel.innerHTML = "";
} else {
window.htmx.ajax("GET", `/admin/library/${libId}/panel`, {
target: `#library-panel-${libId}`,
swap: "innerHTML",
});
}
}
function openEditModal(id: string, name: string, description: string): void {
const form = document.getElementById("edit-library-form");
if (!form) return;
form.setAttribute("hx-put", `/admin/library/${id}`);
const nameInput = document.getElementById("edit-library-name");
if (nameInput) (nameInput as HTMLInputElement).value = name;
const descInput = document.getElementById("edit-library-desc");
if (descInput) (descInput as HTMLInputElement).value = description;
window.htmx.process(form);
document.getElementById("edit-library-modal")?.classList.remove("hidden");
}
function openDeleteModal(id: string, name: string): void {
const btn = document.getElementById("delete-library-confirm");
if (!btn) return;
btn.setAttribute("hx-delete", `/admin/library/${id}`);
btn.setAttribute("hx-target", "#libraries-container");
btn.setAttribute("hx-swap", "innerHTML");
const nameEl = document.getElementById("delete-library-name");
if (nameEl) nameEl.textContent = name;
window.htmx.process(btn);
document.getElementById("delete-library-modal")?.classList.remove("hidden");
}
function openFolderBrowser(targetInputId: string, libraryId: string): void {
const content = document.getElementById("folder-browser-content");
if (!content) return;
content.setAttribute("hx-get", "/admin/library/browse");
content.setAttribute(
"hx-vals",
JSON.stringify({ target_input: targetInputId, library_id: libraryId }),
);
content.setAttribute("hx-trigger", "load");
window.htmx.process(content);
document.getElementById("folder-browser-modal")?.classList.remove("hidden");
}
// Close the delete-library modal after a successful delete: the htmx swap
// already removes the deleted row from the libraries list, but the modal
// lives outside the swapped container and would otherwise stay open.
function bindDeleteLibraryModalClose(): void {
document
.getElementById("delete-library-confirm")
?.addEventListener("htmx:afterRequest", function (evt) {
const detail = (evt as CustomEvent).detail as { successful?: boolean };
if (detail.successful) {
document
.getElementById("delete-library-modal")
?.classList.add("hidden");
}
});
}
Alpine.data("admin", () => ({
init() {
bindDeleteLibraryModalClose();
},
checkActiveScans,
hideScanProgress,
loadWatchStatus,
scanAllLibraries,
stopScanStatusPolling,
openDeleteModal,
openEditModal,
openFolderBrowser,
toggleLibraryPanel,
}));
+9 -68
View File
@@ -53,62 +53,6 @@ function initializeCollectionWebSocket(): void {
});
}
function renderCollectionBooks(books: BookInfo[]): void {
const container = document.getElementById("books-container");
if (!container) return;
const dataEl = document.getElementById("collection-data");
const isSystem = dataEl?.dataset.isSystem === "true";
// Smart collections are rule-managed: no hand-pick actions either way.
const canEditMembership =
!isSystem && dataEl?.dataset.isSmart !== "true";
if (books.length === 0) {
container.innerHTML = `<div id="empty-state" class="col-span-full text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>`;
return;
}
container.innerHTML = books
.map(
(book) => `
<div class="card p-4 rounded-2xl collection-book-card" data-title="${book.title}" data-author="${book.author || ""}" data-media-id="${book.media_item_id}">
<div class="flex gap-4">
${canEditMembership ? `
<div class="flex-shrink-0 pt-1">
<label class="flex items-center cursor-pointer p-2 -m-2">
<input type="checkbox" class="w-5 h-5"
:checked="selectedBooks.includes('${book.media_item_id}')"
@change="toggleSelection('${book.media_item_id}')"/>
</label>
</div>` : ""}
<div class="flex-1 min-w-0">
<a href="/media/${book.media_item_id}">
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">${book.title}</h3>
</a>
${book.author ? `<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">by ${book.author}</p>` : ""}
</div>
<div class="flex-shrink-0 w-16 sm:w-20">
<a href="/media/${book.media_item_id}">
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}" alt="Cover"
class="w-full aspect-[3/4] object-cover rounded shadow-md"
onerror="this.src='/static/placeholder-book.svg'"/>
</a>
</div>
</div>
${canEditMembership ? `
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button @click="requestRemoveBook('${book.media_item_id}')" class="btn btn-secondary w-full">
🗑️ Remove from Collection
</button>
</div>` : ""}
</div>
`,
)
.join("");
Alpine.initTree(container);
}
async function loadCollectionRules(collectionId: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
@@ -480,19 +424,16 @@ function initCollectionsPage(): void {
if (dataEl) dataEl.dataset.libraryId = libraryId;
await switchWithTransition("books-container", async () => {
const param = libraryId ? `?library_id=${libraryId}` : "";
const response = await fetch(
`/api/collections/${collId}${param}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
},
// Rendering ownership: the grid is server-rendered markup. Swap
// in the templ partial instead of re-rendering from JSON.
const param = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : "";
await window.htmx.ajax(
"GET",
`/collections/${collId}/books-grid${param}`,
{ target: "#books-container", swap: "innerHTML" },
);
if (!response.ok) throw new Error("Failed to load collection");
const data = await response.json();
renderCollectionBooks(data.books || []);
const container = document.getElementById("books-container");
if (container) Alpine.initTree(container);
});
},
});
+94 -54
View File
@@ -2,24 +2,94 @@ import { Alpine } from "./alpine";
import { showToast } from "./toast";
async function refreshConflicts(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
// Rendering ownership: the list and stats are server-rendered markup.
// Swap in the templ partial (stats arrive as an out-of-band swap)
// instead of re-rendering cards from JSON.
try {
const response = await fetch("/api/conflicts", {
headers: { Authorization: `Bearer ${token}` },
await window.htmx.ajax("GET", "/conflicts/list", {
target: "#conflicts-list",
swap: "outerHTML",
});
if (response.ok) {
const data: ConflictListResponse = await response.json();
renderConflicts(data.conflicts);
updateConflictStats(data);
}
const list = document.getElementById("conflicts-list");
if (list) Alpine.initTree(list);
filterConflicts();
updateBulkActions();
} catch (error) {
console.error("Failed to refresh conflicts:", error);
}
}
// --- Client-side filter and bulk selection -------------------------------
// Pure functions over the server-rendered conflict cards (rendering
// ownership: JS decides what shows; templ rendered what they look like).
function filterConflicts(): void {
const select = document.getElementById(
"status-filter",
) as HTMLSelectElement | null;
const filter = select?.value ?? "unresolved";
let total = 0;
let visible = 0;
document.querySelectorAll<HTMLElement>(".conflict-item").forEach((item) => {
total++;
const status = item.dataset.status ?? "unresolved";
const show = filter === "all" || status === filter;
item.style.display = show ? "" : "none";
if (show) visible++;
});
const empty = document.getElementById("filter-empty");
if (empty) empty.classList.toggle("hidden", visible > 0 || total === 0);
// Cards hidden by the filter can't stay selected.
if (visible !== total) updateBulkActions();
}
function toggleAllConflicts(): void {
const selectAll = document.getElementById(
"select-all-conflicts",
) as HTMLInputElement | null;
if (!selectAll) return;
document.querySelectorAll<HTMLElement>(".conflict-item").forEach((item) => {
if (item.style.display === "none") return;
const box = item.querySelector<HTMLInputElement>(".conflict-checkbox");
if (box) box.checked = selectAll.checked;
});
updateBulkActions();
}
function getSelectedConflictIds(): string[] {
return Array.from(
document.querySelectorAll<HTMLInputElement>(".conflict-checkbox:checked"),
)
.map((box) => box.dataset.conflictId || "")
.filter((id) => id !== "");
}
function updateBulkActions(): void {
const selected = getSelectedConflictIds();
const bar = document.getElementById("bulk-actions");
if (bar) bar.classList.toggle("hidden", selected.length === 0);
const count = document.getElementById("selected-count");
if (count) count.textContent = String(selected.length);
}
function resolveSelected(): void {
const ids = getSelectedConflictIds();
if (ids.length === 0) return;
const strategyEl = document.getElementById(
"bulk-strategy",
) as HTMLSelectElement | null;
const strategy = (strategyEl?.value ?? "most_recent") as
| "most_recent"
| "highest_progress";
void bulkResolve(strategy, ids);
}
function dismissSelected(): void {
const ids = getSelectedConflictIds();
if (ids.length === 0) return;
void bulkDismiss(ids);
}
async function resolveConflict(
conflictId: string,
winner: string,
@@ -123,49 +193,6 @@ async function dismissAllResolved(): Promise<void> {
}
}
function renderConflicts(conflicts: ConflictDetailResponse[]): void {
const container = document.getElementById("conflicts-list");
if (!container) return;
if (conflicts.length === 0) {
container.innerHTML =
'<p class="text-center p-4" style="color: var(--text-secondary)">No conflicts found</p>';
return;
}
container.innerHTML = conflicts
.map(
(conflict) => `
<div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex justify-between items-start">
<div>
<h3 class="font-medium" style="color: var(--text-primary)">${conflict.media_item_title}</h3>
<p class="text-sm" style="color: var(--text-secondary)">${conflict.conflict_type} - ${conflict.resolution_status}</p>
</div>
${
conflict.resolution_status === "unresolved"
? `
<div class="flex space-x-2">
<button onclick="window.showResolveModal('${conflict.id}')" class="btn-primary px-3 py-1 rounded text-sm">Resolve</button>
</div>
`
: ""
}
</div>
</div>
`,
)
.join("");
}
function updateConflictStats(data: ConflictListResponse): void {
const totalEl = document.getElementById("conflicts-total");
const unresolvedEl = document.getElementById("conflicts-unresolved");
if (totalEl) totalEl.textContent = String(data.total);
if (unresolvedEl) unresolvedEl.textContent = String(data.unresolved);
}
function showResolveModal(conflictId: string): void {
const modal = document.getElementById("resolve-modal");
const conflictIdInput = document.getElementById(
@@ -214,9 +241,17 @@ export {
refreshConflicts,
resolveConflict,
showResolveModal,
filterConflicts,
toggleAllConflicts,
updateBulkActions,
resolveSelected,
dismissSelected,
};
Alpine.data("conflicts", () => ({
init() {
filterConflicts();
},
bulkDismiss,
bulkResolve,
dismissAllResolved,
@@ -225,4 +260,9 @@ Alpine.data("conflicts", () => ({
refreshConflicts,
resolveConflict,
showResolveModal,
filterConflicts,
toggleAllConflicts,
updateBulkActions,
resolveSelected,
dismissSelected,
}));
+32 -1
View File
@@ -435,6 +435,34 @@ function initDragAndDrop(): void {
});
}
// The week widget re-fetches itself when the body sees `week-refresh`
// (hx-trigger on #week-widget). Resolve once that swap has landed so the
// library switch fades in with the refreshed widget already in place.
// Safety timeout keeps a stalled request from holding the switch hostage,
// and a missing widget (stats disabled) resolves immediately.
function refreshWeekWidget(): Promise<void> {
return new Promise((resolve) => {
if (!document.getElementById("week-widget")) {
resolve();
return;
}
let settled = false;
const finish = (): void => {
if (settled) return;
settled = true;
document.removeEventListener("htmx:afterSwap", onSwap);
resolve();
};
const onSwap = (evt: Event): void => {
const target = (evt as CustomEvent).detail?.target as HTMLElement | undefined;
if (target && target.id === "week-widget") finish();
};
document.addEventListener("htmx:afterSwap", onSwap);
setTimeout(finish, 4000);
document.body.dispatchEvent(new CustomEvent("week-refresh"));
});
}
function initDashboard() {
initDragAndDrop();
@@ -444,7 +472,10 @@ function initDashboard() {
await fetchAndRenderSections(libraryId);
// The "This Week" widget tracks the switched library: htmx
// re-fetches its fragment with the current #library-select value.
document.body.dispatchEvent(new CustomEvent("week-refresh"));
// Await the widget's swap so it lands behind the spinner —
// otherwise it pops in during/after the fade-in and the switch
// reads as two disjoint updates.
await refreshWeekWidget();
}),
});
+32
View File
@@ -15,6 +15,33 @@ export async function switchWithTransition(
if (!container || !loading) return;
// View Transitions (native, where supported): the browser snapshots the
// old shelves, fetchFn swaps the DOM, and the two snapshots crossfade —
// no blank phase, no manual opacity choreography. The dim + spinner is
// shown before the snapshot and hidden inside the update, so the dim
// blends away as part of the crossfade. Browsers without the API fall
// through to the dim + fade sequence below.
const doc = document as Document & {
startViewTransition?: (updateCallback: () => void | Promise<void>) => {
finished: Promise<void>;
};
};
if (doc.startViewTransition) {
try {
loading.classList.remove("hidden");
const transition = doc.startViewTransition(async () => {
await fetchFn();
loading.classList.add("hidden");
});
await transition.finished;
} catch (error) {
loading.classList.add("hidden");
showToast("Failed to load library", "error");
console.error("Switch library error:", error);
}
return;
}
try {
container.classList.add(
"opacity-0",
@@ -27,6 +54,11 @@ export async function switchWithTransition(
await fetchFn();
// Lift the dim BEFORE the fade-in starts, so the new content's fade
// plays fully visible instead of being hard-cut by the overlay
// disappearing mid-transition.
loading.classList.add("hidden");
container.classList.remove("duration-150");
container.classList.add("duration-300");
void container.offsetHeight;
+27
View File
@@ -0,0 +1,27 @@
# Rendering-ownership baseline (AGENTS.md → Rendering ownership).
# Files allowed to build HTML in TypeScript, with their current innerHTML
# assignment counts (trivial `innerHTML = ""` clears don't count; the
# reader — web/src/reader/ — is a sanctioned island and not counted).
# `make lint-rendering` fails when a count grows above its baseline.
# Shrink counts as fragments move to templ partials — the end state of
# the triage is an empty list.
web/src/admin.ts:3
web/src/api-explorer.ts:4
web/src/bookshelf.ts:1
web/src/collection-rules.ts:6
web/src/collections.ts:5
web/src/custom-section-builder.ts:10
web/src/device-management.ts:4
web/src/docs.ts:5
web/src/dashboard.ts:2
web/src/linking.ts:3
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/series.ts:1
web/src/setup.ts:1
web/src/stats.ts:9
web/src/toast.ts:2
web/src/unlinked_books.ts:13
+20
View File
@@ -1,2 +1,22 @@
declare module "markdown-it-footnote";
declare module "turndown-plugin-gfm";
// htmx is loaded as a classic script on every page and used from TS for
// partial swaps (rendering-ownership rule: templ renders, JS swaps).
interface HtmxAjaxOptions {
target?: string;
swap?: string;
values?: Record<string, string>;
headers?: Record<string, string>;
}
interface Window {
htmx: {
ajax(
method: string,
url: string,
options?: HtmxAjaxOptions,
): Promise<boolean>;
process(element: Element): void;
};
}
+1 -1
View File
@@ -52,7 +52,7 @@ const TOKEN_STORAGE_KEY = "token";
// auth pages survive a theme change.
const applyTheme = (theme: string): void => {
const setThemeClass = (el: Element): void => {
[...el.classList].forEach((c) => {
Array.from(el.classList).forEach((c) => {
if (c.startsWith("theme-")) el.classList.remove(c);
});
el.classList.add(`theme-${theme}`);
+15 -44
View File
@@ -18,52 +18,23 @@ function searchMatches(
matchesList.innerHTML =
'<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>';
const token = getToken();
const url = sha256
? `/api/books/match?sha256=${sha256}`
: `/api/books/match?title=${encodeURIComponent(title)}`;
// Rendering ownership: results are server-rendered markup (scoped to
// the caller's visible libraries); swap in the templ partial instead of
// rebuilding cards from JSON.
const params = new URLSearchParams({ progress_id: progressId });
if (sha256) params.set("sha256", sha256);
if (title) params.set("title", title);
fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then((response) => response.json())
.then((result) => {
if (result.matches && result.matches.length > 0) {
matchesList.innerHTML = result.matches
.map(
(match: any) => `
<div class="card p-4 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow"
style="background-color: var(--bg-primary); border-color: var(--border);"
data-action="auto-link" data-progress-id="${progressId}" data-media-item-id="${match.media_item_id}" data-confidence="${match.confidence}">
<div class="flex gap-4">
<img src="${match.cover_image_path || "/static/placeholder-book.svg"}"
alt="Cover" class="w-16 h-24 object-cover rounded">
<div>
<h5 class="font-semibold" style="color: var(--text-primary)">${match.title}</h5>
<p class="text-sm" style="color: var(--text-secondary)">by ${match.author || "Unknown"}</p>
<div class="mt-2 flex items-center gap-2">
<span class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: var(--bg-primary);">
${Math.round(match.confidence * 100)}% confidence
</span>
<span class="text-xs" style="color: var(--text-secondary)">${match.match_method}</span>
</div>
</div>
</div>
</div>
`,
)
.join("");
} else {
matchesList.innerHTML =
'<p class="text-sm" style="color: var(--text-secondary)">No matches found. Try manual linking.</p>';
}
window.htmx
.ajax("GET", `/unlinked-books/matches?${params.toString()}`, {
target: `#matches-list-${progressId}`,
swap: "innerHTML",
})
.catch((error) => {
console.error("Failed to search", error);
matchesList.innerHTML =
'<p class="text-sm" style="color: var(--error)">Failed to search</p>';
.then((ok) => {
if (!ok) {
matchesList.innerHTML =
'<p class="text-sm" style="color: var(--status-danger)">Failed to search</p>';
}
});
}