Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fffe0b17e6 | ||
|
|
451aa48aec | ||
|
|
5ac407057e | ||
|
|
bf83492bf7 | ||
|
|
33c69e7c71 | ||
|
|
c2f72ca785 | ||
|
|
ca8c592496 | ||
|
|
1f5b0d0164 | ||
|
|
13cc689bff | ||
|
|
9920fd47b9 | ||
|
|
26f695f480 | ||
|
|
05370d236a | ||
|
|
114a4574b0 | ||
|
|
3c9e4d8126 |
@@ -1,13 +1,25 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# Builds and publishes the Bookhoard container image to the Gitea container registry.
|
# Overrides the default run name (the tagged commit's message) so the Actions
|
||||||
# Triggered ONLY by a version tag push (pushing to main does nothing), so work-in-progress
|
# runs list shows "Release v0.3.0" instead.
|
||||||
# commits never ship. Each release publishes two image tags: the version and "latest".
|
run-name: "Release ${{ gitea.event.inputs.tag || gitea.ref_name }}"
|
||||||
|
|
||||||
|
# Publishes the Bookhoard container image to the Gitea container registry AND
|
||||||
|
# creates a Gitea Release whose body is the annotated tag's message (generated
|
||||||
|
# locally by `make release VERSION=...` via git-cliff). Triggered by a version
|
||||||
|
# tag push, or manually via workflow_dispatch with a tag. Pushing to main does
|
||||||
|
# nothing, so work-in-progress commits never ship. Each release publishes two
|
||||||
|
# image tags: the version (e.g. v0.3.0) and "latest".
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Tag to release (e.g. v0.3.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
@@ -15,9 +27,17 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
env:
|
||||||
|
# Resolve the target tag for both triggers: explicit input on manual
|
||||||
|
# dispatch, otherwise the pushed tag ref.
|
||||||
|
TAG: ${{ gitea.event.inputs.tag || gitea.ref_name }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history ensures the tag annotation (the release notes) is present.
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ gitea.event.inputs.tag || gitea.ref }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -27,7 +47,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
registry: git.linuxhg.com
|
registry: git.linuxhg.com
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
# PAT stored as a repo Actions secret (auto GITHUB_TOKEN lacks package scope in Gitea)
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
@@ -39,5 +60,53 @@ jobs:
|
|||||||
# Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml;
|
# Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml;
|
||||||
# pin or roll back by setting IMAGE_TAG in .env.
|
# pin or roll back by setting IMAGE_TAG in .env.
|
||||||
tags: |
|
tags: |
|
||||||
git.linuxhg.com/bookhoard/bookhoard:${{ gitea.ref_name }}
|
git.linuxhg.com/bookhoard/bookhoard:${{ env.TAG }}
|
||||||
git.linuxhg.com/bookhoard/bookhoard:latest
|
git.linuxhg.com/bookhoard/bookhoard:latest
|
||||||
|
|
||||||
|
- name: Create Gitea Release
|
||||||
|
env:
|
||||||
|
# REGISTRY_TOKEN is reused for release creation because Gitea's auto
|
||||||
|
# GITHUB_TOKEN cannot create releases on this instance. The PAT must
|
||||||
|
# carry write:repository scope. Idempotent: re-runs update an existing
|
||||||
|
# release for this tag instead of failing with 409. On any HTTP error
|
||||||
|
# the API response body is printed so a 403 names the missing scope.
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
REPO: ${{ gitea.repository }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
: "${TAG:?TAG is required}"
|
||||||
|
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
|
||||||
|
AUTH="Authorization: token ${TOKEN}"
|
||||||
|
# Release body = the annotated tag's message (the git-cliff notes).
|
||||||
|
BODY="$(git tag -l --format='%(contents)' "${TAG}")"
|
||||||
|
|
||||||
|
# Tags containing a '-' (e.g. v0.3.0-rc1) are published as pre-releases.
|
||||||
|
PRE="false"; case "${TAG}" in *-*) PRE="true";; esac
|
||||||
|
|
||||||
|
PAYLOAD=$(jq -n \
|
||||||
|
--arg t "${TAG}" --arg n "${TAG}" --arg b "${BODY}" --argjson p "${PRE}" \
|
||||||
|
'{tag_name:$t, name:$n, body:$b, draft:false, prerelease:$p}')
|
||||||
|
|
||||||
|
# POST/PATCH the release, surfacing Gitea's error message on failure
|
||||||
|
# (e.g. "token does not have write scope") instead of failing silently.
|
||||||
|
api_call() {
|
||||||
|
local method="$1" url="$2" resp code rbody
|
||||||
|
resp="$(curl -sS -w '\n%{http_code}' -X "${method}" \
|
||||||
|
-H "${AUTH}" -H "Content-Type: application/json" \
|
||||||
|
-d "${PAYLOAD}" "${url}")"
|
||||||
|
code="$(printf '%s' "${resp}" | tail -n1)"
|
||||||
|
rbody="$(printf '%s' "${resp}" | sed '$d')"
|
||||||
|
if [ "${code}" -ge 400 ]; then
|
||||||
|
echo "::error::Release API ${code} (${method} ${url}): ${rbody}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
EXISTING_ID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null || true)"
|
||||||
|
if [ -n "${EXISTING_ID}" ]; then
|
||||||
|
api_call PATCH "${API}/${EXISTING_ID}"
|
||||||
|
echo "Updated existing release id=${EXISTING_ID} for ${TAG}"
|
||||||
|
else
|
||||||
|
api_call POST "${API}"
|
||||||
|
echo "Created new release for ${TAG}"
|
||||||
|
fi
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help test test-integration test-all rebuild rebuild-force rebuild-app rebuild-app-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down verify-guidelines verify-quick
|
.PHONY: help test test-integration test-all rebuild rebuild-force rebuild-app rebuild-app-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down verify-guidelines verify-quick release
|
||||||
|
|
||||||
# Include .env file for environment variables (single source of truth)
|
# Include .env file for environment variables (single source of truth)
|
||||||
# Ignore if .env doesn't exist yet
|
# Ignore if .env doesn't exist yet
|
||||||
@@ -44,6 +44,9 @@ help:
|
|||||||
@echo "Verification:"
|
@echo "Verification:"
|
||||||
@echo " make verify-guidelines - Run comprehensive guidelines check"
|
@echo " make verify-guidelines - Run comprehensive guidelines check"
|
||||||
@echo " make verify-quick - Run quick guidelines check"
|
@echo " make verify-quick - Run quick guidelines check"
|
||||||
|
@echo ""
|
||||||
|
@echo "Release:"
|
||||||
|
@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:
|
||||||
@@ -158,3 +161,27 @@ verify-guidelines:
|
|||||||
verify-quick:
|
verify-quick:
|
||||||
@echo "Running quick project guidelines verification..."
|
@echo "Running quick project guidelines verification..."
|
||||||
@./scripts/verify-quick.sh
|
@./scripts/verify-quick.sh
|
||||||
|
|
||||||
|
# Create an annotated version tag carrying auto-generated release notes (git-cliff)
|
||||||
|
# and push it. The tag push triggers .gitea/workflows/release.yml, which builds the
|
||||||
|
# image and publishes a Gitea Release whose body is this tag's message. Notes come
|
||||||
|
# entirely from Conventional Commits — no hand-written message required.
|
||||||
|
#
|
||||||
|
# git-cliff's --latest needs the tag to exist to scope the notes, so we create a
|
||||||
|
# throwaway lightweight tag, generate the notes, replace it with an annotated tag,
|
||||||
|
# then push. --cleanup=verbatim keeps the markdown "###" group headers (git's
|
||||||
|
# default cleanup would strip lines starting with "#").
|
||||||
|
#
|
||||||
|
# Requires git-cliff: https://git-cliff.org/install
|
||||||
|
# Usage: make release VERSION=v0.3.0
|
||||||
|
release:
|
||||||
|
@test -n "$(VERSION)" || { echo "Usage: make release VERSION=v0.3.0"; exit 1; }
|
||||||
|
@command -v git-cliff >/dev/null 2>&1 || { echo "git-cliff not found — install: https://git-cliff.org/install"; exit 1; }
|
||||||
|
@if git rev-parse "$(VERSION)" >/dev/null 2>&1; then echo "Tag $(VERSION) already exists locally — delete it first: git tag -d $(VERSION)"; exit 1; fi
|
||||||
|
@echo "Generating release notes for $(VERSION)..."
|
||||||
|
@git tag "$(VERSION)" HEAD && \
|
||||||
|
(git cliff --latest --config cliff.toml > .release-notes.tmp && git tag -d "$(VERSION)" >/dev/null) || \
|
||||||
|
{ git tag -d "$(VERSION)" >/dev/null 2>&1; rm -f .release-notes.tmp; echo "git-cliff failed"; exit 1; }
|
||||||
|
@git tag -a --cleanup=verbatim -F .release-notes.tmp "$(VERSION)" HEAD && rm -f .release-notes.tmp
|
||||||
|
@git push origin "$(VERSION)"
|
||||||
|
@echo "Pushed $(VERSION) — Gitea Actions will build the image and publish the Release."
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ docs: |-
|
|||||||
- `id` (string, required): Media item UUID
|
- `id` (string, required): Media item UUID
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
- `rating` (number, required): Rating value (typically 1-5)
|
- `rating` (number, required): Rating value (1-10 integer scale; displayed as 1-5 stars with half-star precision)
|
||||||
- `review` (string, optional): Review text
|
- `review` (string, optional): Review text
|
||||||
|
|
||||||
**Response:** Updated rating object
|
**Response:** Updated rating object
|
||||||
|
|||||||
@@ -83,9 +83,10 @@ docs:
|
|||||||
- **Update Highlight**: PUT /api/highlights/:id - Update highlight
|
- **Update Highlight**: PUT /api/highlights/:id - Update highlight
|
||||||
- **Delete Highlight**: DELETE /api/highlights/:id - Remove highlight
|
- **Delete Highlight**: DELETE /api/highlights/:id - Remove highlight
|
||||||
Ratings (All Users)
|
Ratings (All Users)
|
||||||
- **Get Rating**: GET /api/ratings/:media_id - User's rating (returns 0 if unrated)
|
- **Get Rating**: GET /api/media-items/:id/rating - User's rating (returns null if unrated)
|
||||||
- **Create/Update Rating**: POST /api/ratings - Rate media item (1-5 stars, half-star precision)
|
- **Create/Update Rating**: POST /api/media-items/:id/rating - Rate media item (1-10 scale, displayed as 1-5 stars with half-star precision). POST upserts; PUT also available.
|
||||||
- **Delete Rating**: DELETE /api/ratings/:media_id - Remove rating
|
- **Update Rating**: PUT /api/media-items/:id/rating - Update rating (upsert)
|
||||||
|
- **Delete Rating**: DELETE /api/media-items/:id/rating - Remove rating
|
||||||
Collections (All Users)
|
Collections (All Users)
|
||||||
- **List Collections**: GET /api/collections - Get user's collections
|
- **List Collections**: GET /api/collections - Get user's collections
|
||||||
- **Get Collection**: GET /api/collections/:id - Collection details with media items
|
- **Get Collection**: GET /api/collections/:id - Collection details with media items
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# git-cliff configuration — generates the body of each Gitea Release from
|
||||||
|
# Conventional Commits accumulated since the previous tag. Invoked in CI by
|
||||||
|
# orhun/git-cliff-action with --latest so only the current tag's section is
|
||||||
|
# emitted (no full history, no header — the Gitea Release title is the tag).
|
||||||
|
# Docs: https://git-cliff.org/docs/configuration
|
||||||
|
|
||||||
|
[changelog]
|
||||||
|
header = ""
|
||||||
|
body = """
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}\
|
||||||
|
### {{ group | upper_first }}
|
||||||
|
{% for commit in commits %}\
|
||||||
|
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }} ({{ commit.id | truncate(length=7, end="") }})
|
||||||
|
{% endfor %}\
|
||||||
|
{% endfor %}\
|
||||||
|
"""
|
||||||
|
trim = true
|
||||||
|
footer = ""
|
||||||
|
|
||||||
|
[git]
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = false
|
||||||
|
require_conventional = false
|
||||||
|
split_commits = false
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^feat", group = "Features" },
|
||||||
|
{ message = "^fix", group = "Bug Fixes" },
|
||||||
|
{ message = "^perf", group = "Performance" },
|
||||||
|
{ message = "^refactor", group = "Refactor" },
|
||||||
|
{ message = "^docs", group = "Documentation" },
|
||||||
|
{ message = "^test", group = "Tests" },
|
||||||
|
{ message = "^chore|^ci", group = "Miscellaneous Tasks" },
|
||||||
|
{ message = ".*", group = "Other" },
|
||||||
|
]
|
||||||
|
filter_commits = false
|
||||||
|
tag_pattern = "v[0-9].*"
|
||||||
|
sort_commits = "oldest"
|
||||||
@@ -986,25 +986,39 @@ GET /opds/devices/{deviceId}/catalog?page={page}&per_page={per_page}
|
|||||||
- `page` (optional): Page number (default: 1)
|
- `page` (optional): Page number (default: 1)
|
||||||
- `per_page` (optional): Items per page (default: 50, max: 200)
|
- `per_page` (optional): Items per page (default: 50, max: 200)
|
||||||
|
|
||||||
|
The feed is paginated via standard OPDS link relations. Clients (e.g. KOReader)
|
||||||
|
walk pages by following the `rel="next"` link until it is absent. OpenSearch
|
||||||
|
paging metadata (`totalResults`, `itemsPerPage`, `startIndex`) is also included.
|
||||||
|
|
||||||
**Response** (200 - OPDS 1.2 XML):
|
**Response** (200 - OPDS 1.2 XML):
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom"
|
<feed xmlns="http://www.w3.org/2005/Atom"
|
||||||
xmlns:opds="http://opds-spec.org/2010/"
|
xmlns:opds="http://opds-spec.org/2010/"
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">
|
||||||
<id>urn:uuid:device-id</id>
|
<id>urn:uuid:device-id</id>
|
||||||
<title>Bookhoard Library</title>
|
<title>Bookhoard Library</title>
|
||||||
<updated>2026-02-01T12:00:00Z</updated>
|
<updated>2026-02-01T12:00:00Z</updated>
|
||||||
|
|
||||||
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog"/>
|
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=2&per_page=50"/>
|
||||||
<link rel="search" href="http://localhost:8765/opds/devices/kobo-id/search"/>
|
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
||||||
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/nav"/>
|
<link rel="first" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
||||||
|
<link rel="previous" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
||||||
|
<link rel="next" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=3&per_page=50"/>
|
||||||
|
<link rel="last" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=37&per_page=50"/>
|
||||||
|
<link rel="search" type="application/opensearchdescription+xml"
|
||||||
|
href="http://localhost:8765/opds/devices/kobo-id/search"/>
|
||||||
|
|
||||||
|
<opensearch:totalResults>1814</opensearch:totalResults>
|
||||||
|
<opensearch:itemsPerPage>50</opensearch:itemsPerPage>
|
||||||
|
<opensearch:startIndex>51</opensearch:startIndex>
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
<id>urn:uuid:bookhoard-uuid-123</id>
|
<id>urn:uuid:bookhoard-uuid-123</id>
|
||||||
<dc:title>The Hobbit</dc:title>
|
<title>The Hobbit</title>
|
||||||
<dc:creator>J.R.R. Tolkien</dc:creator>
|
<author><name>J.R.R. Tolkien</name></author>
|
||||||
<updated>2026-02-01T10:00:00Z</updated>
|
<updated>2026-02-01T10:00:00Z</updated>
|
||||||
|
|
||||||
<link href="http://localhost:8765/opds/devices/kobo-id/download/uuid-123"
|
<link href="http://localhost:8765/opds/devices/kobo-id/download/uuid-123"
|
||||||
@@ -1043,10 +1057,28 @@ GET /opds/devices/{deviceId}/download/{bookId}?format={format}
|
|||||||
### Search OPDS Catalog
|
### Search OPDS Catalog
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /opds/devices/{deviceId}/search?q={query}
|
GET /opds/devices/{deviceId}/search # OpenSearch description
|
||||||
|
GET /opds/devices/{deviceId}/search?q={query} # search results feed
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response** (200 - OPDS 1.2 XML with search results)
|
When called **without** a `q` parameter, returns an OpenSearch description
|
||||||
|
document (`application/opensearchdescription+xml`). OPDS clients fetch this to
|
||||||
|
learn the search URL template, then substitute `{searchTerms}`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||||
|
<ShortName>Bookhoard</ShortName>
|
||||||
|
<Description>Search the Bookhoard library</Description>
|
||||||
|
<InputEncoding>UTF-8</InputEncoding>
|
||||||
|
<OutputEncoding>UTF-8</OutputEncoding>
|
||||||
|
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"
|
||||||
|
template="http://localhost:8765/opds/devices/kobo-id/search?q={searchTerms}"/>
|
||||||
|
</OpenSearchDescription>
|
||||||
|
```
|
||||||
|
|
||||||
|
When called **with** a `q` parameter, **Response** (200 - OPDS 1.2 XML with
|
||||||
|
search results, including `opensearch:totalResults`).
|
||||||
|
|
||||||
### List Available Formats
|
### List Available Formats
|
||||||
|
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ type Querier interface {
|
|||||||
// Get user reading history for analytics
|
// Get user reading history for analytics
|
||||||
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
|
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
|
||||||
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
||||||
|
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
||||||
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
||||||
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
|
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
|
||||||
// Check if book is in collection
|
// Check if book is in collection
|
||||||
|
|||||||
@@ -7493,6 +7493,40 @@ func (q *Queries) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUI
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetVisibleLibraryMediaCounts = `-- name: GetVisibleLibraryMediaCounts :many
|
||||||
|
SELECT l.id, COUNT(mi.id) as media_count
|
||||||
|
FROM libraries l
|
||||||
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||||
|
LEFT JOIN media_items mi ON mi.library_id = l.id
|
||||||
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
GROUP BY l.id
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetVisibleLibraryMediaCountsRow struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
MediaCount int64 `db:"media_count" json:"media_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetVisibleLibraryMediaCounts, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []GetVisibleLibraryMediaCountsRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetVisibleLibraryMediaCountsRow
|
||||||
|
if err := rows.Scan(&i.ID, &i.MediaCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const HasRecentConflictResolution = `-- name: HasRecentConflictResolution :one
|
const HasRecentConflictResolution = `-- name: HasRecentConflictResolution :one
|
||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
SELECT 1 FROM sync_conflicts
|
SELECT 1 FROM sync_conflicts
|
||||||
|
|||||||
@@ -137,6 +137,14 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
ORDER BY l.created_at ASC;
|
ORDER BY l.created_at ASC;
|
||||||
|
|
||||||
|
-- name: GetVisibleLibraryMediaCounts :many
|
||||||
|
SELECT l.id, COUNT(mi.id) as media_count
|
||||||
|
FROM libraries l
|
||||||
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||||
|
LEFT JOIN media_items mi ON mi.library_id = l.id
|
||||||
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
GROUP BY l.id;
|
||||||
|
|
||||||
-- Media Items queries
|
-- Media Items queries
|
||||||
-- name: CreateMediaItem :one
|
-- name: CreateMediaItem :one
|
||||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
||||||
|
|||||||
@@ -1791,6 +1791,10 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
|||||||
"results": []interface{}{},
|
"results": []interface{}{},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
for i := range results {
|
||||||
|
resolved := utils.ResolveMediaURL(results[i].LibraryID, results[i].CoverImagePath)
|
||||||
|
results[i].CoverImagePath = pgtype.Text{String: resolved, Valid: resolved != ""}
|
||||||
|
}
|
||||||
return c.JSON(http.StatusOK, results)
|
return c.JSON(http.StatusOK, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+75
-15
@@ -67,6 +67,42 @@ func appendToken(url, token string) string {
|
|||||||
return url + "?token=" + token
|
return url + "?token=" + token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// catalogMediaType is the OPDS media type for an acquisition catalog feed.
|
||||||
|
const catalogMediaType = "application/atom+xml;profile=opds-catalog;kind=acquisition"
|
||||||
|
|
||||||
|
// addCatalogPaginationLinks adds OPDS pagination links (self, start, first,
|
||||||
|
// previous, next, last) and OpenSearch paging metadata (totalResults,
|
||||||
|
// itemsPerPage, startIndex) to a feed based on the current page position.
|
||||||
|
// catalogBase is the device catalog URL without query parameters. The token
|
||||||
|
// (device auth) is appended to every generated link.
|
||||||
|
func addCatalogPaginationLinks(feed *opds.Feed, catalogBase string, pageNum, perPageNum, totalItems int, token string) {
|
||||||
|
totalPages := 0
|
||||||
|
if totalItems > 0 {
|
||||||
|
totalPages = (totalItems + perPageNum - 1) / perPageNum
|
||||||
|
}
|
||||||
|
startIdx := (pageNum - 1) * perPageNum
|
||||||
|
|
||||||
|
pagedURL := func(page int) string {
|
||||||
|
return appendToken(fmt.Sprintf("%s?page=%d&per_page=%d", catalogBase, page, perPageNum), token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// self reflects the current page; start/first point to the first page
|
||||||
|
feed.AddLink(pagedURL(pageNum), catalogMediaType, "self")
|
||||||
|
feed.AddLink(pagedURL(1), catalogMediaType, "start")
|
||||||
|
feed.AddLink(pagedURL(1), catalogMediaType, "first")
|
||||||
|
if totalPages > 0 {
|
||||||
|
feed.AddLink(pagedURL(totalPages), catalogMediaType, "last")
|
||||||
|
}
|
||||||
|
if pageNum > 1 {
|
||||||
|
feed.AddLink(pagedURL(pageNum-1), catalogMediaType, "previous")
|
||||||
|
}
|
||||||
|
if pageNum < totalPages {
|
||||||
|
feed.AddLink(pagedURL(pageNum+1), catalogMediaType, "next")
|
||||||
|
}
|
||||||
|
|
||||||
|
feed.SetPagination(totalItems, perPageNum, startIdx+1)
|
||||||
|
}
|
||||||
|
|
||||||
// resolveMimeType returns the mime type for a media item, preferring the stored
|
// resolveMimeType returns the mime type for a media item, preferring the stored
|
||||||
// mime_type, then format_mimetype, and finally falling back to EPUB.
|
// mime_type, then format_mimetype, and finally falling back to EPUB.
|
||||||
func resolveMimeType(mime, formatMime pgtype.Text) string {
|
func resolveMimeType(mime, formatMime pgtype.Text) string {
|
||||||
@@ -206,14 +242,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
"Bookhoard Library",
|
"Bookhoard Library",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add feed links
|
// Feed links, including OPDS pagination links (first/previous/next/last) and
|
||||||
|
// OpenSearch paging metadata (totalResults/itemsPerPage/startIndex).
|
||||||
token := h.getAuthToken(c)
|
token := h.getAuthToken(c)
|
||||||
catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
|
catalogBase := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
addCatalogPaginationLinks(feed, catalogBase, pageNum, perPageNum, totalItems, token)
|
||||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
|
||||||
|
|
||||||
|
// OpenSearch: the search link points to an OpenSearch description document
|
||||||
|
// (served by the same /search endpoint when no query is supplied) so that
|
||||||
|
// OPDS clients like KOReader can discover how to formulate search requests.
|
||||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search", opdsBaseURL, deviceID), token)
|
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search", opdsBaseURL, deviceID), token)
|
||||||
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "search")
|
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
|
||||||
|
|
||||||
// Add entries
|
// Add entries
|
||||||
for _, item := range allItems {
|
for _, item := range allItems {
|
||||||
@@ -286,16 +325,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
return c.String(http.StatusOK, xmlString)
|
return c.String(http.StatusOK, xmlString)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchDeviceCatalog searches the OPDS catalog for a device
|
// SearchDeviceCatalog searches the OPDS catalog for a device.
|
||||||
|
//
|
||||||
|
// When no "q" query parameter is supplied it returns an OpenSearch description
|
||||||
|
// document (application/opensearchdescription+xml) so that OPDS clients such as
|
||||||
|
// KOReader can discover the search URL template (which contains the
|
||||||
|
// {searchTerms} placeholder). When "q" is supplied it returns an OPDS
|
||||||
|
// acquisition feed of matching books.
|
||||||
func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||||
deviceID := c.Param("deviceId")
|
deviceID := c.Param("deviceId")
|
||||||
|
|
||||||
query := c.QueryParam("q")
|
query := c.QueryParam("q")
|
||||||
|
|
||||||
if query == "" {
|
|
||||||
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Missing search query"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get base URLs
|
// Get base URLs
|
||||||
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
|
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -316,12 +356,30 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
|||||||
|
|
||||||
// Get user's visible libraries
|
// Get user's visible libraries
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
|
|
||||||
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
token := h.getAuthToken(c)
|
||||||
|
|
||||||
|
// No query: serve the OpenSearch description document so clients can learn
|
||||||
|
// the search template (contains the {searchTerms} placeholder).
|
||||||
|
if query == "" {
|
||||||
|
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token)
|
||||||
|
desc := opds.NewSearchDescription(
|
||||||
|
"Bookhoard",
|
||||||
|
"Search the Bookhoard library",
|
||||||
|
searchURL,
|
||||||
|
)
|
||||||
|
xmlString, err := desc.GenerateXMLString()
|
||||||
|
if err != nil {
|
||||||
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate search description"))
|
||||||
|
}
|
||||||
|
c.Response().Header().Set("Content-Type", "application/opensearchdescription+xml")
|
||||||
|
return c.String(http.StatusOK, xmlString)
|
||||||
|
}
|
||||||
|
|
||||||
// Search media items
|
// Search media items
|
||||||
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
|
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
|
||||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
@@ -340,12 +398,14 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Add feed links
|
// Add feed links
|
||||||
token := h.getAuthToken(c)
|
|
||||||
catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
|
catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
|
||||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
feed.AddLink(catalogURL, catalogMediaType, "start")
|
||||||
|
|
||||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q=%s", opdsBaseURL, deviceID, query), token)
|
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q=%s", opdsBaseURL, deviceID, query), token)
|
||||||
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
feed.AddLink(searchURL, catalogMediaType, "self")
|
||||||
|
|
||||||
|
// OpenSearch paging metadata (search results are a single page)
|
||||||
|
feed.SetPagination(len(allItems), len(allItems), 1)
|
||||||
|
|
||||||
// Add entries (same as catalog)
|
// Add entries (same as catalog)
|
||||||
userUUID := uuid.UUID(userID)
|
userUUID := uuid.UUID(userID)
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookhoard/internal/opds"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rels collects the rel attributes of all links currently on the feed.
|
||||||
|
func rels(feed *opds.Feed) []string {
|
||||||
|
out := make([]string, 0, len(feed.Links))
|
||||||
|
for _, l := range feed.Links {
|
||||||
|
out = append(out, l.Rel)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsRel(feed *opds.Feed, rel string) bool {
|
||||||
|
for _, l := range feed.Links {
|
||||||
|
if l.Rel == rel {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCatalogPaginationLinks_MiddlePage(t *testing.T) {
|
||||||
|
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||||
|
// 1814 items, 50 per page => 37 pages; on page 2
|
||||||
|
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 2, 50, 1814, "tok")
|
||||||
|
|
||||||
|
assert.True(t, containsRel(feed, "self"))
|
||||||
|
assert.True(t, containsRel(feed, "start"))
|
||||||
|
assert.True(t, containsRel(feed, "first"))
|
||||||
|
assert.True(t, containsRel(feed, "last"))
|
||||||
|
assert.True(t, containsRel(feed, "previous"), "middle page must have previous")
|
||||||
|
assert.True(t, containsRel(feed, "next"), "middle page must have next")
|
||||||
|
|
||||||
|
// self must point to the current page
|
||||||
|
var selfHref string
|
||||||
|
for _, l := range feed.Links {
|
||||||
|
if l.Rel == "self" {
|
||||||
|
selfHref = l.Href
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Contains(t, selfHref, "page=2&per_page=50")
|
||||||
|
assert.Contains(t, selfHref, "token=tok")
|
||||||
|
|
||||||
|
// next must advance the page
|
||||||
|
var nextHref string
|
||||||
|
for _, l := range feed.Links {
|
||||||
|
if l.Rel == "next" {
|
||||||
|
nextHref = l.Href
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Contains(t, nextHref, "page=3")
|
||||||
|
|
||||||
|
// OpenSearch metadata
|
||||||
|
require.NotNil(t, feed.TotalResults)
|
||||||
|
assert.Equal(t, 1814, *feed.TotalResults)
|
||||||
|
require.NotNil(t, feed.ItemsPerPage)
|
||||||
|
assert.Equal(t, 50, *feed.ItemsPerPage)
|
||||||
|
require.NotNil(t, feed.StartIndex)
|
||||||
|
assert.Equal(t, 51, *feed.StartIndex, "startIndex should be 1-based offset of first item on page 2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCatalogPaginationLinks_FirstPage_NoPrevious(t *testing.T) {
|
||||||
|
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||||
|
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 1814, "")
|
||||||
|
|
||||||
|
rels := rels(feed)
|
||||||
|
assert.NotContains(t, rels, "previous", "first page must not have previous")
|
||||||
|
assert.Contains(t, rels, "next")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCatalogPaginationLinks_LastPage_NoNext(t *testing.T) {
|
||||||
|
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||||
|
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 37, 50, 1814, "")
|
||||||
|
|
||||||
|
rels := rels(feed)
|
||||||
|
assert.NotContains(t, rels, "next", "last page must not have next")
|
||||||
|
assert.Contains(t, rels, "previous")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCatalogPaginationLinks_SinglePage(t *testing.T) {
|
||||||
|
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||||
|
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 10, "")
|
||||||
|
|
||||||
|
rels := rels(feed)
|
||||||
|
assert.NotContains(t, rels, "previous")
|
||||||
|
assert.NotContains(t, rels, "next")
|
||||||
|
// still emits self/start/first/last
|
||||||
|
assert.Contains(t, rels, "self")
|
||||||
|
assert.Contains(t, rels, "last")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCatalogPaginationLinks_EmptyCatalog(t *testing.T) {
|
||||||
|
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||||
|
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 0, "")
|
||||||
|
|
||||||
|
rels := rels(feed)
|
||||||
|
assert.NotContains(t, rels, "next")
|
||||||
|
assert.NotContains(t, rels, "previous")
|
||||||
|
assert.NotContains(t, rels, "last", "empty catalog should not advertise a last page")
|
||||||
|
require.NotNil(t, feed.TotalResults)
|
||||||
|
assert.Equal(t, 0, *feed.TotalResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCatalogPaginationLinks_TokenAppended(t *testing.T) {
|
||||||
|
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||||
|
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 100, "abc")
|
||||||
|
|
||||||
|
xml, err := feed.GenerateXMLString()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, strings.Count(xml, "token=abc") >= 3, "token should be appended to generated links")
|
||||||
|
}
|
||||||
+89
-17
@@ -9,15 +9,19 @@ import (
|
|||||||
// OPDS 1.2 Feed Structures
|
// OPDS 1.2 Feed Structures
|
||||||
|
|
||||||
type Feed struct {
|
type Feed struct {
|
||||||
XMLName xml.Name `xml:"feed"`
|
XMLName xml.Name `xml:"feed"`
|
||||||
Xmlns string `xml:"xmlns,attr"`
|
Xmlns string `xml:"xmlns,attr"`
|
||||||
OpdsNS string `xml:"xmlns:opds,attr"`
|
OpdsNS string `xml:"xmlns:opds,attr"`
|
||||||
DcNS string `xml:"xmlns:dc,attr"`
|
DcNS string `xml:"xmlns:dc,attr"`
|
||||||
ID string `xml:"id"`
|
OpenSearchNS string `xml:"xmlns:opensearch,attr,omitempty"`
|
||||||
Title string `xml:"title"`
|
ID string `xml:"id"`
|
||||||
Updated string `xml:"updated"`
|
Title string `xml:"title"`
|
||||||
Links []Link `xml:"link"`
|
Updated string `xml:"updated"`
|
||||||
Entries []Entry `xml:"entry"`
|
Links []Link `xml:"link"`
|
||||||
|
TotalResults *int `xml:"opensearch:totalResults,omitempty"`
|
||||||
|
ItemsPerPage *int `xml:"opensearch:itemsPerPage,omitempty"`
|
||||||
|
StartIndex *int `xml:"opensearch:startIndex,omitempty"`
|
||||||
|
Entries []Entry `xml:"entry"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
@@ -64,17 +68,29 @@ type Category struct {
|
|||||||
func NewFeed(feedID, title string) *Feed {
|
func NewFeed(feedID, title string) *Feed {
|
||||||
now := time.Now().Format(time.RFC3339)
|
now := time.Now().Format(time.RFC3339)
|
||||||
return &Feed{
|
return &Feed{
|
||||||
Xmlns: "http://www.w3.org/2005/Atom",
|
Xmlns: "http://www.w3.org/2005/Atom",
|
||||||
OpdsNS: "http://opds-spec.org/2010/",
|
OpdsNS: "http://opds-spec.org/2010/",
|
||||||
DcNS: "http://purl.org/dc/elements/1.1/",
|
DcNS: "http://purl.org/dc/elements/1.1/",
|
||||||
ID: feedID,
|
OpenSearchNS: "http://a9.com/-/spec/opensearch/1.1/",
|
||||||
Title: title,
|
ID: feedID,
|
||||||
Updated: now,
|
Title: title,
|
||||||
Links: []Link{},
|
Updated: now,
|
||||||
Entries: []Entry{},
|
Links: []Link{},
|
||||||
|
Entries: []Entry{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPagination populates the OpenSearch paging metadata (totalResults,
|
||||||
|
// itemsPerPage, startIndex). startIndex is 1-based to match the page model.
|
||||||
|
func (f *Feed) SetPagination(totalResults, itemsPerPage, startIndex int) {
|
||||||
|
tr := totalResults
|
||||||
|
ipp := itemsPerPage
|
||||||
|
si := startIndex
|
||||||
|
f.TotalResults = &tr
|
||||||
|
f.ItemsPerPage = &ipp
|
||||||
|
f.StartIndex = &si
|
||||||
|
}
|
||||||
|
|
||||||
// AddLink adds a link to the feed
|
// AddLink adds a link to the feed
|
||||||
func (f *Feed) AddLink(href, linkType, rel string) {
|
func (f *Feed) AddLink(href, linkType, rel string) {
|
||||||
f.Links = append(f.Links, Link{
|
f.Links = append(f.Links, Link{
|
||||||
@@ -178,6 +194,62 @@ func (f *Feed) GenerateXMLString() (string, error) {
|
|||||||
return xml.Header + string(output), nil
|
return xml.Header + string(output), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenSearchUrl is a single <Url> element in an OpenSearch description.
|
||||||
|
type OpenSearchUrl struct {
|
||||||
|
XMLName xml.Name `xml:"Url"`
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
Template string `xml:"template,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenSearchDescription is an OpenSearch description document used by OPDS
|
||||||
|
// clients (e.g. KOReader) to discover how to perform catalog searches. Clients
|
||||||
|
// fetch this document at the catalog's rel="search" link, then substitute
|
||||||
|
// {searchTerms} in the Url template to execute a query.
|
||||||
|
type OpenSearchDescription struct {
|
||||||
|
XMLName xml.Name `xml:"OpenSearchDescription"`
|
||||||
|
Xmlns string `xml:"xmlns,attr"`
|
||||||
|
ShortName string `xml:"ShortName"`
|
||||||
|
Description string `xml:"Description"`
|
||||||
|
InputEncoding string `xml:"InputEncoding"`
|
||||||
|
OutputEncoding string `xml:"OutputEncoding"`
|
||||||
|
Url OpenSearchUrl `xml:"Url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSearchDescription creates an OpenSearch description document whose Url
|
||||||
|
// template points clients back to the search results endpoint. The template
|
||||||
|
// must contain the {searchTerms} placeholder.
|
||||||
|
func NewSearchDescription(shortName, description, template string) *OpenSearchDescription {
|
||||||
|
return &OpenSearchDescription{
|
||||||
|
Xmlns: "http://a9.com/-/spec/opensearch/1.1/",
|
||||||
|
ShortName: shortName,
|
||||||
|
Description: description,
|
||||||
|
InputEncoding: "UTF-8",
|
||||||
|
OutputEncoding: "UTF-8",
|
||||||
|
Url: OpenSearchUrl{
|
||||||
|
Type: "application/atom+xml;profile=opds-catalog;kind=acquisition",
|
||||||
|
Template: template,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateXML generates the OpenSearch description XML
|
||||||
|
func (d *OpenSearchDescription) GenerateXML() ([]byte, error) {
|
||||||
|
output, err := xml.MarshalIndent(d, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal OpenSearch description: %w", err)
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateXMLString generates the OpenSearch description XML as a string
|
||||||
|
func (d *OpenSearchDescription) GenerateXMLString() (string, error) {
|
||||||
|
output, err := d.GenerateXML()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return xml.Header + string(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
// NewErrorFeed creates an error feed
|
// NewErrorFeed creates an error feed
|
||||||
func NewErrorFeed(message string) *Feed {
|
func NewErrorFeed(message string) *Feed {
|
||||||
feed := NewFeed(
|
feed := NewFeed(
|
||||||
|
|||||||
+105
-4
@@ -71,8 +71,8 @@ func TestNewEntry(t *testing.T) {
|
|||||||
t.Errorf("expected Title to be 'Test Title', got '%s'", entry.Title)
|
t.Errorf("expected Title to be 'Test Title', got '%s'", entry.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.Creator != "Test Author" {
|
if entry.Author == nil || entry.Author.Name != "Test Author" {
|
||||||
t.Errorf("expected Creator to be 'Test Author', got '%s'", entry.Creator)
|
t.Errorf("expected Author.Name to be 'Test Author', got %v", entry.Author)
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.Updated != "2023-01-01T00:00:00Z" {
|
if entry.Updated != "2023-01-01T00:00:00Z" {
|
||||||
@@ -201,8 +201,8 @@ func TestFeedGenerateXML(t *testing.T) {
|
|||||||
`<title>Test Feed</title>`,
|
`<title>Test Feed</title>`,
|
||||||
`<entry>`,
|
`<entry>`,
|
||||||
`<id>urn:uuid:book-id</id>`,
|
`<id>urn:uuid:book-id</id>`,
|
||||||
`<dc:title>Test Book</dc:title>`,
|
`<title>Test Book</title>`,
|
||||||
`<dc:creator>Test Author</dc:creator>`,
|
`<name>Test Author</name>`,
|
||||||
`<link href="http://example.com/book.epub"`,
|
`<link href="http://example.com/book.epub"`,
|
||||||
`rel="http://opds-spec.org/acquisition/open-access"`,
|
`rel="http://opds-spec.org/acquisition/open-access"`,
|
||||||
`<dc:identifier id="bookhoard">book-uuid-123</dc:identifier>`,
|
`<dc:identifier id="bookhoard">book-uuid-123</dc:identifier>`,
|
||||||
@@ -232,6 +232,107 @@ func TestNewErrorFeed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFeedSetPagination(t *testing.T) {
|
||||||
|
feed := NewFeed("urn:uuid:test-id", "Test Feed")
|
||||||
|
feed.SetPagination(1814, 50, 51)
|
||||||
|
|
||||||
|
if feed.TotalResults == nil || *feed.TotalResults != 1814 {
|
||||||
|
t.Errorf("expected TotalResults to be 1814, got %v", feed.TotalResults)
|
||||||
|
}
|
||||||
|
if feed.ItemsPerPage == nil || *feed.ItemsPerPage != 50 {
|
||||||
|
t.Errorf("expected ItemsPerPage to be 50, got %v", feed.ItemsPerPage)
|
||||||
|
}
|
||||||
|
if feed.StartIndex == nil || *feed.StartIndex != 51 {
|
||||||
|
t.Errorf("expected StartIndex to be 51, got %v", feed.StartIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeedGenerateXMLPagination(t *testing.T) {
|
||||||
|
feed := NewFeed("urn:uuid:test-id", "Test Feed")
|
||||||
|
feed.AddLink("http://example.com/catalog?page=1", "application/atom+xml", "first")
|
||||||
|
feed.AddLink("http://example.com/catalog?page=1", "application/atom+xml", "previous")
|
||||||
|
feed.AddLink("http://example.com/catalog?page=2", "application/atom+xml", "self")
|
||||||
|
feed.AddLink("http://example.com/catalog?page=3", "application/atom+xml", "next")
|
||||||
|
feed.AddLink("http://example.com/catalog?page=37", "application/atom+xml", "last")
|
||||||
|
feed.SetPagination(1814, 50, 51)
|
||||||
|
|
||||||
|
output, err := feed.GenerateXML()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate XML: %v", err)
|
||||||
|
}
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
requiredStrings := []string{
|
||||||
|
`xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/"`,
|
||||||
|
`<opensearch:totalResults>1814</opensearch:totalResults>`,
|
||||||
|
`<opensearch:itemsPerPage>50</opensearch:itemsPerPage>`,
|
||||||
|
`<opensearch:startIndex>51</opensearch:startIndex>`,
|
||||||
|
`rel="first"`,
|
||||||
|
`rel="previous"`,
|
||||||
|
`rel="next"`,
|
||||||
|
`rel="last"`,
|
||||||
|
`page=3`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, required := range requiredStrings {
|
||||||
|
if !contains(outputStr, required) {
|
||||||
|
t.Errorf("generated XML missing required string: %s", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeedGenerateXMLOmitsPaginationWhenUnset(t *testing.T) {
|
||||||
|
feed := NewFeed("urn:uuid:test-id", "Test Feed")
|
||||||
|
|
||||||
|
output, err := feed.GenerateXML()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate XML: %v", err)
|
||||||
|
}
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
if contains(outputStr, "opensearch:totalResults") {
|
||||||
|
t.Errorf("expected no totalResults when pagination unset, but found it")
|
||||||
|
}
|
||||||
|
if contains(outputStr, "opensearch:itemsPerPage") {
|
||||||
|
t.Errorf("expected no itemsPerPage when pagination unset, but found it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSearchDescription(t *testing.T) {
|
||||||
|
template := "http://example.com/opds/devices/abc/search?q={searchTerms}&token=xyz"
|
||||||
|
desc := NewSearchDescription("Bookhoard", "Search the library", template)
|
||||||
|
|
||||||
|
if desc.ShortName != "Bookhoard" {
|
||||||
|
t.Errorf("expected ShortName 'Bookhoard', got '%s'", desc.ShortName)
|
||||||
|
}
|
||||||
|
if desc.Url.Template != template {
|
||||||
|
t.Errorf("expected template '%s', got '%s'", template, desc.Url.Template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchDescriptionGenerateXML(t *testing.T) {
|
||||||
|
template := "http://example.com/opds/devices/abc/search?q={searchTerms}"
|
||||||
|
desc := NewSearchDescription("Bookhoard", "Search the library", template)
|
||||||
|
|
||||||
|
output, err := desc.GenerateXMLString()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate XML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requiredStrings := []string{
|
||||||
|
`<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">`,
|
||||||
|
`<ShortName>Bookhoard</ShortName>`,
|
||||||
|
`<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"`,
|
||||||
|
`template="http://example.com/opds/devices/abc/search?q={searchTerms}"`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, required := range requiredStrings {
|
||||||
|
if !contains(output, required) {
|
||||||
|
t.Errorf("generated OpenSearch XML missing required string: %s", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func contains(s, substr string) bool {
|
func contains(s, substr string) bool {
|
||||||
return len(s) >= len(substr) && indexOf(s, substr) >= 0
|
return len(s) >= len(substr) && indexOf(s, substr) >= 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,17 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
|
|||||||
libraries = []database.GetUserVisibleLibrariesRow{}
|
libraries = []database.GetUserVisibleLibrariesRow{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
counts, countErr := cfg.Queries.GetVisibleLibraryMediaCounts(c.Request().Context(), uuidToPGType(userU))
|
||||||
|
if countErr != nil {
|
||||||
|
log.Printf("GetVisibleLibraryMediaCounts failed: %v", countErr)
|
||||||
|
counts = []database.GetVisibleLibraryMediaCountsRow{}
|
||||||
|
}
|
||||||
|
countMap := make(map[string]int64, len(counts))
|
||||||
|
for _, mc := range counts {
|
||||||
|
mcUUID, _ := uuid.FromBytes(mc.ID.Bytes[0:16])
|
||||||
|
countMap[mcUUID.String()] = mc.MediaCount
|
||||||
|
}
|
||||||
|
|
||||||
res.Libraries = make([]templates.LibraryData, len(libraries))
|
res.Libraries = make([]templates.LibraryData, len(libraries))
|
||||||
for i, lib := range libraries {
|
for i, lib := range libraries {
|
||||||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||||
@@ -126,6 +137,7 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
|
|||||||
Name: lib.Name,
|
Name: lib.Name,
|
||||||
Description: getText(lib.Description),
|
Description: getText(lib.Description),
|
||||||
TypeName: lib.TypeName,
|
TypeName: lib.TypeName,
|
||||||
|
MediaCount: countMap[libUUID.String()],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# Project-attached wrapper around `make release` so you can run:
|
||||||
|
# ./release v0.3.0 (or) ./release 0.3.0
|
||||||
|
# instead of:
|
||||||
|
# make release VERSION=v0.3.0
|
||||||
|
# Lives in the repo (no machine-specific alias needed).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
[ "$#" -ge 1 ] || { echo "Usage: ./release v0.3.0" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Accept "0.3.0" or "v0.3.0"; ensure the tag starts with 'v' (the workflow
|
||||||
|
# only triggers on v* tags).
|
||||||
|
VERSION="v${1#v}"
|
||||||
|
|
||||||
|
exec make release "VERSION=${VERSION}"
|
||||||
@@ -19,7 +19,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="bookDetail" class="theme-{ user.Theme }" data-format-group={ book.FormatGroup } data-library-id={ uuidToString(book.LibraryID) }>
|
<body x-data="bookDetail" class="theme-{ user.Theme }" data-format-group={ book.FormatGroup } data-library-id={ uuidToString(book.LibraryID) } data-rating={ fmt.Sprintf("%d", getBookRating(book.Rating)) } data-conflict-id={ conflictID(book.ActiveConflict) } data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }>
|
||||||
@Header(user, "/media/{ uuidToString(book.ID) }")
|
@Header(user, "/media/{ uuidToString(book.ID) }")
|
||||||
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
|
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
|
||||||
<div class="w-full px-4 py-3 flex items-center gap-4">
|
<div class="w-full px-4 py-3 flex items-center gap-4">
|
||||||
@@ -67,6 +67,34 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
>
|
>
|
||||||
📖 Read Now
|
📖 Read Now
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Mark as Read / Unread toggle -->
|
||||||
|
if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 {
|
||||||
|
<button
|
||||||
|
@click="toggleRead(false)"
|
||||||
|
:disabled="readSaving"
|
||||||
|
class="px-6 py-3 rounded-lg border"
|
||||||
|
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||||
|
>
|
||||||
|
<span x-show="!readSaving">✅ Mark as Unread</span>
|
||||||
|
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
} else {
|
||||||
|
<button
|
||||||
|
@click="toggleRead(true)"
|
||||||
|
:disabled="readSaving"
|
||||||
|
class="px-6 py-3 rounded-lg border"
|
||||||
|
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||||
|
>
|
||||||
|
<span x-show="!readSaving">📖 Mark as Read</span>
|
||||||
|
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
<!-- Sync Progress -->
|
<!-- Sync Progress -->
|
||||||
if book.ActiveConflict != nil || book.ReadingProgress != nil {
|
if book.ActiveConflict != nil || book.ReadingProgress != nil {
|
||||||
<button
|
<button
|
||||||
@@ -103,13 +131,42 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Rating Display -->
|
<!-- Rating Display -->
|
||||||
<div class="mb-6">
|
<div class="mb-6" @mouseleave="ratingHover = 0">
|
||||||
<span class="text-2xl">
|
<span class="text-2xl">
|
||||||
@templ.Raw(renderStars(getBookRating(book.Rating)))
|
<template x-for="i in 5" :key="i">
|
||||||
</span>
|
<span style="position: relative; display: inline-block;">
|
||||||
<span class="ml-2 text-sm" style="color: var(--text-secondary);">
|
<span :style="starFill(i)">★</span>
|
||||||
({ fmt.Sprintf("%.1f", float64(getBookRating(book.Rating))/2.0) } / 5)
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="setRating(i*2-1)"
|
||||||
|
@mouseenter="ratingHover = i*2-1"
|
||||||
|
:disabled="ratingSaving"
|
||||||
|
:aria-label="'Rate ' + ((i*2-1)/2) + ' of 5 stars'"
|
||||||
|
style="position: absolute; left: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="setRating(i*2)"
|
||||||
|
@mouseenter="ratingHover = i*2"
|
||||||
|
:disabled="ratingSaving"
|
||||||
|
:aria-label="'Rate ' + ((i*2)/2) + ' of 5 stars'"
|
||||||
|
style="position: absolute; right: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
|
||||||
|
></button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="ml-2 text-sm align-middle" style="color: var(--text-secondary);" x-text="ratingText()"></span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-show="userRating > 0 && !ratingSaving"
|
||||||
|
@click="clearRating()"
|
||||||
|
class="ml-2 text-xs underline hover:opacity-70"
|
||||||
|
style="color: var(--text-secondary);"
|
||||||
|
>Clear</button>
|
||||||
|
<svg x-show="ratingSaving" class="animate-spin inline-block h-4 w-4 ml-1 align-middle" viewBox="0 0 24 24" fill="none" style="color: var(--text-secondary);">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<!-- Community Rating Display (from metadata) -->
|
<!-- Community Rating Display (from metadata) -->
|
||||||
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
|
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
|
||||||
|
|||||||
@@ -737,214 +737,238 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div></div></div><!-- Comic/Manga --><div class=\"border rounded-lg\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\" @click=\"toggleSection('comic')\"><span class=\"font-semibold\">Comic/Manga</span> <span x-text=\"openSections.comic ? '▾' : '▸'\">▸</span></button><div x-show=\"openSections.comic\" x-transition class=\"p-4 space-y-3\" style=\"background-color: var(--bg-primary);\"><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Manga Type</label> <select name=\"manga_type\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"><option value=\"unknown\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div></div></div><!-- Comic/Manga --><div class=\"border rounded-lg\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\" @click=\"toggleSection('comic')\"><span class=\"font-semibold\">Comic/Manga</span> <span x-text=\"openSections.comic ? '▾' : '▸'\">▸</span></button><div x-show=\"openSections.comic\" x-transition class=\"p-4 space-y-3\" style=\"background-color: var(--bg-primary);\"><div><label for=\"manga_type\" class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Manga Type</label> <select id=\"manga_type\" name=\"manga_type\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"><option value=\"unknown\" selected=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if textToString(book.MangaType) == "unknown" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, ">Unknown</option> <option value=\"no\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if textToString(book.MangaType) == "no" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, ">No</option> <option value=\"yes\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if textToString(book.MangaType) == "yes" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, ">Yes</option> <option value=\"yes_and_right_to_left\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if textToString(book.MangaType) == "yes_and_right_to_left" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, ">Yes (Right to Left)</option></select></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Reading Direction</label> <select name=\"reading_direction\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"><option value=\"auto\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if textToString(book.ReadingDirection) == "auto" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, ">Auto</option> <option value=\"ltr\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if textToString(book.ReadingDirection) == "ltr" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, ">Left to Right</option> <option value=\"rtl\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if textToString(book.ReadingDirection) == "rtl" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, ">Right to Left</option> <option value=\"vertical\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if textToString(book.ReadingDirection) == "vertical" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, ">Vertical</option></select></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Age Rating</label> <input type=\"text\" name=\"age_rating\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var46 string
|
var templ_7745c5c3_Var46 string
|
||||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.AgeRating))
|
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "unknown")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 501, Col: 81}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 482, Col: 85}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Story Arc</label> <input type=\"text\" name=\"story_arc\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\">Unknown</option> <option value=\"no\" selected=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var47 string
|
var templ_7745c5c3_Var47 string
|
||||||
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.StoryArc))
|
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "no")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 507, Col: 79}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 483, Col: 75}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Imprint</label> <input type=\"text\" name=\"imprint\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\">No</option> <option value=\"yes\" selected=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var48 string
|
var templ_7745c5c3_Var48 string
|
||||||
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Imprint))
|
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 513, Col: 76}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 484, Col: 77}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Scan Information</label> <input type=\"text\" name=\"scan_information\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\">Yes</option> <option value=\"yes_and_right_to_left\" selected=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var49 string
|
var templ_7745c5c3_Var49 string
|
||||||
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ScanInformation))
|
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes_and_right_to_left")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 519, Col: 93}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 485, Col: 113}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Metadata Notes</label> <textarea name=\"metadata_notes\" rows=\"2\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\">Yes (Right to Left)</option></select></div><div><label for=\"reading_direction\" class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Reading Direction</label> <select id=\"reading_direction\" name=\"reading_direction\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"><option value=\"auto\" selected=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var50 string
|
var templ_7745c5c3_Var50 string
|
||||||
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.MetadataNotes))
|
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "auto")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 528, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 493, Col: 86}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
|
||||||
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, 85, "</textarea></div><div class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"is_black_and_white\" id=\"is_black_and_white\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\">Auto</option> <option value=\"ltr\" selected=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if book.IsBlackAndWhite.Bool {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, " checked")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, " class=\"rounded\"> <label for=\"is_black_and_white\" class=\"text-sm\" style=\"color: var(--text-secondary);\">Black & White</label></div></div></div><!-- Technical --><div class=\"border rounded-lg\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\" @click=\"toggleSection('technical')\"><span class=\"font-semibold\">Technical</span> <span x-text=\"openSections.technical ? '▾' : '▸'\">▸</span></button><div x-show=\"openSections.technical\" x-transition class=\"p-4 space-y-3\" style=\"background-color: var(--bg-primary);\"><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Page Count</label> <input type=\"number\" name=\"page_count\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var51 string
|
var templ_7745c5c3_Var51 string
|
||||||
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.PageCount.Int32))
|
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "ltr")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 550, Col: 56}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 494, Col: 84}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var51)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var51)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Contributors (comma-separated)</label> <input type=\"text\" name=\"contributors\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\">Left to Right</option> <option value=\"rtl\" selected=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var52 string
|
var templ_7745c5c3_Var52 string
|
||||||
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(stringSliceToString(book.Contributors))
|
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "rtl")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 556, Col: 93}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 495, Col: 84}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div><div class=\"grid grid-cols-2 gap-3\"><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Format</label> <input type=\"text\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\">Right to Left</option> <option value=\"vertical\" selected=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var53 string
|
var templ_7745c5c3_Var53 string
|
||||||
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MimeType))
|
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "vertical")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 563, Col: 63}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 496, Col: 94}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" readonly class=\"w-full px-3 py-2 rounded-lg border text-sm opacity-60\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">File Size</label> <input type=\"text\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\">Vertical</option></select></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Age Rating</label> <input type=\"text\" name=\"age_rating\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var54 string
|
var templ_7745c5c3_Var54 string
|
||||||
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024))
|
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.AgeRating))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 569, Col: 98}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 501, Col: 81}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\" readonly class=\"w-full px-3 py-2 rounded-lg border text-sm opacity-60\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div></div></div></div></div></div><div class=\"flex justify-end space-x-3 p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><button @click=\"hideMetadataEditor()\" class=\"px-4 py-2 rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);\">Cancel</button> <button @click=\"saveMetadata()\" class=\"px-6 py-2 rounded-lg font-semibold\" style=\"background-color: var(--accent); color: white;\">Save</button></div></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Story Arc</label> <input type=\"text\" name=\"story_arc\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var55 string
|
||||||
|
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.StoryArc))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 507, Col: 79}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Imprint</label> <input type=\"text\" name=\"imprint\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var56 string
|
||||||
|
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Imprint))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 513, Col: 76}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Scan Information</label> <input type=\"text\" name=\"scan_information\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var57 string
|
||||||
|
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ScanInformation))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 519, Col: 93}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Metadata Notes</label> <textarea name=\"metadata_notes\" rows=\"2\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var58 string
|
||||||
|
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.MetadataNotes))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 528, Col: 43}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</textarea></div><div class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"is_black_and_white\" id=\"is_black_and_white\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if book.IsBlackAndWhite.Bool {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " checked")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " class=\"rounded\"> <label for=\"is_black_and_white\" class=\"text-sm\" style=\"color: var(--text-secondary);\">Black & White</label></div></div></div><!-- Technical --><div class=\"border rounded-lg\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\" @click=\"toggleSection('technical')\"><span class=\"font-semibold\">Technical</span> <span x-text=\"openSections.technical ? '▾' : '▸'\">▸</span></button><div x-show=\"openSections.technical\" x-transition class=\"p-4 space-y-3\" style=\"background-color: var(--bg-primary);\"><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Page Count</label> <input type=\"number\" name=\"page_count\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var59 string
|
||||||
|
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.PageCount.Int32))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 550, Col: 56}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Contributors (comma-separated)</label> <input type=\"text\" name=\"contributors\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var60 string
|
||||||
|
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(stringSliceToString(book.Contributors))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 556, Col: 93}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div><div class=\"grid grid-cols-2 gap-3\"><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Format</label> <input type=\"text\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var61 string
|
||||||
|
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MimeType))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 563, Col: 63}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" readonly class=\"w-full px-3 py-2 rounded-lg border text-sm opacity-60\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">File Size</label> <input type=\"text\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var62 string
|
||||||
|
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 569, Col: 98}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" readonly class=\"w-full px-3 py-2 rounded-lg border text-sm opacity-60\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div></div></div></div></div></div><div class=\"flex justify-end space-x-3 p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><button @click=\"hideMetadataEditor()\" class=\"px-4 py-2 rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);\">Cancel</button> <button @click=\"saveMetadata()\" class=\"px-6 py-2 rounded-lg font-semibold\" style=\"background-color: var(--accent); color: white;\">Save</button></div></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+575
-542
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -50,22 +50,22 @@ templ BookShelf(
|
|||||||
class="w-full px-3 py-2 border rounded-lg"
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
>
|
>
|
||||||
if len(libraries) == 0 {
|
if len(libraries) == 0 {
|
||||||
<option value="">No libraries available</option>
|
<option value="">No libraries available</option>
|
||||||
|
} else {
|
||||||
|
if currentLibraryID == "" {
|
||||||
|
<option value="" selected>All Books ({ TotalMediaCount(libraries) })</option>
|
||||||
} else {
|
} else {
|
||||||
if currentLibraryID == "" {
|
<option value="">All Books ({ TotalMediaCount(libraries) })</option>
|
||||||
<option value="" selected>All Books</option>
|
}
|
||||||
|
for _, lib := range libraries {
|
||||||
|
if lib.ID == currentLibraryID {
|
||||||
|
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
|
||||||
} else {
|
} else {
|
||||||
<option value="">All Books</option>
|
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
|
||||||
}
|
|
||||||
for _, lib := range libraries {
|
|
||||||
if lib.ID == currentLibraryID {
|
|
||||||
<option value={ lib.ID } selected>{ lib.Name }</option>
|
|
||||||
} else {
|
|
||||||
<option value={ lib.ID }>{ lib.Name }</option>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<!-- Search Input -->
|
<!-- Search Input -->
|
||||||
|
|||||||
+120
-68
File diff suppressed because one or more lines are too long
@@ -11,14 +11,14 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ..
|
|||||||
class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||||||
style="background-color: var(--bg-secondary); color: var(--text-primary);"
|
style="background-color: var(--bg-secondary); color: var(--text-primary);"
|
||||||
>
|
>
|
||||||
<option value="">All Libraries</option>
|
<option value="">All Libraries ({ TotalMediaCount(libData) })</option>
|
||||||
for _, lib := range libData {
|
for _, lib := range libData {
|
||||||
if lib.ID == currentLibraryID {
|
if lib.ID == currentLibraryID {
|
||||||
<option value={ lib.ID } selected>{ lib.Name }</option>
|
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
|
||||||
} else {
|
} else {
|
||||||
<option value={ lib.ID }>{ lib.Name }</option>
|
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
if len(actions) > 0 {
|
if len(actions) > 0 {
|
||||||
|
|||||||
@@ -29,81 +29,120 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
|
|||||||
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, "<div class=\"sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b\" style=\"background-color: var(--bg-primary);\"><div class=\"w-full px-4 py-3 flex items-center justify-between\"><div class=\"flex items-center gap-4\"><label class=\"text-sm font-medium\" style=\"color: var(--text-secondary)\">Library:</label> <select id=\"library-select\" name=\"library_id\" class=\"px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-secondary); color: var(--text-primary);\"><option value=\"\">All Libraries</option> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b\" style=\"background-color: var(--bg-primary);\"><div class=\"w-full px-4 py-3 flex items-center justify-between\"><div class=\"flex items-center gap-4\"><label class=\"text-sm font-medium\" style=\"color: var(--text-secondary)\">Library:</label> <select id=\"library-select\" name=\"library_id\" class=\"px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-secondary); color: var(--text-primary);\"><option value=\"\">All Libraries (")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var2 string
|
||||||
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 14, Col: 62}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, ")</option> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, lib := range libData {
|
for _, lib := range libData {
|
||||||
if lib.ID == currentLibraryID {
|
if lib.ID == currentLibraryID {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<option value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<option value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var2 string
|
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 29}
|
|
||||||
}
|
|
||||||
_, 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, "\" selected>")
|
|
||||||
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_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 51}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, 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, 4, "</option>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" selected>")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<option value=\"")
|
|
||||||
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_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 29}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 50}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
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, 6, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " (")
|
||||||
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_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 42}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 70}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, 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, 7, "</option>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, ")</option>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<option value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 string
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 28}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 41}
|
||||||
|
}
|
||||||
|
_, 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, " (")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 61}
|
||||||
|
}
|
||||||
|
_, 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, 10, ")</option>")
|
||||||
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, "</select></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</select></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(actions) > 0 {
|
if len(actions) > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"flex items-center gap-2\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"flex items-center gap-2\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -113,12 +152,12 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</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, 11, "</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, 14, "</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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -142,12 +181,12 @@ func DashboardActions() templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var6 == nil {
|
if templ_7745c5c3_Var9 == nil {
|
||||||
templ_7745c5c3_Var6 = templ.NopComponent
|
templ_7745c5c3_Var9 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<button data-action=\"open-dashboard-settings\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Customize Dashboard\">⚙️</button> <button data-action=\"reload-page\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Refresh\">🔄</button>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<button data-action=\"open-dashboard-settings\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Customize Dashboard\">⚙️</button> <button data-action=\"reload-page\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Refresh\">🔄</button>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type LibraryData struct {
|
|||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
TypeName string
|
TypeName string
|
||||||
|
MediaCount int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type SeriesCardData struct {
|
type SeriesCardData struct {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package templates
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/handlers"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -29,6 +31,16 @@ func ContainsString(slice []string, item string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TotalMediaCount sums the MediaCount across the given libraries,
|
||||||
|
// used to display the total next to the "All Libraries" option.
|
||||||
|
func TotalMediaCount(libs []LibraryData) int64 {
|
||||||
|
var total int64
|
||||||
|
for _, l := range libs {
|
||||||
|
total += l.MediaCount
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
func uuidToString(id pgtype.UUID) string {
|
func uuidToString(id pgtype.UUID) string {
|
||||||
if !id.Valid {
|
if !id.Valid {
|
||||||
return ""
|
return ""
|
||||||
@@ -146,6 +158,37 @@ func getBookRating(rating *database.MediaRatings) int32 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// conflictWinnerSource returns a valid source key from a conflict's
|
||||||
|
// conflict_data map, to pass as the "winner" when resolving it. It prefers
|
||||||
|
// "web" (since the user is acting via the web UI) and otherwise falls back to
|
||||||
|
// the lexicographically smallest key. The chosen winner does not affect the
|
||||||
|
// final read/unread state, which is set by a subsequent progress write; it only
|
||||||
|
// needs to be a key present in the conflict data so the resolve endpoint
|
||||||
|
// accepts it and arms its 10-minute suppression window.
|
||||||
|
func conflictWinnerSource(c *handlers.ConflictDetailResponse) string {
|
||||||
|
if c == nil || len(c.ConflictData) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if _, ok := c.ConflictData["web"]; ok {
|
||||||
|
return "web"
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(c.ConflictData))
|
||||||
|
for k := range c.ConflictData {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// conflictID returns the active conflict's ID, or "" when there is none. Used
|
||||||
|
// to render a data-conflict-id attribute the frontend can read.
|
||||||
|
func conflictID(c *handlers.ConflictDetailResponse) string {
|
||||||
|
if c == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.ID
|
||||||
|
}
|
||||||
|
|
||||||
// getAlternateSeries extracts the alternate series name from JSONB data
|
// getAlternateSeries extracts the alternate series name from JSONB data
|
||||||
func getAlternateSeries(data []byte) string {
|
func getAlternateSeries(data []byte) string {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ interface MetadataEditorState {
|
|||||||
coverFile: Blob | null;
|
coverFile: Blob | null;
|
||||||
coverAction: string;
|
coverAction: string;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
|
userRating: number;
|
||||||
|
ratingHover: number;
|
||||||
|
ratingSaving: boolean;
|
||||||
|
conflictId: string;
|
||||||
|
conflictWinner: string;
|
||||||
|
readSaving: boolean;
|
||||||
toggleSection(section: string): void;
|
toggleSection(section: string): void;
|
||||||
showMetadataEditor(): void;
|
showMetadataEditor(): void;
|
||||||
hideMetadataEditor(): void;
|
hideMetadataEditor(): void;
|
||||||
@@ -104,6 +110,11 @@ interface MetadataEditorState {
|
|||||||
generateCover(): Promise<void>;
|
generateCover(): Promise<void>;
|
||||||
removeCover(): void;
|
removeCover(): void;
|
||||||
saveMetadata(): Promise<void>;
|
saveMetadata(): Promise<void>;
|
||||||
|
starFill(i: number): string;
|
||||||
|
ratingText(): string;
|
||||||
|
setRating(value: number): Promise<void>;
|
||||||
|
clearRating(): Promise<void>;
|
||||||
|
toggleRead(read: boolean): Promise<void>;
|
||||||
resolveConflict(conflictId: string, winner: string): Promise<void>;
|
resolveConflict(conflictId: string, winner: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +147,12 @@ Alpine.data("bookDetail", () => {
|
|||||||
coverFile: null as Blob | null,
|
coverFile: null as Blob | null,
|
||||||
coverAction: "keep",
|
coverAction: "keep",
|
||||||
saving: false,
|
saving: false,
|
||||||
|
userRating: 0,
|
||||||
|
ratingHover: 0,
|
||||||
|
ratingSaving: false,
|
||||||
|
conflictId: "",
|
||||||
|
conflictWinner: "",
|
||||||
|
readSaving: false,
|
||||||
editorTags: initialTags,
|
editorTags: initialTags,
|
||||||
tagSearch: "",
|
tagSearch: "",
|
||||||
tagSuggestions: [] as TagSuggestion[],
|
tagSuggestions: [] as TagSuggestion[],
|
||||||
@@ -178,6 +195,15 @@ Alpine.data("bookDetail", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
const ratingAttr = document.body.getAttribute("data-rating");
|
||||||
|
this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0;
|
||||||
|
|
||||||
|
const conflictId = document.body.getAttribute("data-conflict-id");
|
||||||
|
const conflictWinner = document.body.getAttribute("data-conflict-winner");
|
||||||
|
this.conflictId = conflictId && conflictId !== "null" ? conflictId : "";
|
||||||
|
this.conflictWinner =
|
||||||
|
conflictWinner && conflictWinner !== "null" ? conflictWinner : "";
|
||||||
|
|
||||||
const link = document.getElementById("back-link");
|
const link = document.getElementById("back-link");
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
const storageKey = "book_detail_back";
|
const storageKey = "book_detail_back";
|
||||||
@@ -207,6 +233,150 @@ Alpine.data("bookDetail", () => {
|
|||||||
this.openSections[section] = !this.openSections[section];
|
this.openSections[section] = !this.openSections[section];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
starFill(i: number): string {
|
||||||
|
const display = this.ratingHover || this.userRating;
|
||||||
|
if (i * 2 <= display) {
|
||||||
|
return "color: var(--accent);";
|
||||||
|
} else if (i * 2 - 1 === display) {
|
||||||
|
return "background: linear-gradient(90deg, var(--accent) 50%, var(--text-secondary) 50%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;";
|
||||||
|
}
|
||||||
|
return "color: var(--text-secondary);";
|
||||||
|
},
|
||||||
|
|
||||||
|
ratingText(): string {
|
||||||
|
if (this.userRating === 0) return "(not rated)";
|
||||||
|
return `(${(this.userRating / 2).toFixed(1)} / 5)`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async setRating(value: number) {
|
||||||
|
if (this.ratingSaving) return;
|
||||||
|
this.ratingSaving = true;
|
||||||
|
const mediaId = getMediaId();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/media-items/${mediaId}/rating`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: getAuthHeader(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ rating: value }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || "Failed to save rating");
|
||||||
|
}
|
||||||
|
this.userRating = value;
|
||||||
|
this.ratingHover = 0;
|
||||||
|
showToast("Rating saved", "success");
|
||||||
|
} catch (e) {
|
||||||
|
showToast(
|
||||||
|
e instanceof Error ? e.message : "Failed to save rating",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.ratingSaving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async clearRating() {
|
||||||
|
if (this.ratingSaving) return;
|
||||||
|
this.ratingSaving = true;
|
||||||
|
const mediaId = getMediaId();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/media-items/${mediaId}/rating`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: getAuthHeader() },
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || "Failed to clear rating");
|
||||||
|
}
|
||||||
|
this.userRating = 0;
|
||||||
|
this.ratingHover = 0;
|
||||||
|
showToast("Rating cleared", "success");
|
||||||
|
} catch (e) {
|
||||||
|
showToast(
|
||||||
|
e instanceof Error ? e.message : "Failed to clear rating",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.ratingSaving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleRead(read: boolean) {
|
||||||
|
if (this.readSaving) return;
|
||||||
|
this.readSaving = true;
|
||||||
|
const mediaId = getMediaId();
|
||||||
|
try {
|
||||||
|
// Clear any active sync conflict first. Resolving arms a 10-minute
|
||||||
|
// suppression window so the progress write below does not spawn a new
|
||||||
|
// conflict. The winner only needs to be a valid source key; the final
|
||||||
|
// read/unread state is set by the progress write that follows.
|
||||||
|
if (this.conflictId && this.conflictWinner) {
|
||||||
|
const cr = await fetch(
|
||||||
|
`/api/conflicts/${this.conflictId}/resolve`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: getAuthHeader(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ winner: this.conflictWinner }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// 400 means it was already resolved - treat as no conflict.
|
||||||
|
if (!cr.ok && cr.status !== 400) {
|
||||||
|
const err = await cr.json().catch(() => ({}));
|
||||||
|
throw new Error(
|
||||||
|
err.error || err.message || "Failed to clear sync conflict",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (read) {
|
||||||
|
// Mark as Read: PUT percentage 1.0. (Cannot PUT 0 to unread - the
|
||||||
|
// server silently ignores percentage < 0.005 when progress > 0.01.)
|
||||||
|
const resp = await fetch(`/api/media-items/${mediaId}/progress`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: getAuthHeader(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ percentage: 1.0 }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || "Failed to mark as read");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Mark as Unread: DELETE the progress row. Notes, highlights and
|
||||||
|
// ratings are independent and are NOT affected.
|
||||||
|
const resp = await fetch(`/api/media-items/${mediaId}/progress`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: getAuthHeader() },
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || "Failed to mark as unread");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(
|
||||||
|
read ? "Marked as read" : "Marked as unread",
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(
|
||||||
|
e instanceof Error ? e.message : "Failed to update read state",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.readSaving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
handleCoverUpload(event: Event) {
|
handleCoverUpload(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
|
|||||||
+10
-20
@@ -1,5 +1,4 @@
|
|||||||
import { Alpine } from "./alpine";
|
import { Alpine } from "./alpine";
|
||||||
import { setSelectedLibrary } from "./storage";
|
|
||||||
|
|
||||||
let searchInputTimeout: ReturnType<typeof setTimeout> | null = null;
|
let searchInputTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
@@ -190,12 +189,6 @@ function showSearchResults(results: MediaItemSummary[], query: string, activeId?
|
|||||||
|
|
||||||
searchResults.dataset.selectedIndex = "-1";
|
searchResults.dataset.selectedIndex = "-1";
|
||||||
|
|
||||||
const libraryIconMap: Record<string, string> = {
|
|
||||||
ebooks: "📚",
|
|
||||||
comics: "📖",
|
|
||||||
manga: "🗾",
|
|
||||||
};
|
|
||||||
|
|
||||||
let html = `
|
let html = `
|
||||||
<div class="p-3 border-b" style="border-color: var(--border)">
|
<div class="p-3 border-b" style="border-color: var(--border)">
|
||||||
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">
|
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">
|
||||||
@@ -206,19 +199,23 @@ function showSearchResults(results: MediaItemSummary[], query: string, activeId?
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
results.forEach((item, index) => {
|
results.forEach((item, index) => {
|
||||||
const icon = libraryIconMap[item.library_type_name] || "📁";
|
|
||||||
const titleHtml = highlightMatch(item.title, query);
|
const titleHtml = highlightMatch(item.title, query);
|
||||||
const authorHtml = item.author ? highlightMatch(item.author, query) : "";
|
const authorHtml = item.author ? highlightMatch(item.author, query) : "";
|
||||||
|
const coverUrl = item.cover_image_path || "/static/placeholder-book.svg";
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="search-result-item p-3 border-b hover:bg-opacity-50 transition-colors cursor-pointer"
|
<div class="search-result-item p-3 border-b hover:bg-opacity-50 transition-colors cursor-pointer"
|
||||||
style="border-color: var(--border); background-color: var(--bg-secondary)"
|
style="border-color: var(--border); background-color: var(--bg-secondary)"
|
||||||
data-index="${index}">
|
data-index="${index}">
|
||||||
<a href="/bookshelf"
|
<a href="/media/${item.id}" class="block">
|
||||||
class="block"
|
|
||||||
onclick="window.selectLibraryAndBook('${item.library_id}', '${item.id}')">
|
|
||||||
<div class="flex items-start space-x-3">
|
<div class="flex items-start space-x-3">
|
||||||
<div class="text-2xl">${icon}</div>
|
<div class="w-10 h-14 flex-shrink-0 rounded overflow-hidden bg-gradient-to-br from-gray-700 to-gray-900">
|
||||||
|
<img src="${coverUrl}"
|
||||||
|
alt="${searchEscapeHtml(item.title)}"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
onerror="this.src='/static/placeholder-book.svg'">
|
||||||
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h4 class="text-sm font-medium truncate" style="color: var(--text-primary)">
|
<h4 class="text-sm font-medium truncate" style="color: var(--text-primary)">
|
||||||
${titleHtml}
|
${titleHtml}
|
||||||
@@ -323,15 +320,8 @@ function searchEscapeHtml(text: string): string {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectLibraryAndBook(libraryId: string, bookId: string): void {
|
export { initializeSearch };
|
||||||
setSelectedLibrary(libraryId);
|
|
||||||
localStorage.setItem("selectedBook", bookId);
|
|
||||||
hideSearchResults();
|
|
||||||
}
|
|
||||||
|
|
||||||
export { selectLibraryAndBook, initializeSearch };
|
|
||||||
|
|
||||||
Alpine.data("search", () => ({
|
Alpine.data("search", () => ({
|
||||||
selectLibraryAndBook,
|
|
||||||
initializeSearch,
|
initializeSearch,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -45,14 +45,6 @@ function setSelectedLibrary(libraryId: string): void {
|
|||||||
document.cookie = `selectedLibrary=${encodeURIComponent(value)};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`;
|
document.cookie = `selectedLibrary=${encodeURIComponent(value)};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedBook(): string | null {
|
|
||||||
return localStorage.getItem("selectedBook");
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSelectedBook(bookId: string): void {
|
|
||||||
localStorage.setItem("selectedBook", bookId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAll(): void {
|
function clearAll(): void {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
}
|
}
|
||||||
@@ -61,14 +53,12 @@ export {
|
|||||||
ALL_LIBRARIES,
|
ALL_LIBRARIES,
|
||||||
clearAll,
|
clearAll,
|
||||||
getRefreshToken,
|
getRefreshToken,
|
||||||
getSelectedBook,
|
|
||||||
getSelectedLibrary,
|
getSelectedLibrary,
|
||||||
getTheme,
|
getTheme,
|
||||||
getToken,
|
getToken,
|
||||||
removeRefreshToken,
|
removeRefreshToken,
|
||||||
removeToken,
|
removeToken,
|
||||||
setRefreshToken,
|
setRefreshToken,
|
||||||
setSelectedBook,
|
|
||||||
setSelectedLibrary,
|
setSelectedLibrary,
|
||||||
setTheme,
|
setTheme,
|
||||||
setToken
|
setToken
|
||||||
|
|||||||
Reference in New Issue
Block a user