Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d74db03cf3 | ||
|
|
9e831085ef | ||
|
|
327fc2c0f9 | ||
|
|
a31da89df5 | ||
|
|
a11b4a9cc4 | ||
|
|
040c10ac76 | ||
|
|
4a68fe7cab | ||
|
|
cb4e892b10 | ||
|
|
506a04b55d | ||
|
|
f1844591b4 | ||
|
|
1fdf306f2d |
No files matched your search
@@ -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
|
Project-wide context lives in the sibling repos' AGENTS.md files
|
||||||
(../bookhoard-app/AGENTS.md is the Android client's).
|
(../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
|
## Library visibility (family-lens rule) — ALWAYS apply
|
||||||
|
|
||||||
Per-user library visibility (`library_visibility` table, default-ALLOW:
|
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 }')"`
|
values** — it emits the literal text. `x-init="f('{ col.ID }')"`
|
||||||
shipped a request to `/api/collections/{ collection.ID }` found by
|
shipped a request to `/api/collections/{ collection.ID }` found by
|
||||||
the request logs; the whole attribute value must BE an expression:
|
the request logs; the whole attribute value must BE an expression:
|
||||||
`x-init={ "f('" + col.ID + "')" }`. Note
|
`x-init={ "f('" + col.ID + "')" }`. (Every occurrence of this bug —
|
||||||
`class="theme-{ user.Theme }"` has always been literal (harmlessly
|
including `class="theme-{ user.Theme }"` on 22 page bodies and 26
|
||||||
masked by the JS theme engine).
|
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
|
2. **SettingsRegistry caches**: writes must go through the settings
|
||||||
API (Reload on save). Direct SQL writes to system_settings leave
|
API (Reload on save). Direct SQL writes to system_settings leave
|
||||||
the cache stale until the next API-path write — never "preview"
|
the cache stale until the next API-path write — never "preview"
|
||||||
|
|||||||
@@ -47,9 +47,34 @@ help:
|
|||||||
@echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)"
|
@echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)"
|
||||||
|
|
||||||
# Run unit tests locally (fast, no containers)
|
# Run unit tests locally (fast, no containers)
|
||||||
test:
|
test: lint-rendering
|
||||||
go test ./internal/... -v -short
|
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)
|
# Run integration tests in containers (matches production environment)
|
||||||
test-integration:
|
test-integration:
|
||||||
@echo "Building test containers..."
|
@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"`))
|
||||||
|
}
|
||||||
@@ -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_device ON unlinked_books(device_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_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 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
|
-- Create saved_filters for users to be able to filter different parts of the application
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ type Querier interface {
|
|||||||
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
|
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
|
||||||
// Count unlinked books for a device
|
// Count unlinked books for a device
|
||||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
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)
|
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||||
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
|
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
|
||||||
// COLLECTIONS QUERIES
|
// COLLECTIONS QUERIES
|
||||||
@@ -469,6 +471,8 @@ type Querier interface {
|
|||||||
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
|
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
|
||||||
// List unresolved unlinked books with pagination
|
// List unresolved unlinked books with pagination
|
||||||
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
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)
|
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||||
// First consecutive scan that cannot find the file on disk.
|
// First consecutive scan that cannot find the file on disk.
|
||||||
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
|
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
|
||||||
@@ -607,6 +611,9 @@ type Querier interface {
|
|||||||
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
||||||
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
||||||
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, 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)
|
var _ Querier = (*Queries)(nil)
|
||||||
@@ -332,6 +332,21 @@ func (q *Queries) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID)
|
|||||||
return count, err
|
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
|
const CountUserDevices = `-- name: CountUserDevices :one
|
||||||
SELECT COUNT(*) FROM devices WHERE user_id = $1
|
SELECT COUNT(*) FROM devices WHERE user_id = $1
|
||||||
`
|
`
|
||||||
@@ -11365,6 +11380,69 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
|
|||||||
return items, nil
|
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
|
const ListUsers = `-- name: ListUsers :many
|
||||||
SELECT
|
SELECT
|
||||||
u.id,
|
u.id,
|
||||||
@@ -15382,3 +15460,54 @@ func (q *Queries) UpsertSystemSetting(ctx context.Context, arg UpsertSystemSetti
|
|||||||
)
|
)
|
||||||
return i, err
|
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
|
||||||
|
}
|
||||||
@@ -2619,6 +2619,34 @@ WHERE ub.resolved = false
|
|||||||
ORDER BY ub.last_seen_at DESC
|
ORDER BY ub.last_seen_at DESC
|
||||||
LIMIT $1 OFFSET $2;
|
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
|
-- CAROUSEL-STYLE DASHBOARD
|
||||||
-- ============================================
|
-- ============================================
|
||||||
|
|||||||
@@ -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)
|
alias, err := matchingService.LinkBook(c.Request().Context(), deviceID, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
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)
|
unlinked, err := matchingService.GetUnlinkedBooks(c.Request().Context(), deviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
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
|
// GetBookMatches handles GET /api/books/match
|
||||||
func (h *Handler) GetBookMatches(c *echo.Context) error {
|
func (h *Handler) GetBookMatches(c *echo.Context) error {
|
||||||
matchingService := h.getMatchingService()
|
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
|
// Get query parameters
|
||||||
identifiers := c.QueryParams()["identifier"]
|
identifiers := c.QueryParams()["identifier"]
|
||||||
sha256 := c.QueryParam("sha256")
|
sha256 := c.QueryParam("sha256")
|
||||||
@@ -532,11 +620,12 @@ func (h *Handler) GetBookMatches(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req := &services.BookQueryRequest{
|
req := &services.BookQueryRequest{
|
||||||
Identifiers: identifiers,
|
Identifiers: identifiers,
|
||||||
SHA256: sha256,
|
SHA256: sha256,
|
||||||
Title: title,
|
Title: title,
|
||||||
Author: author,
|
Author: author,
|
||||||
FileSize: fileSize,
|
FileSize: fileSize,
|
||||||
|
VisibleLibraryIDs: visibleIDs,
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := matchingService.QueryBooks(c.Request().Context(), req)
|
response, err := matchingService.QueryBooks(c.Request().Context(), req)
|
||||||
|
|||||||
@@ -451,6 +451,10 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
|
|||||||
for _, book := range req.Books {
|
for _, book := range req.Books {
|
||||||
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
|
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
|
||||||
if !mediaItemID.Valid {
|
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{
|
bookResults = append(bookResults, KOReaderBookSyncResult{
|
||||||
SHA256: book.SHA256,
|
SHA256: book.SHA256,
|
||||||
Synced: false,
|
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()
|
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)
|
// Priority 1: UUID provided (highest confidence - 1.0)
|
||||||
if book.UUID != "" {
|
if book.UUID != "" {
|
||||||
mediaUUID, err := uuid.Parse(book.UUID)
|
mediaUUID, err := uuid.Parse(book.UUID)
|
||||||
|
|||||||
+166
-91
@@ -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
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -660,18 +670,19 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Collection detail page (works for both system and user collections)
|
// 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 {
|
frontendProtected.GET("/collections/:id", func(c *echo.Context) error {
|
||||||
user, err := getTemplateUserWithTheme(c, cfg)
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
}
|
}
|
||||||
// Parse collection ID from URL
|
|
||||||
collectionID := c.Param("id")
|
collectionID := c.Param("id")
|
||||||
collUUID, err := uuid.Parse(collectionID)
|
collUUID, err := uuid.Parse(collectionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return renderErrorPage(c, "Invalid collection ID", "invalid_id")
|
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})
|
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "no rows in result set" {
|
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")
|
return renderErrorPage(c, "Error loading collection", "collection_load_error")
|
||||||
}
|
}
|
||||||
// Fetch books in collection
|
books, libRes, isSmart, errCode := cfg.loadCollectionBooks(c, user, collection, collectionID)
|
||||||
userUUID, _ := uuid.Parse(user.ID)
|
if errCode != "" {
|
||||||
var books []handlers.BookInfo
|
msg := map[string]string{
|
||||||
|
"books_load_error": "Error loading books",
|
||||||
libRes := resolveLibrary(c, cfg, user.ID)
|
"invalid_library_id": "Invalid library ID",
|
||||||
libraryID := libRes.LibraryID
|
"invalid_id": "Invalid collection ID",
|
||||||
|
}[errCode]
|
||||||
var libScope pgtype.UUID
|
return renderErrorPage(c, msg, errCode)
|
||||||
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),
|
|
||||||
}
|
}
|
||||||
|
colData := collectionDataFromRow(collection, collectionID, isSmart, len(books))
|
||||||
|
|
||||||
var buf bytes.Buffer
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.HTML(http.StatusOK, buf.String())
|
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
|
// Week-widget fragment: the collapsible "This Week" strip re-fetches
|
||||||
// itself here (htmx) whenever the library switcher dispatches
|
// itself here (htmx) whenever the library switcher dispatches
|
||||||
// week-refresh, carrying the current #library-select value. Same
|
// week-refresh, carrying the current #library-select value. Same
|
||||||
// resolution rules as the dashboard page (family-lens safe).
|
// 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 {
|
frontendProtected.GET("/dashboard/week-widget", func(c *echo.Context) error {
|
||||||
user, err := getTemplateUserWithTheme(c, cfg)
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -945,6 +996,29 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
return c.HTML(http.StatusOK, buf.String())
|
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;
|
// Analytics page → /stats (the rebuilt reading-statistics page;
|
||||||
// nobody bookmarks orphan pages twice).
|
// nobody bookmarks orphan pages twice).
|
||||||
@@ -1118,7 +1192,8 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
archivedCount, _ := cfg.Queries.CountArchivedMediaItems(c.Request().Context())
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/handlers"
|
||||||
|
"bookhoard/internal/services"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"bookhoard/templates"
|
"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))
|
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)
|
||||||
|
}
|
||||||
@@ -81,7 +81,8 @@ func registerSetupRoutes(cfg *Config) {
|
|||||||
return c.Redirect(http.StatusFound, "/")
|
return c.Redirect(http.StatusFound, "/")
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
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)
|
log.Printf("Failed to render setup template: %v", err)
|
||||||
return c.HTML(http.StatusInternalServerError, "Failed to render setup page")
|
return c.HTML(http.StatusInternalServerError, "Failed to render setup page")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
|
||||||
|
"bookhoard/internal/utils"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
@@ -16,6 +18,10 @@ type BookMatch struct {
|
|||||||
BookhoardUUID uuid.UUID `json:"bookhoard_uuid"`
|
BookhoardUUID uuid.UUID `json:"bookhoard_uuid"`
|
||||||
Confidence float64 `json:"confidence"`
|
Confidence float64 `json:"confidence"`
|
||||||
MatchMethod string `json:"match_method"`
|
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
|
// BookQueryRequest represents a book query from a device
|
||||||
@@ -24,7 +30,11 @@ type BookQueryRequest struct {
|
|||||||
SHA256 string `json:"sha256"`
|
SHA256 string `json:"sha256"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Author string `json:"author"`
|
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
|
// 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
|
// Determine action
|
||||||
action := s.determineAction(matches)
|
action := s.determineAction(matches)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package templates
|
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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -11,7 +13,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
|
|||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body class={ "theme-" + user.Theme }>
|
<body x-data="admin" class={ "theme-" + user.Theme }>
|
||||||
@Header(user, "/admin/library")
|
@Header(user, "/admin/library")
|
||||||
<main class="p-8">
|
<main class="p-8">
|
||||||
<div class="max-w-4xl">
|
<div class="max-w-4xl">
|
||||||
@@ -88,9 +90,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Type</label>
|
<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>
|
<select name="type" class="input" required>
|
||||||
<option value="ebooks">Ebooks</option>
|
@LibraryTypeOptions(libraryTypes)
|
||||||
<option value="comics">Comics</option>
|
|
||||||
<option value="manga">Manga</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
@@ -173,57 +173,20 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</body>
|
||||||
</html>
|
</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) {
|
templ LibraryList(user User, libraries []LibraryData, users []User) {
|
||||||
if len(libraries) == 0 {
|
if len(libraries) == 0 {
|
||||||
<div class="card text-center py-16">
|
<div class="card text-center py-16">
|
||||||
@@ -273,7 +236,7 @@ templ LibraryList(user User, libraries []LibraryData, users []User) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
data-lib-id={ library.ID }
|
data-lib-id={ library.ID }
|
||||||
onclick="toggleLibraryPanel(this)"
|
@click="toggleLibraryPanel($el)"
|
||||||
class="btn btn-secondary text-xs px-3 py-1.5"
|
class="btn btn-secondary text-xs px-3 py-1.5"
|
||||||
>
|
>
|
||||||
@Icon("chevron-down", "h-4 w-4")
|
@Icon("chevron-down", "h-4 w-4")
|
||||||
@@ -331,7 +294,7 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
|
|||||||
type="button"
|
type="button"
|
||||||
data-library-id={ libraryID }
|
data-library-id={ libraryID }
|
||||||
data-target-input={ "folder-input-" + 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"
|
class="btn btn-secondary text-sm shrink-0"
|
||||||
>
|
>
|
||||||
@Icon("folder", "h-4 w-4")
|
@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-id={ libraryID }
|
||||||
data-edit-name={ library.Name }
|
data-edit-name={ library.Name }
|
||||||
data-edit-desc={ library.Description }
|
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"
|
class="btn btn-secondary text-sm"
|
||||||
>
|
>
|
||||||
@Icon("edit", "h-4 w-4")
|
@Icon("edit", "h-4 w-4")
|
||||||
@@ -401,7 +364,7 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
|
|||||||
<button
|
<button
|
||||||
data-delete-id={ libraryID }
|
data-delete-id={ libraryID }
|
||||||
data-delete-name={ library.Name }
|
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"
|
class="btn btn-danger text-sm"
|
||||||
>
|
>
|
||||||
@Icon("trash", "h-4 w-4")
|
@Icon("trash", "h-4 w-4")
|
||||||
|
|||||||
+392
-322
File diff suppressed because it is too large.
Load diff
+71
-62
@@ -14,6 +14,76 @@ func pluralizeBooks(n int) string {
|
|||||||
return "books"
|
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) {
|
templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -210,68 +280,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
if len(books) == 0 {
|
@CollectionBooksGrid(collection, books)
|
||||||
<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>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
if !collection.IsSystem && !collection.IsSmart {
|
if !collection.IsSystem && !collection.IsSmart {
|
||||||
|
|||||||
+422
-388
File diff suppressed because it is too large.
Load diff
+88
-60
@@ -2,6 +2,89 @@ package templates
|
|||||||
|
|
||||||
import "bookhoard/internal/handlers"
|
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) {
|
templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage string) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -30,7 +113,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
|||||||
<div class="flex space-x-3">
|
<div class="flex space-x-3">
|
||||||
<select
|
<select
|
||||||
id="status-filter"
|
id="status-filter"
|
||||||
onchange="filterConflicts()"
|
@change="filterConflicts()"
|
||||||
class="input"
|
class="input"
|
||||||
>
|
>
|
||||||
<option value="unresolved">Unresolved Only</option>
|
<option value="unresolved">Unresolved Only</option>
|
||||||
@@ -41,18 +124,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="stats-bar" class="mt-4 grid grid-cols-3 gap-4 max-w-lg">
|
<div id="stats-bar" class="mt-4 grid grid-cols-3 gap-4 max-w-lg">
|
||||||
<div class="stat-card text-center py-3">
|
@ConflictStatsBar(total, unresolved)
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="bulk-actions" class="mt-4 hidden">
|
<div id="bulk-actions" class="mt-4 hidden">
|
||||||
<div class="card p-4 flex items-center justify-between gap-4 flex-wrap">
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-2">
|
<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")
|
@Icon("check", "h-4 w-4")
|
||||||
Resolve Selected
|
Resolve Selected
|
||||||
</button>
|
</button>
|
||||||
<button @click="bulkDismiss()" class="btn btn-danger text-sm">
|
<button @click="dismissSelected()" class="btn btn-danger text-sm">
|
||||||
Dismiss Selected
|
Dismiss Selected
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="conflicts-list" class="space-y-4">
|
<div id="conflicts-list" class="space-y-4">
|
||||||
<div id="select-all-container" class="mb-4 hidden">
|
@ConflictsListContent(user, conflicts)
|
||||||
<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>
|
|
||||||
}
|
|
||||||
</div>
|
</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 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="card p-6 w-full max-w-4xl my-8" style="box-shadow: var(--shadow-pop);">
|
||||||
|
|||||||
+278
-138
@@ -10,7 +10,10 @@ import templruntime "github.com/a-h/templ/runtime"
|
|||||||
|
|
||||||
import "bookhoard/internal/handlers"
|
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) {
|
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
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
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
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
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>")
|
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
|
|
||||||
}
|
|
||||||
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>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(conflicts) == 0 {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -137,39 +47,52 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, conflict := range conflicts {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ResolutionStatus)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -177,83 +100,300 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("showResolveModal(" + JSONArg(conflict.ID) + ")")
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("showResolveModal(" + JSONArg(conflict.ID) + ")")
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if conflict.ResolvedBy != "" {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -261,7 +401,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -269,7 +409,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -23,6 +23,24 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
|
|||||||
re-render wipes that container's innerHTML, and anything
|
re-render wipes that container's innerHTML, and anything
|
||||||
server-rendered inside it dies on the first library
|
server-rendered inside it dies on the first library
|
||||||
switch. The widget re-fetches itself via htmx instead. -->
|
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 {
|
if week != nil {
|
||||||
<div class="w-full px-4 sm:px-6 lg:px-8 pt-6">
|
<div class="w-full px-4 sm:px-6 lg:px-8 pt-6">
|
||||||
@DashboardWeekWidget(*week, activeLibType)
|
@DashboardWeekWidget(*week, activeLibType)
|
||||||
|
|||||||
+425
-385
File diff suppressed because it is too large.
Load diff
@@ -43,8 +43,8 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title stri
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="loading-spinner"
|
id="loading-spinner"
|
||||||
class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
|
class="hidden fixed inset-0 flex items-center justify-center z-50"
|
||||||
style="background-color: var(--bg-primary);"
|
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 class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
|||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
templ Setup() {
|
import "bookhoard/internal/database"
|
||||||
|
|
||||||
|
templ Setup(libraryTypes []database.LibraryTypes) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -211,9 +213,7 @@ templ Setup() {
|
|||||||
x-model="newLibrary.type"
|
x-model="newLibrary.type"
|
||||||
class="input mt-2"
|
class="input mt-2"
|
||||||
>
|
>
|
||||||
<option value="ebooks">Ebooks</option>
|
@LibraryTypeOptions(libraryTypes)
|
||||||
<option value="comics">Comics</option>
|
|
||||||
<option value="manga">Manga</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+24
-14
@@ -8,7 +8,9 @@ package templates
|
|||||||
import "github.com/a-h/templ"
|
import "github.com/a-h/templ"
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
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) {
|
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
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
@@ -85,7 +87,15 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -93,7 +103,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -101,7 +111,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -109,7 +119,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -117,7 +127,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -125,7 +135,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -133,7 +143,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -141,7 +151,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -149,7 +159,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -157,7 +167,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -165,7 +175,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -173,7 +183,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -181,7 +191,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,53 @@
|
|||||||
package templates
|
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) {
|
templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|||||||
+262
-100
@@ -8,7 +8,17 @@ package templates
|
|||||||
import "github.com/a-h/templ"
|
import "github.com/a-h/templ"
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
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) {
|
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
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
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
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 = []any{"theme-" + user.Theme}
|
var templ_7745c5c3_Var11 = []any{"theme-" + user.Theme}
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<body class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var11).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 1, Col: 0}
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -59,7 +221,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -67,12 +229,12 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(unlinkedBooks) > 0 {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -80,7 +242,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -88,7 +250,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -96,17 +258,17 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(unlinkedBooks) == 0 {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -114,39 +276,39 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, book := range unlinkedBooks {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.ProgressID)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.ProgressID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.TitleFromDevice)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.TitleFromDevice)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -166,46 +328,46 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
|
|||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceType)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceType)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -213,101 +375,101 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(book.FilePath)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.FilePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if book.SHA256 != "" {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.SHA256)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.SHA256)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256)
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastSyncTime)
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastSyncTime)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if book.ConfidenceScore > 0 {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(book.ConfidenceScore)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(book.ConfidenceScore)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("searchMatches(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.SHA256) + ", " + JSONArg(book.TitleFromDevice) + ")")
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("searchMatches(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.SHA256) + ", " + JSONArg(book.TitleFromDevice) + ")")
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -315,20 +477,20 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("showManualLinkModal(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.TitleFromDevice) + ")")
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("showManualLinkModal(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.TitleFromDevice) + ")")
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -336,38 +498,38 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-" + book.ProgressID)
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-" + book.ProgressID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var26 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-list-" + book.ProgressID)
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-list-" + book.ProgressID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -375,7 +537,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -383,7 +545,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -391,7 +553,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,3 +409,61 @@ func badgeLabel(badge string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
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
@@ -1,6 +1,10 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
func TestGroupTunableSettings(t *testing.T) {
|
func TestGroupTunableSettings(t *testing.T) {
|
||||||
entries := []SettingEntry{
|
entries := []SettingEntry{
|
||||||
@@ -54,3 +58,40 @@ func TestThemeCheckClass(t *testing.T) {
|
|||||||
t.Errorf("inactive theme should be hidden, got %q", got)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -450,10 +450,86 @@ export {
|
|||||||
stopScanStatusPolling,
|
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", () => ({
|
Alpine.data("admin", () => ({
|
||||||
|
init() {
|
||||||
|
bindDeleteLibraryModalClose();
|
||||||
|
},
|
||||||
checkActiveScans,
|
checkActiveScans,
|
||||||
hideScanProgress,
|
hideScanProgress,
|
||||||
loadWatchStatus,
|
loadWatchStatus,
|
||||||
scanAllLibraries,
|
scanAllLibraries,
|
||||||
stopScanStatusPolling,
|
stopScanStatusPolling,
|
||||||
|
openDeleteModal,
|
||||||
|
openEditModal,
|
||||||
|
openFolderBrowser,
|
||||||
|
toggleLibraryPanel,
|
||||||
}));
|
}));
|
||||||
+9
-68
@@ -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> {
|
async function loadCollectionRules(collectionId: string): Promise<void> {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -480,19 +424,16 @@ function initCollectionsPage(): void {
|
|||||||
if (dataEl) dataEl.dataset.libraryId = libraryId;
|
if (dataEl) dataEl.dataset.libraryId = libraryId;
|
||||||
|
|
||||||
await switchWithTransition("books-container", async () => {
|
await switchWithTransition("books-container", async () => {
|
||||||
const param = libraryId ? `?library_id=${libraryId}` : "";
|
// Rendering ownership: the grid is server-rendered markup. Swap
|
||||||
const response = await fetch(
|
// in the templ partial instead of re-rendering from JSON.
|
||||||
`/api/collections/${collId}${param}`,
|
const param = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : "";
|
||||||
{
|
await window.htmx.ajax(
|
||||||
headers: {
|
"GET",
|
||||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
`/collections/${collId}/books-grid${param}`,
|
||||||
"Content-Type": "application/json",
|
{ target: "#books-container", swap: "innerHTML" },
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new Error("Failed to load collection");
|
const container = document.getElementById("books-container");
|
||||||
const data = await response.json();
|
if (container) Alpine.initTree(container);
|
||||||
renderCollectionBooks(data.books || []);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+94
-54
@@ -2,24 +2,94 @@ import { Alpine } from "./alpine";
|
|||||||
import { showToast } from "./toast";
|
import { showToast } from "./toast";
|
||||||
|
|
||||||
async function refreshConflicts(): Promise<void> {
|
async function refreshConflicts(): Promise<void> {
|
||||||
const token = localStorage.getItem("token");
|
// Rendering ownership: the list and stats are server-rendered markup.
|
||||||
if (!token) return;
|
// Swap in the templ partial (stats arrive as an out-of-band swap)
|
||||||
|
// instead of re-rendering cards from JSON.
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/conflicts", {
|
await window.htmx.ajax("GET", "/conflicts/list", {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
target: "#conflicts-list",
|
||||||
|
swap: "outerHTML",
|
||||||
});
|
});
|
||||||
|
const list = document.getElementById("conflicts-list");
|
||||||
if (response.ok) {
|
if (list) Alpine.initTree(list);
|
||||||
const data: ConflictListResponse = await response.json();
|
filterConflicts();
|
||||||
renderConflicts(data.conflicts);
|
updateBulkActions();
|
||||||
updateConflictStats(data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to refresh conflicts:", 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(
|
async function resolveConflict(
|
||||||
conflictId: string,
|
conflictId: string,
|
||||||
winner: 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 {
|
function showResolveModal(conflictId: string): void {
|
||||||
const modal = document.getElementById("resolve-modal");
|
const modal = document.getElementById("resolve-modal");
|
||||||
const conflictIdInput = document.getElementById(
|
const conflictIdInput = document.getElementById(
|
||||||
@@ -214,9 +241,17 @@ export {
|
|||||||
refreshConflicts,
|
refreshConflicts,
|
||||||
resolveConflict,
|
resolveConflict,
|
||||||
showResolveModal,
|
showResolveModal,
|
||||||
|
filterConflicts,
|
||||||
|
toggleAllConflicts,
|
||||||
|
updateBulkActions,
|
||||||
|
resolveSelected,
|
||||||
|
dismissSelected,
|
||||||
};
|
};
|
||||||
|
|
||||||
Alpine.data("conflicts", () => ({
|
Alpine.data("conflicts", () => ({
|
||||||
|
init() {
|
||||||
|
filterConflicts();
|
||||||
|
},
|
||||||
bulkDismiss,
|
bulkDismiss,
|
||||||
bulkResolve,
|
bulkResolve,
|
||||||
dismissAllResolved,
|
dismissAllResolved,
|
||||||
@@ -225,4 +260,9 @@ Alpine.data("conflicts", () => ({
|
|||||||
refreshConflicts,
|
refreshConflicts,
|
||||||
resolveConflict,
|
resolveConflict,
|
||||||
showResolveModal,
|
showResolveModal,
|
||||||
|
filterConflicts,
|
||||||
|
toggleAllConflicts,
|
||||||
|
updateBulkActions,
|
||||||
|
resolveSelected,
|
||||||
|
dismissSelected,
|
||||||
}));
|
}));
|
||||||
+32
-1
@@ -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() {
|
function initDashboard() {
|
||||||
initDragAndDrop();
|
initDragAndDrop();
|
||||||
|
|
||||||
@@ -444,7 +472,10 @@ function initDashboard() {
|
|||||||
await fetchAndRenderSections(libraryId);
|
await fetchAndRenderSections(libraryId);
|
||||||
// The "This Week" widget tracks the switched library: htmx
|
// The "This Week" widget tracks the switched library: htmx
|
||||||
// re-fetches its fragment with the current #library-select value.
|
// 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();
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,33 @@ export async function switchWithTransition(
|
|||||||
|
|
||||||
if (!container || !loading) return;
|
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 {
|
try {
|
||||||
container.classList.add(
|
container.classList.add(
|
||||||
"opacity-0",
|
"opacity-0",
|
||||||
@@ -27,6 +54,11 @@ export async function switchWithTransition(
|
|||||||
|
|
||||||
await fetchFn();
|
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.remove("duration-150");
|
||||||
container.classList.add("duration-300");
|
container.classList.add("duration-300");
|
||||||
void container.offsetHeight;
|
void container.offsetHeight;
|
||||||
|
|||||||
@@ -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
|
||||||
Vendored
+20
@@ -1,2 +1,22 @@
|
|||||||
declare module "markdown-it-footnote";
|
declare module "markdown-it-footnote";
|
||||||
declare module "turndown-plugin-gfm";
|
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
@@ -52,7 +52,7 @@ const TOKEN_STORAGE_KEY = "token";
|
|||||||
// auth pages survive a theme change.
|
// auth pages survive a theme change.
|
||||||
const applyTheme = (theme: string): void => {
|
const applyTheme = (theme: string): void => {
|
||||||
const setThemeClass = (el: Element): void => {
|
const setThemeClass = (el: Element): void => {
|
||||||
[...el.classList].forEach((c) => {
|
Array.from(el.classList).forEach((c) => {
|
||||||
if (c.startsWith("theme-")) el.classList.remove(c);
|
if (c.startsWith("theme-")) el.classList.remove(c);
|
||||||
});
|
});
|
||||||
el.classList.add(`theme-${theme}`);
|
el.classList.add(`theme-${theme}`);
|
||||||
|
|||||||
+15
-44
@@ -18,52 +18,23 @@ function searchMatches(
|
|||||||
matchesList.innerHTML =
|
matchesList.innerHTML =
|
||||||
'<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>';
|
'<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>';
|
||||||
|
|
||||||
const token = getToken();
|
// Rendering ownership: results are server-rendered markup (scoped to
|
||||||
const url = sha256
|
// the caller's visible libraries); swap in the templ partial instead of
|
||||||
? `/api/books/match?sha256=${sha256}`
|
// rebuilding cards from JSON.
|
||||||
: `/api/books/match?title=${encodeURIComponent(title)}`;
|
const params = new URLSearchParams({ progress_id: progressId });
|
||||||
|
if (sha256) params.set("sha256", sha256);
|
||||||
|
if (title) params.set("title", title);
|
||||||
|
|
||||||
fetch(url, {
|
window.htmx
|
||||||
headers: {
|
.ajax("GET", `/unlinked-books/matches?${params.toString()}`, {
|
||||||
Authorization: `Bearer ${token}`,
|
target: `#matches-list-${progressId}`,
|
||||||
},
|
swap: "innerHTML",
|
||||||
})
|
|
||||||
.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>';
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.then((ok) => {
|
||||||
console.error("Failed to search", error);
|
if (!ok) {
|
||||||
matchesList.innerHTML =
|
matchesList.innerHTML =
|
||||||
'<p class="text-sm" style="color: var(--error)">Failed to search</p>';
|
'<p class="text-sm" style="color: var(--status-danger)">Failed to search</p>';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user