Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04e2a069d6 | ||
|
|
05c7431d86 | ||
|
|
9a62c10803 | ||
|
|
8d65e03555 | ||
|
|
ab465d8e0e | ||
|
|
1461273162 | ||
|
|
598d70f735 | ||
|
|
537330e7e0 | ||
|
|
885f6d8187 | ||
|
|
936a48405b | ||
|
|
757398bf15 | ||
|
|
d12911d3c8 | ||
|
|
457a38306d | ||
|
|
bc47450653 | ||
|
|
89ea310414 | ||
|
|
4b152bbe4e | ||
|
|
64ad10b3f2 | ||
|
|
a42232e67a | ||
|
|
006cc0c2c9 | ||
|
|
706be09dec | ||
|
|
a87c8afc22 | ||
|
|
2976dad4e5 | ||
|
|
acc9d08c60 | ||
|
|
e09c55cecc | ||
|
|
6f1c13998c | ||
|
|
0a0caa9533 | ||
|
|
4716790564 | ||
|
|
8e2c1a4b3a | ||
|
|
4a870a7f18 | ||
|
|
40dabfd788 | ||
|
|
ef3c05714a | ||
|
|
816ee0ec80 | ||
|
|
73a2852ee3 | ||
|
|
f67232a20b | ||
|
|
7b465ac97e | ||
|
|
033a012069 | ||
|
|
9165c4eb3f | ||
|
|
9ef6c5b6ed | ||
|
|
987ece38f0 | ||
|
|
c5c2270007 | ||
|
|
b23f6b0bab | ||
|
|
8ea70ea7f8 | ||
|
|
07578e0206 | ||
|
|
f402a3ee03 | ||
|
|
f3f908eb7e | ||
|
|
94a4facc6c | ||
|
|
11617c1860 | ||
|
|
d0040fe428 | ||
|
|
59d5de3607 | ||
|
|
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."
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml"
|
bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/SetBaseUrl.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml"
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
info:
|
||||||
|
name: SetBaseUrl
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: PUT
|
||||||
|
url: '{{base_url}}/api/system/config'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
jsonBody: |-
|
||||||
|
{
|
||||||
|
"base_url": "http://localhost:8765"
|
||||||
|
}
|
||||||
|
headers:
|
||||||
|
- key: Authorization
|
||||||
|
value: Bearer {{token}}
|
||||||
|
- key: Content-Type
|
||||||
|
value: application/json
|
||||||
|
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## Set Base URL
|
||||||
|
|
||||||
|
Configures the server's base_url during initial dev database setup.
|
||||||
|
|
||||||
|
Must be run after RegisterUser (which provides the auth token) and before
|
||||||
|
any library/device creation (which require setup to be complete).
|
||||||
|
|
||||||
|
**Method:** PUT
|
||||||
|
**Endpoint:** /api/system/config
|
||||||
|
**Auth:** Bearer token (from RegisterUser)
|
||||||
@@ -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"
|
||||||
+57
-6
@@ -14,6 +14,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
_ "time/tzdata"
|
||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
@@ -48,15 +50,59 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Println("✅ Database schema initialized and verified, starting server...")
|
log.Println("✅ Database schema initialized and verified, starting server...")
|
||||||
|
|
||||||
// Create login attempt tracker: 5 failed attempts = 15 minute lockout
|
// Load tunable settings from the DB into the registry. All values fall back
|
||||||
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
|
// to compiled defaults if a row is missing, so this never blocks startup.
|
||||||
|
registry := database.NewSettingsRegistry(queries)
|
||||||
|
if err := registry.Load(ctx); err != nil {
|
||||||
|
log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
|
||||||
|
}
|
||||||
|
// Wire the registry into the package-level password validator so live
|
||||||
|
// rule changes apply to the echo struct-tag validator and ValidatePassword.
|
||||||
|
middleware.SetDefaultPasswordSettings(registry)
|
||||||
|
|
||||||
|
// Seed base_url from env var if not already configured. Uses conditional
|
||||||
|
// UPDATE so admin-set values are never overwritten on restart.
|
||||||
|
if cfg.BaseURL != "" {
|
||||||
|
_, err = dbPool.Exec(ctx, `
|
||||||
|
INSERT INTO system_config (key, value)
|
||||||
|
VALUES ('base_url', $1)
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value
|
||||||
|
WHERE system_config.value = ''
|
||||||
|
`, cfg.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠️ Could not seed base_url: %v", err)
|
||||||
|
} else {
|
||||||
|
// Also seed derived URLs
|
||||||
|
for key, suffix := range map[string]string{
|
||||||
|
"opds_base_url": "/opds",
|
||||||
|
"api_base_url": "/api",
|
||||||
|
} {
|
||||||
|
_, _ = dbPool.Exec(ctx, `
|
||||||
|
INSERT INTO system_config (key, value)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value
|
||||||
|
WHERE system_config.value = ''
|
||||||
|
`, key, cfg.BaseURL+suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create login attempt tracker from configured (or default) lockout policy.
|
||||||
|
loginMaxAttempts, loginLockout := registry.LoginLockout()
|
||||||
|
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(loginMaxAttempts, loginLockout, 5*time.Minute)
|
||||||
|
|
||||||
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
|
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
|
||||||
|
authHandler.SetSettings(registry)
|
||||||
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
|
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
|
||||||
|
systemSettingsHandler.SetSettings(registry)
|
||||||
sidecarHandler := handlers.NewSidecarHandler(queries, cfg)
|
sidecarHandler := handlers.NewSidecarHandler(queries, cfg)
|
||||||
|
sidecarHandler.SetSettings(registry)
|
||||||
libraryHandler := handlers.NewLibraryHandler(queries)
|
libraryHandler := handlers.NewLibraryHandler(queries)
|
||||||
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
|
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
|
||||||
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
|
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
|
||||||
|
deviceAuthMiddleware.SetSettings(registry)
|
||||||
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
|
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
|
||||||
|
|
||||||
// Create WebSocket connection manager
|
// Create WebSocket connection manager
|
||||||
@@ -64,10 +110,11 @@ func main() {
|
|||||||
|
|
||||||
progressService := sync.NewProgressService(queries, connManager)
|
progressService := sync.NewProgressService(queries, connManager)
|
||||||
annotationService := sync.NewAnnotationService(queries, connManager)
|
annotationService := sync.NewAnnotationService(queries, connManager)
|
||||||
tombstonePurgerCancel := annotationService.StartTombstonePurger()
|
annotationService.SetSettings(registry)
|
||||||
defer tombstonePurgerCancel()
|
maintenanceCancel := annotationService.StartDailyMaintenance()
|
||||||
|
defer maintenanceCancel()
|
||||||
|
|
||||||
queueProcessor := sync.NewSyncQueueProcessor(queries)
|
queueProcessor := sync.NewSyncQueueProcessorWithConfig(queries, registry.SyncQueueConfig().Interval, registry.SyncQueueConfig().BatchSize)
|
||||||
queueProcessor.SetProgressService(progressService)
|
queueProcessor.SetProgressService(progressService)
|
||||||
queueProcessor.SetAnnotationService(annotationService)
|
queueProcessor.SetAnnotationService(annotationService)
|
||||||
|
|
||||||
@@ -78,7 +125,8 @@ func main() {
|
|||||||
libraryService.SyncAllowedExtensions(context.Background())
|
libraryService.SyncAllowedExtensions(context.Background())
|
||||||
|
|
||||||
// Create worker for background tasks
|
// Create worker for background tasks
|
||||||
worker := services.NewWorker(3, connManager)
|
workerCfg := registry.WorkerPoolConfig()
|
||||||
|
worker := services.NewWorkerWithConfig(workerCfg.Size, workerCfg.QueueCap, connManager)
|
||||||
services.WorkerInstance = worker
|
services.WorkerInstance = worker
|
||||||
|
|
||||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||||
@@ -91,7 +139,9 @@ func main() {
|
|||||||
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
||||||
|
|
||||||
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
|
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
|
||||||
|
conversionService.SetSettings(registry)
|
||||||
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
||||||
|
opdsHandler.SetSettings(registry)
|
||||||
|
|
||||||
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
||||||
dashboardService := services.NewDashboardService(queries)
|
dashboardService := services.NewDashboardService(queries)
|
||||||
@@ -144,6 +194,7 @@ func main() {
|
|||||||
Echo: e,
|
Echo: e,
|
||||||
Queries: queries,
|
Queries: queries,
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
|
Settings: registry,
|
||||||
DBPool: dbPool,
|
DBPool: dbPool,
|
||||||
AuthHandler: authHandler,
|
AuthHandler: authHandler,
|
||||||
LibraryHandler: libraryHandler,
|
LibraryHandler: libraryHandler,
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ func TestCalibreLibraryScan(t *testing.T) {
|
|||||||
// Create scanner and configure it
|
// Create scanner and configure it
|
||||||
scanner := services.NewMediaScanner(setup.DB)
|
scanner := services.NewMediaScanner(setup.DB)
|
||||||
scanner.SetAdminID(adminID)
|
scanner.SetAdminID(adminID)
|
||||||
err = scanner.SetFolders([]string{tmpDir})
|
err = scanner.SetFolders([]string{tmpDir}, false)
|
||||||
require.NoError(t, err, "Failed to set scanner folders")
|
require.NoError(t, err, "Failed to set scanner folders")
|
||||||
|
|
||||||
// Scan library
|
// Scan library
|
||||||
@@ -167,7 +167,7 @@ func TestCalibreLibraryScanWithoutSidecar(t *testing.T) {
|
|||||||
// Create scanner and configure it
|
// Create scanner and configure it
|
||||||
scanner := services.NewMediaScanner(setup.DB)
|
scanner := services.NewMediaScanner(setup.DB)
|
||||||
scanner.SetAdminID(adminID)
|
scanner.SetAdminID(adminID)
|
||||||
err = scanner.SetFolders([]string{tmpDir})
|
err = scanner.SetFolders([]string{tmpDir}, false)
|
||||||
require.NoError(t, err, "Failed to set scanner folders")
|
require.NoError(t, err, "Failed to set scanner folders")
|
||||||
|
|
||||||
// Scan library
|
// Scan library
|
||||||
|
|||||||
+49
-11
@@ -45,11 +45,47 @@ CREATE TABLE IF NOT EXISTS system_settings (
|
|||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Insert default system settings
|
-- Extend system_settings with typed metadata so it can back the admin UI's
|
||||||
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
|
-- configurable tunables. All columns are nullable for backward compatibility
|
||||||
('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'),
|
-- with the original three rows and any pre-existing data.
|
||||||
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'),
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS setting_type VARCHAR(20);
|
||||||
('default_timezone', 'UTC', 'System default timezone')
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS min_value TEXT;
|
||||||
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS max_value TEXT;
|
||||||
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS requires_restart BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS category VARCHAR(40);
|
||||||
|
|
||||||
|
-- Insert default system settings (original scan/timezone rows + tunables).
|
||||||
|
-- Values match the previous hardcoded literals, so behavior is unchanged on upgrade.
|
||||||
|
-- ON CONFLICT DO NOTHING preserves any admin-modified values.
|
||||||
|
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) VALUES
|
||||||
|
('scan_poll_interval_seconds', '60', 'How often to scan all libraries (seconds)', 'int', '1', '3600', FALSE, 'scanner'),
|
||||||
|
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide', 'bool', NULL, NULL, FALSE, 'scanner'),
|
||||||
|
('default_timezone', 'UTC', 'System default timezone', 'string', NULL, NULL, FALSE, 'general'),
|
||||||
|
-- security / auth (live)
|
||||||
|
('session_duration_seconds', '604800', 'How long a login session stays valid', 'int', '300', '31536000', FALSE, 'security'),
|
||||||
|
('password_min_length', '8', 'Minimum password length', 'int', '1', '128', FALSE, 'security'),
|
||||||
|
('password_require_upper', 'true', 'Require at least one uppercase letter (A-Z)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||||
|
('password_require_lower', 'true', 'Require at least one lowercase letter (a-z)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||||
|
('password_require_number', 'true', 'Require at least one number (0-9)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||||
|
('password_require_special', 'true', 'Require at least one special character', 'bool', NULL, NULL, FALSE, 'security'),
|
||||||
|
-- security / auth (restart required)
|
||||||
|
('auth_rate_limit_per_min', '10', 'Global auth API rate limit (requests per minute)', 'int', '1', '10000', TRUE, 'security'),
|
||||||
|
('login_max_attempts', '5', 'Failed login attempts before lockout', 'int', '1', '100', TRUE, 'security'),
|
||||||
|
('login_lockout_minutes', '15', 'Lockout duration after too many failed logins', 'int', '1', '10080', TRUE, 'security'),
|
||||||
|
-- api (live)
|
||||||
|
('opds_default_page_size', '50', 'Default OPDS page size', 'int', '1', '500', FALSE, 'api'),
|
||||||
|
('opds_max_page_size', '200', 'Maximum OPDS page size', 'int', '1', '1000', FALSE, 'api'),
|
||||||
|
('device_rate_sync_per_min', '60', 'Device sync requests per minute', 'int', '1', '10000', FALSE, 'api'),
|
||||||
|
('device_rate_progress_per_min', '120', 'Device progress requests per minute', 'int', '1', '10000', FALSE, 'api'),
|
||||||
|
('device_rate_metadata_per_min', '30', 'Device metadata requests per minute', 'int', '1', '10000', FALSE, 'api'),
|
||||||
|
-- sync / performance (live)
|
||||||
|
('annotation_tombstone_ttl_days', '30', 'How long deleted annotations are kept before purge', 'int', '1', '3650', FALSE, 'sync'),
|
||||||
|
('conversion_cache_ttl_hours', '24', 'How long converted (kepub) files are cached', 'int', '1', '720', FALSE, 'performance'),
|
||||||
|
-- sync / performance (restart required)
|
||||||
|
('sync_queue_interval_seconds', '5', 'How often the sync queue flushes', 'int', '1', '3600', TRUE, 'sync'),
|
||||||
|
('sync_queue_batch_size', '50', 'Maximum items processed per sync queue flush', 'int', '1', '10000', TRUE, 'sync'),
|
||||||
|
('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'),
|
||||||
|
('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance')
|
||||||
ON CONFLICT (setting_key) DO NOTHING;
|
ON CONFLICT (setting_key) DO NOTHING;
|
||||||
|
|
||||||
-- Create refresh_tokens table
|
-- Create refresh_tokens table
|
||||||
@@ -1149,12 +1185,14 @@ CREATE TABLE IF NOT EXISTS system_config (
|
|||||||
updated_by UUID REFERENCES users(id)
|
updated_by UUID REFERENCES users(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Pre-seeded values
|
-- One-time cleanup: clear the old placeholder seed so the startup logic
|
||||||
INSERT INTO system_config (key, value) VALUES
|
-- can re-seed from the BASE_URL env var (or the setup wizard can set it).
|
||||||
('base_url', 'https://bookhoard.example.com'),
|
UPDATE system_config SET value = ''
|
||||||
('opds_base_url', 'https://bookhoard.example.com/opds'),
|
WHERE key = 'base_url' AND value = 'https://bookhoard.example.com';
|
||||||
('api_base_url', 'https://bookhoard.example.com/api')
|
UPDATE system_config SET value = ''
|
||||||
ON CONFLICT (key) DO NOTHING;
|
WHERE key = 'opds_base_url' AND value = 'https://bookhoard.example.com/opds';
|
||||||
|
UPDATE system_config SET value = ''
|
||||||
|
WHERE key = 'api_base_url' AND value = 'https://bookhoard.example.com/api';
|
||||||
|
|
||||||
-- Create opds_tokens table (device-specific OPDS access tokens)
|
-- Create opds_tokens table (device-specific OPDS access tokens)
|
||||||
CREATE TABLE IF NOT EXISTS opds_tokens (
|
CREATE TABLE IF NOT EXISTS opds_tokens (
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ services:
|
|||||||
app:
|
app:
|
||||||
image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
|
image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
|
||||||
container_name: bookhoard
|
container_name: bookhoard
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DATABASE_HOST: db
|
DATABASE_HOST: db
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ require (
|
|||||||
github.com/yuin/goldmark v1.8.2
|
github.com/yuin/goldmark v1.8.2
|
||||||
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
|
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
|
||||||
golang.org/x/crypto v0.50.0
|
golang.org/x/crypto v0.50.0
|
||||||
|
golang.org/x/net v0.53.0
|
||||||
golang.org/x/text v0.36.0
|
golang.org/x/text v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,7 +58,6 @@ require (
|
|||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/xyproto/randomstring v1.2.0 // indirect
|
github.com/xyproto/randomstring v1.2.0 // indirect
|
||||||
golang.org/x/image v0.39.0 // indirect
|
golang.org/x/image v0.39.0 // indirect
|
||||||
golang.org/x/net v0.53.0 // indirect
|
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
|
|||||||
@@ -45,30 +45,19 @@ func (c *Config) DatabaseURL() string {
|
|||||||
c.DatabaseUser, c.DatabasePassword, c.DatabaseHost, c.DatabasePort, c.DatabaseName)
|
c.DatabaseUser, c.DatabasePassword, c.DatabaseHost, c.DatabasePort, c.DatabaseName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBaseURL returns the base URL from system configuration database with fallback to config/env var
|
// SystemConfigGetter returns the value for a system config key, or an error.
|
||||||
func GetBaseURL(ctx context.Context, db interface{}) string {
|
type SystemConfigGetter func(ctx context.Context, key string) (string, error)
|
||||||
// Try to get from database first
|
|
||||||
type SystemConfigQuerier interface {
|
|
||||||
GetSystemConfig(ctx context.Context, key string) (SystemConfigRow, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
if querier, ok := db.(SystemConfigQuerier); ok {
|
// GetBaseURL returns the base URL from system configuration database, or empty
|
||||||
config, err := querier.GetSystemConfig(ctx, "base_url")
|
// string if not set. The getter abstraction avoids importing the database package.
|
||||||
if err == nil && config.Value != "" {
|
func GetBaseURL(ctx context.Context, getter SystemConfigGetter) string {
|
||||||
return config.Value
|
val, err := getter(ctx, "base_url")
|
||||||
}
|
if err == nil && val != "" {
|
||||||
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: return empty string - caller should use their own fallback
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemConfigRow represents a system configuration row
|
|
||||||
type SystemConfigRow struct {
|
|
||||||
Key string
|
|
||||||
Value string
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnv(key, defaultValue string) string {
|
func getEnv(key, defaultValue string) string {
|
||||||
if value := os.Getenv(key); value != "" {
|
if value := os.Getenv(key); value != "" {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
@@ -484,11 +484,16 @@ type SystemConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SystemSettings struct {
|
type SystemSettings struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
SettingKey string `db:"setting_key" json:"setting_key"`
|
SettingKey string `db:"setting_key" json:"setting_key"`
|
||||||
SettingValue string `db:"setting_value" json:"setting_value"`
|
SettingValue string `db:"setting_value" json:"setting_value"`
|
||||||
Description pgtype.Text `db:"description" json:"description"`
|
Description pgtype.Text `db:"description" json:"description"`
|
||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
|
SettingType pgtype.Text `db:"setting_type" json:"setting_type"`
|
||||||
|
MinValue pgtype.Text `db:"min_value" json:"min_value"`
|
||||||
|
MaxValue pgtype.Text `db:"max_value" json:"max_value"`
|
||||||
|
RequiresRestart pgtype.Bool `db:"requires_restart" json:"requires_restart"`
|
||||||
|
Category pgtype.Text `db:"category" json:"category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UnlinkedBooks struct {
|
type UnlinkedBooks struct {
|
||||||
@@ -532,3 +537,4 @@ type Users struct {
|
|||||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ type Querier interface {
|
|||||||
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
|
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
|
||||||
// Cleanup expired OPDS tokens
|
// Cleanup expired OPDS tokens
|
||||||
CleanupExpiredOpdsTokens(ctx context.Context) error
|
CleanupExpiredOpdsTokens(ctx context.Context) error
|
||||||
CleanupExpiredRefreshTokens(ctx context.Context) error
|
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
|
||||||
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
||||||
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
||||||
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
||||||
@@ -137,6 +137,7 @@ type Querier interface {
|
|||||||
// Get all system config
|
// Get all system config
|
||||||
GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error)
|
GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error)
|
||||||
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
||||||
|
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
|
||||||
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
|
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
|
||||||
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
||||||
// Get collection
|
// Get collection
|
||||||
@@ -278,6 +279,7 @@ type Querier interface {
|
|||||||
GetSystemConfig(ctx context.Context, key string) (SystemConfig, error)
|
GetSystemConfig(ctx context.Context, key string) (SystemConfig, error)
|
||||||
// System Settings queries
|
// System Settings queries
|
||||||
GetSystemSetting(ctx context.Context, settingKey string) (string, error)
|
GetSystemSetting(ctx context.Context, settingKey string) (string, error)
|
||||||
|
GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error)
|
||||||
GetSystemTimezone(ctx context.Context) (string, error)
|
GetSystemTimezone(ctx context.Context) (string, error)
|
||||||
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
|
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
|
||||||
// Get universal progress for a book
|
// Get universal progress for a book
|
||||||
@@ -303,6 +305,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
|
||||||
@@ -427,6 +430,7 @@ type Querier interface {
|
|||||||
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||||
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
||||||
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
||||||
|
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ Querier = (*Queries)(nil)
|
var _ Querier = (*Queries)(nil)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
// source: queries.sql
|
// source: queries.sql
|
||||||
|
|
||||||
package database
|
package database
|
||||||
@@ -186,11 +186,11 @@ func (q *Queries) CleanupExpiredOpdsTokens(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CleanupExpiredRefreshTokens = `-- name: CleanupExpiredRefreshTokens :exec
|
const CleanupExpiredRefreshTokens = `-- name: CleanupExpiredRefreshTokens :exec
|
||||||
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days')
|
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - make_interval(secs => $1::double precision))
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context) error {
|
func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error {
|
||||||
_, err := q.db.Exec(ctx, CleanupExpiredRefreshTokens)
|
_, err := q.db.Exec(ctx, CleanupExpiredRefreshTokens, dollar_1)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2258,6 +2258,41 @@ func (q *Queries) GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSetti
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetAllSystemSettingsFull = `-- name: GetAllSystemSettingsFull :many
|
||||||
|
SELECT id, setting_key, setting_value, description, updated_at, setting_type, min_value, max_value, requires_restart, category FROM system_settings ORDER BY category, setting_key
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetAllSystemSettingsFull)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []SystemSettings{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i SystemSettings
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SettingKey,
|
||||||
|
&i.SettingValue,
|
||||||
|
&i.Description,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.SettingType,
|
||||||
|
&i.MinValue,
|
||||||
|
&i.MaxValue,
|
||||||
|
&i.RequiresRestart,
|
||||||
|
&i.Category,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const GetAnnotationsForBook = `-- name: GetAnnotationsForBook :many
|
const GetAnnotationsForBook = `-- name: GetAnnotationsForBook :many
|
||||||
SELECT
|
SELECT
|
||||||
mh.id,
|
mh.id,
|
||||||
@@ -6587,6 +6622,28 @@ func (q *Queries) GetSystemSetting(ctx context.Context, settingKey string) (stri
|
|||||||
return setting_value, err
|
return setting_value, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetSystemSettingFull = `-- name: GetSystemSettingFull :one
|
||||||
|
SELECT id, setting_key, setting_value, description, updated_at, setting_type, min_value, max_value, requires_restart, category FROM system_settings WHERE setting_key = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetSystemSettingFull, settingKey)
|
||||||
|
var i SystemSettings
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SettingKey,
|
||||||
|
&i.SettingValue,
|
||||||
|
&i.Description,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.SettingType,
|
||||||
|
&i.MinValue,
|
||||||
|
&i.MaxValue,
|
||||||
|
&i.RequiresRestart,
|
||||||
|
&i.Category,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const GetSystemTimezone = `-- name: GetSystemTimezone :one
|
const GetSystemTimezone = `-- name: GetSystemTimezone :one
|
||||||
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone'
|
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone'
|
||||||
`
|
`
|
||||||
@@ -7493,6 +7550,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
|
||||||
@@ -12246,3 +12337,56 @@ func (q *Queries) UpsertReaderSettings(ctx context.Context, arg UpsertReaderSett
|
|||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UpsertSystemSetting = `-- name: UpsertSystemSetting :one
|
||||||
|
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT (setting_key) DO UPDATE
|
||||||
|
SET setting_value = EXCLUDED.setting_value,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
setting_type = EXCLUDED.setting_type,
|
||||||
|
min_value = EXCLUDED.min_value,
|
||||||
|
max_value = EXCLUDED.max_value,
|
||||||
|
requires_restart = EXCLUDED.requires_restart,
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id, setting_key, setting_value, description, updated_at, setting_type, min_value, max_value, requires_restart, category
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertSystemSettingParams struct {
|
||||||
|
SettingKey string `db:"setting_key" json:"setting_key"`
|
||||||
|
SettingValue string `db:"setting_value" json:"setting_value"`
|
||||||
|
Description pgtype.Text `db:"description" json:"description"`
|
||||||
|
SettingType pgtype.Text `db:"setting_type" json:"setting_type"`
|
||||||
|
MinValue pgtype.Text `db:"min_value" json:"min_value"`
|
||||||
|
MaxValue pgtype.Text `db:"max_value" json:"max_value"`
|
||||||
|
RequiresRestart pgtype.Bool `db:"requires_restart" json:"requires_restart"`
|
||||||
|
Category pgtype.Text `db:"category" json:"category"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error) {
|
||||||
|
row := q.db.QueryRow(ctx, UpsertSystemSetting,
|
||||||
|
arg.SettingKey,
|
||||||
|
arg.SettingValue,
|
||||||
|
arg.Description,
|
||||||
|
arg.SettingType,
|
||||||
|
arg.MinValue,
|
||||||
|
arg.MaxValue,
|
||||||
|
arg.RequiresRestart,
|
||||||
|
arg.Category,
|
||||||
|
)
|
||||||
|
var i SystemSettings
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SettingKey,
|
||||||
|
&i.SettingValue,
|
||||||
|
&i.Description,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.SettingType,
|
||||||
|
&i.MinValue,
|
||||||
|
&i.MaxValue,
|
||||||
|
&i.RequiresRestart,
|
||||||
|
&i.Category,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -365,9 +373,29 @@ SELECT setting_value FROM system_settings WHERE setting_key = $1;
|
|||||||
-- name: UpdateSystemSetting :exec
|
-- name: UpdateSystemSetting :exec
|
||||||
UPDATE system_settings SET setting_value = $2, updated_at = NOW() WHERE setting_key = $1;
|
UPDATE system_settings SET setting_value = $2, updated_at = NOW() WHERE setting_key = $1;
|
||||||
|
|
||||||
|
-- name: UpsertSystemSetting :one
|
||||||
|
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT (setting_key) DO UPDATE
|
||||||
|
SET setting_value = EXCLUDED.setting_value,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
setting_type = EXCLUDED.setting_type,
|
||||||
|
min_value = EXCLUDED.min_value,
|
||||||
|
max_value = EXCLUDED.max_value,
|
||||||
|
requires_restart = EXCLUDED.requires_restart,
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetSystemSettingFull :one
|
||||||
|
SELECT * FROM system_settings WHERE setting_key = $1;
|
||||||
|
|
||||||
-- name: GetAllSystemSettings :many
|
-- name: GetAllSystemSettings :many
|
||||||
SELECT setting_key, setting_value, description FROM system_settings ORDER BY setting_key;
|
SELECT setting_key, setting_value, description FROM system_settings ORDER BY setting_key;
|
||||||
|
|
||||||
|
-- name: GetAllSystemSettingsFull :many
|
||||||
|
SELECT * FROM system_settings ORDER BY category, setting_key;
|
||||||
|
|
||||||
-- name: CreateMediaRating :one
|
-- name: CreateMediaRating :one
|
||||||
INSERT INTO media_ratings (media_item_id, user_id, rating)
|
INSERT INTO media_ratings (media_item_id, user_id, rating)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
@@ -999,7 +1027,7 @@ UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1;
|
|||||||
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL;
|
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL;
|
||||||
|
|
||||||
-- name: CleanupExpiredRefreshTokens :exec
|
-- name: CleanupExpiredRefreshTokens :exec
|
||||||
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days');
|
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - make_interval(secs => $1::double precision));
|
||||||
|
|
||||||
-- ============================================
|
-- ============================================
|
||||||
-- FORMAT DETECTION & PROGRESS
|
-- FORMAT DETECTION & PROGRESS
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
// SettingsRegistry provides a typed, cached view over the system_settings table.
|
||||||
|
// It is the single source of truth for tunable runtime values that used to be
|
||||||
|
// hardcoded as Go literals.
|
||||||
|
//
|
||||||
|
// Consumers call the domain-specific getters (SessionDuration, OpdsPageSize,
|
||||||
|
// etc.) which read from an in-memory cache. The cache is populated by Load at
|
||||||
|
// startup and refreshed by Reload whenever a setting is written. Getters always
|
||||||
|
// fall back to a compiled-in default if the DB value is missing or unparsable,
|
||||||
|
// so a corrupt or deleted row can never break the app.
|
||||||
|
//
|
||||||
|
// SettingsRegistry lives in the database package (rather than its own package)
|
||||||
|
// so that every consumer already imports database and does not need to take on
|
||||||
|
// a new package import.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingType enumerates the value types stored in system_settings.setting_type.
|
||||||
|
const (
|
||||||
|
SettingTypeInt = "int"
|
||||||
|
SettingTypeBool = "bool"
|
||||||
|
SettingTypeString = "string"
|
||||||
|
SettingTypeStringList = "string_list"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SecondsPerDay / SecondsPerHour are conversion helpers used by defaults.
|
||||||
|
const (
|
||||||
|
SecondsPerMinute = 60
|
||||||
|
SecondsPerHour = 3600
|
||||||
|
SecondsPerDay = 86400
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingDefault holds the fallback value for a key. These mirror the literals that
|
||||||
|
// were previously hardcoded in the source so an empty/corrupt DB row preserves
|
||||||
|
// prior behavior exactly.
|
||||||
|
type SettingDefault struct {
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
Type string
|
||||||
|
Min string
|
||||||
|
Max string
|
||||||
|
RequiresRestart bool
|
||||||
|
Category string
|
||||||
|
Group string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingDefaults is the source of truth for fallback values and metadata. New keys
|
||||||
|
// must be added here AND seeded in database/schema/schema.sql. Entries are ordered
|
||||||
|
// by (RequiresRestart, Group) so the admin UI renders coherent sub-sections.
|
||||||
|
var SettingDefaults = []SettingDefault{
|
||||||
|
{Key: "scan_poll_interval_seconds", Value: "60", Type: SettingTypeInt, Min: "1", Max: "3600", Category: "scanner", Group: "Scanning", Description: "How often to scan all libraries (seconds)"},
|
||||||
|
{Key: "auto_scan_enabled", Value: "true", Type: SettingTypeBool, Category: "scanner", Group: "Scanning", Description: "Whether auto-scanning is enabled system-wide"},
|
||||||
|
{Key: "default_timezone", Value: "UTC", Type: SettingTypeString, Category: "general", Group: "System Defaults", Description: "System default timezone"},
|
||||||
|
{Key: "session_duration_seconds", Value: "604800", Type: SettingTypeInt, Min: "300", Max: "31536000", Category: "security", Group: "Session", Description: "How long a login session stays valid"},
|
||||||
|
{Key: "password_min_length", Value: "8", Type: SettingTypeInt, Min: "1", Max: "128", Category: "security", Group: "Password Quality", Description: "Minimum password length"},
|
||||||
|
{Key: "password_require_upper", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one uppercase letter (A-Z)"},
|
||||||
|
{Key: "password_require_lower", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one lowercase letter (a-z)"},
|
||||||
|
{Key: "password_require_number", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one number (0-9)"},
|
||||||
|
{Key: "password_require_special", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one special character"},
|
||||||
|
{Key: "opds_default_page_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "500", Category: "api", Group: "OPDS Catalog", Description: "Default OPDS page size"},
|
||||||
|
{Key: "opds_max_page_size", Value: "200", Type: SettingTypeInt, Min: "1", Max: "1000", Category: "api", Group: "OPDS Catalog", Description: "Maximum OPDS page size"},
|
||||||
|
{Key: "device_rate_sync_per_min", Value: "60", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device sync requests per minute"},
|
||||||
|
{Key: "device_rate_progress_per_min", Value: "120", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device progress requests per minute"},
|
||||||
|
{Key: "device_rate_metadata_per_min", Value: "30", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device metadata requests per minute"},
|
||||||
|
{Key: "annotation_tombstone_ttl_days", Value: "30", Type: SettingTypeInt, Min: "1", Max: "3650", Category: "sync", Group: "Annotation Retention", Description: "How long deleted annotations are kept before purge"},
|
||||||
|
{Key: "conversion_cache_ttl_hours", Value: "24", Type: SettingTypeInt, Min: "1", Max: "720", Category: "performance", Group: "Conversion Cache", Description: "How long converted (kepub) files are cached"},
|
||||||
|
{Key: "auth_rate_limit_per_min", Value: "10", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "security", Group: "Auth Rate Limiting", Description: "Global auth API rate limit (requests per minute)"},
|
||||||
|
{Key: "login_max_attempts", Value: "5", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "security", Group: "Login Lockout", Description: "Failed login attempts before lockout"},
|
||||||
|
{Key: "login_lockout_minutes", Value: "15", Type: SettingTypeInt, Min: "1", Max: "10080", RequiresRestart: true, Category: "security", Group: "Login Lockout", Description: "Lockout duration after too many failed logins"},
|
||||||
|
{Key: "sync_queue_interval_seconds", Value: "5", Type: SettingTypeInt, Min: "1", Max: "3600", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "How often the sync queue flushes"},
|
||||||
|
{Key: "sync_queue_batch_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "Maximum items processed per sync queue flush"},
|
||||||
|
{Key: "worker_pool_size", Value: "3", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Number of background worker goroutines"},
|
||||||
|
{Key: "worker_queue_cap", Value: "100", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Background worker job queue capacity"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultBy indexes SettingDefaults by key for O(1) lookup.
|
||||||
|
var defaultBy = func() map[string]SettingDefault {
|
||||||
|
m := make(map[string]SettingDefault, len(SettingDefaults))
|
||||||
|
for _, d := range SettingDefaults {
|
||||||
|
m[d.Key] = d
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Registry caches system_settings values in memory. The zero value is not
|
||||||
|
// usable; construct with New.
|
||||||
|
type SettingsRegistry struct {
|
||||||
|
q *Queries
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
values map[string]string
|
||||||
|
loadedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Registry backed by the given queries. The cache is empty
|
||||||
|
// until Load is called.
|
||||||
|
func NewSettingsRegistry(q *Queries) *SettingsRegistry {
|
||||||
|
return &SettingsRegistry{q: q, values: make(map[string]string)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load populates the cache from the database. Missing rows fall back to the
|
||||||
|
// compiled defaults. Safe to call multiple times.
|
||||||
|
func (r *SettingsRegistry) Load(ctx context.Context) error {
|
||||||
|
rows, err := r.q.GetAllSystemSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fresh := make(map[string]string, len(SettingDefaults))
|
||||||
|
for _, d := range SettingDefaults {
|
||||||
|
fresh[d.Key] = d.Value
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if _, ok := fresh[row.SettingKey]; ok {
|
||||||
|
fresh[row.SettingKey] = row.SettingValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.values = fresh
|
||||||
|
r.loadedAt = time.Now()
|
||||||
|
r.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload refreshes the cache from the database. Should be called after any
|
||||||
|
// setting write. On error the cache is left untouched and the error is logged.
|
||||||
|
func (r *SettingsRegistry) Reload(ctx context.Context) {
|
||||||
|
if err := r.Load(ctx); err != nil {
|
||||||
|
log.Printf("settings: reload failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw returns the cached string value for a key (or the default), clamped to
|
||||||
|
// [min, max] for int-typed keys.
|
||||||
|
func (r *SettingsRegistry) raw(key string) string {
|
||||||
|
r.mu.RLock()
|
||||||
|
v, ok := r.values[key]
|
||||||
|
r.mu.RUnlock()
|
||||||
|
if !ok || v == "" {
|
||||||
|
v = defaultBy[key].Value
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SettingsRegistry) getInt(key string) int {
|
||||||
|
d := defaultBy[key]
|
||||||
|
v := r.raw(key)
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
n, _ = strconv.Atoi(d.Value)
|
||||||
|
}
|
||||||
|
if d.Min != "" {
|
||||||
|
if mn, err := strconv.Atoi(d.Min); err == nil && n < mn {
|
||||||
|
n = mn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.Max != "" {
|
||||||
|
if mx, err := strconv.Atoi(d.Max); err == nil && n > mx {
|
||||||
|
n = mx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SettingsRegistry) getBool(key string) bool {
|
||||||
|
v := r.raw(key)
|
||||||
|
b, err := strconv.ParseBool(v)
|
||||||
|
if err != nil {
|
||||||
|
b, _ = strconv.ParseBool(defaultBy[key].Value)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Domain-specific getters (call sites use these) ----
|
||||||
|
|
||||||
|
// ScanPollInterval is how often the scanner polls, as a duration.
|
||||||
|
func (r *SettingsRegistry) ScanPollInterval() time.Duration {
|
||||||
|
return time.Duration(r.getInt("scan_poll_interval_seconds")) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoScanEnabled reports whether auto-scanning is on.
|
||||||
|
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
|
||||||
|
|
||||||
|
// DefaultTimezone returns the configured default timezone name.
|
||||||
|
func (r *SettingsRegistry) DefaultTimezone() string { return r.raw("default_timezone") }
|
||||||
|
|
||||||
|
// SessionDuration is how long a login session / refresh token stays valid.
|
||||||
|
func (r *SettingsRegistry) SessionDuration() time.Duration {
|
||||||
|
return time.Duration(r.getInt("session_duration_seconds")) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordMinLength is the minimum password length.
|
||||||
|
func (r *SettingsRegistry) PasswordMinLength() int { return r.getInt("password_min_length") }
|
||||||
|
|
||||||
|
// PasswordRules bundles the active complexity requirements.
|
||||||
|
type PasswordRules struct {
|
||||||
|
MinLength int
|
||||||
|
Upper bool
|
||||||
|
Lower bool
|
||||||
|
Number bool
|
||||||
|
Special bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordRules returns the active password complexity configuration.
|
||||||
|
func (r *SettingsRegistry) PasswordRules() PasswordRules {
|
||||||
|
return PasswordRules{
|
||||||
|
MinLength: r.PasswordMinLength(),
|
||||||
|
Upper: r.getBool("password_require_upper"),
|
||||||
|
Lower: r.getBool("password_require_lower"),
|
||||||
|
Number: r.getBool("password_require_number"),
|
||||||
|
Special: r.getBool("password_require_special"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthRateLimit is the global auth endpoint rate limit (requests/minute). Read
|
||||||
|
// once at startup.
|
||||||
|
func (r *SettingsRegistry) AuthRateLimit() int { return r.getInt("auth_rate_limit_per_min") }
|
||||||
|
|
||||||
|
// LoginLockout returns (max attempts, lockout duration). Read once at startup.
|
||||||
|
func (r *SettingsRegistry) LoginLockout() (int, time.Duration) {
|
||||||
|
return r.getInt("login_max_attempts"), time.Duration(r.getInt("login_lockout_minutes")) * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpdsDefaultPageSize is the default OPDS items-per-page.
|
||||||
|
func (r *SettingsRegistry) OpdsDefaultPageSize() int { return r.getInt("opds_default_page_size") }
|
||||||
|
|
||||||
|
// OpdsMaxPageSize is the maximum items-per-page a client may request.
|
||||||
|
func (r *SettingsRegistry) OpdsMaxPageSize() int { return r.getInt("opds_max_page_size") }
|
||||||
|
|
||||||
|
// DeviceRateLimits bundles the per-route device rate limits (requests/minute).
|
||||||
|
type DeviceRateLimits struct {
|
||||||
|
Sync int
|
||||||
|
Progress int
|
||||||
|
Metadata int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceRateLimits returns the active device rate limits.
|
||||||
|
func (r *SettingsRegistry) DeviceRateLimits() DeviceRateLimits {
|
||||||
|
return DeviceRateLimits{
|
||||||
|
Sync: r.getInt("device_rate_sync_per_min"),
|
||||||
|
Progress: r.getInt("device_rate_progress_per_min"),
|
||||||
|
Metadata: r.getInt("device_rate_metadata_per_min"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TombstoneTTL is how long deleted annotations are retained before purge.
|
||||||
|
func (r *SettingsRegistry) TombstoneTTL() time.Duration {
|
||||||
|
return time.Duration(r.getInt("annotation_tombstone_ttl_days")) * 24 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConversionCacheTTL is how long converted (kepub) files are served from cache.
|
||||||
|
func (r *SettingsRegistry) ConversionCacheTTL() time.Duration {
|
||||||
|
return time.Duration(r.getInt("conversion_cache_ttl_hours")) * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncQueueConfig bundles the sync queue interval and batch size. Read at
|
||||||
|
// startup; changes require a restart.
|
||||||
|
type SyncQueueConfig struct {
|
||||||
|
Interval time.Duration
|
||||||
|
BatchSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncQueueConfig returns the active sync queue configuration.
|
||||||
|
func (r *SettingsRegistry) SyncQueueConfig() SyncQueueConfig {
|
||||||
|
return SyncQueueConfig{
|
||||||
|
Interval: time.Duration(r.getInt("sync_queue_interval_seconds")) * time.Second,
|
||||||
|
BatchSize: r.getInt("sync_queue_batch_size"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkerPoolConfig bundles worker count and queue capacity. Read at startup;
|
||||||
|
// changes require a restart.
|
||||||
|
type WorkerPoolConfig struct {
|
||||||
|
Size int
|
||||||
|
QueueCap int
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkerPoolConfig returns the active worker pool configuration.
|
||||||
|
func (r *SettingsRegistry) WorkerPoolConfig() WorkerPoolConfig {
|
||||||
|
return WorkerPoolConfig{
|
||||||
|
Size: r.getInt("worker_pool_size"),
|
||||||
|
QueueCap: r.getInt("worker_queue_cap"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingEntry exposes one setting's metadata + current value, for the admin UI/API.
|
||||||
|
type SettingEntry struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Min string `json:"min,omitempty"`
|
||||||
|
Max string `json:"max,omitempty"`
|
||||||
|
RequiresRestart bool `json:"requires_restart"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// All returns metadata + current values for every known setting, grouped by
|
||||||
|
// the in-memory cache (which reflects the DB after Load/Reload).
|
||||||
|
func (r *SettingsRegistry) All() []SettingEntry {
|
||||||
|
r.mu.RLock()
|
||||||
|
vals := make(map[string]string, len(r.values))
|
||||||
|
for k, v := range r.values {
|
||||||
|
vals[k] = v
|
||||||
|
}
|
||||||
|
r.mu.RUnlock()
|
||||||
|
out := make([]SettingEntry, 0, len(SettingDefaults))
|
||||||
|
for _, d := range SettingDefaults {
|
||||||
|
v, ok := vals[d.Key]
|
||||||
|
if !ok {
|
||||||
|
v = d.Value
|
||||||
|
}
|
||||||
|
out = append(out, SettingEntry{
|
||||||
|
Key: d.Key,
|
||||||
|
Value: v,
|
||||||
|
Type: d.Type,
|
||||||
|
Min: d.Min,
|
||||||
|
Max: d.Max,
|
||||||
|
RequiresRestart: d.RequiresRestart,
|
||||||
|
Category: d.Category,
|
||||||
|
Group: d.Group,
|
||||||
|
Description: d.Description,
|
||||||
|
IsDefault: v == d.Value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupDefault returns the compiled-in SettingDefault for a key (ok=false if unknown).
|
||||||
|
func LookupDefault(key string) (SettingDefault, bool) {
|
||||||
|
d, ok := defaultBy[key]
|
||||||
|
return d, ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSettingDefaults ensures every seeded setting has a compiled default with
|
||||||
|
// a valid value for its declared type. This guards against typos that would
|
||||||
|
// silently fall back at runtime.
|
||||||
|
func TestSettingDefaults(t *testing.T) {
|
||||||
|
if len(SettingDefaults) == 0 {
|
||||||
|
t.Fatal("SettingDefaults is empty")
|
||||||
|
}
|
||||||
|
for _, d := range SettingDefaults {
|
||||||
|
if d.Key == "" {
|
||||||
|
t.Errorf("default has empty key: %+v", d)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch d.Type {
|
||||||
|
case SettingTypeInt:
|
||||||
|
if _, err := strconv.Atoi(d.Value); err != nil {
|
||||||
|
t.Errorf("int setting %s default %q is not an int: %v", d.Key, d.Value, err)
|
||||||
|
}
|
||||||
|
if d.Min != "" {
|
||||||
|
if _, err := strconv.Atoi(d.Min); err != nil {
|
||||||
|
t.Errorf("int setting %s min %q is not an int", d.Key, d.Min)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.Max != "" {
|
||||||
|
if _, err := strconv.Atoi(d.Max); err != nil {
|
||||||
|
t.Errorf("int setting %s max %q is not an int", d.Key, d.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case SettingTypeBool:
|
||||||
|
if _, err := strconv.ParseBool(d.Value); err != nil {
|
||||||
|
t.Errorf("bool setting %s default %q is not a bool", d.Key, d.Value)
|
||||||
|
}
|
||||||
|
case SettingTypeString:
|
||||||
|
if d.Value == "" {
|
||||||
|
t.Errorf("string setting %s has empty default", d.Key)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Errorf("setting %s has unknown type %q", d.Key, d.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsRegistryGetIntClamping verifies that out-of-range DB values are
|
||||||
|
// clamped to the declared min/max, and that garbage falls back to the default.
|
||||||
|
func TestSettingsRegistryGetIntClamping(t *testing.T) {
|
||||||
|
r := &SettingsRegistry{values: map[string]string{}, q: nil}
|
||||||
|
|
||||||
|
// Seed with an over-max value; expect clamping to the max (3600).
|
||||||
|
r.values["scan_poll_interval_seconds"] = "999999"
|
||||||
|
if got := r.ScanPollInterval(); got.Seconds() != 3600 {
|
||||||
|
t.Errorf("expected clamp to 3600, got %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed with an under-min value; expect clamp to min (1).
|
||||||
|
r.values["scan_poll_interval_seconds"] = "0"
|
||||||
|
if got := r.ScanPollInterval(); got.Seconds() != 1 {
|
||||||
|
t.Errorf("expected clamp to 1, got %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed with garbage; expect fallback to default (60).
|
||||||
|
r.values["scan_poll_interval_seconds"] = "not-a-number"
|
||||||
|
if got := r.ScanPollInterval(); got.Seconds() != 60 {
|
||||||
|
t.Errorf("expected fallback default 60, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsRegistryGetBoolFallback verifies bool parsing and fallback.
|
||||||
|
func TestSettingsRegistryGetBoolFallback(t *testing.T) {
|
||||||
|
r := &SettingsRegistry{values: map[string]string{}, q: nil}
|
||||||
|
|
||||||
|
r.values["auto_scan_enabled"] = "true"
|
||||||
|
if !r.AutoScanEnabled() {
|
||||||
|
t.Error("expected true")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.values["auto_scan_enabled"] = "garbage"
|
||||||
|
// garbage falls back to default ("true")
|
||||||
|
if !r.AutoScanEnabled() {
|
||||||
|
t.Error("expected fallback to default true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLookupDefaultUnknownKey verifies unknown keys return ok=false.
|
||||||
|
func TestLookupDefaultUnknownKey(t *testing.T) {
|
||||||
|
if _, ok := LookupDefault("does_not_exist"); ok {
|
||||||
|
t.Error("expected ok=false for unknown key")
|
||||||
|
}
|
||||||
|
if _, ok := LookupDefault("session_duration_seconds"); !ok {
|
||||||
|
t.Error("expected ok=true for known key")
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-11
@@ -26,14 +26,34 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Session duration constants
|
// DefaultSessionDuration is the fallback session duration used when no
|
||||||
// Follows same pattern as refresh_token.go
|
// settings registry is wired (matches the historical 7-day value).
|
||||||
SessionDuration = 7 * 24 * time.Hour // 7 days
|
DefaultSessionDuration = 7 * 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// SessionDurationSec is the session duration in seconds for use in cookies and API responses
|
// SessionDurationSec is retained for backward compatibility; new code uses the
|
||||||
// Note: This is computed from SessionDuration to avoid magic numbers
|
// registry via AuthHandler.sessionDuration().
|
||||||
var SessionDurationSec = int(SessionDuration.Seconds())
|
var SessionDurationSec = int(DefaultSessionDuration.Seconds())
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry (optional).
|
||||||
|
func (h *AuthHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
|
||||||
|
|
||||||
|
// sessionDuration returns the active session duration from the registry.
|
||||||
|
func (h *AuthHandler) sessionDuration() time.Duration {
|
||||||
|
if h.settings != nil {
|
||||||
|
return h.settings.SessionDuration()
|
||||||
|
}
|
||||||
|
return DefaultSessionDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshTokenTTL returns the active refresh-token lifetime (shared with the
|
||||||
|
// session duration), with a compiled-default fallback.
|
||||||
|
func (h *AuthHandler) refreshTokenTTL() time.Duration {
|
||||||
|
if h.settings != nil {
|
||||||
|
return h.settings.SessionDuration()
|
||||||
|
}
|
||||||
|
return DefaultSessionDuration
|
||||||
|
}
|
||||||
|
|
||||||
var secure = os.Getenv("COOKIE_SECURE")
|
var secure = os.Getenv("COOKIE_SECURE")
|
||||||
|
|
||||||
@@ -41,6 +61,7 @@ type AuthHandler struct {
|
|||||||
db *database.Queries
|
db *database.Queries
|
||||||
jwtKey []byte
|
jwtKey []byte
|
||||||
loginAttemptTracker *middleware.LoginAttemptTracker
|
loginAttemptTracker *middleware.LoginAttemptTracker
|
||||||
|
settings *database.SettingsRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthHandler(db *database.Queries, jwtSecret string, loginAttemptTracker *middleware.LoginAttemptTracker) *AuthHandler {
|
func NewAuthHandler(db *database.Queries, jwtSecret string, loginAttemptTracker *middleware.LoginAttemptTracker) *AuthHandler {
|
||||||
@@ -265,7 +286,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
|||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: secure == "true", // TODO: Set to true in production with HTTPS
|
Secure: secure == "true", // TODO: Set to true in production with HTTPS
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
MaxAge: SessionDurationSec,
|
MaxAge: int(h.sessionDuration().Seconds()),
|
||||||
}
|
}
|
||||||
c.SetCookie(cookie)
|
c.SetCookie(cookie)
|
||||||
|
|
||||||
@@ -298,7 +319,7 @@ window.location.href = '/dashboard';
|
|||||||
Token: accessToken,
|
Token: accessToken,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
TokenType: "Bearer",
|
TokenType: "Bearer",
|
||||||
ExpiresIn: SessionDurationSec,
|
ExpiresIn: int(h.sessionDuration().Seconds()),
|
||||||
User: UserProfile{
|
User: UserProfile{
|
||||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
@@ -411,7 +432,7 @@ func (h *AuthHandler) Login(c *echo.Context) error {
|
|||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: secure == "true", // TODO: Set to true in production with HTTPS
|
Secure: secure == "true", // TODO: Set to true in production with HTTPS
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
MaxAge: SessionDurationSec,
|
MaxAge: int(h.sessionDuration().Seconds()),
|
||||||
}
|
}
|
||||||
c.SetCookie(cookie)
|
c.SetCookie(cookie)
|
||||||
|
|
||||||
@@ -450,7 +471,7 @@ window.location.href = '%s';
|
|||||||
Token: accessToken,
|
Token: accessToken,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
TokenType: "Bearer",
|
TokenType: "Bearer",
|
||||||
ExpiresIn: SessionDurationSec,
|
ExpiresIn: int(h.sessionDuration().Seconds()),
|
||||||
User: UserProfile{
|
User: UserProfile{
|
||||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
@@ -1014,7 +1035,7 @@ func (h *AuthHandler) generateJWTWithAllClaims(userID, userRole, userEmail, user
|
|||||||
"user_role": userRole,
|
"user_role": userRole,
|
||||||
"user_email": userEmail,
|
"user_email": userEmail,
|
||||||
"user_username": userUsername,
|
"user_username": userUsername,
|
||||||
"exp": time.Now().Add(SessionDuration).Unix(),
|
"exp": time.Now().Add(h.sessionDuration()).Unix(),
|
||||||
"iat": time.Now().Unix(),
|
"iat": time.Now().Unix(),
|
||||||
}
|
}
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ type BookInfo struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
CoverImagePath string `json:"cover_image_path"`
|
CoverImagePath string `json:"cover_image_path"`
|
||||||
|
HasConflict bool `json:"has_conflict"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SectionData struct {
|
type SectionData struct {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -64,6 +65,7 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sectionData := BuildSections(sections, libraryID)
|
sectionData := BuildSections(sections, libraryID)
|
||||||
|
sectionData = MarkActiveConflictsSections(c.Request().Context(), h.db, user.ID, sectionData)
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
|
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
|
||||||
}
|
}
|
||||||
@@ -188,6 +190,59 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID string
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// activeConflictSet returns the set of media item IDs (as strings) that have an
|
||||||
|
// active (unresolved) progress sync conflict for the given user. A single query
|
||||||
|
// is issued; resolved conflicts are filtered out in memory.
|
||||||
|
func activeConflictSet(ctx context.Context, db *database.Queries, userID pgtype.UUID) map[string]bool {
|
||||||
|
conflicts, err := db.ListSyncConflictsByUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
set := make(map[string]bool, len(conflicts))
|
||||||
|
for _, c := range conflicts {
|
||||||
|
if c.ResolutionStatus.String == "unresolved" {
|
||||||
|
set[uuid.UUID(c.MediaItemID.Bytes).String()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkActiveConflicts stamps HasConflict on each book whose media item has an
|
||||||
|
// active progress sync conflict for the user. It performs a single query
|
||||||
|
// regardless of how many books are passed.
|
||||||
|
func MarkActiveConflicts(ctx context.Context, db *database.Queries, userID pgtype.UUID, books []BookInfo) []BookInfo {
|
||||||
|
if len(books) == 0 {
|
||||||
|
return books
|
||||||
|
}
|
||||||
|
set := activeConflictSet(ctx, db, userID)
|
||||||
|
for i := range books {
|
||||||
|
if set[books[i].MediaItemID] {
|
||||||
|
books[i].HasConflict = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return books
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkActiveConflictsSections is the section-aware variant of MarkActiveConflicts,
|
||||||
|
// used by the dashboard which renders books grouped into sections.
|
||||||
|
func MarkActiveConflictsSections(ctx context.Context, db *database.Queries, userID pgtype.UUID, sections []SectionData) []SectionData {
|
||||||
|
if len(sections) == 0 {
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
set := activeConflictSet(ctx, db, userID)
|
||||||
|
if len(set) == 0 {
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
for s := range sections {
|
||||||
|
for i := range sections[s].Items {
|
||||||
|
if set[sections[s].Items[i].MediaItemID] {
|
||||||
|
sections[s].Items[i].HasConflict = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
func getViewAllURL(collectionID string, libraryID string) string {
|
func getViewAllURL(collectionID string, libraryID string) string {
|
||||||
if collectionID != "" {
|
if collectionID != "" {
|
||||||
if libraryID != "" {
|
if libraryID != "" {
|
||||||
|
|||||||
@@ -623,7 +623,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if h.annotationSvc != nil && len(processedBooks) > 0 {
|
if h.annotationSvc != nil && len(processedBooks) > 0 {
|
||||||
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true}
|
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), Valid: true}
|
||||||
for mediaItemID, contentId := range processedBooks {
|
for mediaItemID, contentId := range processedBooks {
|
||||||
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
|
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
|
||||||
MediaItemID: mediaItemID,
|
MediaItemID: mediaItemID,
|
||||||
|
|||||||
@@ -854,7 +854,7 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
|||||||
annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark)
|
annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark)
|
||||||
}
|
}
|
||||||
|
|
||||||
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true}
|
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), Valid: true}
|
||||||
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
|
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
|
||||||
MediaItemID: pgBookUUID,
|
MediaItemID: pgBookUUID,
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+105
-24
@@ -26,6 +26,26 @@ type OPDSHandler struct {
|
|||||||
conversionService interface {
|
conversionService interface {
|
||||||
ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error)
|
ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error)
|
||||||
}
|
}
|
||||||
|
settings *database.SettingsRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry (OPDS page size).
|
||||||
|
func (h *OPDSHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
|
||||||
|
|
||||||
|
// opdsDefaultPageSize returns the configured default page size (50 if unset).
|
||||||
|
func (h *OPDSHandler) opdsDefaultPageSize() int {
|
||||||
|
if h.settings != nil {
|
||||||
|
return h.settings.OpdsDefaultPageSize()
|
||||||
|
}
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
|
||||||
|
// opdsMaxPageSize returns the configured maximum page size (200 if unset).
|
||||||
|
func (h *OPDSHandler) opdsMaxPageSize() int {
|
||||||
|
if h.settings != nil {
|
||||||
|
return h.settings.OpdsMaxPageSize()
|
||||||
|
}
|
||||||
|
return 200
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryService, conversionService interface {
|
func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryService, conversionService interface {
|
||||||
@@ -38,15 +58,15 @@ func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryServic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get base URL from system config
|
// Helper function to get base URL from system config with request-derived fallback
|
||||||
func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) {
|
func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) {
|
||||||
baseURL, err := h.db.GetSystemConfig(c.Request().Context(), "base_url")
|
var dbBaseURL string
|
||||||
if err != nil {
|
if config, err := h.db.GetSystemConfig(c.Request().Context(), "base_url"); err == nil {
|
||||||
return "", "", fmt.Errorf("failed to get base_url from config: %w", err)
|
dbBaseURL = config.Value
|
||||||
}
|
}
|
||||||
|
baseURL := deriveBaseURL(c, dbBaseURL)
|
||||||
opdsBaseURL := baseURL.Value + "/opds"
|
opdsBaseURL := baseURL + "/opds"
|
||||||
return baseURL.Value, opdsBaseURL, nil
|
return baseURL, opdsBaseURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *OPDSHandler) getAuthToken(c *echo.Context) string {
|
func (h *OPDSHandler) getAuthToken(c *echo.Context) string {
|
||||||
@@ -67,6 +87,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 {
|
||||||
@@ -132,9 +188,10 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
perPageNum := 50
|
perPageNum := h.opdsDefaultPageSize()
|
||||||
|
maxPerPage := h.opdsMaxPageSize()
|
||||||
if perPage != "" {
|
if perPage != "" {
|
||||||
if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= 200 {
|
if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= maxPerPage {
|
||||||
perPageNum = num
|
perPageNum = num
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,14 +263,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 +346,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 +377,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 +419,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")
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -139,3 +140,8 @@ func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c *echo.Context) error {
|
|||||||
"message": "Issue deleted",
|
"message": "Issue deleted",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProcessingIssueStatsData returns stats for SSR (not JSON response)
|
||||||
|
func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) {
|
||||||
|
return h.db.GetProcessingIssueStats(ctx, libraryID)
|
||||||
|
}
|
||||||
|
|||||||
@@ -374,6 +374,11 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
|
|||||||
deviceName = progress.LastSyncDevice.String
|
deviceName = progress.LastSyncDevice.String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastUpdated := ""
|
||||||
|
if progress.LastReadAt.Valid {
|
||||||
|
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
|
||||||
|
}
|
||||||
|
|
||||||
progressList = append(progressList, ProgressWithMedia{
|
progressList = append(progressList, ProgressWithMedia{
|
||||||
MediaItemID: progress.MediaItemID.Bytes,
|
MediaItemID: progress.MediaItemID.Bytes,
|
||||||
Title: mediaItem.Title,
|
Title: mediaItem.Title,
|
||||||
@@ -386,6 +391,11 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
|
|||||||
Epubcfi: epubcfi,
|
Epubcfi: epubcfi,
|
||||||
LastSyncDevice: deviceName,
|
LastSyncDevice: deviceName,
|
||||||
ProgressPercentage: progress.Percentage.Float64 * 100,
|
ProgressPercentage: progress.Percentage.Float64 * 100,
|
||||||
|
EpubCFI: epubcfi,
|
||||||
|
LastUpdated: lastUpdated,
|
||||||
|
DeviceIcon: getDeviceIcon(deviceName),
|
||||||
|
DeviceName: deviceName,
|
||||||
|
DeviceType: deviceName,
|
||||||
FormatGroup: mediaItem.FormatGroup,
|
FormatGroup: mediaItem.FormatGroup,
|
||||||
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ import (
|
|||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
refreshTokenExpiration = 7 * 24 * time.Hour // 7 days
|
|
||||||
)
|
|
||||||
|
|
||||||
type RefreshTokenRequest struct {
|
type RefreshTokenRequest struct {
|
||||||
RefreshToken string `json:"refresh_token" validate:"required"`
|
RefreshToken string `json:"refresh_token" validate:"required"`
|
||||||
}
|
}
|
||||||
@@ -72,7 +68,7 @@ func (h *AuthHandler) RefreshAccessToken(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, RefreshTokenResponse{
|
return c.JSON(http.StatusOK, RefreshTokenResponse{
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
TokenType: "Bearer",
|
TokenType: "Bearer",
|
||||||
ExpiresIn: SessionDurationSec,
|
ExpiresIn: int(h.refreshTokenTTL().Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +97,7 @@ func (h *AuthHandler) CreateRefreshToken(userID uuid.UUID) (string, string, erro
|
|||||||
tokenUUID := uuid.New()
|
tokenUUID := uuid.New()
|
||||||
refreshToken := tokenUUID.String()
|
refreshToken := tokenUUID.String()
|
||||||
|
|
||||||
expiresAt := time.Now().Add(refreshTokenExpiration)
|
expiresAt := time.Now().Add(h.refreshTokenTTL())
|
||||||
_, err := h.db.CreateRefreshToken(context.Background(), database.CreateRefreshTokenParams{
|
_, err := h.db.CreateRefreshToken(context.Background(), database.CreateRefreshTokenParams{
|
||||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
Token: pgtype.UUID{Bytes: tokenUUID, Valid: true},
|
Token: pgtype.UUID{Bytes: tokenUUID, Valid: true},
|
||||||
|
|||||||
@@ -128,8 +128,8 @@ func (h *Handler) StartScanner(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the folder paths
|
// Set the folder paths (watch=true: this long-lived scanner reads events)
|
||||||
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
|
if err := h.scanner.SetFolders(req.FolderPaths, true); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ func (h *Handler) StartWatchModeForLibrary(ctx context.Context, libraryID pgtype
|
|||||||
}
|
}
|
||||||
|
|
||||||
scanner := services.NewMediaScanner(h.db)
|
scanner := services.NewMediaScanner(h.db)
|
||||||
if err := scanner.SetFolders(folderPaths); err != nil {
|
if err := scanner.SetFolders(folderPaths, true); err != nil {
|
||||||
return fmt.Errorf("failed to set scanner folders: %v", err)
|
return fmt.Errorf("failed to set scanner folders: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/config"
|
"bookhoard/internal/config"
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/setupstatus"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,14 +15,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SidecarHandler struct {
|
type SidecarHandler struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
|
settings *database.SettingsRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler {
|
func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler {
|
||||||
return &SidecarHandler{db: db, cfg: cfg}
|
return &SidecarHandler{db: db, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry so the timezone write path
|
||||||
|
// keeps the cache consistent.
|
||||||
|
func (h *SidecarHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
|
||||||
|
|
||||||
type SidecarConfig struct {
|
type SidecarConfig struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Bookhoard SidecarBookhoardConfig `json:"bookhoard"`
|
Bookhoard SidecarBookhoardConfig `json:"bookhoard"`
|
||||||
@@ -81,15 +87,13 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
|||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||||
|
|
||||||
// Get base URL and compute paths
|
// Get base URL and compute paths (with request-derived fallback)
|
||||||
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||||
if baseURL.Value == "" {
|
baseURL := deriveBaseURL(c, dbBaseURL.Value)
|
||||||
baseURL.Value = h.cfg.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate URLs
|
// Generate URLs
|
||||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
|
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
|
||||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
|
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
|
||||||
|
|
||||||
// Get user's visible libraries with media items
|
// Get user's visible libraries with media items
|
||||||
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
||||||
@@ -176,8 +180,8 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
|||||||
Bookhoard: SidecarBookhoardConfig{
|
Bookhoard: SidecarBookhoardConfig{
|
||||||
OPDSCatalog: opdsCatalogURL,
|
OPDSCatalog: opdsCatalogURL,
|
||||||
SyncAPI: syncAPIURL,
|
SyncAPI: syncAPIURL,
|
||||||
OPDSBaseURL: baseURL.Value + "/opds",
|
OPDSBaseURL: baseURL + "/opds",
|
||||||
APIBaseURL: baseURL.Value + "/api",
|
APIBaseURL: baseURL + "/api",
|
||||||
DeviceID: deviceID.String(),
|
DeviceID: deviceID.String(),
|
||||||
DeviceToken: device.AuthToken,
|
DeviceToken: device.AuthToken,
|
||||||
},
|
},
|
||||||
@@ -219,15 +223,13 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
|
|||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||||
|
|
||||||
// Get base URL and compute paths
|
// Get base URL and compute paths (with request-derived fallback)
|
||||||
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||||
if baseURL.Value == "" {
|
baseURL := deriveBaseURL(c, dbBaseURL.Value)
|
||||||
baseURL.Value = h.cfg.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate URLs
|
// Generate URLs
|
||||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
|
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
|
||||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
|
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
|
||||||
|
|
||||||
// Get user's visible libraries with media items
|
// Get user's visible libraries with media items
|
||||||
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
||||||
@@ -309,8 +311,8 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
|
|||||||
Bookhoard: SidecarBookhoardConfig{
|
Bookhoard: SidecarBookhoardConfig{
|
||||||
OPDSCatalog: opdsCatalogURL,
|
OPDSCatalog: opdsCatalogURL,
|
||||||
SyncAPI: syncAPIURL,
|
SyncAPI: syncAPIURL,
|
||||||
OPDSBaseURL: baseURL.Value + "/opds",
|
OPDSBaseURL: baseURL + "/opds",
|
||||||
APIBaseURL: baseURL.Value + "/api",
|
APIBaseURL: baseURL + "/api",
|
||||||
DeviceID: deviceID.String(),
|
DeviceID: deviceID.String(),
|
||||||
DeviceToken: device.AuthToken,
|
DeviceToken: device.AuthToken,
|
||||||
},
|
},
|
||||||
@@ -403,6 +405,9 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
|||||||
"error": "failed to update default timezone",
|
"error": "failed to update default timezone",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if h.settings != nil {
|
||||||
|
h.settings.Reload(ctx)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
|
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
|
||||||
@@ -434,6 +439,10 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidate setup status cache so the middleware picks up the new
|
||||||
|
// base_url immediately (setup is not complete until base_url is set).
|
||||||
|
setupstatus.Invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for HTMX request
|
// Check for HTMX request
|
||||||
|
|||||||
@@ -2,17 +2,21 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SystemSettingsHandler struct {
|
type SystemSettingsHandler struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
|
settings *database.SettingsRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
|
func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
|
||||||
@@ -21,6 +25,154 @@ func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry. Required for the unified
|
||||||
|
// /api/system/settings endpoints and for cache invalidation after writes.
|
||||||
|
func (h *SystemSettingsHandler) SetSettings(s *database.SettingsRegistry) {
|
||||||
|
h.settings = s
|
||||||
|
}
|
||||||
|
|
||||||
|
// reload refreshes the in-memory cache after a write.
|
||||||
|
func (h *SystemSettingsHandler) reload(c *echo.Context) {
|
||||||
|
if h.settings != nil {
|
||||||
|
h.settings.Reload(c.Request().Context())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Unified /api/system/settings endpoints ----
|
||||||
|
|
||||||
|
// GetSettings handles GET /api/system/settings.
|
||||||
|
func (h *SystemSettingsHandler) GetSettings(c *echo.Context) error {
|
||||||
|
if h.settings == nil {
|
||||||
|
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "settings registry not initialized"})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, h.settings.All())
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettingRequest is the body for PUT /api/system/settings.
|
||||||
|
type UpdateSettingRequest struct {
|
||||||
|
Key string `json:"key" form:"key"`
|
||||||
|
Value string `json:"value" form:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettingResponse mirrors a settings entry plus a reload hint.
|
||||||
|
type UpdateSettingResponse struct {
|
||||||
|
database.SettingEntry
|
||||||
|
ReloadRequired bool `json:"reload_required"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSetting handles PUT /api/system/settings.
|
||||||
|
func (h *SystemSettingsHandler) UpdateSetting(c *echo.Context) error {
|
||||||
|
if h.settings == nil {
|
||||||
|
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "settings registry not initialized"})
|
||||||
|
}
|
||||||
|
var req UpdateSettingRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
resp, err := h.ApplySetting(c.Request().Context(), req.Key, req.Value)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplySetting validates, persists, and reloads a single setting. Shared by the
|
||||||
|
// JSON API and the HTMX admin endpoint.
|
||||||
|
func (h *SystemSettingsHandler) ApplySetting(ctx context.Context, key, value string) (UpdateSettingResponse, error) {
|
||||||
|
if h.settings == nil {
|
||||||
|
return UpdateSettingResponse{}, fmt.Errorf("settings registry not initialized")
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
return UpdateSettingResponse{}, fmt.Errorf("key is required")
|
||||||
|
}
|
||||||
|
def, ok := database.LookupDefault(key)
|
||||||
|
if !ok {
|
||||||
|
return UpdateSettingResponse{}, fmt.Errorf("unknown setting key: %s", key)
|
||||||
|
}
|
||||||
|
if err := validateSettingValue(def, value); err != nil {
|
||||||
|
return UpdateSettingResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
desc := def.Description
|
||||||
|
rType := pgtype.Text{}
|
||||||
|
if def.Type != "" {
|
||||||
|
rType = pgtype.Text{String: def.Type, Valid: true}
|
||||||
|
}
|
||||||
|
var minP, maxP pgtype.Text
|
||||||
|
if def.Min != "" {
|
||||||
|
minP = pgtype.Text{String: def.Min, Valid: true}
|
||||||
|
}
|
||||||
|
if def.Max != "" {
|
||||||
|
maxP = pgtype.Text{String: def.Max, Valid: true}
|
||||||
|
}
|
||||||
|
if _, err := h.db.UpsertSystemSetting(ctx, database.UpsertSystemSettingParams{
|
||||||
|
SettingKey: key,
|
||||||
|
SettingValue: value,
|
||||||
|
Description: pgtype.Text{String: desc, Valid: desc != ""},
|
||||||
|
SettingType: rType,
|
||||||
|
MinValue: minP,
|
||||||
|
MaxValue: maxP,
|
||||||
|
RequiresRestart: pgtype.Bool{Bool: def.RequiresRestart, Valid: true},
|
||||||
|
Category: pgtype.Text{String: def.Category, Valid: def.Category != ""},
|
||||||
|
}); err != nil {
|
||||||
|
return UpdateSettingResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
h.settings.Reload(ctx)
|
||||||
|
|
||||||
|
resp := UpdateSettingResponse{ReloadRequired: def.RequiresRestart}
|
||||||
|
for _, e := range h.settings.All() {
|
||||||
|
if e.Key == key {
|
||||||
|
resp.SettingEntry = e
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if def.RequiresRestart {
|
||||||
|
resp.Message = "Saved. Restart the server for this change to take full effect."
|
||||||
|
} else {
|
||||||
|
resp.Message = "Saved."
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSettingValue checks a candidate value against the setting's type and bounds.
|
||||||
|
func validateSettingValue(def database.SettingDefault, value string) error {
|
||||||
|
switch def.Type {
|
||||||
|
case database.SettingTypeInt:
|
||||||
|
n, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("value must be an integer")
|
||||||
|
}
|
||||||
|
if def.Min != "" {
|
||||||
|
if mn, err := strconv.Atoi(def.Min); err == nil && n < mn {
|
||||||
|
return fmt.Errorf("value must be >= %s", def.Min)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if def.Max != "" {
|
||||||
|
if mx, err := strconv.Atoi(def.Max); err == nil && n > mx {
|
||||||
|
return fmt.Errorf("value must be <= %s", def.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case database.SettingTypeBool:
|
||||||
|
if _, err := strconv.ParseBool(value); err != nil {
|
||||||
|
return fmt.Errorf("value must be true or false")
|
||||||
|
}
|
||||||
|
case database.SettingTypeString:
|
||||||
|
if value == "" {
|
||||||
|
return fmt.Errorf("value must not be empty")
|
||||||
|
}
|
||||||
|
if def.Key == "default_timezone" {
|
||||||
|
if _, err := time.LoadLocation(value); err != nil {
|
||||||
|
return fmt.Errorf("invalid timezone: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Legacy scan-settings endpoints (retained for backward compatibility) ----
|
||||||
|
|
||||||
type UpdateScanSettingsRequest struct {
|
type UpdateScanSettingsRequest struct {
|
||||||
ScanPollIntervalSeconds int32 `json:"scan_poll_interval_seconds" validate:"required,min=1,max=3600"`
|
ScanPollIntervalSeconds int32 `json:"scan_poll_interval_seconds" validate:"required,min=1,max=3600"`
|
||||||
AutoScanEnabled bool `json:"auto_scan_enabled"`
|
AutoScanEnabled bool `json:"auto_scan_enabled"`
|
||||||
@@ -51,6 +203,7 @@ func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
h.reload(c)
|
||||||
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
|
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +241,8 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.reload(c)
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
||||||
ScanPollIntervalSeconds: req.ScanPollIntervalSeconds,
|
ScanPollIntervalSeconds: req.ScanPollIntervalSeconds,
|
||||||
AutoScanEnabled: req.AutoScanEnabled,
|
AutoScanEnabled: req.AutoScanEnabled,
|
||||||
@@ -96,6 +251,15 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error {
|
func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error {
|
||||||
|
// Prefer the registry (single source of truth after Load).
|
||||||
|
if h.settings != nil {
|
||||||
|
interval := int32(h.settings.ScanPollInterval().Seconds())
|
||||||
|
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
||||||
|
ScanPollIntervalSeconds: interval,
|
||||||
|
AutoScanEnabled: h.settings.AutoScanEnabled(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds")
|
scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deriveBaseURL returns the base URL to use for constructing self-referential
|
||||||
|
// links (OPDS feeds, sidecar config, etc.). It prefers the database-configured
|
||||||
|
// base_url when available, and falls back to deriving the URL from the incoming
|
||||||
|
// HTTP request (Host header + scheme), which is always reachable by the client.
|
||||||
|
//
|
||||||
|
// Proxy header support: X-Forwarded-Proto and X-Forwarded-Host are respected so
|
||||||
|
// that deployments behind TLS-terminating reverse proxies advertise the correct
|
||||||
|
// external URL.
|
||||||
|
func deriveBaseURL(c *echo.Context, dbBaseURL string) string {
|
||||||
|
if dbBaseURL != "" {
|
||||||
|
return strings.TrimRight(dbBaseURL, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme := "http"
|
||||||
|
if c.Request().TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
if proto := c.Request().Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||||
|
scheme = proto
|
||||||
|
}
|
||||||
|
|
||||||
|
host := c.Request().Host
|
||||||
|
if forwarded := c.Request().Header.Get("X-Forwarded-Host"); forwarded != "" {
|
||||||
|
host = forwarded
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheme + "://" + host
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ type DeviceContext struct {
|
|||||||
type DeviceAuthMiddleware struct {
|
type DeviceAuthMiddleware struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
rateLimiter *DeviceRateLimiter
|
rateLimiter *DeviceRateLimiter
|
||||||
|
settings *database.SettingsRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware {
|
func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware {
|
||||||
@@ -34,6 +35,41 @@ func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry so device rate limits are
|
||||||
|
// read live on each authenticated request.
|
||||||
|
func (m *DeviceAuthMiddleware) SetSettings(s *database.SettingsRegistry) { m.settings = s }
|
||||||
|
|
||||||
|
// rateLimitConfig returns the active device rate limits from the registry, or
|
||||||
|
// the historical defaults when no registry is wired.
|
||||||
|
func (m *DeviceAuthMiddleware) rateLimitConfig() DeviceRateLimitConfig {
|
||||||
|
if m.settings != nil {
|
||||||
|
dl := m.settings.DeviceRateLimits()
|
||||||
|
return DeviceRateLimitConfig{
|
||||||
|
SyncRequestsPerMinute: dl.Sync,
|
||||||
|
ProgressUpdatesPerMinute: dl.Progress,
|
||||||
|
MetadataRequestsPerMinute: dl.Metadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DeviceRateLimitConfig{
|
||||||
|
SyncRequestsPerMinute: DefaultSyncRequestsPerMinute,
|
||||||
|
ProgressUpdatesPerMinute: DefaultProgressUpdatesPerMinute,
|
||||||
|
MetadataRequestsPerMinute: DefaultMetadataRequestsPerMinute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rateLimitForRequestType returns the configured per-minute limit for a given
|
||||||
|
// request type, for use in X-RateLimit-* headers.
|
||||||
|
func (m *DeviceAuthMiddleware) rateLimitForRequestType(requestType string, config DeviceRateLimitConfig) int {
|
||||||
|
switch requestType {
|
||||||
|
case "progress":
|
||||||
|
return config.ProgressUpdatesPerMinute
|
||||||
|
case "metadata":
|
||||||
|
return config.MetadataRequestsPerMinute
|
||||||
|
default: // "sync" and any unknown type
|
||||||
|
return config.SyncRequestsPerMinute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc {
|
func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c *echo.Context) error {
|
return func(c *echo.Context) error {
|
||||||
var device database.Devices
|
var device database.Devices
|
||||||
@@ -115,15 +151,12 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
|
|||||||
deviceUUID := uuid.UUID(device.ID.Bytes)
|
deviceUUID := uuid.UUID(device.ID.Bytes)
|
||||||
deviceID := deviceUUID.String()
|
deviceID := deviceUUID.String()
|
||||||
|
|
||||||
config := DeviceRateLimitConfig{
|
config := m.rateLimitConfig()
|
||||||
SyncRequestsPerMinute: 60,
|
limitForType := m.rateLimitForRequestType(requestType, config)
|
||||||
ProgressUpdatesPerMinute: 120,
|
|
||||||
MetadataRequestsPerMinute: 30,
|
|
||||||
}
|
|
||||||
|
|
||||||
if !m.rateLimiter.CheckRateLimit(deviceID, requestType, config) {
|
if !m.rateLimiter.CheckRateLimit(deviceID, requestType, config) {
|
||||||
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
|
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
|
||||||
c.Response().Header().Set("X-RateLimit-Limit", "60")
|
c.Response().Header().Set("X-RateLimit-Limit", strconv.Itoa(limitForType))
|
||||||
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
||||||
c.Response().Header().Set("X-RateLimit-Reset", "60")
|
c.Response().Header().Set("X-RateLimit-Reset", "60")
|
||||||
return c.JSON(http.StatusTooManyRequests, map[string]string{
|
return c.JSON(http.StatusTooManyRequests, map[string]string{
|
||||||
@@ -134,7 +167,7 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
|
|||||||
}
|
}
|
||||||
|
|
||||||
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
|
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
|
||||||
c.Response().Header().Set("X-RateLimit-Limit", "60")
|
c.Response().Header().Set("X-RateLimit-Limit", strconv.Itoa(limitForType))
|
||||||
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
||||||
|
|
||||||
ctx := DeviceContext{
|
ctx := DeviceContext{
|
||||||
|
|||||||
@@ -1,96 +1,146 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PasswordValidator validates password complexity requirements
|
// specialCharRegex matches the historical "special character" set used by the
|
||||||
type PasswordValidator struct{}
|
// password complexity rules.
|
||||||
|
const specialCharRegex = `[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`
|
||||||
|
|
||||||
// Validate checks if a password meets complexity requirements:
|
// PasswordValidator validates password complexity against the configured rules.
|
||||||
// - Minimum 8 characters
|
// When a database.SettingsRegistry is wired via SetSettings, rules are read live and the
|
||||||
// - At least one uppercase letter
|
// regex set is recompiled under a mutex on each validation. Without a registry
|
||||||
// - At least one lowercase letter
|
// the historical hardcoded defaults (8+ chars, upper/lower/number/special) apply.
|
||||||
// - At least one number
|
type PasswordValidator struct {
|
||||||
// - At least one special character
|
settings *database.SettingsRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry.
|
||||||
|
func (v *PasswordValidator) SetSettings(s *database.SettingsRegistry) { v.settings = s }
|
||||||
|
|
||||||
|
// compileSpecialRegex isolates the regexp compile (which is safe to call
|
||||||
|
// concurrently, but we keep it behind a cached var for the no-registry path).
|
||||||
|
var (
|
||||||
|
specialOnce sync.Once
|
||||||
|
specialRe *regexp.Regexp
|
||||||
|
)
|
||||||
|
|
||||||
|
func specialRegex() *regexp.Regexp {
|
||||||
|
specialOnce.Do(func() {
|
||||||
|
specialRe = regexp.MustCompile(specialCharRegex)
|
||||||
|
})
|
||||||
|
return specialRe
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *PasswordValidator) rules() database.PasswordRules {
|
||||||
|
if v.settings != nil {
|
||||||
|
return v.settings.PasswordRules()
|
||||||
|
}
|
||||||
|
return database.PasswordRules{MinLength: 8, Upper: true, Lower: true, Number: true, Special: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks if a password meets the configured complexity requirements.
|
||||||
func (v *PasswordValidator) Validate(fl validator.FieldLevel) bool {
|
func (v *PasswordValidator) Validate(fl validator.FieldLevel) bool {
|
||||||
password := fl.Field().String()
|
return v.CheckPassword(fl.Field().String())
|
||||||
|
}
|
||||||
|
|
||||||
// Check minimum length
|
// CheckPassword applies the active rules to a single password.
|
||||||
if len(password) < 8 {
|
func (v *PasswordValidator) CheckPassword(password string) bool {
|
||||||
|
r := v.rules()
|
||||||
|
if len(password) < r.MinLength {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) {
|
||||||
// Check for uppercase
|
|
||||||
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
|
|
||||||
if !hasUpper {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
||||||
// Check for lowercase
|
|
||||||
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
|
|
||||||
if !hasLower {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
||||||
// Check for number
|
|
||||||
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
|
|
||||||
if !hasNumber {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if r.Special && !specialRegex().MatchString(password) {
|
||||||
// Check for special character
|
|
||||||
hasSpecial := regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password)
|
|
||||||
if !hasSpecial {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPasswordRequirements returns a human-readable list of password requirements
|
// GetPasswordRequirements returns a human-readable list of the active password
|
||||||
|
// requirements, driven by the configured rules when a registry is wired.
|
||||||
func GetPasswordRequirements() []string {
|
func GetPasswordRequirements() []string {
|
||||||
return []string{
|
return defaultPasswordValidator.Requirements()
|
||||||
"At least 8 characters long",
|
|
||||||
"At least one uppercase letter (A-Z)",
|
|
||||||
"At least one lowercase letter (a-z)",
|
|
||||||
"At least one number (0-9)",
|
|
||||||
"At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidatePassword checks a password and returns an error if it doesn't meet requirements
|
// Requirements returns the human-readable list for the receiver's active rules.
|
||||||
func ValidatePassword(password string) error {
|
func (v *PasswordValidator) Requirements() []string {
|
||||||
if len(password) < 8 {
|
r := v.rules()
|
||||||
return fmt.Errorf("password must be at least 8 characters long")
|
var out []string
|
||||||
|
out = append(out, fmt.Sprintf("At least %d characters long", r.MinLength))
|
||||||
|
if r.Upper {
|
||||||
|
out = append(out, "At least one uppercase letter (A-Z)")
|
||||||
}
|
}
|
||||||
|
if r.Lower {
|
||||||
|
out = append(out, "At least one lowercase letter (a-z)")
|
||||||
|
}
|
||||||
|
if r.Number {
|
||||||
|
out = append(out, "At least one number (0-9)")
|
||||||
|
}
|
||||||
|
if r.Special {
|
||||||
|
out = append(out, "At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
if !regexp.MustCompile(`[A-Z]`).MatchString(password) {
|
// ValidatePassword checks a password against the default (hardcoded) rules and
|
||||||
|
// returns an error describing the first unmet requirement. Retained for callers
|
||||||
|
// that don't have access to a configured PasswordValidator instance.
|
||||||
|
func ValidatePassword(password string) error {
|
||||||
|
v := defaultPasswordValidator
|
||||||
|
r := v.rules()
|
||||||
|
if len(password) < r.MinLength {
|
||||||
|
return fmt.Errorf("password must be at least %d characters long", r.MinLength)
|
||||||
|
}
|
||||||
|
if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) {
|
||||||
return fmt.Errorf("password must contain at least one uppercase letter")
|
return fmt.Errorf("password must contain at least one uppercase letter")
|
||||||
}
|
}
|
||||||
|
if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
||||||
if !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
|
||||||
return fmt.Errorf("password must contain at least one lowercase letter")
|
return fmt.Errorf("password must contain at least one lowercase letter")
|
||||||
}
|
}
|
||||||
|
if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
||||||
if !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
|
||||||
return fmt.Errorf("password must contain at least one number")
|
return fmt.Errorf("password must contain at least one number")
|
||||||
}
|
}
|
||||||
|
if r.Special && !specialRegex().MatchString(password) {
|
||||||
if !regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password) {
|
|
||||||
return fmt.Errorf("password must contain at least one special character")
|
return fmt.Errorf("password must contain at least one special character")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterPasswordValidation registers the password validator with the validator instance
|
// defaultPasswordValidator is used by the package-level helpers
|
||||||
|
// (GetPasswordRequirements, ValidatePassword) and as the fallback inside
|
||||||
|
// RegisterPasswordValidation when no registry has been wired. Callers that want
|
||||||
|
// live rule updates should construct their own PasswordValidator and call
|
||||||
|
// SetSettings.
|
||||||
|
var defaultPasswordValidator = &PasswordValidator{}
|
||||||
|
|
||||||
|
// RegisterPasswordValidation registers the password validator with the
|
||||||
|
// validator instance. The registered func re-evaluates rules on every call, so
|
||||||
|
// changes to the wired registry take effect immediately.
|
||||||
func RegisterPasswordValidation(v *validator.Validate) error {
|
func RegisterPasswordValidation(v *validator.Validate) error {
|
||||||
return v.RegisterValidation("passwordcomplex", func(fl validator.FieldLevel) bool {
|
return v.RegisterValidation("passwordcomplex", func(fl validator.FieldLevel) bool {
|
||||||
pv := &PasswordValidator{}
|
return defaultPasswordValidator.CheckPassword(fl.Field().String())
|
||||||
return pv.Validate(fl)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDefaultPasswordSettings wires the settings registry into the package-level
|
||||||
|
// default validator so that the struct-tag validator (used by echo's
|
||||||
|
// CustomValidator) and ValidatePassword follow live configuration. Intended to
|
||||||
|
// be called once at startup.
|
||||||
|
func SetDefaultPasswordSettings(s *database.SettingsRegistry) {
|
||||||
|
defaultPasswordValidator.SetSettings(s)
|
||||||
|
}
|
||||||
|
|||||||
+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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/handlers"
|
||||||
|
"bookhoard/templates"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerAdminLibraryRoutes(cfg *Config, frontendProtected *echo.Group) {
|
||||||
|
g := frontendProtected.Group("", handlers.AdminMiddleware)
|
||||||
|
|
||||||
|
// HTMX: Create library
|
||||||
|
g.POST("/admin/library/create", func(c *echo.Context) error {
|
||||||
|
user := c.Get("user").(database.Users)
|
||||||
|
|
||||||
|
name := c.FormValue("name")
|
||||||
|
desc := c.FormValue("description")
|
||||||
|
libType := c.FormValue("type")
|
||||||
|
if name == "" || libType == "" {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Name and type are required</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := cfg.LibraryService.CreateLibrary(
|
||||||
|
c.Request().Context(),
|
||||||
|
name,
|
||||||
|
desc,
|
||||||
|
libType,
|
||||||
|
user.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to create library</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderLibraryList(c, cfg)
|
||||||
|
})
|
||||||
|
|
||||||
|
// HTMX: Update library
|
||||||
|
g.PUT("/admin/library/:id", func(c *echo.Context) error {
|
||||||
|
libraryID, err := parseAdminUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := c.FormValue("name")
|
||||||
|
desc := c.FormValue("description")
|
||||||
|
if name == "" {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Name is required</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = cfg.LibraryService.UpdateLibrary(c.Request().Context(), libraryID, name, desc)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to update library</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderLibraryList(c, cfg)
|
||||||
|
})
|
||||||
|
|
||||||
|
// HTMX: Delete library
|
||||||
|
g.DELETE("/admin/library/:id", func(c *echo.Context) error {
|
||||||
|
libraryID, err := parseAdminUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.LibraryService.DeleteLibrary(c.Request().Context(), libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to delete library</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderLibraryList(c, cfg)
|
||||||
|
})
|
||||||
|
|
||||||
|
// HTMX: Library expanded panel
|
||||||
|
g.GET("/admin/library/:id/panel", func(c *echo.Context) error {
|
||||||
|
libraryID, err := parseAdminUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderLibraryPanel(c, cfg, libraryID)
|
||||||
|
})
|
||||||
|
|
||||||
|
// HTMX: Add folder
|
||||||
|
g.POST("/admin/library/:id/folders", func(c *echo.Context) error {
|
||||||
|
libraryID, err := parseAdminUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
folderPath := c.FormValue("folder_path")
|
||||||
|
if folderPath == "" {
|
||||||
|
return renderLibraryPanel(c, cfg, libraryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(folderPath, "..") {
|
||||||
|
return renderLibraryPanelWithError(c, cfg, libraryID, "Path traversal not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanPath := filepath.Clean(folderPath)
|
||||||
|
fileInfo, err := os.Stat(cleanPath)
|
||||||
|
if err != nil {
|
||||||
|
return renderLibraryPanelWithError(c, cfg, libraryID, "Folder path does not exist")
|
||||||
|
}
|
||||||
|
if !fileInfo.IsDir() {
|
||||||
|
return renderLibraryPanelWithError(c, cfg, libraryID, "Path must be a directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = cfg.LibraryService.AddLibraryFolder(c.Request().Context(), libraryID, cleanPath)
|
||||||
|
if err != nil {
|
||||||
|
return renderLibraryPanelWithError(c, cfg, libraryID, "Failed to add folder: "+err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderLibraryPanel(c, cfg, libraryID)
|
||||||
|
})
|
||||||
|
|
||||||
|
// HTMX: Remove folder
|
||||||
|
g.DELETE("/admin/library/:id/folders", func(c *echo.Context) error {
|
||||||
|
libraryID, err := parseAdminUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
folderPath := c.FormValue("folder_path")
|
||||||
|
if folderPath == "" {
|
||||||
|
return renderLibraryPanel(c, cfg, libraryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.LibraryService.DeleteLibraryFolder(c.Request().Context(), libraryID, folderPath)
|
||||||
|
if err != nil {
|
||||||
|
return renderLibraryPanelWithError(c, cfg, libraryID, "Failed to remove folder")
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderLibraryPanel(c, cfg, libraryID)
|
||||||
|
})
|
||||||
|
|
||||||
|
// HTMX: Folder browser
|
||||||
|
g.GET("/admin/library/browse", func(c *echo.Context) error {
|
||||||
|
path := c.QueryParam("path")
|
||||||
|
if path == "" {
|
||||||
|
path = "/"
|
||||||
|
}
|
||||||
|
targetInput := c.QueryParam("target_input")
|
||||||
|
libraryID := c.QueryParam("library_id")
|
||||||
|
|
||||||
|
dirs, currentPath, parentPath, err := cfg.LibraryService.BrowseDirectories(c.Request().Context(), path)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Cannot browse: `+err.Error()+`</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make([]templates.DirEntry, len(dirs))
|
||||||
|
for i, d := range dirs {
|
||||||
|
fullPath := filepath.Join(currentPath, d)
|
||||||
|
entries[i] = templates.DirEntry{Name: d, Path: fullPath}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = templates.FolderBrowserContent(currentPath, parentPath, entries, targetInput, libraryID).Render(c.Request().Context(), &buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
// HTMX: Set user visibility for library
|
||||||
|
g.POST("/admin/library/:id/visibility", func(c *echo.Context) error {
|
||||||
|
libraryID, err := parseAdminUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDStr := c.FormValue("user_id")
|
||||||
|
isVisible := c.FormValue("is_visible") == "true"
|
||||||
|
|
||||||
|
userID, err := parseAdminUUID(userIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid user ID</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = cfg.LibraryService.SetLibraryVisibility(c.Request().Context(), userID, libraryID, isVisible)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to update visibility</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderLibraryPanel(c, cfg, libraryID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderLibraryList fetches all libraries + users and renders the LibraryList partial.
|
||||||
|
func renderLibraryList(c *echo.Context, cfg *Config) error {
|
||||||
|
libraries, err := cfg.LibraryHandler.ListLibrariesData(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to load libraries</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
libData := make([]templates.LibraryData, len(libraries))
|
||||||
|
for i, lib := range libraries {
|
||||||
|
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||||
|
folderCount := getFolderCount(c.Request().Context(), cfg, lib.ID)
|
||||||
|
libData[i] = templates.LibraryData{
|
||||||
|
ID: libUUID.String(),
|
||||||
|
Name: lib.Name,
|
||||||
|
Description: getText(lib.Description),
|
||||||
|
TypeName: lib.TypeName,
|
||||||
|
TypeValue: lib.TypeName,
|
||||||
|
FolderCount: folderCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := cfg.Queries.ListUsers(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ListUsers failed: %v", err)
|
||||||
|
users = []database.ListUsersRow{}
|
||||||
|
}
|
||||||
|
userData := make([]templates.User, len(users))
|
||||||
|
for i, u := range users {
|
||||||
|
userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
|
||||||
|
userData[i] = templates.User{
|
||||||
|
ID: userUUID.String(),
|
||||||
|
Username: u.Username,
|
||||||
|
Email: u.Email,
|
||||||
|
Role: u.Role,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = templates.LibraryList(templates.User{}, libData, userData).Render(c.Request().Context(), &buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderLibraryPanel fetches library details and renders the LibraryPanel partial.
|
||||||
|
func renderLibraryPanel(c *echo.Context, cfg *Config, libraryID pgtype.UUID) error {
|
||||||
|
return renderLibraryPanelWithError(c, cfg, libraryID, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderLibraryPanelWithError(c *echo.Context, cfg *Config, libraryID pgtype.UUID, errMsg string) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
libraryIDStr := uuid.UUID(libraryID.Bytes).String()
|
||||||
|
|
||||||
|
// Get library details
|
||||||
|
lib, err := cfg.LibraryService.GetLibrary(ctx, libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusNotFound, `<div class="text-sm" style="color: var(--status-danger);">Library not found</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
libData := templates.LibraryData{
|
||||||
|
ID: libraryIDStr,
|
||||||
|
Name: lib.Name,
|
||||||
|
Description: getText(lib.Description),
|
||||||
|
TypeName: lib.TypeName,
|
||||||
|
TypeValue: lib.TypeName,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get folders
|
||||||
|
dbFolders, err := cfg.LibraryService.GetLibraryFolders(ctx, libraryID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GetLibraryFolders failed: %v", err)
|
||||||
|
}
|
||||||
|
folders := make([]templates.FolderData, len(dbFolders))
|
||||||
|
for i, f := range dbFolders {
|
||||||
|
folders[i] = templates.FolderData{FolderPath: f.FolderPath}
|
||||||
|
}
|
||||||
|
libData.FolderCount = len(folders)
|
||||||
|
|
||||||
|
// Get users
|
||||||
|
dbUsers, err := cfg.Queries.ListUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ListUsers failed: %v", err)
|
||||||
|
dbUsers = []database.ListUsersRow{}
|
||||||
|
}
|
||||||
|
userData := make([]templates.User, len(dbUsers))
|
||||||
|
for i, u := range dbUsers {
|
||||||
|
userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
|
||||||
|
userData[i] = templates.User{
|
||||||
|
ID: userUUID.String(),
|
||||||
|
Username: u.Username,
|
||||||
|
Email: u.Email,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get visibility for all users
|
||||||
|
visibility := make([]templates.UserVisibilityData, len(userData))
|
||||||
|
for i, u := range userData {
|
||||||
|
userUUID, _ := parseAdminUUID(u.ID)
|
||||||
|
visibleLibs, err := cfg.LibraryService.GetUserVisibleLibraries(ctx, userUUID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
||||||
|
}
|
||||||
|
isVisible := false
|
||||||
|
for _, vl := range visibleLibs {
|
||||||
|
if vl.ID.Bytes == libraryID.Bytes {
|
||||||
|
isVisible = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visibility[i] = templates.UserVisibilityData{
|
||||||
|
UserID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
Email: u.Email,
|
||||||
|
IsVisible: isVisible,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get issue count
|
||||||
|
issueStats, err := cfg.ProcessingIssuesHandler.GetProcessingIssueStatsData(ctx, libraryID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GetProcessingIssueStats failed: %v", err)
|
||||||
|
}
|
||||||
|
issueCount := issueStats.ErrorCount + issueStats.WarningCount + issueStats.InfoCount
|
||||||
|
|
||||||
|
// Get current user for template
|
||||||
|
tmplUser := templates.User{}
|
||||||
|
if u, ok := c.Get("user").(database.Users); ok {
|
||||||
|
userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
|
||||||
|
tmplUser = templates.User{
|
||||||
|
ID: userUUID.String(),
|
||||||
|
Username: u.Username,
|
||||||
|
Role: u.Role,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = templates.LibraryPanel(tmplUser, libraryIDStr, libData, folders, userData, visibility, int(issueCount)).Render(ctx, &buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
html := buf.String()
|
||||||
|
if errMsg != "" {
|
||||||
|
html = `<div class="p-3 mb-3 rounded-lg text-sm" style="background-color: color-mix(in srgb, var(--status-danger) 12%, var(--bg-secondary)); color: var(--status-danger);">` + errMsg + `</div>` + html
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, html)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFolderCount(ctx context.Context, cfg *Config, libraryID pgtype.UUID) int {
|
||||||
|
folders, err := cfg.LibraryService.GetLibraryFolders(ctx, libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(folders)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAdminUUID(s string) (pgtype.UUID, error) {
|
||||||
|
parsed, err := uuid.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
return pgtype.UUID{}, err
|
||||||
|
}
|
||||||
|
return pgtype.UUID{Bytes: parsed, Valid: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAdminStats(ctx context.Context, cfg *Config) templates.AdminStats {
|
||||||
|
stats := templates.AdminStats{}
|
||||||
|
|
||||||
|
libs, _ := cfg.LibraryHandler.ListLibrariesData(ctx)
|
||||||
|
stats.LibraryCount = len(libs)
|
||||||
|
|
||||||
|
users, _ := cfg.Queries.ListUsers(ctx)
|
||||||
|
stats.UserCount = len(users)
|
||||||
|
|
||||||
|
if pool, ok := cfg.DBPool.(*pgxpool.Pool); ok {
|
||||||
|
_ = pool.QueryRow(ctx, "SELECT COUNT(*) FROM media_items").Scan(&stats.MediaCount)
|
||||||
|
_ = pool.QueryRow(ctx, "SELECT COUNT(*) FROM devices").Scan(&stats.DeviceCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerAdminSettingsRoutes(cfg *Config, frontendProtected *echo.Group) {
|
||||||
|
g := frontendProtected.Group("", handlers.AdminMiddleware)
|
||||||
|
|
||||||
|
g.PUT("/admin/settings/scan", func(c *echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
|
autoScan := c.FormValue("auto_scan_enabled") == "true"
|
||||||
|
intervalStr := c.FormValue("scan_poll_interval_seconds")
|
||||||
|
interval, err := strconv.Atoi(intervalStr)
|
||||||
|
if err != nil || interval < 1 || interval > 3600 {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Interval must be between 1 and 3600 seconds</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
autoScanStr := "false"
|
||||||
|
if autoScan {
|
||||||
|
autoScanStr = "true"
|
||||||
|
}
|
||||||
|
_ = cfg.Queries.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{
|
||||||
|
SettingKey: "auto_scan_enabled",
|
||||||
|
SettingValue: autoScanStr,
|
||||||
|
})
|
||||||
|
_ = cfg.Queries.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{
|
||||||
|
SettingKey: "scan_poll_interval_seconds",
|
||||||
|
SettingValue: strconv.Itoa(interval),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refresh the registry cache so the change is visible immediately.
|
||||||
|
if cfg.Settings != nil {
|
||||||
|
cfg.Settings.Reload(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
scanSettings := templates.ScanSettingsData{
|
||||||
|
AutoScanEnabled: autoScan,
|
||||||
|
ScanPollIntervalSeconds: interval,
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = templates.ScanSettingsSection(scanSettings).Render(ctx, &buf)
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
// HTMX endpoint for saving a single tunable setting. Returns a small HTML
|
||||||
|
// status snippet rendered into the row's status span.
|
||||||
|
g.PUT("/admin/settings/tunable", func(c *echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
key := c.FormValue("key")
|
||||||
|
value := c.FormValue("value")
|
||||||
|
|
||||||
|
if cfg.SystemSettingsHandler == nil {
|
||||||
|
return c.HTML(http.StatusServiceUnavailable, `<span style="color: var(--status-danger);">settings unavailable</span>`)
|
||||||
|
}
|
||||||
|
resp, err := cfg.SystemSettingsHandler.ApplySetting(ctx, key, value)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, fmt.Sprintf(`<span style="color: var(--status-danger);">%s</span>`, err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
color := "var(--status-success)"
|
||||||
|
msg := "Saved"
|
||||||
|
if resp.ReloadRequired {
|
||||||
|
color = "var(--status-warning)"
|
||||||
|
msg = "Saved — restart required"
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, fmt.Sprintf(`<span style="color: %s;">%s</span>`, color, msg))
|
||||||
|
})
|
||||||
|
}
|
||||||
+73
-18
@@ -9,7 +9,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"bookhoard/internal/config"
|
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
@@ -215,6 +214,9 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
bookInfoList = []handlers.BookInfo{}
|
bookInfoList = []handlers.BookInfo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
seriesUserUUID, _ := uuid.Parse(user.ID)
|
||||||
|
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: seriesUserUUID, Valid: true}, bookInfoList)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.BrowseDetail(user, "📚", "Series", seriesName, seriesName, "/series", "All Series", "📚", "This series doesn't have any books yet", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
|
err = templates.BrowseDetail(user, "📚", "Series", seriesName, seriesName, "/series", "All Series", "📚", "This series doesn't have any books yet", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -267,6 +269,9 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
bookInfoList = []handlers.BookInfo{}
|
bookInfoList = []handlers.BookInfo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tagUserUUID, _ := uuid.Parse(user.ID)
|
||||||
|
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: tagUserUUID, Valid: true}, bookInfoList)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.BrowseDetail(user, "🏷️", "Tag", tagName, tagName, "/bookshelf", "Bookshelf", "🏷️", "No books found with this tag", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
|
err = templates.BrowseDetail(user, "🏷️", "Tag", tagName, tagName, "/bookshelf", "Bookshelf", "🏷️", "No books found with this tag", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -354,6 +359,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: userUUID, Valid: true}, bookInfoList)
|
||||||
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf)
|
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -408,7 +414,8 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
// Get only visible sections for the dashboard display
|
// Get only visible sections for the dashboard display
|
||||||
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
|
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
|
||||||
|
|
||||||
sectionData := handlers.BuildSections(visibleSections, libraryID)
|
userPgID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||||
|
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID))
|
||||||
allSectionsData := handlers.BuildSections(allSections, libraryID)
|
allSectionsData := handlers.BuildSections(allSections, libraryID)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
@@ -623,6 +630,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
Description: collection.Description.String,
|
Description: collection.Description.String,
|
||||||
Color: collection.Color.String,
|
Color: collection.Color.String,
|
||||||
Icon: collection.Icon.String,
|
Icon: collection.Icon.String,
|
||||||
|
IsSystem: collection.IsSystemCollection.Bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
@@ -725,10 +733,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get base URL from database config with fallback to config/env var
|
// Get base URL from database config with fallback to config/env var
|
||||||
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
baseURL := cfg.getBaseURL(c.Request().Context())
|
||||||
if baseURL == "" {
|
|
||||||
baseURL = cfg.Cfg.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
||||||
@@ -805,8 +810,9 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
}
|
}
|
||||||
|
stats := getAdminStats(c.Request().Context(), cfg)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.Admin(user).Render(c.Request().Context(), &buf)
|
err = templates.Admin(user, stats).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -818,8 +824,9 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
}
|
}
|
||||||
|
stats := getAdminStats(c.Request().Context(), cfg)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.Admin(user).Render(c.Request().Context(), &buf)
|
err = templates.Admin(user, stats).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -843,11 +850,14 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
libData := make([]templates.LibraryData, len(libraries))
|
libData := 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])
|
||||||
|
folders, _ := cfg.LibraryService.GetLibraryFolders(c.Request().Context(), lib.ID)
|
||||||
libData[i] = templates.LibraryData{
|
libData[i] = templates.LibraryData{
|
||||||
ID: libUUID.String(),
|
ID: libUUID.String(),
|
||||||
Name: lib.Name,
|
Name: lib.Name,
|
||||||
Description: getText(lib.Description),
|
Description: getText(lib.Description),
|
||||||
TypeName: lib.TypeName,
|
TypeName: lib.TypeName,
|
||||||
|
TypeValue: lib.TypeName,
|
||||||
|
FolderCount: len(folders),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -985,24 +995,67 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
// Fetch current system configuration - just base_url
|
// Fetch current system configuration - just base_url
|
||||||
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
baseURL := cfg.getBaseURL(ctx)
|
||||||
if baseURL == "" {
|
|
||||||
baseURL = cfg.Cfg.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
systemConfig := map[string]string{
|
systemConfig := map[string]string{
|
||||||
"base_url": baseURL,
|
"base_url": baseURL,
|
||||||
"default_timezone": "UTC",
|
"default_timezone": "UTC",
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultTimezone, err := cfg.Queries.GetSystemTimezone(c.Request().Context())
|
defaultTimezone, err := cfg.Queries.GetSystemTimezone(ctx)
|
||||||
if err == nil && defaultTimezone != "" {
|
if err == nil && defaultTimezone != "" {
|
||||||
systemConfig["default_timezone"] = defaultTimezone
|
systemConfig["default_timezone"] = defaultTimezone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch scan settings
|
||||||
|
scanSettings := templates.ScanSettingsData{
|
||||||
|
AutoScanEnabled: true,
|
||||||
|
ScanPollIntervalSeconds: 60,
|
||||||
|
}
|
||||||
|
if val, err := cfg.Queries.GetSystemSetting(ctx, "auto_scan_enabled"); err == nil {
|
||||||
|
scanSettings.AutoScanEnabled = val == "true"
|
||||||
|
}
|
||||||
|
if val, err := cfg.Queries.GetSystemSetting(ctx, "scan_poll_interval_seconds"); err == nil {
|
||||||
|
if n, err := strconv.Atoi(val); err == nil {
|
||||||
|
scanSettings.ScanPollIntervalSeconds = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tunable settings entries from the registry. Exclude keys that
|
||||||
|
// already have their own dedicated UI cards (timezone dropdown, scan
|
||||||
|
// settings) so they aren't listed twice.
|
||||||
|
dedicatedUI := map[string]bool{
|
||||||
|
"default_timezone": true,
|
||||||
|
"scan_poll_interval_seconds": true,
|
||||||
|
"auto_scan_enabled": true,
|
||||||
|
}
|
||||||
|
var tunableSettings []templates.SettingEntry
|
||||||
|
if cfg.Settings != nil {
|
||||||
|
for _, e := range cfg.Settings.All() {
|
||||||
|
if dedicatedUI[e.Key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tunableSettings = append(tunableSettings, templates.SettingEntry{
|
||||||
|
Key: e.Key,
|
||||||
|
Value: e.Value,
|
||||||
|
Type: e.Type,
|
||||||
|
Min: e.Min,
|
||||||
|
Max: e.Max,
|
||||||
|
RequiresRestart: e.RequiresRestart,
|
||||||
|
Category: e.Category,
|
||||||
|
Group: e.Group,
|
||||||
|
Description: e.Description,
|
||||||
|
IsDefault: e.IsDefault,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
liveGroups, restartGroups := templates.GroupTunableSettings(tunableSettings)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.AdminSettings(user, systemConfig, "").Render(c.Request().Context(), &buf)
|
err = templates.AdminSettings(user, systemConfig, scanSettings, liveGroups, restartGroups, "").Render(ctx, &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1044,6 +1097,11 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return c.HTML(http.StatusOK, buf.String())
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Admin library HTMX endpoints
|
||||||
|
registerAdminLibraryRoutes(cfg, frontendProtected)
|
||||||
|
// Admin settings HTMX endpoints
|
||||||
|
registerAdminSettingsRoutes(cfg, frontendProtected)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// LEGACY API ROUTES (for backward compatibility)
|
// LEGACY API ROUTES (for backward compatibility)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1077,10 +1135,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get base URL from database config with fallback to config/env var
|
// Get base URL from database config with fallback to config/env var
|
||||||
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
baseURL := cfg.getBaseURL(c.Request().Context())
|
||||||
if baseURL == "" {
|
|
||||||
baseURL = cfg.Cfg.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
||||||
|
|||||||
@@ -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()],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ func registerLibraryRoutes(cfg *Config) {
|
|||||||
adminLibrary.GET("/:id/stats", cfg.LibraryHandler.GetLibraryStats)
|
adminLibrary.GET("/:id/stats", cfg.LibraryHandler.GetLibraryStats)
|
||||||
adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
|
adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
|
||||||
adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats)
|
adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats)
|
||||||
|
adminLibrary.POST("/:id/issues/:issueId/:mediaItemId/resolve", cfg.ProcessingIssuesHandler.ResolveProcessingIssue)
|
||||||
|
adminLibrary.DELETE("/:id/issues/:issueId", cfg.ProcessingIssuesHandler.DeleteProcessingIssue)
|
||||||
adminLibrary.POST("/:id/scan", func(c *echo.Context) error {
|
adminLibrary.POST("/:id/scan", func(c *echo.Context) error {
|
||||||
libraryID := c.Param("id")
|
libraryID := c.Param("id")
|
||||||
scanReq := map[string]interface{}{
|
scanReq := map[string]interface{}{
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type Config struct {
|
|||||||
Echo *echo.Echo
|
Echo *echo.Echo
|
||||||
Queries *database.Queries
|
Queries *database.Queries
|
||||||
Cfg *config.Config
|
Cfg *config.Config
|
||||||
|
Settings *database.SettingsRegistry
|
||||||
DBPool interface{} // pgxpool.Pool interface
|
DBPool interface{} // pgxpool.Pool interface
|
||||||
AuthHandler *handlers.AuthHandler
|
AuthHandler *handlers.AuthHandler
|
||||||
LibraryHandler *handlers.LibraryHandler
|
LibraryHandler *handlers.LibraryHandler
|
||||||
@@ -72,6 +73,24 @@ type Config struct {
|
|||||||
LibraryService *services.LibraryService
|
LibraryService *services.LibraryService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getBaseURL returns the configured base URL from the database, falling back to
|
||||||
|
// the env var / config default. Uses a closure to adapt the database query to
|
||||||
|
// config.SystemConfigGetter.
|
||||||
|
func (cfg *Config) getBaseURL(ctx context.Context) string {
|
||||||
|
getter := func(ctx context.Context, key string) (string, error) {
|
||||||
|
row, err := cfg.Queries.GetSystemConfig(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return row.Value, nil
|
||||||
|
}
|
||||||
|
baseURL := config.GetBaseURL(ctx, getter)
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = cfg.Cfg.BaseURL
|
||||||
|
}
|
||||||
|
return baseURL
|
||||||
|
}
|
||||||
|
|
||||||
// createJWTMiddleware creates a JWT middleware with proper user context setup
|
// createJWTMiddleware creates a JWT middleware with proper user context setup
|
||||||
func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
|
func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||||
return echojwt.WithConfig(echojwt.Config{
|
return echojwt.WithConfig(echojwt.Config{
|
||||||
@@ -193,10 +212,12 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
// Setup redirect middleware - must run before all routes
|
// Setup redirect middleware - must run before all routes
|
||||||
e.Pre(setupRedirectMiddleware(cfg))
|
e.Pre(setupRedirectMiddleware(cfg))
|
||||||
|
|
||||||
// Rate limiter
|
// Rate limiter. The per-minute value comes from the settings registry (DB);
|
||||||
|
// the enabled flag stays env-driven since disabling rate limiting is a
|
||||||
|
// deployment-time decision, not a runtime tunable.
|
||||||
rateLimiterConfig := ratelimit.RateLimiterConfig{
|
rateLimiterConfig := ratelimit.RateLimiterConfig{
|
||||||
Enabled: cfg.Cfg.RateLimitEnabled,
|
Enabled: cfg.Cfg.RateLimitEnabled,
|
||||||
RequestsPerMinute: cfg.Cfg.RequestsPerMinute,
|
RequestsPerMinute: cfg.Settings.AuthRateLimit(),
|
||||||
CleanupInterval: 5 * time.Minute,
|
CleanupInterval: 5 * time.Minute,
|
||||||
}
|
}
|
||||||
rateLimiter := ratelimit.NewRateLimiter(rateLimiterConfig)
|
rateLimiter := ratelimit.NewRateLimiter(rateLimiterConfig)
|
||||||
|
|||||||
@@ -112,9 +112,15 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
|
|||||||
CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath),
|
CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Render using BooksGrid template
|
// Stamp active conflict flags so cards route the play action correctly
|
||||||
|
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList)
|
||||||
|
// Render using BooksGrid template (or BookPickerGrid for collection picker)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
|
if c.QueryParam("show_checkbox") == "true" {
|
||||||
|
err = templates.BookPickerGrid(bookInfoList).Render(c.Request().Context(), &buf)
|
||||||
|
} else {
|
||||||
|
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Template render error: %v", err)
|
log.Printf("Template render error: %v", err)
|
||||||
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Render error</div>`)
|
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Render error</div>`)
|
||||||
|
|||||||
+40
-10
@@ -14,7 +14,40 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func isSetupComplete(cfg *Config) bool {
|
func isSetupComplete(cfg *Config) bool {
|
||||||
return setupstatus.IsSetupComplete(context.Background(), cfg.Queries)
|
getter := func(ctx context.Context) (string, error) {
|
||||||
|
row, err := cfg.Queries.GetSystemConfig(ctx, "base_url")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return row.Value, nil
|
||||||
|
}
|
||||||
|
return setupstatus.IsSetupComplete(context.Background(), cfg.Queries, getter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupAllowedAPIRoutes lists API endpoints that remain accessible before
|
||||||
|
// initial setup is complete so the server can be configured via API.
|
||||||
|
var setupAllowedAPIRoutes = []string{
|
||||||
|
"/api/auth/register",
|
||||||
|
"/api/auth/login",
|
||||||
|
"/api/system/config",
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllowedDuringSetup reports whether a request path should bypass the setup
|
||||||
|
// gate. This includes the setup page itself, static assets, health checks, and
|
||||||
|
// the minimal set of API routes needed to perform initial configuration.
|
||||||
|
func isAllowedDuringSetup(path string) bool {
|
||||||
|
if path == "/setup" || path == "/setup/" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, route := range setupAllowedAPIRoutes {
|
||||||
|
if path == route || strings.HasPrefix(path, route+"/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
|
func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||||
@@ -22,19 +55,16 @@ func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
|
|||||||
return func(c *echo.Context) error {
|
return func(c *echo.Context) error {
|
||||||
path := c.Request().URL.Path
|
path := c.Request().URL.Path
|
||||||
|
|
||||||
if path == "/setup" || path == "/setup/" {
|
if isAllowedDuringSetup(path) {
|
||||||
return next(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(path, "/api/") {
|
|
||||||
return next(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" {
|
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isSetupComplete(cfg) {
|
if !isSetupComplete(cfg) {
|
||||||
|
if strings.HasPrefix(path, "/api/") {
|
||||||
|
return c.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||||
|
"error": "Server setup is not complete. Configure an admin account and base_url via the setup wizard or API.",
|
||||||
|
})
|
||||||
|
}
|
||||||
return c.Redirect(http.StatusFound, "/setup")
|
return c.Redirect(http.StatusFound, "/setup")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,4 +17,10 @@ func registerSystemRoutes(cfg *Config) {
|
|||||||
// System configuration routes (admin-only)
|
// System configuration routes (admin-only)
|
||||||
system.GET("/config", cfg.SidecarHandler.GetSystemConfiguration)
|
system.GET("/config", cfg.SidecarHandler.GetSystemConfiguration)
|
||||||
system.PUT("/config", cfg.SidecarHandler.UpdateSystemConfiguration)
|
system.PUT("/config", cfg.SidecarHandler.UpdateSystemConfiguration)
|
||||||
|
|
||||||
|
// Unified tunable settings (admin-only). These back the admin UI's
|
||||||
|
// editable System Settings sections and supersede the legacy
|
||||||
|
// /api/libraries/scan-settings JSON routes.
|
||||||
|
system.GET("/settings", cfg.SystemSettingsHandler.GetSettings)
|
||||||
|
system.PUT("/settings", cfg.SystemSettingsHandler.UpdateSetting)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// defaultConversionCacheTTL is the fallback kepub cache lifetime when no
|
||||||
|
// settings registry is wired. Matches the historical hardcoded 24h.
|
||||||
|
const defaultConversionCacheTTL = 24 * time.Hour
|
||||||
|
|
||||||
type ConvertedKEPUB struct {
|
type ConvertedKEPUB struct {
|
||||||
Path string
|
Path string
|
||||||
SHA256 string
|
SHA256 string
|
||||||
@@ -27,6 +31,7 @@ type ConversionService struct {
|
|||||||
cacheDir string
|
cacheDir string
|
||||||
conversionTool string
|
conversionTool string
|
||||||
conversionCacheTTL time.Duration
|
conversionCacheTTL time.Duration
|
||||||
|
settings *database.SettingsRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConversionService(db *database.Queries, cacheDir string) *ConversionService {
|
func NewConversionService(db *database.Queries, cacheDir string) *ConversionService {
|
||||||
@@ -34,17 +39,32 @@ func NewConversionService(db *database.Queries, cacheDir string) *ConversionServ
|
|||||||
db: db,
|
db: db,
|
||||||
cacheDir: cacheDir,
|
cacheDir: cacheDir,
|
||||||
conversionTool: "/usr/bin/kepubify",
|
conversionTool: "/usr/bin/kepubify",
|
||||||
conversionCacheTTL: 24 * time.Hour,
|
conversionCacheTTL: defaultConversionCacheTTL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry. When wired, the cache TTL
|
||||||
|
// is read live on each conversion request.
|
||||||
|
func (s *ConversionService) SetSettings(reg *database.SettingsRegistry) { s.settings = reg }
|
||||||
|
|
||||||
|
// cacheTTL returns the active conversion cache TTL.
|
||||||
|
func (s *ConversionService) cacheTTL() time.Duration {
|
||||||
|
if s.settings != nil {
|
||||||
|
return s.settings.ConversionCacheTTL()
|
||||||
|
}
|
||||||
|
if s.conversionCacheTTL > 0 {
|
||||||
|
return s.conversionCacheTTL
|
||||||
|
}
|
||||||
|
return defaultConversionCacheTTL
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ConversionService) ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*ConvertedKEPUB, error) {
|
func (s *ConversionService) ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*ConvertedKEPUB, error) {
|
||||||
existing, err := s.db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{
|
existing, err := s.db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{
|
||||||
MediaItemID: mediaItemID,
|
MediaItemID: mediaItemID,
|
||||||
FormatType: "kepub",
|
FormatType: "kepub",
|
||||||
})
|
})
|
||||||
if err == nil && existing.FilePath.Valid {
|
if err == nil && existing.FilePath.Valid {
|
||||||
if time.Since(existing.CreatedAt.Time) < s.conversionCacheTTL {
|
if time.Since(existing.CreatedAt.Time) < s.cacheTTL() {
|
||||||
return &ConvertedKEPUB{
|
return &ConvertedKEPUB{
|
||||||
Path: existing.FilePath.String,
|
Path: existing.FilePath.String,
|
||||||
SHA256: existing.FileSha256.String,
|
SHA256: existing.FileSha256.String,
|
||||||
|
|||||||
@@ -65,5 +65,7 @@ func TestConversionServiceDefaults(t *testing.T) {
|
|||||||
assert.NotNil(t, service)
|
assert.NotNil(t, service)
|
||||||
assert.Equal(t, cacheDir, service.cacheDir)
|
assert.Equal(t, cacheDir, service.cacheDir)
|
||||||
assert.Equal(t, "/usr/bin/kepubify", service.conversionTool)
|
assert.Equal(t, "/usr/bin/kepubify", service.conversionTool)
|
||||||
assert.Equal(t, int64(24*3600*1000000000), service.conversionCacheTTL.Nanoseconds(), "Default TTL should be 24 hours")
|
assert.Equal(t, int64(24*3600*1000000000), service.conversionCacheTTL.Nanoseconds(), "Default TTL field should be 24 hours")
|
||||||
|
// cacheTTL() must reflect the same default when no registry is wired.
|
||||||
|
assert.Equal(t, int64(24*3600*1000000000), service.cacheTTL().Nanoseconds(), "Default TTL accessor should return 24 hours")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,16 +146,18 @@ type CalibreOPFMetadata struct {
|
|||||||
Timestamp *time.Time
|
Timestamp *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMediaScanner creates a new media scanner instance
|
// NewMediaScanner creates a new media scanner instance.
|
||||||
|
//
|
||||||
|
// The fsnotify watcher is NOT created here. It is created lazily inside
|
||||||
|
// SetFolders only when watch=true (the long-lived watch-mode scanner).
|
||||||
|
// Ephemeral one-off scan jobs pass watch=false, so they never allocate a
|
||||||
|
// watcher (and thus can never panic on EMFILE/ENOSPC). This fixes the
|
||||||
|
// fd/inotify-watch leak where every scan job created a watcher that was
|
||||||
|
// never closed.
|
||||||
func NewMediaScanner(db *database.Queries) *MediaScanner {
|
func NewMediaScanner(db *database.Queries) *MediaScanner {
|
||||||
watcher, err := fsnotify.NewWatcher()
|
|
||||||
if err != nil {
|
|
||||||
panic(fmt.Sprintf("Failed to create file watcher: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
return &MediaScanner{
|
return &MediaScanner{
|
||||||
db: db,
|
db: db,
|
||||||
watcher: watcher,
|
watcher: nil,
|
||||||
settingsCache: NewSettingsCache(30 * time.Second),
|
settingsCache: NewSettingsCache(30 * time.Second),
|
||||||
dirtyDirs: make(map[string]time.Time),
|
dirtyDirs: make(map[string]time.Time),
|
||||||
fileStability: make(map[string]*atomic.Bool),
|
fileStability: make(map[string]*atomic.Bool),
|
||||||
@@ -238,24 +240,34 @@ func (s *MediaScanner) GetStats() (int, int, int) {
|
|||||||
return s.totalFiles, s.newItems, s.errors
|
return s.totalFiles, s.newItems, s.errors
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaScanner) SetFolders(folders []string) error {
|
// SetFolders configures the scanner's folders and (optionally) sets up an
|
||||||
|
// fsnotify watcher over the full directory tree.
|
||||||
|
//
|
||||||
|
// watch should be true only for the single long-lived watch-mode scanner that
|
||||||
|
// actually consumes watcher.Events. Ephemeral scan jobs must pass false so no
|
||||||
|
// watcher (and thus no fd/inotify watches) is allocated — the watcher is never
|
||||||
|
// read by scan jobs and previously leaked one watcher per job.
|
||||||
|
func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
|
||||||
s.folders = folders
|
s.folders = folders
|
||||||
|
|
||||||
// Remove old watch if exists
|
// Always close any previously-owned watcher so reconfiguration doesn't leak.
|
||||||
if s.watcher != nil {
|
if s.watcher != nil {
|
||||||
if s.watcher != nil {
|
if err := s.watcher.Close(); err != nil {
|
||||||
if err := s.watcher.Close(); err != nil {
|
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
||||||
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
s.watcher = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new watcher
|
// Create + populate a fresh watcher only when the caller intends to read events.
|
||||||
watcher, err := fsnotify.NewWatcher()
|
if watch {
|
||||||
if err != nil {
|
watcher, err := fsnotify.NewWatcher()
|
||||||
return fmt.Errorf("failed to create watcher: %v", err)
|
if err != nil {
|
||||||
|
// Return an error instead of panicking so a failed watcher can't
|
||||||
|
// take down the whole process.
|
||||||
|
return fmt.Errorf("failed to create watcher: %w", err)
|
||||||
|
}
|
||||||
|
s.watcher = watcher
|
||||||
}
|
}
|
||||||
s.watcher = watcher
|
|
||||||
|
|
||||||
// Build cache of allowed extensions per folder
|
// Build cache of allowed extensions per folder
|
||||||
// Uses Go AllowedExtensions map as source of truth (not DB)
|
// Uses Go AllowedExtensions map as source of truth (not DB)
|
||||||
@@ -286,31 +298,36 @@ func (s *MediaScanner) SetFolders(folders []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all folders and their subdirectories to the watcher (like Audiobookshelf)
|
// Add all folders and their subdirectories to the watcher (like Audiobookshelf).
|
||||||
watchCount := 0
|
// Only when watching; scan jobs (watch=false) skip this entirely.
|
||||||
for _, folder := range folders {
|
if s.watcher != nil {
|
||||||
if err := s.watcher.Add(folder); err != nil {
|
watchCount := 0
|
||||||
fmt.Printf("[WATCHER] Warning: failed to watch root folder %s: %v\n", folder, err)
|
for _, folder := range folders {
|
||||||
} else {
|
if err := s.watcher.Add(folder); err != nil {
|
||||||
watchCount++
|
fmt.Printf("[WATCHER] Warning: failed to watch root folder %s: %v\n", folder, err)
|
||||||
}
|
|
||||||
filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !d.IsDir() || path == folder {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := s.watcher.Add(path); err != nil {
|
|
||||||
fmt.Printf("[WATCHER] Warning: failed to watch subdirectory %s: %v\n", path, err)
|
|
||||||
} else {
|
} else {
|
||||||
watchCount++
|
watchCount++
|
||||||
}
|
}
|
||||||
return nil
|
filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
|
||||||
})
|
if err != nil {
|
||||||
}
|
return err
|
||||||
|
}
|
||||||
|
if !d.IsDir() || path == folder {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.watcher.Add(path); err != nil {
|
||||||
|
fmt.Printf("[WATCHER] Warning: failed to watch subdirectory %s: %v\n", path, err)
|
||||||
|
} else {
|
||||||
|
watchCount++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("[WATCHER] Now watching %d directories across %d root folders\n", watchCount, len(folders))
|
fmt.Printf("[WATCHER] Now watching %d directories across %d root folders\n", watchCount, len(folders))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[SCANNER] Configured %d root folders (watch mode disabled, no inotify watcher)\n", len(folders))
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -417,8 +434,10 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
if err := s.watcher.Add(path); err != nil {
|
if s.watcher != nil {
|
||||||
fmt.Printf("Warning: failed to watch subdirectory %s: %v\n", path, err)
|
if err := s.watcher.Add(path); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to watch subdirectory %s: %v\n", path, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -2601,6 +2620,13 @@ func (s *MediaScanner) WatchChanges(ctx context.Context) error {
|
|||||||
go s.startBackupScan(ctx)
|
go s.startBackupScan(ctx)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
// The event loop only runs if a real watcher was set up (watch=true).
|
||||||
|
// If watching with no watcher (e.g. inotify unavailable through a Docker
|
||||||
|
// bind mount), polling via startBackupScan above still handles detection.
|
||||||
|
if s.watcher == nil {
|
||||||
|
fmt.Printf("[WATCHER] No inotify watcher configured; relying on periodic polling for change detection\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
fmt.Printf("[WATCHER] Event loop started for %d folders\n", len(s.folders))
|
fmt.Printf("[WATCHER] Event loop started for %d folders\n", len(s.folders))
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -176,10 +176,17 @@ func (w *Worker) GetActiveJobCount() int {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
func NewWorker(numWorkers int, connManager *wsync.ConnectionManager) *Worker {
|
func NewWorker(numWorkers int, connManager *wsync.ConnectionManager) *Worker {
|
||||||
|
return NewWorkerWithConfig(numWorkers, 100, connManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWorkerWithConfig constructs a worker pool with the given worker count and
|
||||||
|
// job-queue capacity. Used at startup to source values from the settings
|
||||||
|
// registry.
|
||||||
|
func NewWorkerWithConfig(numWorkers, queueCap int, connManager *wsync.ConnectionManager) *Worker {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
w := &Worker{
|
w := &Worker{
|
||||||
jobQueue: make(chan *Job, 100),
|
jobQueue: make(chan *Job, queueCap),
|
||||||
results: make(map[string]*JobResult),
|
results: make(map[string]*JobResult),
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
@@ -205,7 +212,23 @@ func (w *Worker) worker() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.processJob(job)
|
// Recover from any panic inside a job so a single failing job can
|
||||||
|
// never crash the whole worker goroutine (and thus the process).
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
fmt.Printf("[WORKER] panic in job %s (%s): %v\n", job.ID, job.Type, r)
|
||||||
|
w.mu.Lock()
|
||||||
|
w.results[job.ID] = &JobResult{
|
||||||
|
JobID: job.ID,
|
||||||
|
Status: JobStatusFailed,
|
||||||
|
Error: fmt.Sprintf("panic: %v", r),
|
||||||
|
}
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
w.processJob(job)
|
||||||
|
}()
|
||||||
|
|
||||||
case <-w.ctx.Done():
|
case <-w.ctx.Done():
|
||||||
return
|
return
|
||||||
@@ -348,6 +371,7 @@ func (w *Worker) processScanJob(job *Job) (interface{}, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
scanner := NewMediaScanner(db)
|
scanner := NewMediaScanner(db)
|
||||||
|
defer scanner.Close()
|
||||||
scanner.job = job
|
scanner.job = job
|
||||||
|
|
||||||
job.ProgressCallback = func(progress float64, filesScanned, newItems, errors int) {
|
job.ProgressCallback = func(progress float64, filesScanned, newItems, errors int) {
|
||||||
@@ -377,7 +401,7 @@ func (w *Worker) processScanJob(job *Job) (interface{}, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := scanner.SetFolders(folders); err != nil {
|
if err := scanner.SetFolders(folders, false); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,7 +545,8 @@ func (w *Worker) processSetFoldersJob(job *Job) (interface{}, error) {
|
|||||||
|
|
||||||
// Create scanner and configure folders
|
// Create scanner and configure folders
|
||||||
scanner := NewMediaScanner(db)
|
scanner := NewMediaScanner(db)
|
||||||
if err := scanner.SetFolders(folders); err != nil {
|
defer scanner.Close()
|
||||||
|
if err := scanner.SetFolders(folders, false); err != nil {
|
||||||
return nil, fmt.Errorf("failed to set folders: %w", err)
|
return nil, fmt.Errorf("failed to set folders: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -900,6 +925,7 @@ func (w *Worker) processDirectoryScanJob(job *Job) (interface{}, error) {
|
|||||||
|
|
||||||
// Create temporary scanner instance for this job
|
// Create temporary scanner instance for this job
|
||||||
scanner := NewMediaScanner(db)
|
scanner := NewMediaScanner(db)
|
||||||
|
defer scanner.Close()
|
||||||
scanner.job = job
|
scanner.job = job
|
||||||
// Find which library owns this directory (prefix match for subdirectories)
|
// Find which library owns this directory (prefix match for subdirectories)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -918,7 +944,7 @@ func (w *Worker) processDirectoryScanJob(job *Job) (interface{}, error) {
|
|||||||
folderPaths = append(folderPaths, f.FolderPath)
|
folderPaths = append(folderPaths, f.FolderPath)
|
||||||
}
|
}
|
||||||
// Configure scanner with folders
|
// Configure scanner with folders
|
||||||
if err := scanner.SetFolders(folderPaths); err != nil {
|
if err := scanner.SetFolders(folderPaths, false); err != nil {
|
||||||
return nil, fmt.Errorf("failed to set folders: %w", err)
|
return nil, fmt.Errorf("failed to set folders: %w", err)
|
||||||
}
|
}
|
||||||
// Now scan the directory
|
// Now scan the directory
|
||||||
@@ -976,3 +1002,4 @@ func (w *Worker) Shutdown() {
|
|||||||
close(w.jobQueue)
|
close(w.jobQueue)
|
||||||
w.wg.Wait()
|
w.wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Package setupstatus reports whether the application's initial setup has been
|
// Package setupstatus reports whether the application's initial setup has been
|
||||||
// completed. Setup is considered complete as soon as at least one admin user
|
// completed. Setup is considered complete when at least one admin user exists
|
||||||
// exists, regardless of how that user was created (setup wizard, API, or a
|
// AND a non-empty base_url has been configured, regardless of how those were
|
||||||
// future CLI). This keeps the setup gate a derived property of real data
|
// created (setup wizard, API, or a future CLI). This keeps the setup gate a
|
||||||
// rather than a manually-flipped flag that can drift out of sync.
|
// derived property of real data rather than a manually-flipped flag that can
|
||||||
|
// drift out of sync.
|
||||||
package setupstatus
|
package setupstatus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -18,6 +19,12 @@ type AdminCounter interface {
|
|||||||
CountAdmins(ctx context.Context) (int64, error)
|
CountAdmins(ctx context.Context) (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BaseURLGetter returns the configured base_url value from the database, or an
|
||||||
|
// error if it cannot be read. Defined as a function type (not an interface) so
|
||||||
|
// it can be satisfied by a closure wrapping *database.Queries.GetSystemConfig
|
||||||
|
// without importing the database package.
|
||||||
|
type BaseURLGetter func(ctx context.Context) (string, error)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
cacheComplete bool = true
|
cacheComplete bool = true
|
||||||
@@ -26,10 +33,11 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// IsSetupComplete reports whether setup is complete. Setup is complete when at
|
// IsSetupComplete reports whether setup is complete. Setup is complete when at
|
||||||
// least one admin user exists. A short in-memory cache avoids hammering the
|
// least one admin user exists AND base_url is configured. A short in-memory
|
||||||
// database on every request. On a database error the function fails open
|
// cache avoids hammering the database on every request. On a database error the
|
||||||
// (returns true) so a transient outage does not lock users out of the app.
|
// function fails open (returns true) so a transient outage does not lock users
|
||||||
func IsSetupComplete(ctx context.Context, q AdminCounter) bool {
|
// out of the app.
|
||||||
|
func IsSetupComplete(ctx context.Context, q AdminCounter, baseURLGetter BaseURLGetter) bool {
|
||||||
cacheMu.RLock()
|
cacheMu.RLock()
|
||||||
if time.Now().Before(cacheExpiry) {
|
if time.Now().Before(cacheExpiry) {
|
||||||
complete := cacheComplete
|
complete := cacheComplete
|
||||||
@@ -38,12 +46,20 @@ func IsSetupComplete(ctx context.Context, q AdminCounter) bool {
|
|||||||
}
|
}
|
||||||
cacheMu.RUnlock()
|
cacheMu.RUnlock()
|
||||||
|
|
||||||
count, err := q.CountAdmins(ctx)
|
|
||||||
complete := true
|
complete := true
|
||||||
|
|
||||||
|
count, err := q.CountAdmins(ctx)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
complete = count > 0
|
complete = count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if complete && baseURLGetter != nil {
|
||||||
|
baseURL, err := baseURLGetter(ctx)
|
||||||
|
if err == nil {
|
||||||
|
complete = baseURL != ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cacheMu.Lock()
|
cacheMu.Lock()
|
||||||
cacheComplete = complete
|
cacheComplete = complete
|
||||||
cacheExpiry = time.Now().Add(cacheTTL)
|
cacheExpiry = time.Now().Add(cacheTTL)
|
||||||
@@ -53,7 +69,8 @@ func IsSetupComplete(ctx context.Context, q AdminCounter) bool {
|
|||||||
|
|
||||||
// Invalidate clears the cached setup status so the next call to IsSetupComplete
|
// Invalidate clears the cached setup status so the next call to IsSetupComplete
|
||||||
// re-reads from the database. Call this after any write that could change the
|
// re-reads from the database. Call this after any write that could change the
|
||||||
// admin user count (user creation, role promotion/demotion, user deletion).
|
// admin user count (user creation, role promotion/demotion, user deletion) or
|
||||||
|
// the base_url configuration.
|
||||||
func Invalidate() {
|
func Invalidate() {
|
||||||
cacheMu.Lock()
|
cacheMu.Lock()
|
||||||
cacheComplete = true
|
cacheComplete = true
|
||||||
|
|||||||
@@ -18,8 +18,38 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TombstoneTTL is the fallback retention for soft-deleted annotations when no
|
||||||
|
// settings registry is wired (e.g. in tests). It matches the historical value.
|
||||||
const TombstoneTTL = 30 * 24 * time.Hour
|
const TombstoneTTL = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
type AnnotationService struct {
|
||||||
|
db *database.Queries
|
||||||
|
connMgr *ConnectionManager
|
||||||
|
settings *database.SettingsRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAnnotationService(db *database.Queries, connMgr *ConnectionManager) *AnnotationService {
|
||||||
|
return &AnnotationService{db: db, connMgr: connMgr}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry. When wired, the tombstone
|
||||||
|
// TTL is read live from the DB; otherwise the package const TombstoneTTL is
|
||||||
|
// used.
|
||||||
|
func (s *AnnotationService) SetSettings(reg *database.SettingsRegistry) { s.settings = reg }
|
||||||
|
|
||||||
|
// tombstoneTTL returns the active tombstone retention window.
|
||||||
|
func (s *AnnotationService) tombstoneTTL() time.Duration {
|
||||||
|
if s.settings != nil {
|
||||||
|
return s.settings.TombstoneTTL()
|
||||||
|
}
|
||||||
|
return TombstoneTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveTombstoneTTL exposes the configured tombstone retention window for
|
||||||
|
// callers outside the sync package (e.g. kobo/koreader handlers) that need to
|
||||||
|
// compute cutoffs consistently with the service.
|
||||||
|
func (s *AnnotationService) ActiveTombstoneTTL() time.Duration { return s.tombstoneTTL() }
|
||||||
|
|
||||||
type SaveOutcome string
|
type SaveOutcome string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -29,15 +59,6 @@ const (
|
|||||||
SaveOutcomeDeleted SaveOutcome = "deleted"
|
SaveOutcomeDeleted SaveOutcome = "deleted"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AnnotationService struct {
|
|
||||||
db *database.Queries
|
|
||||||
connMgr *ConnectionManager
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAnnotationService(db *database.Queries, connMgr *ConnectionManager) *AnnotationService {
|
|
||||||
return &AnnotationService{db: db, connMgr: connMgr}
|
|
||||||
}
|
|
||||||
|
|
||||||
type SaveHighlightRequest struct {
|
type SaveHighlightRequest struct {
|
||||||
MediaItemID pgtype.UUID
|
MediaItemID pgtype.UUID
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
@@ -79,7 +100,7 @@ func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlight
|
|||||||
}
|
}
|
||||||
|
|
||||||
if existing.Deleted.Bool {
|
if existing.Deleted.Bool {
|
||||||
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < TombstoneTTL {
|
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < s.tombstoneTTL() {
|
||||||
return &SaveHighlightResult{Highlight: existing, Outcome: SaveOutcomeDeleted}, nil
|
return &SaveHighlightResult{Highlight: existing, Outcome: SaveOutcomeDeleted}, nil
|
||||||
}
|
}
|
||||||
return s.createHighlight(ctx, req, dedupKey)
|
return s.createHighlight(ctx, req, dedupKey)
|
||||||
@@ -238,7 +259,7 @@ func (s *AnnotationService) TombstoneHighlightByID(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error {
|
func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error {
|
||||||
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-TombstoneTTL), Valid: true}
|
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-s.tombstoneTTL()), Valid: true}
|
||||||
if err := s.db.PurgeExpiredHighlightTombstones(ctx, cutoff); err != nil {
|
if err := s.db.PurgeExpiredHighlightTombstones(ctx, cutoff); err != nil {
|
||||||
return fmt.Errorf("purge highlight tombstones: %w", err)
|
return fmt.Errorf("purge highlight tombstones: %w", err)
|
||||||
}
|
}
|
||||||
@@ -251,7 +272,13 @@ func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AnnotationService) StartTombstonePurger() context.CancelFunc {
|
// StartDailyMaintenance launches a single background goroutine that runs all
|
||||||
|
// periodic cleanup tasks once every 24 hours: expired annotation tombstones,
|
||||||
|
// expired/revoked refresh tokens (retention follows the configured session
|
||||||
|
// duration), and expired OPDS tokens. Each task is independent; a failure in
|
||||||
|
// one is logged and does not skip the others. The returned CancelFunc stops the
|
||||||
|
// goroutine and the underlying ticker; it must be invoked on shutdown.
|
||||||
|
func (s *AnnotationService) StartDailyMaintenance() context.CancelFunc {
|
||||||
ticker := time.NewTicker(24 * time.Hour)
|
ticker := time.NewTicker(24 * time.Hour)
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
@@ -262,9 +289,7 @@ func (s *AnnotationService) StartTombstonePurger() context.CancelFunc {
|
|||||||
ticker.Stop()
|
ticker.Stop()
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if err := s.PurgeExpiredTombstones(ctx); err != nil {
|
s.runDailyMaintenance(ctx)
|
||||||
log.Printf("AnnotationService: tombstone purge failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -272,6 +297,28 @@ func (s *AnnotationService) StartTombstonePurger() context.CancelFunc {
|
|||||||
return cancel
|
return cancel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runDailyMaintenance executes every periodic cleanup task. Tasks run
|
||||||
|
// sequentially under the single daily-tick goroutine so there is no added
|
||||||
|
// concurrency. All three queries only delete rows that are already unusable
|
||||||
|
// (expired or revoked), so this never logs out active sessions.
|
||||||
|
func (s *AnnotationService) runDailyMaintenance(ctx context.Context) {
|
||||||
|
if err := s.PurgeExpiredTombstones(ctx); err != nil {
|
||||||
|
log.Printf("maintenance: tombstone purge failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.db.CleanupExpiredOpdsTokens(ctx); err != nil {
|
||||||
|
log.Printf("maintenance: OPDS token purge failed: %v", err)
|
||||||
|
}
|
||||||
|
// Refresh-token retention follows the configured session duration; re-read
|
||||||
|
// on every tick so live settings changes are honored. Guarded so unwired
|
||||||
|
// test paths simply skip cleanup (production always wires the registry).
|
||||||
|
if s.settings != nil {
|
||||||
|
retention := s.settings.SessionDuration().Seconds()
|
||||||
|
if err := s.db.CleanupExpiredRefreshTokens(ctx, retention); err != nil {
|
||||||
|
log.Printf("maintenance: refresh token purge failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type SaveNoteRequest struct {
|
type SaveNoteRequest struct {
|
||||||
MediaItemID pgtype.UUID
|
MediaItemID pgtype.UUID
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
@@ -457,7 +504,7 @@ func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
if existing.Deleted.Bool {
|
if existing.Deleted.Bool {
|
||||||
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < TombstoneTTL {
|
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < s.tombstoneTTL() {
|
||||||
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
|
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
|
||||||
}
|
}
|
||||||
return s.createBookmark(ctx, req, dedupKey)
|
return s.createBookmark(ctx, req, dedupKey)
|
||||||
|
|||||||
@@ -74,11 +74,18 @@ type SyncQueueItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewSyncQueueProcessor(db *database.Queries) *SyncQueueProcessor {
|
func NewSyncQueueProcessor(db *database.Queries) *SyncQueueProcessor {
|
||||||
|
return NewSyncQueueProcessorWithConfig(db, 5*time.Second, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSyncQueueProcessorWithConfig constructs a processor with the given flush
|
||||||
|
// interval and batch size. Used at startup to source values from the settings
|
||||||
|
// registry.
|
||||||
|
func NewSyncQueueProcessorWithConfig(db *database.Queries, interval time.Duration, batchSize int) *SyncQueueProcessor {
|
||||||
return &SyncQueueProcessor{
|
return &SyncQueueProcessor{
|
||||||
db: db,
|
db: db,
|
||||||
progressChan: make(chan *ProgressUpdate, 100),
|
progressChan: make(chan *ProgressUpdate, 100),
|
||||||
interval: 5 * time.Second,
|
interval: interval,
|
||||||
batchSize: 50,
|
batchSize: batchSize,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
+36
-18
@@ -44,25 +44,43 @@ const config: Config = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
primary: {
|
// Semantic surface tokens (page bg, cards, raised layers)
|
||||||
DEFAULT: "#7aa2f7",
|
surface: {
|
||||||
50: "#f0f9ff",
|
DEFAULT: "var(--bg-primary)",
|
||||||
100: "#e0f2fe",
|
raised: "var(--bg-secondary)",
|
||||||
200: "#bae6fd",
|
hover: "var(--surface-hover)",
|
||||||
300: "#7dd3fc",
|
overlay: "var(--surface-overlay)",
|
||||||
400: "#38bdf8",
|
|
||||||
500: "#0ea5e9",
|
|
||||||
600: "#0284c7",
|
|
||||||
700: "#0369a1",
|
|
||||||
800: "#075985",
|
|
||||||
900: "#0c4a6e",
|
|
||||||
},
|
},
|
||||||
"bg-primary": "var(--bg-primary)",
|
// Text tokens
|
||||||
"bg-secondary": "var(--bg-secondary)",
|
content: {
|
||||||
"text-primary": "var(--text-primary)",
|
DEFAULT: "var(--text-primary)",
|
||||||
"text-secondary": "var(--text-secondary)",
|
muted: "var(--text-secondary)",
|
||||||
accent: "var(--accent)",
|
},
|
||||||
border: "var(--border)",
|
// Accent / brand
|
||||||
|
brand: {
|
||||||
|
DEFAULT: "var(--accent)",
|
||||||
|
muted: "var(--accent-muted)",
|
||||||
|
},
|
||||||
|
// Borders / hairlines
|
||||||
|
line: {
|
||||||
|
DEFAULT: "var(--border)",
|
||||||
|
strong: "var(--border-strong)",
|
||||||
|
},
|
||||||
|
// Status colors (theme-aware via vars, fall back to fixed)
|
||||||
|
success: "var(--status-success)",
|
||||||
|
warning: "var(--status-warning)",
|
||||||
|
danger: "var(--status-danger)",
|
||||||
|
info: "var(--status-info)",
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
xl: "0.875rem",
|
||||||
|
"2xl": "1.25rem",
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
card: "var(--shadow-card)",
|
||||||
|
"card-hover": "var(--shadow-card-hover)",
|
||||||
|
pop: "var(--shadow-pop)",
|
||||||
|
bar: "var(--shadow-bar)",
|
||||||
},
|
},
|
||||||
backgroundImage: {
|
backgroundImage: {
|
||||||
"wood-light": "url('/static/textures/wood-light.png')",
|
"wood-light": "url('/static/textures/wood-light.png')",
|
||||||
|
|||||||
+112
-94
@@ -1,118 +1,136 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
templ Admin(user User) {
|
templ Admin(user User, stats AdminStats) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<title>Admin Dashboard - Bookhoard</title>
|
<title>Admin Dashboard - Bookhoard</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="admin" x-init="loadWatchStatus(); initializeScanWebSocket()" class="theme-tokyo-night">
|
<body x-data="admin" x-init="loadWatchStatus()" class="theme-{ user.Theme }">
|
||||||
@Header(user, "/admin")
|
@Header(user, "/admin")
|
||||||
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
|
<main class="p-8">
|
||||||
@AdminSidebar(user, "/admin")
|
<div class="max-w-4xl">
|
||||||
<main class="flex-1 p-8">
|
<div class="mb-8">
|
||||||
<div class="max-w-4xl">
|
<div class="flex items-center gap-3 mb-1">
|
||||||
<div class="mb-8">
|
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Dashboard</h1>
|
@Icon("grid", "h-5 w-5")
|
||||||
<p style="color: var(--text-secondary)">Overview of your Bookhoard library and settings</p>
|
</span>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Dashboard</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
<p class="text-sm" style="color: var(--text-secondary)">Overview of your Bookhoard instance</p>
|
||||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
</div>
|
||||||
<div class="flex items-center space-x-3">
|
<!-- Stats Grid -->
|
||||||
<div class="text-3xl">📖</div>
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
<div>
|
<div class="stat-card">
|
||||||
<h3 class="font-semibold" style="color: var(--text-primary)">Library</h3>
|
<div class="flex items-center gap-2 mb-2" style="color: var(--text-secondary);">
|
||||||
<p style="color: var(--text-secondary)" class="text-sm">Manage your ebook collection</p>
|
@Icon("library", "h-4 w-4")
|
||||||
</div>
|
<span class="text-xs font-semibold uppercase tracking-wide">Libraries</span>
|
||||||
</div>
|
|
||||||
<a href="/" class="mt-4 inline-block text-sm btn-secondary px-3 py-1 rounded">View Library</a>
|
|
||||||
</div>
|
|
||||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<div class="text-3xl">👁️</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="font-semibold" style="color: var(--text-primary)">Scan Watch Status</h3>
|
|
||||||
<p style="color: var(--text-secondary)" class="text-sm">Auto-detecting new files</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="watch-status" class="mt-4 text-sm" style="color: var(--text-secondary)">
|
|
||||||
<span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-2"></span>
|
|
||||||
Watching <span id="watch-count">0</span> libraries
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ stats.LibraryCount }</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
<div class="stat-card">
|
||||||
<h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Quick Actions</h3>
|
<div class="flex items-center gap-2 mb-2" style="color: var(--text-secondary);">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
@Icon("book", "h-4 w-4")
|
||||||
<button @click="scanAllLibraries()" class="btn-primary p-4 rounded-lg text-left">
|
<span class="text-xs font-semibold uppercase tracking-wide">Books</span>
|
||||||
<div class="font-medium">Rescan Library</div>
|
|
||||||
<div style="color: var(--text-secondary)" class="text-sm">Re-scan existing files and fix metadata</div>
|
|
||||||
</button>
|
|
||||||
<a href="/admin/library" class="btn-secondary p-4 rounded-lg text-left block">
|
|
||||||
<div class="font-medium">Manage Libraries and Folders</div>
|
|
||||||
<div style="color: var(--text-secondary)" class="text-sm">Add or remove libraries and scan directories</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ stats.MediaCount }</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Scan Progress Section -->
|
<div class="stat-card">
|
||||||
<div id="scan-progress-container" class="hidden mt-6 p-6 rounded-lg border opacity-0 -translate-y-2.5 transition-all duration-300 ease-out" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="flex items-center gap-2 mb-2" style="color: var(--text-secondary);">
|
||||||
<div class="flex justify-between items-center mb-4">
|
@Icon("users", "h-4 w-4")
|
||||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">
|
<span class="text-xs font-semibold uppercase tracking-wide">Users</span>
|
||||||
📚 Scanning Libraries
|
|
||||||
</h3>
|
|
||||||
<button @click="hideScanProgress()" class="p-2 hover:bg-gray-700 rounded">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Overall Progress -->
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ stats.UserCount }</p>
|
||||||
<div class="mb-4">
|
</div>
|
||||||
<div class="flex justify-between text-sm mb-2">
|
<div class="stat-card">
|
||||||
<span style="color: var(--text-secondary)">Overall Progress</span>
|
<div class="flex items-center gap-2 mb-2" style="color: var(--text-secondary);">
|
||||||
<span id="scan-progress-text" style="color: var(--text-primary)">0%</span>
|
@Icon("device", "h-4 w-4")
|
||||||
</div>
|
<span class="text-xs font-semibold uppercase tracking-wide">Devices</span>
|
||||||
<div class="w-full bg-gray-700 rounded-full h-3">
|
|
||||||
<div
|
|
||||||
id="scan-progress-bar"
|
|
||||||
class="h-3 rounded-full transition-all duration-500"
|
|
||||||
style="width: 0%; background-color: var(--accent);"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div id="scan-status" class="text-sm mt-2" style="color: var(--text-secondary)">
|
|
||||||
Starting scan...
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Per-Library Progress -->
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ stats.DeviceCount }</p>
|
||||||
<div id="library-progress-list" class="space-y-3">
|
</div>
|
||||||
<!-- Dynamically populated -->
|
</div>
|
||||||
|
<!-- Watch Status -->
|
||||||
|
<div class="stat-card mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="grid place-items-center h-11 w-11 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
@Icon("sync", "h-5 w-5")
|
||||||
|
</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="font-semibold" style="color: var(--text-primary)">File Watcher</h3>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Auto-detects new files in library folders</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Results Summary -->
|
<div class="text-right text-sm" style="color: var(--text-secondary)">
|
||||||
<div id="scan-results" class="hidden mt-6 p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border);">
|
<span class="inline-block w-2 h-2 rounded-full mr-2" style="background-color: var(--status-success);"></span>
|
||||||
<h4 class="font-semibold mb-2" style="color: var(--text-primary)">✅ Scan Complete!</h4>
|
Watching <span id="watch-count">0</span> libraries
|
||||||
<div id="scan-results-content" style="color: var(--text-secondary)">
|
|
||||||
<!-- Results populated by JS -->
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 flex gap-2">
|
|
||||||
<button
|
|
||||||
@click="window.location.reload()"
|
|
||||||
class="btn-primary px-4 py-2 rounded-lg"
|
|
||||||
>
|
|
||||||
Refresh to View Books
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="hideScanProgress()"
|
|
||||||
class="btn-secondary px-4 py-2 rounded-lg"
|
|
||||||
>
|
|
||||||
Dismiss
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<!-- Quick Actions -->
|
||||||
</div>
|
<div class="card p-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Quick Actions</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<button @click="scanAllLibraries()" class="btn btn-primary py-4 flex-col items-start gap-1">
|
||||||
|
<span class="flex items-center gap-2 font-medium">
|
||||||
|
@Icon("refresh", "h-5 w-5")
|
||||||
|
Scan All Libraries
|
||||||
|
</span>
|
||||||
|
<span class="text-xs font-normal opacity-80">Re-scan existing files and detect new items</span>
|
||||||
|
</button>
|
||||||
|
<a href="/admin/library" class="btn btn-secondary py-4 flex-col items-start gap-1">
|
||||||
|
<span class="flex items-center gap-2 font-medium">
|
||||||
|
@Icon("library", "h-5 w-5")
|
||||||
|
Manage Libraries
|
||||||
|
</span>
|
||||||
|
<span class="text-xs font-normal opacity-80">Add or remove libraries and folders</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Scan Progress Section -->
|
||||||
|
<div id="scan-progress-container" class="card hidden mt-6 p-6 opacity-0 -translate-y-2.5 transition-all duration-300 ease-out">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-semibold flex items-center gap-2" style="color: var(--text-primary)">
|
||||||
|
@Icon("refresh", "h-5 w-5")
|
||||||
|
Scanning Libraries
|
||||||
|
</h3>
|
||||||
|
<button @click="hideScanProgress()" class="icon-btn" aria-label="Close">
|
||||||
|
@Icon("close", "h-5 w-5")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex justify-between text-sm mb-2">
|
||||||
|
<span style="color: var(--text-secondary)">Overall Progress</span>
|
||||||
|
<span id="scan-progress-text" style="color: var(--text-primary)">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full rounded-full h-3" style="background-color: var(--surface-hover);">
|
||||||
|
<div
|
||||||
|
id="scan-progress-bar"
|
||||||
|
class="h-3 rounded-full transition-all duration-500"
|
||||||
|
style="width: 0%; background-color: var(--accent);"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div id="scan-status" class="text-sm mt-2" style="color: var(--text-secondary)">
|
||||||
|
Starting scan...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="library-progress-list" class="space-y-3"></div>
|
||||||
|
<div id="scan-results" class="hidden mt-6 p-4 rounded-xl border" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||||
|
<h4 class="font-semibold mb-2 flex items-center gap-2" style="color: var(--status-success);">
|
||||||
|
@Icon("check-circle", "h-5 w-5")
|
||||||
|
Scan Complete!
|
||||||
|
</h4>
|
||||||
|
<div id="scan-results-content" style="color: var(--text-secondary)"></div>
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<button @click="window.location.reload()" class="btn btn-primary">Refresh to View Books</button>
|
||||||
|
<button @click="hideScanProgress()" class="btn btn-secondary">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
|
|||||||
+374
-110
@@ -6,150 +6,414 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<title>Library Management - Bookhoard</title>
|
<title>Library Management - Bookhoard</title>
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="library" x-init="initializeLibraryAdmin" class="theme-{ user.Theme }">
|
<body class="theme-{ user.Theme }">
|
||||||
@Header(user, "/admin/library")
|
@Header(user, "/admin/library")
|
||||||
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
|
<main class="p-8">
|
||||||
@AdminSidebar(user, "/admin/library")
|
<div class="max-w-4xl">
|
||||||
<main class="flex-1 p-8">
|
<div class="mb-8">
|
||||||
<div class="w-full">
|
<div class="flex items-center justify-between gap-4 flex-wrap mb-4">
|
||||||
<div class="mb-8">
|
<div>
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center gap-3 mb-1">
|
||||||
<a href="/admin" class="btn-secondary px-4 py-2 rounded-lg font-medium">
|
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
← Back to Dashboard
|
@Icon("library", "h-5 w-5")
|
||||||
</a>
|
</span>
|
||||||
<button data-action="show-create-modal" class="btn-primary px-4 py-2 rounded-lg font-medium">
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Libraries</h1>
|
||||||
+ Create Library
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Library Management</h1>
|
|
||||||
<p style="color: var(--text-secondary)">Manage libraries and configure media scanning</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
<!-- Libraries Section -->
|
|
||||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
|
||||||
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Libraries</h3>
|
|
||||||
<p style="color: var(--text-secondary)" class="mb-4">Manage media libraries and their folders</p>
|
|
||||||
<div id="libraries-list" class="space-y-3 mb-6">
|
|
||||||
if len(libraries) == 0 {
|
|
||||||
<p style="color: var(--text-secondary)" class="text-center py-8">
|
|
||||||
No libraries yet. Create your first library to get started.
|
|
||||||
</p>
|
|
||||||
} else {
|
|
||||||
for _, library := range libraries {
|
|
||||||
<div class="p-4 border rounded-lg" style="background-color: var(--bg-primary); border-color: var(--border)">
|
|
||||||
<div class="flex justify-between items-start mb-2">
|
|
||||||
<div>
|
|
||||||
<h4 class="font-semibold" style="color: var(--text-primary)">{ library.Name }</h4>
|
|
||||||
if library.Description != "" {
|
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">{ library.Description }</p>
|
|
||||||
}
|
|
||||||
<span class="inline-block px-2 py-1 text-xs rounded" style="background-color: var(--accent); color: var(--bg-primary)">
|
|
||||||
{ library.TypeName }
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex space-x-2">
|
|
||||||
<button data-library-id={ library.ID } data-action="show-folders" class="text-xs px-2 py-1 rounded" style="background-color: var(--bg-secondary); color: var(--text-primary)">Folders</button>
|
|
||||||
<button data-library-id={ library.ID } data-action="edit" class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: var(--bg-primary)">Edit</button>
|
|
||||||
<button data-library-id={ library.ID } data-action="delete" class="text-xs px-2 py-1 rounded text-red-500">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id={ "library-folders-" + library.ID } class="hidden mt-3 space-y-2"></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Manage media libraries, folders, and scanning</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- User Library Visibility Section -->
|
<button
|
||||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
onclick="document.getElementById('create-library-modal').classList.remove('hidden')"
|
||||||
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Library Visibility</h3>
|
class="btn btn-primary"
|
||||||
<p style="color: var(--text-secondary)" class="mb-4">Control which libraries are visible to users</p>
|
>
|
||||||
<div id="visibility-controls" class="space-y-4">
|
@Icon("plus", "h-4 w-4")
|
||||||
<!-- Visibility controls will be loaded here -->
|
Create Library
|
||||||
</div>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- User Visibility Management -->
|
|
||||||
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
|
||||||
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">User Library Access</h3>
|
|
||||||
<p style="color: var(--text-secondary)" class="mb-4">Manage individual user access to specific libraries</p>
|
|
||||||
<div class="mb-4">
|
|
||||||
<select id="user-select" onchange="loadUserVisibility()" class="px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
|
|
||||||
<option value="">Select a user...</option>
|
|
||||||
for _, user := range users {
|
|
||||||
<option value="{ user.ID }">{ user.Username } ({ user.Email })</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="user-libraries" class="space-y-3">
|
|
||||||
<!-- User library checkboxes will be loaded here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<div id="libraries-container">
|
||||||
</div>
|
@LibraryList(user, libraries, users)
|
||||||
<!-- Create Library Modal -->
|
</div>
|
||||||
<div id="create-library-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
</div>
|
||||||
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
</main>
|
||||||
|
<!-- Create / Edit Library Modal -->
|
||||||
|
<div id="create-library-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||||
|
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Library</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Library</h2>
|
||||||
<button type="button" data-action="hide-create-modal" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
<button type="button" onclick="document.getElementById('create-library-modal').classList.add('hidden')" class="icon-btn" aria-label="Close">
|
||||||
|
@Icon("close", "h-5 w-5")
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="create-library-form">
|
<form
|
||||||
<input type="hidden" id="library-id" name="id"/>
|
hx-post="/admin/library/create"
|
||||||
|
hx-target="#libraries-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
onsubmit="document.getElementById('create-library-modal').classList.add('hidden')"
|
||||||
|
>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Library Name</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Name</label>
|
||||||
<input type="text" name="name" placeholder="My Ebook Library" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
|
<input type="text" name="name" placeholder="My Ebook Library" class="input" required/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Description</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Description</label>
|
||||||
<textarea name="description" placeholder="Optional description" rows="3" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"></textarea>
|
<textarea name="description" placeholder="Optional description" rows="3" class="input"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-6">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Library Type</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Type</label>
|
||||||
<select name="type" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
|
<select name="type" class="input" required>
|
||||||
<option value="ebooks">📚 Ebooks</option>
|
<option value="ebooks">Ebooks</option>
|
||||||
<option value="comics">📖 Comics</option>
|
<option value="comics">Comics</option>
|
||||||
<option value="manga">🗾 Manga</option>
|
<option value="manga">Manga</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" data-action="hide-create-modal" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
|
<button type="button" onclick="document.getElementById('create-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button>
|
||||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Create</button>
|
<button type="submit" class="btn btn-primary">
|
||||||
|
@Icon("plus", "h-4 w-4")
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Edit Library Modal -->
|
||||||
|
<div id="edit-library-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||||
|
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit Library</h2>
|
||||||
|
<button type="button" onclick="document.getElementById('edit-library-modal').classList.add('hidden')" class="icon-btn" aria-label="Close">
|
||||||
|
@Icon("close", "h-5 w-5")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="edit-library-form" hx-target="#libraries-container" hx-swap="innerHTML" onsubmit="document.getElementById('edit-library-modal').classList.add('hidden')">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Name</label>
|
||||||
|
<input type="text" name="name" id="edit-library-name" class="input" required/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Description</label>
|
||||||
|
<textarea name="description" id="edit-library-desc" rows="3" class="input"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<button type="button" onclick="document.getElementById('edit-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
@Icon("save", "h-4 w-4")
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Folder Browser Modal -->
|
<!-- Folder Browser Modal -->
|
||||||
<div id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
<div id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||||
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="card p-6 w-full max-w-lg mx-4" style="box-shadow: var(--shadow-pop);">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Browse Folders</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Browse Folders</h2>
|
||||||
<button type="button" data-action="browse-cancel" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
<button type="button" onclick="document.getElementById('folder-browser-modal').classList.add('hidden')" class="icon-btn" aria-label="Close">
|
||||||
</div>
|
@Icon("close", "h-5 w-5")
|
||||||
<div id="folder-browser-content">
|
</button>
|
||||||
<!-- Directory listings will be rendered here -->
|
|
||||||
</div>
|
</div>
|
||||||
|
<div id="folder-browser-content"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Delete Library Confirmation Modal -->
|
<!-- Delete Library Modal -->
|
||||||
<div id="delete-library-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
<div id="delete-library-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||||
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="card p-6 w-full max-w-md mx-4" style="box-shadow: var(--shadow-pop);">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Delete Library</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Delete Library</h2>
|
||||||
<button type="button" data-action="hide-delete-modal" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
<button type="button" onclick="document.getElementById('delete-library-modal').classList.add('hidden')" class="icon-btn" aria-label="Close">
|
||||||
</div>
|
@Icon("close", "h-5 w-5")
|
||||||
<div id="delete-modal-content" class="mb-6" style="color: var(--text-primary)">
|
</button>
|
||||||
<!-- Dynamic content will be injected here -->
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mb-6" style="color: var(--text-primary)">
|
||||||
|
Are you sure you want to delete <strong id="delete-library-name"></strong>?
|
||||||
|
This will remove the library and all its folder mappings. Media files will not be deleted.
|
||||||
|
</p>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" data-action="hide-delete-modal" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
|
<button type="button" onclick="document.getElementById('delete-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button>
|
||||||
<button type="button" data-action="confirm-delete" class="btn-primary px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600">Delete</button>
|
<button type="button" id="delete-library-confirm" class="btn btn-danger">
|
||||||
|
@Icon("trash", "h-4 w-4")
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script>
|
||||||
|
function toggleLibraryPanel(btn) {
|
||||||
|
var libId = btn.dataset.libId;
|
||||||
|
var panel = document.getElementById('library-panel-' + libId);
|
||||||
|
if (panel.innerHTML.trim() !== '') {
|
||||||
|
panel.innerHTML = '';
|
||||||
|
} else {
|
||||||
|
htmx.ajax('GET', '/admin/library/' + libId + '/panel', {
|
||||||
|
target: '#library-panel-' + libId,
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function openEditModal(id, name, description) {
|
||||||
|
document.getElementById('edit-library-form').setAttribute('hx-put', '/admin/library/' + id);
|
||||||
|
document.getElementById('edit-library-name').value = name;
|
||||||
|
document.getElementById('edit-library-desc').value = description;
|
||||||
|
htmx.process(document.getElementById('edit-library-form'));
|
||||||
|
document.getElementById('edit-library-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function openDeleteModal(id, name) {
|
||||||
|
const btn = document.getElementById('delete-library-confirm');
|
||||||
|
btn.setAttribute('hx-delete', '/admin/library/' + id);
|
||||||
|
btn.setAttribute('hx-target', '#libraries-container');
|
||||||
|
btn.setAttribute('hx-swap', 'innerHTML');
|
||||||
|
document.getElementById('delete-library-name').textContent = name;
|
||||||
|
htmx.process(btn);
|
||||||
|
document.getElementById('delete-library-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function openFolderBrowser(targetInputId, libraryId) {
|
||||||
|
const content = document.getElementById('folder-browser-content');
|
||||||
|
content.setAttribute('hx-get', '/admin/library/browse');
|
||||||
|
content.setAttribute('hx-vals', '{"target_input": "' + targetInputId + '", "library_id": "' + libraryId + '"}');
|
||||||
|
content.setAttribute('hx-trigger', 'load');
|
||||||
|
htmx.process(content);
|
||||||
|
document.getElementById('folder-browser-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ LibraryList(user User, libraries []LibraryData, users []User) {
|
||||||
|
if len(libraries) == 0 {
|
||||||
|
<div class="card text-center py-16">
|
||||||
|
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
@Icon("library", "h-7 w-7")
|
||||||
|
</span>
|
||||||
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Libraries Yet</h3>
|
||||||
|
<p class="mb-4 text-sm" style="color: var(--text-secondary)">Create your first library to get started</p>
|
||||||
|
<button onclick="document.getElementById('create-library-modal').classList.remove('hidden')" class="btn btn-primary">Create Your First Library</button>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="space-y-4">
|
||||||
|
for _, library := range libraries {
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between gap-4 p-5">
|
||||||
|
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
@Icon("library", "h-5 w-5")
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 class="font-semibold truncate" style="color: var(--text-primary)">{ library.Name }</h3>
|
||||||
|
if library.Description != "" {
|
||||||
|
<p class="text-sm truncate" style="color: var(--text-secondary)">{ library.Description }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="chip shrink-0">
|
||||||
|
@Icon("tag", "h-3 w-3")
|
||||||
|
{ library.TypeName }
|
||||||
|
</span>
|
||||||
|
if library.FolderCount > 0 {
|
||||||
|
<span class="chip shrink-0">
|
||||||
|
@Icon("folder", "h-3 w-3")
|
||||||
|
{ library.FolderCount } folders
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
hx-post={ "/api/libraries/" + library.ID + "/scan" }
|
||||||
|
hx-vals='{"force": "true"}'
|
||||||
|
hx-target="#scan-indicator"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="btn btn-secondary text-xs px-3 py-1.5"
|
||||||
|
>
|
||||||
|
@Icon("refresh", "h-4 w-4")
|
||||||
|
Scan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-lib-id={ library.ID }
|
||||||
|
onclick="toggleLibraryPanel(this)"
|
||||||
|
class="btn btn-secondary text-xs px-3 py-1.5"
|
||||||
|
>
|
||||||
|
@Icon("chevron-down", "h-4 w-4")
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id={ "library-panel-" + library.ID }></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div id="scan-indicator"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ LibraryPanel(user User, libraryID string, library LibraryData, folders []FolderData, users []User, visibility []UserVisibilityData, issueCount int) {
|
||||||
|
<div class="border-t p-5 space-y-6" style="border-color: var(--border);">
|
||||||
|
<!-- Folders -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
@Icon("folder", "h-4 w-4 shrink-0")
|
||||||
|
<h4 class="text-sm font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Folders</h4>
|
||||||
|
</div>
|
||||||
|
if len(folders) == 0 {
|
||||||
|
<p class="text-sm mb-3" style="color: var(--text-secondary)">No folders configured. Add a folder to enable scanning.</p>
|
||||||
|
} else {
|
||||||
|
<div class="space-y-2 mb-3">
|
||||||
|
for _, folder := range folders {
|
||||||
|
<div class="flex items-center justify-between gap-2 p-2 rounded-lg" style="background-color: var(--bg-primary);">
|
||||||
|
<code class="text-xs flex-1 truncate" style="color: var(--text-primary)">{ folder.FolderPath }</code>
|
||||||
|
<button
|
||||||
|
class="icon-btn h-7 w-7 shrink-0"
|
||||||
|
hx-delete={ "/admin/library/" + libraryID + "/folders" }
|
||||||
|
hx-vals={ `{"folder_path": "` + folder.FolderPath + `"}` }
|
||||||
|
hx-target={ "#library-panel-" + libraryID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-confirm="Remove this folder from the library?"
|
||||||
|
>
|
||||||
|
@Icon("trash", "h-3.5 w-3.5")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<form class="flex gap-2" hx-post={ "/admin/library/" + libraryID + "/folders" } hx-target={ "#library-panel-" + libraryID } hx-swap="innerHTML">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="folder_path"
|
||||||
|
id={ "folder-input-" + libraryID }
|
||||||
|
placeholder="/path/to/books"
|
||||||
|
class="input flex-1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-library-id={ libraryID }
|
||||||
|
data-target-input={ "folder-input-" + libraryID }
|
||||||
|
onclick="openFolderBrowser(this.dataset.targetInput, this.dataset.libraryId)"
|
||||||
|
class="btn btn-secondary text-sm shrink-0"
|
||||||
|
>
|
||||||
|
@Icon("folder", "h-4 w-4")
|
||||||
|
Browse
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary text-sm shrink-0">
|
||||||
|
@Icon("plus", "h-4 w-4")
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<!-- User Visibility -->
|
||||||
|
if len(users) > 0 {
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
@Icon("users", "h-4 w-4 shrink-0")
|
||||||
|
<h4 class="text-sm font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">User Access</h4>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
for _, u := range users {
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer p-2 rounded-lg transition-colors hover:bg-surface-hover">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="w-4 h-4 rounded"
|
||||||
|
name="is_visible"
|
||||||
|
value="true"
|
||||||
|
checked?={ isUserVisible(u.ID, visibility) }
|
||||||
|
hx-post={ "/admin/library/" + libraryID + "/visibility" }
|
||||||
|
hx-vals={ `{"user_id": "` + u.ID + `"}` }
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target={ "#library-panel-" + libraryID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
/>
|
||||||
|
<span class="text-sm" style="color: var(--text-primary)">{ u.Username }</span>
|
||||||
|
<span class="text-xs" style="color: var(--text-secondary)">{ u.Email }</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Processing Issues -->
|
||||||
|
if issueCount > 0 {
|
||||||
|
<div>
|
||||||
|
<a href={ "/admin/libraries/" + libraryID + "/issues" } class="flex items-center gap-2 text-sm" style="color: var(--status-warning);">
|
||||||
|
@Icon("alert", "h-4 w-4")
|
||||||
|
if issueCount == 1 {
|
||||||
|
<span>1 processing issue</span>
|
||||||
|
} else {
|
||||||
|
<span>{ issueCount } processing issues</span>
|
||||||
|
}
|
||||||
|
@Icon("chevron-right", "h-4 w-4")
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-2 pt-2 border-t" style="border-color: var(--border);">
|
||||||
|
<button
|
||||||
|
data-edit-id={ libraryID }
|
||||||
|
data-edit-name={ library.Name }
|
||||||
|
data-edit-desc={ library.Description }
|
||||||
|
onclick="openEditModal(this.dataset.editId, this.dataset.editName, this.dataset.editDesc)"
|
||||||
|
class="btn btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
@Icon("edit", "h-4 w-4")
|
||||||
|
Edit Details
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-delete-id={ libraryID }
|
||||||
|
data-delete-name={ library.Name }
|
||||||
|
onclick="openDeleteModal(this.dataset.deleteId, this.dataset.deleteName)"
|
||||||
|
class="btn btn-danger text-sm"
|
||||||
|
>
|
||||||
|
@Icon("trash", "h-4 w-4")
|
||||||
|
Delete Library
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ FolderBrowserContent(currentPath string, parentPath string, entries []DirEntry, targetInput string, libraryID string) {
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-3 p-2 rounded-lg" style="background-color: var(--bg-primary);">
|
||||||
|
@Icon("folder", "h-4 w-4 shrink-0")
|
||||||
|
<code class="text-xs flex-1 truncate" style="color: var(--text-primary)">{ currentPath }</code>
|
||||||
|
</div>
|
||||||
|
if parentPath != "" {
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover"
|
||||||
|
style="color: var(--text-secondary);"
|
||||||
|
hx-get={ "/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID }
|
||||||
|
hx-target="#folder-browser-content"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
@Icon("arrow-left", "h-4 w-4")
|
||||||
|
<span>..</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<div class="space-y-1 max-h-64 overflow-y-auto">
|
||||||
|
for _, entry := range entries {
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover"
|
||||||
|
style="color: var(--text-primary);"
|
||||||
|
hx-get={ "/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID }
|
||||||
|
hx-target="#folder-browser-content"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
@Icon("folder", "h-4 w-4 shrink-0")
|
||||||
|
<span class="truncate">{ entry.Name }</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-target-input={ targetInput }
|
||||||
|
data-current-path={ currentPath }
|
||||||
|
onclick="document.getElementById(this.dataset.targetInput).value = this.dataset.currentPath; document.getElementById('folder-browser-modal').classList.add('hidden')"
|
||||||
|
class="btn btn-primary text-sm"
|
||||||
|
>
|
||||||
|
@Icon("check", "h-4 w-4")
|
||||||
|
Select This Folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Library Management - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"library\" x-init=\"initializeLibraryAdmin\" class=\"theme-{ user.Theme }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Library Management - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -37,169 +37,893 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex min-h-screen\" style=\"background-color: var(--bg-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center justify-between gap-4 flex-wrap mb-4\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = AdminSidebar(user, "/admin/library").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-8\"><div class=\"w-full\"><div class=\"mb-8\"><div class=\"flex items-center justify-between mb-4\"><a href=\"/admin\" class=\"btn-secondary px-4 py-2 rounded-lg font-medium\">← Back to Dashboard</a> <button data-action=\"show-create-modal\" class=\"btn-primary px-4 py-2 rounded-lg font-medium\">+ Create Library</button></div><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">Library Management</h1><p style=\"color: var(--text-secondary)\">Manage libraries and configure media scanning</p></div><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-8\"><!-- Libraries Section --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><h3 class=\"text-xl font-semibold mb-6\" style=\"color: var(--text-primary)\">Libraries</h3><p style=\"color: var(--text-secondary)\" class=\"mb-4\">Manage media libraries and their folders</p><div id=\"libraries-list\" class=\"space-y-3 mb-6\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Libraries</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Manage media libraries, folders, and scanning</p></div><button onclick=\"document.getElementById('create-library-modal').classList.remove('hidden')\" class=\"btn btn-primary\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Create Library</button></div></div><div id=\"libraries-container\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = LibraryList(user, libraries, users).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></div></main><!-- Create / Edit Library Modal --><div id=\"create-library-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Create Library</h2><button type=\"button\" onclick=\"document.getElementById('create-library-modal').classList.add('hidden')\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</button></div><form hx-post=\"/admin/library/create\" hx-target=\"#libraries-container\" hx-swap=\"innerHTML\" onsubmit=\"document.getElementById('create-library-modal').classList.add('hidden')\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Library Name</label> <input type=\"text\" name=\"name\" placeholder=\"My Ebook Library\" class=\"input\" required></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" placeholder=\"Optional description\" rows=\"3\" class=\"input\"></textarea></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Library Type</label> <select name=\"type\" class=\"input\" required><option value=\"ebooks\">Ebooks</option> <option value=\"comics\">Comics</option> <option value=\"manga\">Manga</option></select></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" onclick=\"document.getElementById('create-library-modal').classList.add('hidden')\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "Create</button></div></form></div></div><!-- Edit Library Modal --><div id=\"edit-library-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Edit Library</h2><button type=\"button\" onclick=\"document.getElementById('edit-library-modal').classList.add('hidden')\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</button></div><form id=\"edit-library-form\" hx-target=\"#libraries-container\" hx-swap=\"innerHTML\" onsubmit=\"document.getElementById('edit-library-modal').classList.add('hidden')\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Library Name</label> <input type=\"text\" name=\"name\" id=\"edit-library-name\" class=\"input\" required></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" id=\"edit-library-desc\" rows=\"3\" class=\"input\"></textarea></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" onclick=\"document.getElementById('edit-library-modal').classList.add('hidden')\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("save", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Save</button></div></form></div></div><!-- Folder Browser Modal --><div id=\"folder-browser-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-lg mx-4\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Browse Folders</h2><button type=\"button\" onclick=\"document.getElementById('folder-browser-modal').classList.add('hidden')\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</button></div><div id=\"folder-browser-content\"></div></div></div><!-- Delete Library Modal --><div id=\"delete-library-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Delete Library</h2><button type=\"button\" onclick=\"document.getElementById('delete-library-modal').classList.add('hidden')\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</button></div><p class=\"mb-6\" style=\"color: var(--text-primary)\">Are you sure you want to delete <strong id=\"delete-library-name\"></strong>? This will remove the library and all its folder mappings. Media files will not be deleted.</p><div class=\"flex justify-end space-x-3\"><button type=\"button\" onclick=\"document.getElementById('delete-library-modal').classList.add('hidden')\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" id=\"delete-library-confirm\" class=\"btn btn-danger\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Delete</button></div></div></div><script>\n\t\t\t\tfunction toggleLibraryPanel(btn) {\n\t\t\t\t\tvar libId = btn.dataset.libId;\n\t\t\t\t\tvar panel = document.getElementById('library-panel-' + libId);\n\t\t\t\t\tif (panel.innerHTML.trim() !== '') {\n\t\t\t\t\t\tpanel.innerHTML = '';\n\t\t\t\t\t} else {\n\t\t\t\t\t\thtmx.ajax('GET', '/admin/library/' + libId + '/panel', {\n\t\t\t\t\t\t\ttarget: '#library-panel-' + libId,\n\t\t\t\t\t\t\tswap: 'innerHTML'\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfunction openEditModal(id, name, description) {\n\t\t\t\t\tdocument.getElementById('edit-library-form').setAttribute('hx-put', '/admin/library/' + id);\n\t\t\t\t\tdocument.getElementById('edit-library-name').value = name;\n\t\t\t\t\tdocument.getElementById('edit-library-desc').value = description;\n\t\t\t\t\thtmx.process(document.getElementById('edit-library-form'));\n\t\t\t\t\tdocument.getElementById('edit-library-modal').classList.remove('hidden');\n\t\t\t\t}\n\t\t\t\tfunction openDeleteModal(id, name) {\n\t\t\t\t\tconst btn = document.getElementById('delete-library-confirm');\n\t\t\t\t\tbtn.setAttribute('hx-delete', '/admin/library/' + id);\n\t\t\t\t\tbtn.setAttribute('hx-target', '#libraries-container');\n\t\t\t\t\tbtn.setAttribute('hx-swap', 'innerHTML');\n\t\t\t\t\tdocument.getElementById('delete-library-name').textContent = name;\n\t\t\t\t\thtmx.process(btn);\n\t\t\t\t\tdocument.getElementById('delete-library-modal').classList.remove('hidden');\n\t\t\t\t}\n\t\t\t\tfunction openFolderBrowser(targetInputId, libraryId) {\n\t\t\t\t\tconst content = document.getElementById('folder-browser-content');\n\t\t\t\t\tcontent.setAttribute('hx-get', '/admin/library/browse');\n\t\t\t\t\tcontent.setAttribute('hx-vals', '{\"target_input\": \"' + targetInputId + '\", \"library_id\": \"' + libraryId + '\"}');\n\t\t\t\t\tcontent.setAttribute('hx-trigger', 'load');\n\t\t\t\t\thtmx.process(content);\n\t\t\t\t\tdocument.getElementById('folder-browser-modal').classList.remove('hidden');\n\t\t\t\t}\n\t\t\t</script></body></html>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func LibraryList(user User, libraries []LibraryData, users []User) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var2 == nil {
|
||||||
|
templ_7745c5c3_Var2 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
if len(libraries) == 0 {
|
if len(libraries) == 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<p style=\"color: var(--text-secondary)\" class=\"text-center py-8\">No libraries yet. Create your first library to get started.</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("library", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Libraries Yet</h3><p class=\"mb-4 text-sm\" style=\"color: var(--text-secondary)\">Create your first library to get started</p><button onclick=\"document.getElementById('create-library-modal').classList.remove('hidden')\" class=\"btn btn-primary\">Create Your First Library</button></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"space-y-4\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
for _, library := range libraries {
|
for _, library := range libraries {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"p-4 border rounded-lg\" style=\"background-color: var(--bg-primary); border-color: var(--border)\"><div class=\"flex justify-between items-start mb-2\"><div><h4 class=\"font-semibold\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"card overflow-hidden\"><div class=\"flex items-center justify-between gap-4 p-5\"><div class=\"flex items-center gap-3 min-w-0 flex-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 44, Col: 89}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</h4>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</span><div class=\"min-w-0\"><h3 class=\"font-semibold truncate\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 207, Col: 92}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</h3>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if library.Description != "" {
|
if library.Description != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<p class=\"text-sm truncate\" style=\"color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 46, Col: 92}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 209, Col: 95}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, 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, 8, "</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span class=\"inline-block px-2 py-1 text-xs rounded\" style=\"background-color: var(--accent); color: var(--bg-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div><span class=\"chip shrink-0\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
templ_7745c5c3_Err = Icon("tag", "h-3 w-3").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 49, Col: 33}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span></div><div class=\"flex space-x-2\"><button data-library-id=\"")
|
|
||||||
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.ResolveAttributeValue(library.ID)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 53, Col: 50}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 214, Col: 26}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 11, "\" data-action=\"show-folders\" class=\"text-xs px-2 py-1 rounded\" style=\"background-color: var(--bg-secondary); color: var(--text-primary)\">Folders</button> <button data-library-id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
if library.FolderCount > 0 {
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"chip shrink-0\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 54, Col: 50}
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("folder", "h-3 w-3").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 string
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 219, Col: 30}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " folders</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><div class=\"flex items-center gap-2 shrink-0\"><button hx-post=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" data-action=\"edit\" class=\"text-xs px-2 py-1 rounded\" style=\"background-color: var(--accent); color: var(--bg-primary)\">Edit</button> <button data-library-id=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 55, Col: 50}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 225, Col: 58}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" data-action=\"delete\" class=\"text-xs px-2 py-1 rounded text-red-500\">Delete</button></div></div><div id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" hx-vals='{\"force\": \"true\"}' hx-target=\"#scan-indicator\" hx-swap=\"innerHTML\" class=\"btn btn-secondary text-xs px-3 py-1.5\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "Scan</button> <button data-lib-id=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-folders-" + library.ID)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 58, Col: 53}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 235, Col: 32}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"hidden mt-3 space-y-2\"></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" onclick=\"toggleLibraryPanel(this)\" class=\"btn btn-secondary text-xs px-3 py-1.5\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("chevron-down", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Manage</button></div></div><div id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-panel-" + library.ID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 244, Col: 44}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\"></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div></div><!-- User Library Visibility Section --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><h3 class=\"text-xl font-semibold mb-6\" style=\"color: var(--text-primary)\">Library Visibility</h3><p style=\"color: var(--text-secondary)\" class=\"mb-4\">Control which libraries are visible to users</p><div id=\"visibility-controls\" class=\"space-y-4\"><!-- Visibility controls will be loaded here --></div></div></div><!-- User Visibility Management --><div class=\"mt-8 card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><h3 class=\"text-xl font-semibold mb-6\" style=\"color: var(--text-primary)\">User Library Access</h3><p style=\"color: var(--text-secondary)\" class=\"mb-4\">Manage individual user access to specific libraries</p><div class=\"mb-4\"><select id=\"user-select\" onchange=\"loadUserVisibility()\" class=\"px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\"><option value=\"\">Select a user...</option> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div id=\"scan-indicator\"></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, user := range users {
|
return nil
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<option value=\"{ user.ID }\">")
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func LibraryPanel(user User, libraryID string, library LibraryData, folders []FolderData, users []User, visibility []UserVisibilityData, issueCount int) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var10 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var10 == nil {
|
||||||
|
templ_7745c5c3_Var10 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"border-t p-5 space-y-6\" style=\"border-color: var(--border);\"><!-- Folders --><div><div class=\"flex items-center gap-2 mb-3\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("folder", "h-4 w-4 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<h4 class=\"text-sm font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Folders</h4></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if len(folders) == 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<p class=\"text-sm mb-3\" style=\"color: var(--text-secondary)\">No folders configured. Add a folder to enable scanning.</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
} else {
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<div class=\"space-y-2 mb-3\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 81, Col: 53}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " (")
|
for _, folder := range folders {
|
||||||
if templ_7745c5c3_Err != nil {
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"flex items-center justify-between gap-2 p-2 rounded-lg\" style=\"background-color: var(--bg-primary);\"><code class=\"text-xs flex-1 truncate\" style=\"color: var(--text-primary)\">")
|
||||||
return templ_7745c5c3_Err
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var11 string
|
||||||
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 266, Col: 99}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</code> <button class=\"icon-btn h-7 w-7 shrink-0\" hx-delete=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 269, Col: 62}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" hx-vals=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 string
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"folder_path": "` + folder.FolderPath + `"}`)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 270, Col: 64}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" hx-target=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var14 string
|
||||||
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 271, Col: 49}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" hx-swap=\"innerHTML\" hx-confirm=\"Remove this folder from the library?\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("trash", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</button></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div>")
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 81, Col: 69}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ")</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, 19, "</select></div><div id=\"user-libraries\" class=\"space-y-3\"><!-- User library checkboxes will be loaded here --></div></div></div></main></div><!-- Create Library Modal --><div id=\"create-library-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: rgba(0, 0, 0, 0.7);\"><div class=\"card rounded-lg p-6 w-full max-w-md mx-4\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Create Library</h2><button type=\"button\" data-action=\"hide-create-modal\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div><form id=\"create-library-form\"><input type=\"hidden\" id=\"library-id\" name=\"id\"><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-primary)\">Library Name</label> <input type=\"text\" name=\"name\" placeholder=\"My Ebook Library\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-primary)\">Description</label> <textarea name=\"description\" placeholder=\"Optional description\" rows=\"3\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\"></textarea></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-primary)\">Library Type</label> <select name=\"type\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required><option value=\"ebooks\">📚 Ebooks</option> <option value=\"comics\">📖 Comics</option> <option value=\"manga\">🗾 Manga</option></select></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" data-action=\"hide-create-modal\" class=\"btn-secondary px-4 py-2 rounded-lg\">Cancel</button> <button type=\"submit\" class=\"btn-primary px-4 py-2 rounded-lg\">Create</button></div></form></div></div><!-- Folder Browser Modal --><div id=\"folder-browser-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: rgba(0, 0, 0, 0.7);\"><div class=\"card rounded-lg p-6 w-full max-w-md mx-4\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Browse Folders</h2><button type=\"button\" data-action=\"browse-cancel\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div><div id=\"folder-browser-content\"><!-- Directory listings will be rendered here --></div></div></div><!-- Delete Library Confirmation Modal --><div id=\"delete-library-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: rgba(0, 0, 0, 0.7);\"><div class=\"card rounded-lg p-6 w-full max-w-md mx-4\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Delete Library</h2><button type=\"button\" data-action=\"hide-delete-modal\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div><div id=\"delete-modal-content\" class=\"mb-6\" style=\"color: var(--text-primary)\"><!-- Dynamic content will be injected here --></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" data-action=\"hide-delete-modal\" class=\"btn-secondary px-4 py-2 rounded-lg\">Cancel</button> <button type=\"button\" data-action=\"confirm-delete\" class=\"btn-primary px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600\">Delete</button></div></div></div><script src=\"/static/htmx.min.js\"></script></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<form class=\"flex gap-2\" hx-post=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 281, Col: 80}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" hx-target=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var16 string
|
||||||
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 281, Col: 124}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" hx-swap=\"innerHTML\"><input type=\"text\" name=\"folder_path\" id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var17 string
|
||||||
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 285, Col: 37}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" placeholder=\"/path/to/books\" class=\"input flex-1\" required> <button type=\"button\" data-library-id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var18 string
|
||||||
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 292, Col: 32}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" data-target-input=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var19 string
|
||||||
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 293, Col: 52}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" onclick=\"openFolderBrowser(this.dataset.targetInput, this.dataset.libraryId)\" class=\"btn btn-secondary text-sm shrink-0\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("folder", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "Browse</button> <button type=\"submit\" class=\"btn btn-primary text-sm shrink-0\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "Add</button></form></div><!-- User Visibility -->")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if len(users) > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div><div class=\"flex items-center gap-2 mb-3\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("users", "h-4 w-4 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<h4 class=\"text-sm font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">User Access</h4></div><div class=\"space-y-1\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<label class=\"flex items-center gap-3 cursor-pointer p-2 rounded-lg transition-colors hover:bg-surface-hover\"><input type=\"checkbox\" class=\"w-4 h-4 rounded\" name=\"is_visible\" value=\"true\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if isUserVisible(u.ID, visibility) {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " checked")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " hx-post=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var20 string
|
||||||
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 322, Col: 63}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" hx-vals=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var21 string
|
||||||
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 323, Col: 47}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" hx-trigger=\"change\" hx-target=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var22 string
|
||||||
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 325, Col: 49}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" hx-swap=\"innerHTML\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var23 string
|
||||||
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 328, Col: 76}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</span> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var24 string
|
||||||
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 329, Col: 75}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</span></label>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<!-- Processing Issues -->")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if issueCount > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<div><a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var25 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 338, Col: 57}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" class=\"flex items-center gap-2 text-sm\" style=\"color: var(--status-warning);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("alert", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if issueCount == 1 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<span>1 processing issue</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var26 string
|
||||||
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 343, Col: 24}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " processing issues</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("chevron-right", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</a></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<!-- Actions --><div class=\"flex gap-2 pt-2 border-t\" style=\"border-color: var(--border);\"><button data-edit-id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var27 string
|
||||||
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 352, Col: 28}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" data-edit-name=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var28 string
|
||||||
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 353, Col: 33}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" data-edit-desc=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var29 string
|
||||||
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 354, Col: 40}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" onclick=\"openEditModal(this.dataset.editId, this.dataset.editName, this.dataset.editDesc)\" class=\"btn btn-secondary text-sm\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("edit", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "Edit Details</button> <button data-delete-id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var30 string
|
||||||
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 362, Col: 30}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" data-delete-name=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var31 string
|
||||||
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 363, Col: 35}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\" onclick=\"openDeleteModal(this.dataset.deleteId, this.dataset.deleteName)\" class=\"btn btn-danger text-sm\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "Delete Library</button></div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func FolderBrowserContent(currentPath string, parentPath string, entries []DirEntry, targetInput string, libraryID string) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var32 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var32 == nil {
|
||||||
|
templ_7745c5c3_Var32 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<div><div class=\"flex items-center gap-2 mb-3 p-2 rounded-lg\" style=\"background-color: var(--bg-primary);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("folder", "h-4 w-4 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<code class=\"text-xs flex-1 truncate\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var33 string
|
||||||
|
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 378, Col: 89}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</code></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if parentPath != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<button class=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" hx-get=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var34 string
|
||||||
|
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 384, Col: 117}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("arrow-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<span>..</span></button>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<div class=\"space-y-1 max-h-64 overflow-y-auto\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<button class=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\" hx-get=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var35 string
|
||||||
|
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 397, Col: 118}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("folder", "h-4 w-4 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<span class=\"truncate\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var36 string
|
||||||
|
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 402, Col: 40}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span></button>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div><div class=\"mt-4 flex justify-end\"><button type=\"button\" data-target-input=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var37 string
|
||||||
|
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 409, Col: 35}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" data-current-path=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var38 string
|
||||||
|
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 410, Col: 35}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\" onclick=\"document.getElementById(this.dataset.targetInput).value = this.dataset.currentPath; document.getElementById('folder-browser-modal').classList.add('hidden')\" class=\"btn btn-primary text-sm\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("check", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "Select This Folder</button></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssueData, stats IssueStats) {
|
templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssueData, stats IssueStats) {
|
||||||
@@ -8,87 +7,107 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<title>Processing Issues - Bookhoard</title>
|
<title>Processing Issues - Bookhoard</title>
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="processingIssues" x-init="initializeProcessingIssues('{ libraryID }')" class="theme-{ user.Theme }">
|
<body class="theme-{ user.Theme }">
|
||||||
@Header(user, "/admin/libraries/"+libraryID)
|
@Header(user, "/admin/library")
|
||||||
<main class="flex-1 p-8">
|
<main class="p-8">
|
||||||
|
<div class="mx-auto max-w-4xl">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div>
|
||||||
<div>
|
<div class="flex items-center gap-3 mb-1">
|
||||||
<h1 class="text-3xl font-bold mb-2">Processing Issues</h1>
|
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
<p class="text-gray-600">Items that couldn't be processed in this library</p>
|
@Icon("alert", "h-5 w-5")
|
||||||
|
</span>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Processing Issues</h1>
|
||||||
</div>
|
</div>
|
||||||
<a href="/admin/libraries/{ libraryID }" class="btn-secondary px-4 py-2 rounded-lg">
|
<p class="text-sm" style="color: var(--text-secondary)">Items that couldn't be processed in this library</p>
|
||||||
← Back to Library
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
|
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
|
||||||
<!-- Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
if stats.ErrorCount > 0 {
|
if stats.ErrorCount > 0 {
|
||||||
<div class="card p-6 rounded-lg border-l-4 border-red-500">
|
<div class="stat-card" style="border-left: 4px solid var(--status-danger);">
|
||||||
<h3 class="text-lg font-semibold text-red-600 mb-2">Errors</h3>
|
<div class="flex items-center gap-2 mb-2" style="color: var(--status-danger);">
|
||||||
<p class="text-3xl font-bold">{ stats.ErrorCount }</p>
|
@Icon("x-circle", "h-5 w-5")
|
||||||
</div>
|
<h3 class="text-sm font-semibold uppercase tracking-wide">Errors</h3>
|
||||||
}
|
</div>
|
||||||
if stats.WarningCount > 0 {
|
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.ErrorCount }</p>
|
||||||
<div class="card p-6 rounded-lg border-l-4 border-yellow-500">
|
</div>
|
||||||
<h3 class="text-lg font-semibold text-yellow-600 mb-2">Warnings</h3>
|
}
|
||||||
<p class="text-3xl font-bold">{ stats.WarningCount }</p>
|
if stats.WarningCount > 0 {
|
||||||
</div>
|
<div class="stat-card" style="border-left: 4px solid var(--status-warning);">
|
||||||
}
|
<div class="flex items-center gap-2 mb-2" style="color: var(--status-warning);">
|
||||||
if stats.InfoCount > 0 {
|
@Icon("alert", "h-5 w-5")
|
||||||
<div class="card p-6 rounded-lg border-l-4 border-blue-500">
|
<h3 class="text-sm font-semibold uppercase tracking-wide">Warnings</h3>
|
||||||
<h3 class="text-lg font-semibold text-blue-600 mb-2">Info</h3>
|
</div>
|
||||||
<p class="text-3xl font-bold">{ stats.InfoCount }</p>
|
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.WarningCount }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
if stats.InfoCount > 0 {
|
||||||
}
|
<div class="stat-card" style="border-left: 4px solid var(--status-info);">
|
||||||
if len(issues) == 0 {
|
<div class="flex items-center gap-2 mb-2" style="color: var(--status-info);">
|
||||||
<div class="card p-8 rounded-lg text-center">
|
@Icon("info", "h-5 w-5")
|
||||||
<p class="text-gray-600">No processing issues found for this library.</p>
|
<h3 class="text-sm font-semibold uppercase tracking-wide">Info</h3>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.InfoCount }</p>
|
||||||
<!-- Issues List -->
|
</div>
|
||||||
<div class="space-y-4">
|
}
|
||||||
for _, issue := range issues {
|
</div>
|
||||||
<div class="card p-6 rounded-lg">
|
}
|
||||||
<div class="flex justify-between items-start mb-4">
|
if len(issues) == 0 {
|
||||||
<div class="flex-1">
|
<div class="card p-8 text-center">
|
||||||
<h4 class="text-lg font-semibold mb-2">{ issue.Title }</h4>
|
<span class="grid place-items-center h-12 w-12 mx-auto mb-3 rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
<p class="text-gray-700 mb-3">{ issue.IssueDescription }</p>
|
@Icon("check-circle", "h-6 w-6")
|
||||||
<div class="text-sm text-gray-500 space-y-1">
|
</span>
|
||||||
<p><strong>Type:</strong> { issue.IssueType }</p>
|
<p class="text-sm" style="color: var(--text-secondary)">No processing issues found for this library.</p>
|
||||||
<p><strong>Format:</strong> { issue.FormatGroup }</p>
|
</div>
|
||||||
<p><strong>File:</strong> { issue.FilePath }</p>
|
} else {
|
||||||
<p><strong>Library:</strong> { issue.LibraryTypeName }</p>
|
<!-- Issues List -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
for _, issue := range issues {
|
||||||
|
<div class="card p-6" id={ "issue-" + issue.ID }>
|
||||||
|
<div class="flex justify-between items-start gap-4 mb-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ issue.Title }</h4>
|
||||||
|
<p class="mb-3 text-sm" style="color: var(--text-secondary)">{ issue.IssueDescription }</p>
|
||||||
|
<div class="text-sm space-y-1" style="color: var(--text-secondary)">
|
||||||
|
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Type:</span> { issue.IssueType }</p>
|
||||||
|
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Format:</span> { issue.FormatGroup }</p>
|
||||||
|
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">File:</span> { issue.FilePath }</p>
|
||||||
|
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Library:</span> { issue.LibraryTypeName }</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 shrink-0">
|
||||||
|
if issue.Severity == "error" {
|
||||||
|
<span class="badge status-failed">{ issue.Severity }</span>
|
||||||
|
} else if issue.Severity == "warning" {
|
||||||
|
<span class="badge status-pending">{ issue.Severity }</span>
|
||||||
|
} else {
|
||||||
|
<span class="badge status-processing">{ issue.Severity }</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
|
||||||
<span
|
|
||||||
class="inline-block px-3 py-1 text-sm rounded-full font-medium"
|
|
||||||
style="background-color: var(--accent); color: white;"
|
|
||||||
>
|
|
||||||
{ issue.Severity }
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3 mt-4">
|
<div class="flex gap-3 mt-4">
|
||||||
if issue.Severity == "warning" || issue.Severity == "info" {
|
if issue.Severity == "warning" || issue.Severity == "info" {
|
||||||
<button
|
<button
|
||||||
@click="dismissIssue('{ issue.ID }', '{ issue.MediaItemID }')"
|
hx-post={ "/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve" }
|
||||||
class="btn-secondary px-4 py-2 rounded text-sm"
|
hx-target={ "#issue-" + issue.ID }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm="Dismiss this issue?"
|
||||||
|
class="btn btn-secondary text-sm"
|
||||||
>
|
>
|
||||||
|
@Icon("close", "h-4 w-4")
|
||||||
Dismiss
|
Dismiss
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
// templ: version: v0.3.1020
|
||||||
|
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
@@ -30,208 +29,341 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Processing Issues - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"processingIssues\" x-init=\"initializeProcessingIssues('{ libraryID }')\" class=\"theme-{ user.Theme }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Processing Issues - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = Header(user, "/admin/libraries/"+libraryID).Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Header(user, "/admin/library").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"flex-1 p-8\"><div class=\"mb-8\"><div class=\"flex items-center justify-between mb-4\"><div><h1 class=\"text-3xl font-bold mb-2\">Processing Issues</h1><p class=\"text-gray-600\">Items that couldn't be processed in this library</p></div><a href=\"/admin/libraries/{ libraryID }\" class=\"btn-secondary px-4 py-2 rounded-lg\">← Back to Library</a></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"p-8\"><div class=\"mx-auto max-w-4xl\"><div class=\"mb-8\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Processing Issues</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Items that couldn't be processed in this library</p></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
|
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<!-- Stats Cards --> <div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 mb-8\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<!-- Stats Cards --> <div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 mb-8\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if stats.ErrorCount > 0 {
|
if stats.ErrorCount > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"card p-6 rounded-lg border-l-4 border-red-500\"><h3 class=\"text-lg font-semibold text-red-600 mb-2\">Errors</h3><p class=\"text-3xl font-bold\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-danger);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-danger);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("x-circle", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Errors</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 32, Col: 56}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 36, Col: 92}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</p></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if stats.WarningCount > 0 {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"card p-6 rounded-lg border-l-4 border-yellow-500\"><h3 class=\"text-lg font-semibold text-yellow-600 mb-2\">Warnings</h3><p class=\"text-3xl font-bold\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var3 string
|
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 38, Col: 58}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</p></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</p></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if stats.WarningCount > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-warning);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-warning);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Warnings</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 45, Col: 94}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</p></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
if stats.InfoCount > 0 {
|
if stats.InfoCount > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"card p-6 rounded-lg border-l-4 border-blue-500\"><h3 class=\"text-lg font-semibold text-blue-600 mb-2\">Info</h3><p class=\"text-3xl font-bold\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-info);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-info);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("info", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Info</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 44, Col: 55}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 54, Col: 91}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(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, 9, "</p></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</p></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"card p-8 rounded-lg text-center\"><p class=\"text-gray-600\">No processing issues found for this library.</p></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"card p-8 text-center\"><span class=\"grid place-items-center h-12 w-12 mx-auto mb-3 rounded-2xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("check-circle", "h-6 w-6").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span><p class=\"text-sm\" style=\"color: var(--text-secondary)\">No processing issues found for this library.</p></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<!-- Issues List --> <div class=\"space-y-4\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<!-- Issues List --> <div class=\"space-y-4\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, issue := range issues {
|
for _, issue := range issues {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"card p-6 rounded-lg\"><div class=\"flex justify-between items-start mb-4\"><div class=\"flex-1\"><h4 class=\"text-lg font-semibold mb-2\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"card p-6\" id=\"")
|
||||||
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(issue.Title)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 60, Col: 62}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 70, Col: 54}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 14, "</h4><p class=\"text-gray-700 mb-3\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"><div class=\"flex justify-between items-start gap-4 mb-4\"><div class=\"flex-1 min-w-0\"><h4 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 61, Col: 64}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 73, Col: 98}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</p><div class=\"text-sm text-gray-500 space-y-1\"><p><strong>Type:</strong> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</h4><p class=\"mb-3 text-sm\" style=\"color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 63, Col: 54}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 96}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p><p><strong>Format:</strong> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p><div class=\"text-sm space-y-1\" style=\"color: var(--text-secondary)\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Type:</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 64, Col: 58}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 76, Col: 140}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</p><p><strong>File:</strong> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Format:</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 65, Col: 53}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 77, Col: 144}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p><p><strong>Library:</strong> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">File:</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 66, Col: 63}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 139}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</p></div></div><div class=\"ml-4\"><span class=\"inline-block px-3 py-1 text-sm rounded-full font-medium\" style=\"background-color: var(--accent); color: white;\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Library:</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 149}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span></div></div><div class=\"flex gap-3 mt-4\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</p></div></div><div class=\"ml-2 shrink-0\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if issue.Severity == "warning" || issue.Severity == "info" {
|
if issue.Severity == "error" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<button @click=\"dismissIssue('{ issue.ID }', '{ issue.MediaItemID }')\" class=\"btn-secondary px-4 py-2 rounded text-sm\">Dismiss</button>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span class=\"badge status-failed\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 84, Col: 62}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else if issue.Severity == "warning" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<span class=\"badge status-pending\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 string
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 86, Col: 63}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span class=\"badge status-processing\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var14 string
|
||||||
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 88, Col: 66}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div></div><div class=\"flex gap-3 mt-4\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if issue.Severity == "warning" || issue.Severity == "info" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<button hx-post=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 95, Col: 113}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-target=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var16 string
|
||||||
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 96, Col: 43}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" hx-swap=\"outerHTML\" hx-confirm=\"Dismiss this issue?\" class=\"btn btn-secondary text-sm\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("close", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "Dismiss</button>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</main></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div></main></body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+190
-39
@@ -1,6 +1,8 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
templ AdminSettings(user User, systemConfig map[string]string, errorMessage string) {
|
import "fmt"
|
||||||
|
|
||||||
|
templ AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -8,58 +10,63 @@ templ AdminSettings(user User, systemConfig map[string]string, errorMessage stri
|
|||||||
<title>System Settings - Bookhoard</title>
|
<title>System Settings - Bookhoard</title>
|
||||||
<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"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body class="theme-{ user.Theme }" x-data="adminSettings">
|
<body class="theme-{ user.Theme }">
|
||||||
@Header(user, "/admin/settings")
|
@Header(user, "/admin/settings")
|
||||||
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
|
<main class="p-8">
|
||||||
@AdminSidebar(user, "/admin/settings")
|
<div class="max-w-4xl">
|
||||||
<main class="flex-1 p-8">
|
<div class="mb-8">
|
||||||
<div class="max-w-4xl">
|
<div class="flex items-center gap-3 mb-1">
|
||||||
<div class="mb-8">
|
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
<div class="flex items-center justify-between mb-4">
|
@Icon("gear", "h-5 w-5")
|
||||||
<a href="/admin" class="btn-secondary px-4 py-2 rounded-lg font-medium">
|
</span>
|
||||||
← Back to Dashboard
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">System Settings</h1>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
<p class="text-sm" style="color: var(--text-secondary)">Configure your Bookhoard instance</p>
|
||||||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">System Settings</h1>
|
</div>
|
||||||
<p style="color: var(--text-secondary)">Configure your Bookhoard instance</p>
|
|
||||||
</div>
|
|
||||||
if errorMessage != "" {
|
if errorMessage != "" {
|
||||||
<div class="mb-6 p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--error); color: var(--error);">
|
<div class="mb-6 p-4 rounded-xl border flex items-start gap-3" style="background-color: color-mix(in srgb, var(--status-danger) 12%, var(--bg-secondary)); border-color: var(--status-danger); color: var(--status-danger);">
|
||||||
{ errorMessage }
|
@Icon("alert", "h-5 w-5 shrink-0 mt-0.5")
|
||||||
|
<span>{ errorMessage }</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<form id="settings-form" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
|
<form id="settings-form" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
|
||||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="card p-6">
|
||||||
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Base URL</h3>
|
<div class="flex items-center gap-2 mb-6">
|
||||||
|
@Icon("globe", "h-5 w-5 shrink-0")
|
||||||
|
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Base URL</h3>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Base URL</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Base URL</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
name="base_url"
|
name="base_url"
|
||||||
value={ systemConfig["base_url"] }
|
value={ systemConfig["base_url"] }
|
||||||
placeholder="https://books.example.com"
|
placeholder="https://books.example.com"
|
||||||
class="w-full px-4 py-2 rounded-lg border"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p class="text-sm mt-1" style="color: var(--text-secondary)">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p>
|
<p class="text-sm mt-2" style="color: var(--text-secondary)">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
|
<button type="submit" class="btn btn-primary">
|
||||||
|
@Icon("save", "h-4 w-4")
|
||||||
Save Settings
|
Save Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="mt-6 card p-6">
|
||||||
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">System Defaults</h3>
|
<div class="flex items-center gap-2 mb-6">
|
||||||
|
@Icon("clock", "h-5 w-5 shrink-0")
|
||||||
|
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">System Defaults</h3>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Default Timezone</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Default Timezone</label>
|
||||||
<select
|
<select
|
||||||
name="default_timezone"
|
name="default_timezone"
|
||||||
id="default_timezone"
|
id="default_timezone"
|
||||||
class="w-full px-4 py-2 rounded-lg border"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
>
|
>
|
||||||
<option value="UTC" selected?={ systemConfig["default_timezone"] == "UTC" }>UTC (UTC+0)</option>
|
<option value="UTC" selected?={ systemConfig["default_timezone"] == "UTC" }>UTC (UTC+0)</option>
|
||||||
<option value="Pacific/Honolulu" selected?={ systemConfig["default_timezone"] == "Pacific/Honolulu" }>Hawaii (UTC-10)</option>
|
<option value="Pacific/Honolulu" selected?={ systemConfig["default_timezone"] == "Pacific/Honolulu" }>Hawaii (UTC-10)</option>
|
||||||
@@ -86,21 +93,165 @@ templ AdminSettings(user User, systemConfig map[string]string, errorMessage stri
|
|||||||
<option value="Australia/Sydney" selected?={ systemConfig["default_timezone"] == "Australia/Sydney" }>Australian Eastern (UTC+10/+11)</option>
|
<option value="Australia/Sydney" selected?={ systemConfig["default_timezone"] == "Australia/Sydney" }>Australian Eastern (UTC+10/+11)</option>
|
||||||
<option value="Pacific/Auckland" selected?={ systemConfig["default_timezone"] == "Pacific/Auckland" }>New Zealand (UTC+12/+13)</option>
|
<option value="Pacific/Auckland" selected?={ systemConfig["default_timezone"] == "Pacific/Auckland" }>New Zealand (UTC+12/+13)</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="text-sm mt-1" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
|
<p class="text-sm mt-2" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="mt-6 card p-6">
|
||||||
<h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">URL Paths</h3>
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
@Icon("external", "h-5 w-5 shrink-0")
|
||||||
|
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">URL Paths</h3>
|
||||||
|
</div>
|
||||||
<div class="space-y-2 text-sm" style="color: var(--text-secondary);">
|
<div class="space-y-2 text-sm" style="color: var(--text-secondary);">
|
||||||
<p><strong>OPDS:</strong> { systemConfig["base_url"] }/opds</p>
|
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">OPDS:</span> { systemConfig["base_url"] }/opds</p>
|
||||||
<p><strong>API:</strong> { systemConfig["base_url"] }/api</p>
|
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">API:</span> { systemConfig["base_url"] }/api</p>
|
||||||
<p><strong>Device Sync:</strong> { systemConfig["base_url"] }/api/sync</p>
|
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Device Sync:</span> { systemConfig["base_url"] }/api/sync</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
@ScanSettingsSection(scanSettings)
|
||||||
</main>
|
@TunableSettingsSection(liveGroups, false)
|
||||||
|
@TunableSettingsSection(restartGroups, true)
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</main>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ ScanSettingsSection(scanSettings ScanSettingsData) {
|
||||||
|
<div id="scan-settings-section" class="mt-6 card p-6">
|
||||||
|
<div class="flex items-center gap-2 mb-6">
|
||||||
|
@Icon("refresh", "h-5 w-5 shrink-0")
|
||||||
|
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Scanning</h3>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
hx-put="/admin/settings/scan"
|
||||||
|
hx-target="#scan-settings-section"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary)">Auto-Scan</label>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Watch libraries for file changes on startup</p>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer shrink-0">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="auto_scan_enabled"
|
||||||
|
value="true"
|
||||||
|
checked?={ scanSettings.AutoScanEnabled }
|
||||||
|
class="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div class="w-11 h-6 rounded-full peer peer-checked:bg-brand transition-colors" style="background-color: color-mix(in srgb, var(--text-primary) 15%, transparent);"></div>
|
||||||
|
<div class="absolute left-0.5 top-0.5 bg-white rounded-full w-5 h-5 transition-transform peer-checked:translate-x-5"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Scan Interval (seconds)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="scan_poll_interval_seconds"
|
||||||
|
value={ fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds) }
|
||||||
|
min="1"
|
||||||
|
max="3600"
|
||||||
|
class="input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="text-sm mt-2" style="color: var(--text-secondary)">How often to poll libraries for changes (1–3600 seconds). Default: 60.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
@Icon("save", "h-4 w-4")
|
||||||
|
Save Scan Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunableSettingsSection renders the editable tunables for a given bucket
|
||||||
|
// (live vs restart-required). Within the card, settings are clustered into
|
||||||
|
// labeled sub-sections by Group (e.g. "Password Quality", "Device Rate Limits").
|
||||||
|
templ TunableSettingsSection(groups []SettingGroup, restartRequired bool) {
|
||||||
|
<div class="mt-6 card p-6">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
if restartRequired {
|
||||||
|
@Icon("alert", "h-5 w-5 shrink-0")
|
||||||
|
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Tunable Settings — Restart Required</h3>
|
||||||
|
} else {
|
||||||
|
@Icon("settings", "h-5 w-5 shrink-0")
|
||||||
|
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Tunable Settings — Live</h3>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if restartRequired {
|
||||||
|
<p class="text-sm mb-4" style="color: var(--status-warning)">Changes are saved immediately but only take effect after the server restarts.</p>
|
||||||
|
} else {
|
||||||
|
<p class="text-sm mb-4" style="color: var(--text-secondary)">Changes apply immediately — no restart needed.</p>
|
||||||
|
}
|
||||||
|
for _, g := range groups {
|
||||||
|
<div class="mt-5 first:mt-0">
|
||||||
|
<h4 class="text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary)">{ g.Name }</h4>
|
||||||
|
<div>
|
||||||
|
for _, e := range g.Entries {
|
||||||
|
@TunableSettingRow(e)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunableSettingRow renders a single editable setting as an inline HTMX form.
|
||||||
|
templ TunableSettingRow(e SettingEntry) {
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center gap-3 py-3" style="border-top: 1px solid color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<label class="block text-sm font-medium" style="color: var(--text-primary)">{ e.Description }</label>
|
||||||
|
if !e.IsDefault {
|
||||||
|
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">{ e.Key } — modified from default</p>
|
||||||
|
} else {
|
||||||
|
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">{ e.Key }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
class="flex items-center gap-2 shrink-0"
|
||||||
|
hx-put="/admin/settings/tunable"
|
||||||
|
hx-target={ "#status-" + e.Key }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-disinherit="*"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="key" value={ e.Key }/>
|
||||||
|
if e.Type == "bool" {
|
||||||
|
<select name="value" class="input py-1.5 text-sm w-28">
|
||||||
|
<option value="true" selected?={ e.Value == "true" }>Yes</option>
|
||||||
|
<option value="false" selected?={ e.Value != "true" }>No</option>
|
||||||
|
</select>
|
||||||
|
} else if e.Type == "int" {
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="value"
|
||||||
|
value={ e.Value }
|
||||||
|
if e.Min != "" {
|
||||||
|
min={ e.Min }
|
||||||
|
}
|
||||||
|
if e.Max != "" {
|
||||||
|
max={ e.Max }
|
||||||
|
}
|
||||||
|
class="input py-1.5 text-sm w-32"
|
||||||
|
/>
|
||||||
|
} else {
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="value"
|
||||||
|
value={ e.Value }
|
||||||
|
class="input py-1.5 text-sm w-40"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<button type="submit" class="btn btn-secondary px-3 py-1.5 text-sm">
|
||||||
|
@Icon("save", "h-3.5 w-3.5")
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<span id={ "status-" + e.Key } class="text-xs w-24 text-right" style="color: var(--text-secondary)"></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ package templates
|
|||||||
import "github.com/a-h/templ"
|
import "github.com/a-h/templ"
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
func AdminSettings(user User, systemConfig map[string]string, errorMessage string) templ.Component {
|
import "fmt"
|
||||||
|
|
||||||
|
func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
@@ -29,7 +31,7 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>System Settings - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-{ user.Theme }\" x-data=\"adminSettings\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>System Settings - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -37,330 +39,792 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex min-h-screen\" style=\"background-color: var(--bg-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = AdminSidebar(user, "/admin/settings").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Icon("gear", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center justify-between mb-4\"><a href=\"/admin\" class=\"btn-secondary px-4 py-2 rounded-lg font-medium\">← Back to Dashboard</a></div><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">System Settings</h1><p style=\"color: var(--text-secondary)\">Configure your Bookhoard instance</p></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">System Settings</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Configure your Bookhoard instance</p></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if errorMessage != "" {
|
if errorMessage != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"mb-6 p-4 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--error); color: var(--error);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"mb-6 p-4 rounded-xl border flex items-start gap-3\" style=\"background-color: color-mix(in srgb, var(--status-danger) 12%, var(--bg-secondary)); border-color: var(--status-danger); color: var(--status-danger);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("alert", "h-5 w-5 shrink-0 mt-0.5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 29, Col: 22}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 31, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<form id=\"settings-form\" hx-put=\"/api/system/config\" hx-target=\"#settings-form\" hx-swap=\"outerHTML\"><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-xl font-semibold mb-6\" style=\"color: var(--text-primary)\">Base URL</h3><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-primary)\">Base URL</label> <input type=\"url\" name=\"base_url\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<form id=\"settings-form\" hx-put=\"/api/system/config\" hx-target=\"#settings-form\" hx-swap=\"outerHTML\"><div class=\"card p-6\"><div class=\"flex items-center gap-2 mb-6\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("globe", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Base URL</h3></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Base URL</label> <input type=\"url\" name=\"base_url\" value=\"")
|
||||||
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.ResolveAttributeValue(systemConfig["base_url"])
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 40, Col: 42}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 45, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 7, "\" placeholder=\"https://books.example.com\" class=\"w-full px-4 py-2 rounded-lg border\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" required><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p></div><div class=\"mt-6 flex justify-end\"><button type=\"submit\" class=\"btn-primary px-6 py-2 rounded-lg font-medium\">Save Settings</button></div></div><div class=\"mt-8 card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-xl font-semibold mb-6\" style=\"color: var(--text-primary)\">System Defaults</h3><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-primary)\">Default Timezone</label> <select name=\"default_timezone\" id=\"default_timezone\" class=\"w-full px-4 py-2 rounded-lg border\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"UTC\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" placeholder=\"https://books.example.com\" class=\"input\" required><p class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p></div><div class=\"mt-6 flex justify-end\"><button type=\"submit\" class=\"btn btn-primary\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("save", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Save Settings</button></div></div><div class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-6\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("clock", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">System Defaults</h3></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Default Timezone</label> <select name=\"default_timezone\" id=\"default_timezone\" class=\"input\"><option value=\"UTC\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "UTC" {
|
if systemConfig["default_timezone"] == "UTC" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if systemConfig["default_timezone"] == "Pacific/Honolulu" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if systemConfig["default_timezone"] == "America/Anchorage" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "America/Los_Angeles" {
|
if systemConfig["default_timezone"] == "Pacific/Honolulu" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "America/Denver" {
|
if systemConfig["default_timezone"] == "America/Anchorage" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "America/Phoenix" {
|
if systemConfig["default_timezone"] == "America/Los_Angeles" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "America/Chicago" {
|
if systemConfig["default_timezone"] == "America/Denver" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " selected")
|
||||||
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, 21, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "America/New_York" {
|
if systemConfig["default_timezone"] == "America/Phoenix" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "America/Sao_Paulo" {
|
if systemConfig["default_timezone"] == "America/Chicago" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Europe/London" {
|
if systemConfig["default_timezone"] == "America/New_York" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Europe/Paris" {
|
if systemConfig["default_timezone"] == "America/Sao_Paulo" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Europe/Helsinki" {
|
if systemConfig["default_timezone"] == "Europe/London" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Europe/Moscow" {
|
if systemConfig["default_timezone"] == "Europe/Paris" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Asia/Tehran" {
|
if systemConfig["default_timezone"] == "Europe/Helsinki" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Asia/Dubai" {
|
if systemConfig["default_timezone"] == "Europe/Moscow" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Asia/Karachi" {
|
if systemConfig["default_timezone"] == "Asia/Tehran" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected")
|
||||||
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, 39, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Asia/Kolkata" {
|
if systemConfig["default_timezone"] == "Asia/Dubai" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
|
||||||
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, 41, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Asia/Dhaka" {
|
if systemConfig["default_timezone"] == "Asia/Karachi" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
|
||||||
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, 43, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Asia/Bangkok" {
|
if systemConfig["default_timezone"] == "Asia/Kolkata" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
|
||||||
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, 45, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Asia/Shanghai" {
|
if systemConfig["default_timezone"] == "Asia/Dhaka" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
|
||||||
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, 47, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Asia/Tokyo" {
|
if systemConfig["default_timezone"] == "Asia/Bangkok" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
|
||||||
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, 49, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Australia/Darwin" {
|
if systemConfig["default_timezone"] == "Asia/Shanghai" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Australia/Sydney" {
|
if systemConfig["default_timezone"] == "Asia/Tokyo" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
|
||||||
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, 53, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Pacific/Auckland" {
|
if systemConfig["default_timezone"] == "Australia/Darwin" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">New Zealand (UTC+12/+13)</option></select><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">Default timezone for users who haven't set their own.</p></div></div></form><div class=\"mt-8 card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-xl font-semibold mb-4\" style=\"color: var(--text-primary)\">URL Paths</h3><div class=\"space-y-2 text-sm\" style=\"color: var(--text-secondary);\"><p><strong>OPDS:</strong> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if systemConfig["default_timezone"] == "Australia/Sydney" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if systemConfig["default_timezone"] == "Pacific/Auckland" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">New Zealand (UTC+12/+13)</option></select><p class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">Default timezone for users who haven't set their own.</p></div></div></form><div class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-4\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("external", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">URL Paths</h3></div><div class=\"space-y-2 text-sm\" style=\"color: var(--text-secondary);\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">OPDS:</span> ")
|
||||||
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.JoinStringErrs(systemConfig["base_url"])
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 96, Col: 60}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 106, Col: 145}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(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, 56, "/opds</p><p><strong>API:</strong> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "/opds</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">API:</span> ")
|
||||||
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(systemConfig["base_url"])
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 97, Col: 59}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 107, Col: 144}
|
||||||
}
|
}
|
||||||
_, 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, 57, "/api</p><p><strong>Device Sync:</strong> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "/api</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Device Sync:</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 98, Col: 67}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 108, Col: 152}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "/api/sync</p></div></div></div></main></div></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "/api/sync</p></div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = ScanSettingsSection(scanSettings).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = TunableSettingsSection(liveGroups, false).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = TunableSettingsSection(restartGroups, true).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</div></main></body></html>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var7 == nil {
|
||||||
|
templ_7745c5c3_Var7 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<div id=\"scan-settings-section\" class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-6\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("refresh", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Scanning</h3></div><form hx-put=\"/admin/settings/scan\" hx-target=\"#scan-settings-section\" hx-swap=\"outerHTML\"><div class=\"space-y-4\"><div class=\"flex items-center justify-between gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary)\">Auto-Scan</label><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Watch libraries for file changes on startup</p></div><label class=\"relative inline-flex items-center cursor-pointer shrink-0\"><input type=\"checkbox\" name=\"auto_scan_enabled\" value=\"true\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if scanSettings.AutoScanEnabled {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " checked")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " class=\"sr-only peer\"><div class=\"w-11 h-6 rounded-full peer peer-checked:bg-brand transition-colors\" style=\"background-color: color-mix(in srgb, var(--text-primary) 15%, transparent);\"></div><div class=\"absolute left-0.5 top-0.5 bg-white rounded-full w-5 h-5 transition-transform peer-checked:translate-x-5\"></div></label></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Scan Interval (seconds)</label> <input type=\"number\" name=\"scan_poll_interval_seconds\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 154, Col: 69}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" min=\"1\" max=\"3600\" class=\"input\" required><p class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">How often to poll libraries for changes (1–3600 seconds). Default: 60.</p></div><div class=\"flex justify-end\"><button type=\"submit\" class=\"btn btn-primary\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("save", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "Save Scan Settings</button></div></div></form></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunableSettingsSection renders the editable tunables for a given bucket
|
||||||
|
// (live vs restart-required). Within the card, settings are clustered into
|
||||||
|
// labeled sub-sections by Group (e.g. "Password Quality", "Device Rate Limits").
|
||||||
|
func TunableSettingsSection(groups []SettingGroup, restartRequired bool) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var9 == nil {
|
||||||
|
templ_7745c5c3_Var9 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<div class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-2\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if restartRequired {
|
||||||
|
templ_7745c5c3_Err = Icon("alert", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " <h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Tunable Settings — Restart Required</h3>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = Icon("settings", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " <h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Tunable Settings — Live</h3>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if restartRequired {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<p class=\"text-sm mb-4\" style=\"color: var(--status-warning)\">Changes are saved immediately but only take effect after the server restarts.</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Changes apply immediately — no restart needed.</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, g := range groups {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<div class=\"mt-5 first:mt-0\"><h4 class=\"text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 194, Col: 112}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</h4><div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, e := range g.Entries {
|
||||||
|
templ_7745c5c3_Err = TunableSettingRow(e).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunableSettingRow renders a single editable setting as an inline HTMX form.
|
||||||
|
func TunableSettingRow(e SettingEntry) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var11 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var11 == nil {
|
||||||
|
templ_7745c5c3_Var11 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<div class=\"flex flex-col sm:flex-row sm:items-center gap-3 py-3\" style=\"border-top: 1px solid color-mix(in srgb, var(--text-primary) 7%, transparent);\"><div class=\"flex-1 min-w-0\"><label class=\"block text-sm font-medium\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(e.Description)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 209, Col: 94}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</label> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if !e.IsDefault {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 string
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 211, Col: 74}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, " — modified from default</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var14 string
|
||||||
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 213, Col: 74}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</div><form class=\"flex items-center gap-2 shrink-0\" hx-put=\"/admin/settings/tunable\" hx-target=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("#status-" + e.Key)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 219, Col: 33}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" hx-swap=\"innerHTML\" hx-disinherit=\"*\"><input type=\"hidden\" name=\"key\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var16 string
|
||||||
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Key)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 223, Col: 48}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\"> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if e.Type == "bool" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<select name=\"value\" class=\"input py-1.5 text-sm w-28\"><option value=\"true\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if e.Value == "true" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, ">Yes</option> <option value=\"false\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if e.Value != "true" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, ">No</option></select> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else if e.Type == "int" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<input type=\"number\" name=\"value\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var17 string
|
||||||
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 233, Col: 20}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if e.Min != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, " min=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var18 string
|
||||||
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 235, Col: 17}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.Max != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, " max=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var19 string
|
||||||
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 238, Col: 17}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, " class=\"input py-1.5 text-sm w-32\"> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "<input type=\"text\" name=\"value\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var20 string
|
||||||
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 246, Col: 20}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\" class=\"input py-1.5 text-sm w-40\"> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<button type=\"submit\" class=\"btn btn-secondary px-3 py-1.5 text-sm\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("save", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "Save</button></form><span id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var21 string
|
||||||
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 255, Col: 30}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\" class=\"text-xs w-24 text-right\" style=\"color: var(--text-secondary)\"></span></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
package templates
|
|
||||||
|
|
||||||
templ AdminSidebar(user User, currentPath string) {
|
|
||||||
<aside class="w-64 border-r" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
|
||||||
<div class="p-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-6" style="color: var(--text-primary)">Admin Panel</h2>
|
|
||||||
<nav class="space-y-2">
|
|
||||||
<a href="/admin" class={activeClass(currentPath, "/admin")} style="color: var(--text-primary)">
|
|
||||||
🏠 Dashboard
|
|
||||||
</a>
|
|
||||||
<a href="/admin/users" class={activeClass(currentPath, "/admin/users")} style="color: var(--text-primary)">
|
|
||||||
👤 User Administration
|
|
||||||
</a>
|
|
||||||
<a href="/admin/library" class={activeClass(currentPath, "/admin/library")} style="color: var(--text-primary)">
|
|
||||||
📚 Library Management
|
|
||||||
</a>
|
|
||||||
<a href="/admin/settings" class={activeClass(currentPath, "/admin/settings")} style="color: var(--text-primary)">
|
|
||||||
⚙️ System Settings
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
|
||||||
package templates
|
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
|
||||||
|
|
||||||
import "github.com/a-h/templ"
|
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
|
||||||
|
|
||||||
func AdminSidebar(user User, currentPath string) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var1 == nil {
|
|
||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<aside class=\"w-64 border-r\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><div class=\"p-6\"><h2 class=\"text-lg font-semibold mb-6\" style=\"color: var(--text-primary)\">Admin Panel</h2><nav class=\"space-y-2\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var2 = []any{activeClass(currentPath, "/admin")}
|
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<a href=\"/admin\" class=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var3 string
|
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" style=\"color: var(--text-primary)\">🏠 Dashboard</a> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var4 = []any{activeClass(currentPath, "/admin/users")}
|
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<a href=\"/admin/users\" class=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var5 string
|
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var4).String())
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" style=\"color: var(--text-primary)\">👤 User Administration</a> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var6 = []any{activeClass(currentPath, "/admin/library")}
|
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<a href=\"/admin/library\" class=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var7 string
|
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var6).String())
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" style=\"color: var(--text-primary)\">📚 Library Management</a> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var8 = []any{activeClass(currentPath, "/admin/settings")}
|
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"/admin/settings\" class=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var9 string
|
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String())
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" style=\"color: var(--text-primary)\">⚙️ System Settings</a></nav></div></aside>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
|
||||||
+137
-5
@@ -8,7 +8,7 @@ package templates
|
|||||||
import "github.com/a-h/templ"
|
import "github.com/a-h/templ"
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
func Admin(user User) templ.Component {
|
func Admin(user User, stats AdminStats) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
@@ -29,7 +29,7 @@ func Admin(user User) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Admin Dashboard - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"admin\" x-init=\"loadWatchStatus(); initializeScanWebSocket()\" class=\"theme-tokyo-night\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Admin Dashboard - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"admin\" x-init=\"loadWatchStatus()\" class=\"theme-{ user.Theme }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -37,15 +37,147 @@ func Admin(user User) 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, 2, "<div class=\"flex min-h-screen\" style=\"background-color: var(--bg-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = AdminSidebar(user, "/admin").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Icon("grid", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">Dashboard</h1><p style=\"color: var(--text-secondary)\">Overview of your Bookhoard library and settings</p></div><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6 mb-8\"><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><div class=\"flex items-center space-x-3\"><div class=\"text-3xl\">📖</div><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Library</h3><p style=\"color: var(--text-secondary)\" class=\"text-sm\">Manage your ebook collection</p></div></div><a href=\"/\" class=\"mt-4 inline-block text-sm btn-secondary px-3 py-1 rounded\">View Library</a></div><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><div class=\"flex items-center space-x-3\"><div class=\"text-3xl\">👁️</div><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Scan Watch Status</h3><p style=\"color: var(--text-secondary)\" class=\"text-sm\">Auto-detecting new files</p></div></div><div id=\"watch-status\" class=\"mt-4 text-sm\" style=\"color: var(--text-secondary)\"><span class=\"inline-block w-2 h-2 rounded-full bg-green-500 mr-2\"></span> Watching <span id=\"watch-count\">0</span> libraries</div></div></div><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><h3 class=\"text-xl font-semibold mb-4\" style=\"color: var(--text-primary)\">Quick Actions</h3><div class=\"grid grid-cols-1 sm:grid-cols-2 gap-4\"><button @click=\"scanAllLibraries()\" class=\"btn-primary p-4 rounded-lg text-left\"><div class=\"font-medium\">Rescan Library</div><div style=\"color: var(--text-secondary)\" class=\"text-sm\">Re-scan existing files and fix metadata</div></button> <a href=\"/admin/library\" class=\"btn-secondary p-4 rounded-lg text-left block\"><div class=\"font-medium\">Manage Libraries and Folders</div><div style=\"color: var(--text-secondary)\" class=\"text-sm\">Add or remove libraries and scan directories</div></a></div></div><!-- Scan Progress Section --><div id=\"scan-progress-container\" class=\"hidden mt-6 p-6 rounded-lg border opacity-0 -translate-y-2.5 transition-all duration-300 ease-out\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-4\"><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">📚 Scanning Libraries</h3><button @click=\"hideScanProgress()\" class=\"p-2 hover:bg-gray-700 rounded\">✕</button></div><!-- Overall Progress --><div class=\"mb-4\"><div class=\"flex justify-between text-sm mb-2\"><span style=\"color: var(--text-secondary)\">Overall Progress</span> <span id=\"scan-progress-text\" style=\"color: var(--text-primary)\">0%</span></div><div class=\"w-full bg-gray-700 rounded-full h-3\"><div id=\"scan-progress-bar\" class=\"h-3 rounded-full transition-all duration-500\" style=\"width: 0%; background-color: var(--accent);\"></div></div><div id=\"scan-status\" class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">Starting scan...</div></div><!-- Per-Library Progress --><div id=\"library-progress-list\" class=\"space-y-3\"><!-- Dynamically populated --></div><!-- Results Summary --><div id=\"scan-results\" class=\"hidden mt-6 p-4 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><h4 class=\"font-semibold mb-2\" style=\"color: var(--text-primary)\">✅ Scan Complete!</h4><div id=\"scan-results-content\" style=\"color: var(--text-secondary)\"><!-- Results populated by JS --></div><div class=\"mt-4 flex gap-2\"><button @click=\"window.location.reload()\" class=\"btn-primary px-4 py-2 rounded-lg\">Refresh to View Books</button> <button @click=\"hideScanProgress()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Dismiss</button></div></div></div></div></main></div></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Dashboard</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Overview of your Bookhoard instance</p></div><!-- Stats Grid --><div class=\"grid grid-cols-2 md:grid-cols-4 gap-4 mb-6\"><div class=\"stat-card\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--text-secondary);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("library", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Libraries</span></div><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var2 string
|
||||||
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.LibraryCount)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 32, Col: 92}
|
||||||
|
}
|
||||||
|
_, 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, 5, "</p></div><div class=\"stat-card\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--text-secondary);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("book", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Books</span></div><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.MediaCount)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 39, Col: 90}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</p></div><div class=\"stat-card\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--text-secondary);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("users", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Users</span></div><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 string
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.UserCount)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 46, Col: 89}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</p></div><div class=\"stat-card\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--text-secondary);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("device", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Devices</span></div><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.DeviceCount)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 53, Col: 91}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p></div></div><!-- Watch Status --><div class=\"stat-card mb-6\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("sync", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span><div class=\"flex-1\"><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">File Watcher</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Auto-detects new files in library folders</p></div><div class=\"text-right text-sm\" style=\"color: var(--text-secondary)\"><span class=\"inline-block w-2 h-2 rounded-full mr-2\" style=\"background-color: var(--status-success);\"></span> Watching <span id=\"watch-count\">0</span> libraries</div></div></div><!-- Quick Actions --><div class=\"card p-6\"><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary)\">Quick Actions</h3><div class=\"grid grid-cols-1 sm:grid-cols-2 gap-4\"><button @click=\"scanAllLibraries()\" class=\"btn btn-primary py-4 flex-col items-start gap-1\"><span class=\"flex items-center gap-2 font-medium\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("refresh", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "Scan All Libraries</span> <span class=\"text-xs font-normal opacity-80\">Re-scan existing files and detect new items</span></button> <a href=\"/admin/library\" class=\"btn btn-secondary py-4 flex-col items-start gap-1\"><span class=\"flex items-center gap-2 font-medium\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Manage Libraries</span> <span class=\"text-xs font-normal opacity-80\">Add or remove libraries and folders</span></a></div></div><!-- Scan Progress Section --><div id=\"scan-progress-container\" class=\"card hidden mt-6 p-6 opacity-0 -translate-y-2.5 transition-all duration-300 ease-out\"><div class=\"flex justify-between items-center mb-4\"><h3 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("refresh", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Scanning Libraries</h3><button @click=\"hideScanProgress()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button></div><div class=\"mb-4\"><div class=\"flex justify-between text-sm mb-2\"><span style=\"color: var(--text-secondary)\">Overall Progress</span> <span id=\"scan-progress-text\" style=\"color: var(--text-primary)\">0%</span></div><div class=\"w-full rounded-full h-3\" style=\"background-color: var(--surface-hover);\"><div id=\"scan-progress-bar\" class=\"h-3 rounded-full transition-all duration-500\" style=\"width: 0%; background-color: var(--accent);\"></div></div><div id=\"scan-status\" class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">Starting scan...</div></div><div id=\"library-progress-list\" class=\"space-y-3\"></div><div id=\"scan-results\" class=\"hidden mt-6 p-4 rounded-xl border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><h4 class=\"font-semibold mb-2 flex items-center gap-2\" style=\"color: var(--status-success);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("check-circle", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Scan Complete!</h4><div id=\"scan-results-content\" style=\"color: var(--text-secondary)\"></div><div class=\"mt-4 flex gap-2\"><button @click=\"window.location.reload()\" class=\"btn btn-primary\">Refresh to View Books</button> <button @click=\"hideScanProgress()\" class=\"btn btn-secondary\">Dismiss</button></div></div></div></div></main></body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-117
@@ -8,127 +8,131 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
|||||||
<title>Users - Bookhoard Admin</title>
|
<title>Users - Bookhoard Admin</title>
|
||||||
<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"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body class="theme-{ currentUser.Theme }">
|
<body class="theme-{ currentUser.Theme }">
|
||||||
@Header(currentUser, "/admin/users")
|
@Header(currentUser, "/admin/users")
|
||||||
<!-- Modal Container (populated by HTMX) -->
|
<!-- Modal Container (populated by HTMX) -->
|
||||||
<div id="modal-container"></div>
|
<div id="modal-container"></div>
|
||||||
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
|
<main class="p-8">
|
||||||
@AdminSidebar(currentUser, "/admin/users")
|
<div class="max-w-5xl">
|
||||||
<main class="flex-1 p-8">
|
<div class="mb-8">
|
||||||
<div class="w-full">
|
<div class="flex items-center gap-3 mb-1">
|
||||||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">User Management</h1>
|
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
<p style="color: var(--text-secondary)">Manage user accounts and permissions</p>
|
@Icon("users", "h-5 w-5")
|
||||||
</div>
|
</span>
|
||||||
<!-- Users Table -->
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">User Management</h1>
|
||||||
<div class="card rounded-lg border overflow-hidden" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
</div>
|
||||||
<table class="w-full">
|
<p class="text-sm" style="color: var(--text-secondary)">Manage user accounts and permissions</p>
|
||||||
<thead style="background-color: var(--bg-primary)">
|
</div>
|
||||||
<tr>
|
<!-- Users Table -->
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Username</th>
|
<div class="card overflow-hidden">
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Email</th>
|
<table class="w-full">
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Role</th>
|
<thead style="background-color: var(--bg-primary)">
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Created</th>
|
<tr>
|
||||||
<th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Actions</th>
|
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Username</th>
|
||||||
</tr>
|
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Email</th>
|
||||||
</thead>
|
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Role</th>
|
||||||
<tbody class="divide-y" style="divide-color: var(--border)">
|
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Created</th>
|
||||||
for _, user := range users {
|
<th class="px-6 py-3 text-right text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Actions</th>
|
||||||
<tr id={ "user-" + user.ID } class="hover:bg-opacity-50" style="transition: background-color 0.2s;">
|
</tr>
|
||||||
<!-- Username -->
|
</thead>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<tbody class="divide-y" style="divide-color: var(--border)">
|
||||||
<div class="flex items-center">
|
for _, user := range users {
|
||||||
<div>
|
<tr id={ "user-" + user.ID } class="transition-colors hover:bg-surface-hover">
|
||||||
<div class="text-sm font-medium" style="color: var(--text-primary)">{ user.Username }</div>
|
<!-- Username -->
|
||||||
if user.ID == currentUser.ID {
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<span class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: white;">You</span>
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="grid place-items-center h-8 w-8 rounded-full shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
@Icon("user", "h-4 w-4")
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium" style="color: var(--text-primary)">{ user.Username }</div>
|
||||||
|
if user.ID == currentUser.ID {
|
||||||
|
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">You</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<!-- Email -->
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm" style="color: var(--text-primary)">{ user.Email }</div>
|
||||||
|
</td>
|
||||||
|
<!-- Role Toggle (with last-admin protection) -->
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
if user.Role == "admin" && adminCount == 1 {
|
||||||
|
<!-- Last admin - disabled -->
|
||||||
|
<div class="relative">
|
||||||
|
<select
|
||||||
|
disabled
|
||||||
|
class="input w-auto py-1 pr-7 text-xs opacity-50 cursor-not-allowed"
|
||||||
|
title="Cannot demote the last admin"
|
||||||
|
>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="admin" selected>Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<select
|
||||||
|
hx-put={ "/api/auth/profile/" + user.ID }
|
||||||
|
hx-target={ "#role-result-" + user.ID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-trigger="change"
|
||||||
|
name="role"
|
||||||
|
class="input w-auto py-1 pr-7 text-xs"
|
||||||
|
>
|
||||||
|
<option value="user" selected?={ user.Role == "user" }>User</option>
|
||||||
|
<option value="admin" selected?={ user.Role == "admin" }>Admin</option>
|
||||||
|
</select>
|
||||||
|
<div id={ "role-result-" + user.ID } class="text-xs mt-1"></div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<!-- Created -->
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm" style="color: var(--text-secondary)">{ FormatInTimezone(user.CreatedAt, currentUser.Timezone) }</div>
|
||||||
|
</td>
|
||||||
|
<!-- Actions -->
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<div class="inline-flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
hx-get={ "/admin/users/" + user.ID + "/profile-modal" }
|
||||||
|
hx-target="#modal-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="btn btn-secondary text-xs px-2.5 py-1"
|
||||||
|
>
|
||||||
|
@Icon("edit", "h-4 w-4")
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
if user.Role == "admin" && adminCount == 1 {
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
class="btn btn-danger text-xs px-2.5 py-1"
|
||||||
|
title="Cannot delete the last admin"
|
||||||
|
>
|
||||||
|
@Icon("trash", "h-4 w-4")
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
} else {
|
||||||
|
<button
|
||||||
|
hx-delete={ "/api/auth/profile/" + user.ID }
|
||||||
|
hx-target={ "#user-" + user.ID }
|
||||||
|
hx-swap="outerHTML swap:0.5s"
|
||||||
|
hx-confirm="Are you sure you want to delete this user? This action cannot be undone."
|
||||||
|
class="btn btn-danger text-xs px-2.5 py-1"
|
||||||
|
>
|
||||||
|
@Icon("trash", "h-4 w-4")
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
<!-- Email -->
|
}
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
</tbody>
|
||||||
<div class="text-sm" style="color: var(--text-primary)">{ user.Email }</div>
|
</table>
|
||||||
</td>
|
</div>
|
||||||
<!-- Role Toggle (with last-admin protection) -->
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
if user.Role == "admin" && adminCount == 1 {
|
|
||||||
<!-- Last admin - disabled -->
|
|
||||||
<div class="relative">
|
|
||||||
<select
|
|
||||||
disabled
|
|
||||||
class="text-sm rounded px-2 py-1 cursor-not-allowed opacity-50"
|
|
||||||
style="background-color: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border);"
|
|
||||||
title="Cannot demote the last admin"
|
|
||||||
>
|
|
||||||
<option value="user">User</option>
|
|
||||||
<option value="admin" selected>Admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
} else {
|
|
||||||
<select
|
|
||||||
hx-put={ "/api/auth/profile/" + user.ID }
|
|
||||||
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
|
|
||||||
hx-target={ "#role-result-" + user.ID }
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
name="role"
|
|
||||||
class="text-sm rounded px-2 py-1"
|
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
|
|
||||||
onchange="this.dispatchEvent(new Event('htmx:trigger'))"
|
|
||||||
hx-trigger="change"
|
|
||||||
hx-vals='{"role": this.value}'
|
|
||||||
>
|
|
||||||
<option value="user" selected?={ user.Role == "user" }>User</option>
|
|
||||||
<option value="admin" selected?={ user.Role == "admin" }>Admin</option>
|
|
||||||
</select>
|
|
||||||
<div id={ "role-result-" + user.ID } class="text-xs mt-1"></div>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<!-- Created -->
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-sm" style="color: var(--text-secondary)">{ FormatInTimezone(user.CreatedAt, currentUser.Timezone) }</div>
|
|
||||||
</td>
|
|
||||||
<!-- Actions -->
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
|
||||||
<button
|
|
||||||
hx-get={ "/admin/users/" + user.ID + "/profile-modal" }
|
|
||||||
hx-target="#modal-container"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
class="text-sm px-3 py-1 rounded mr-2"
|
|
||||||
style="background-color: var(--accent); color: white;"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
if user.Role == "admin" && adminCount == 1 {
|
|
||||||
<button
|
|
||||||
disabled
|
|
||||||
class="text-sm px-3 py-1 rounded cursor-not-allowed opacity-50"
|
|
||||||
style="background-color: #dc2626; color: white;"
|
|
||||||
title="Cannot delete the last admin"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
} else {
|
|
||||||
<button
|
|
||||||
hx-delete={ "/api/auth/profile/" + user.ID }
|
|
||||||
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
|
|
||||||
hx-target={ "#user-" + user.ID }
|
|
||||||
hx-swap="outerHTML swap:0.5s"
|
|
||||||
hx-confirm="Are you sure you want to delete this user? This action cannot be undone."
|
|
||||||
class="text-sm px-3 py-1 rounded"
|
|
||||||
style="background-color: #dc2626; color: white;"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</main>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Users - Bookhoard Admin</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-{ currentUser.Theme }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Users - Bookhoard Admin</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ currentUser.Theme }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -37,15 +37,15 @@ func AdminUsers(currentUser User, users []User, adminCount int) 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, 2, "<!-- Modal Container (populated by HTMX) --><div id=\"modal-container\"></div><div class=\"flex min-h-screen\" style=\"background-color: var(--bg-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!-- Modal Container (populated by HTMX) --><div id=\"modal-container\"></div><main class=\"p-8\"><div class=\"max-w-5xl\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = AdminSidebar(currentUser, "/admin/users").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Icon("users", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-8\"><div class=\"w-full\"><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">User Management</h1><p style=\"color: var(--text-secondary)\">Manage user accounts and permissions</p></div><!-- Users Table --><div class=\"card rounded-lg border overflow-hidden\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><table class=\"w-full\"><thead style=\"background-color: var(--bg-primary)\"><tr><th class=\"px-6 py-3 text-left text-xs font-medium uppercase tracking-wider\" style=\"color: var(--text-secondary)\">Username</th><th class=\"px-6 py-3 text-left text-xs font-medium uppercase tracking-wider\" style=\"color: var(--text-secondary)\">Email</th><th class=\"px-6 py-3 text-left text-xs font-medium uppercase tracking-wider\" style=\"color: var(--text-secondary)\">Role</th><th class=\"px-6 py-3 text-left text-xs font-medium uppercase tracking-wider\" style=\"color: var(--text-secondary)\">Created</th><th class=\"px-6 py-3 text-right text-xs font-medium uppercase tracking-wider\" style=\"color: var(--text-secondary)\">Actions</th></tr></thead> <tbody class=\"divide-y\" style=\"divide-color: var(--border)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">User Management</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Manage user accounts and permissions</p></div><!-- Users Table --><div class=\"card overflow-hidden\"><table class=\"w-full\"><thead style=\"background-color: var(--bg-primary)\"><tr><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Username</th><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Email</th><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Role</th><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Created</th><th class=\"px-6 py-3 text-right text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Actions</th></tr></thead> <tbody class=\"divide-y\" style=\"divide-color: var(--border)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -57,195 +57,227 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 37, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 42, Col: 36}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"hover:bg-opacity-50\" style=\"transition: background-color 0.2s;\"><!-- Username --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"flex items-center\"><div><div class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"transition-colors hover:bg-surface-hover\"><!-- Username --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"flex items-center gap-2\"><span class=\"grid place-items-center h-8 w-8 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("user", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span><div><div class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 42, Col: 96}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 50, Col: 97}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(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, 6, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.ID == currentUser.ID {
|
if user.ID == currentUser.ID {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span class=\"text-xs px-2 py-1 rounded\" style=\"background-color: var(--accent); color: white;\">You</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">You</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div></div></td><!-- Email --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div></div></td><!-- Email --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 51, Col: 79}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 59, Col: 80}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(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, 9, "</div></td><!-- Role Toggle (with last-admin protection) --><td class=\"px-6 py-4 whitespace-nowrap\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div></td><!-- Role Toggle (with last-admin protection) --><td class=\"px-6 py-4 whitespace-nowrap\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Role == "admin" && adminCount == 1 {
|
if user.Role == "admin" && adminCount == 1 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<!-- Last admin - disabled --> <div class=\"relative\"><select disabled class=\"text-sm rounded px-2 py-1 cursor-not-allowed opacity-50\" style=\"background-color: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border);\" title=\"Cannot demote the last admin\"><option value=\"user\">User</option> <option value=\"admin\" selected>Admin</option></select></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<!-- Last admin - disabled --> <div class=\"relative\"><select disabled class=\"input w-auto py-1 pr-7 text-xs opacity-50 cursor-not-allowed\" title=\"Cannot demote the last admin\"><option value=\"user\">User</option> <option value=\"admin\" selected>Admin</option></select></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<select hx-put=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<select hx-put=\"")
|
||||||
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.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 70, Col: 52}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 77, Col: 53}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 12, "\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" hx-target=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 72, Col: 50}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 78, Col: 51}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" hx-swap=\"innerHTML\" name=\"role\" class=\"text-sm rounded px-2 py-1\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);\" onchange=\"this.dispatchEvent(new Event('htmx:trigger'))\" hx-trigger=\"change\" hx-vals='{\"role\": this.value}'><option value=\"user\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" hx-swap=\"innerHTML\" hx-trigger=\"change\" name=\"role\" class=\"input w-auto py-1 pr-7 text-xs\"><option value=\"user\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Role == "user" {
|
if user.Role == "user" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, ">User</option> <option value=\"admin\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, ">User</option> <option value=\"admin\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Role == "admin" {
|
if user.Role == "admin" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ">Admin</option></select><div id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ">Admin</option></select><div id=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 84, Col: 46}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 87, Col: 47}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"text-xs mt-1\"></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"text-xs mt-1\"></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, 19, "</td><!-- Created --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><!-- Created --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 89, Col: 125}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 92, Col: 126}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></td><!-- Actions --><td class=\"px-6 py-4 whitespace-nowrap text-right\"><button hx-get=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div></td><!-- Actions --><td class=\"px-6 py-4 whitespace-nowrap text-right\"><div class=\"inline-flex items-center gap-1\"><button hx-get=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 94, Col: 65}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 98, Col: 67}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"text-sm px-3 py-1 rounded mr-2\" style=\"background-color: var(--accent); color: white;\">Edit</button> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-secondary text-xs px-2.5 py-1\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("edit", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "Edit</button> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Role == "admin" && adminCount == 1 {
|
if user.Role == "admin" && adminCount == 1 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<button disabled class=\"text-sm px-3 py-1 rounded cursor-not-allowed opacity-50\" style=\"background-color: #dc2626; color: white;\" title=\"Cannot delete the last admin\">Delete</button>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<button disabled class=\"btn btn-danger text-xs px-2.5 py-1\" title=\"Cannot delete the last admin\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "Delete</button>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<button hx-delete=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<button hx-delete=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 113, Col: 55}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 117, Col: 57}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" hx-target=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 115, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 118, Col: 45}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" hx-swap=\"outerHTML swap:0.5s\" hx-confirm=\"Are you sure you want to delete this user? This action cannot be undone.\" class=\"text-sm px-3 py-1 rounded\" style=\"background-color: #dc2626; color: white;\">Delete</button>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" hx-swap=\"outerHTML swap:0.5s\" hx-confirm=\"Are you sure you want to delete this user? This action cannot be undone.\" class=\"btn btn-danger text-xs px-2.5 py-1\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Delete</button>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td></tr>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div></td></tr>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</tbody></table></div></main></div></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</tbody></table></div></div></main></body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-38
@@ -9,81 +9,79 @@ templ Analytics(user User) {
|
|||||||
<title>Reading Analytics - Bookhoard</title>
|
<title>Reading Analytics - Bookhoard</title>
|
||||||
<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"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="analytics" x-init="loadAnalytics" class="theme-{ user.Theme }">
|
<body x-data="analytics" x-init="loadAnalytics" class="theme-{ user.Theme }">
|
||||||
@Header(user, "/analytics")
|
@Header(user, "/analytics")
|
||||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">📊 Reading Analytics</h1>
|
<div class="flex items-center gap-3 mb-1">
|
||||||
<p style="color: var(--text-secondary)">Track your reading habits and device usage</p>
|
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
@Icon("chart", "h-5 w-5")
|
||||||
|
</span>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Reading Analytics</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Track your reading habits and device usage</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Date Range Picker -->
|
<div class="card p-4 mb-6">
|
||||||
<div class="card p-4 rounded-lg border mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="flex flex-wrap gap-4 items-end">
|
||||||
<div class="flex flex-wrap gap-4 items-center">
|
|
||||||
<div class="flex-1 min-w-[200px]">
|
<div class="flex-1 min-w-[200px]">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Start Date</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Start Date</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
id="start-date"
|
id="start-date"
|
||||||
onchange="loadAnalytics()"
|
onchange="loadAnalytics()"
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-[200px]">
|
<div class="flex-1 min-w-[200px]">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">End Date</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">End Date</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
id="end-date"
|
id="end-date"
|
||||||
onchange="loadAnalytics()"
|
onchange="loadAnalytics()"
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-end">
|
<div>
|
||||||
<button @click="loadAnalytics()" class="btn-primary px-6 py-2 rounded-lg">Update</button>
|
<button @click="loadAnalytics()" class="btn btn-primary">Update</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Stats Cards -->
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8" id="stats-container">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8" id="stats-container">
|
<div class="stat-card">
|
||||||
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Books Read</h3>
|
||||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Books Read</h3>
|
<p id="total-books" class="text-3xl font-bold" style="color: var(--accent);">-</p>
|
||||||
<p id="total-books" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="stat-card">
|
||||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Pages Read</h3>
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Pages Read</h3>
|
||||||
<p id="total-pages" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
<p id="total-pages" class="text-3xl font-bold" style="color: var(--accent);">-</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="stat-card">
|
||||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Reading Time</h3>
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Reading Time</h3>
|
||||||
<p id="reading-time" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
<p id="reading-time" class="text-3xl font-bold" style="color: var(--accent);">-</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="stat-card">
|
||||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Completion Rate</h3>
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Completion Rate</h3>
|
||||||
<p id="completion-rate" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
<p id="completion-rate" class="text-3xl font-bold" style="color: var(--accent);">-</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Charts Row -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
<!-- Daily Reading Chart -->
|
<div class="card p-6">
|
||||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary);">Daily Reading Minutes</h3>
|
||||||
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Daily Reading Minutes</h3>
|
|
||||||
<div class="h-80">
|
<div class="h-80">
|
||||||
<canvas id="daily-reading-chart"></canvas>
|
<canvas id="daily-reading-chart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Device Usage Chart -->
|
<div class="card p-6">
|
||||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary);">Device Usage</h3>
|
||||||
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Device Usage</h3>
|
|
||||||
<div class="h-80">
|
<div class="h-80">
|
||||||
<canvas id="device-usage-chart"></canvas>
|
<canvas id="device-usage-chart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Popular Books -->
|
<div class="card p-6">
|
||||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary);">Most Read Books</h3>
|
||||||
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Most Read Books</h3>
|
|
||||||
<div id="popular-books" class="space-y-3">
|
<div id="popular-books" class="space-y-3">
|
||||||
<div class="text-center py-8" style="color: var(--text-secondary);">
|
<div class="text-center py-8" style="color: var(--text-secondary);">
|
||||||
<div class="loading-spinner mx-auto mb-4"></div>
|
<div class="loading-spinner mx-auto mb-4"></div>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func Analytics(user User) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Reading Analytics - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"analytics\" x-init=\"loadAnalytics\" class=\"theme-{ user.Theme }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Reading Analytics - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"analytics\" x-init=\"loadAnalytics\" class=\"theme-{ user.Theme }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,15 @@ func Analytics(user User) 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, 2, "<div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">📊 Reading Analytics</h1><p style=\"color: var(--text-secondary)\">Track your reading habits and device usage</p></div><!-- Date Range Picker --><div class=\"card p-4 rounded-lg border mb-6\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex flex-wrap gap-4 items-center\"><div class=\"flex-1 min-w-[200px]\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary);\">Start Date</label> <input type=\"date\" id=\"start-date\" onchange=\"loadAnalytics()\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div class=\"flex-1 min-w-[200px]\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary);\">End Date</label> <input type=\"date\" id=\"end-date\" onchange=\"loadAnalytics()\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div class=\"flex items-end\"><button @click=\"loadAnalytics()\" class=\"btn-primary px-6 py-2 rounded-lg\">Update</button></div></div></div><!-- Stats Cards --><div class=\"grid grid-cols-1 md:grid-cols-4 gap-6 mb-8\" id=\"stats-container\"><div class=\"card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-secondary);\">Books Read</h3><p id=\"total-books\" class=\"text-3xl font-bold\" style=\"color: var(--text-primary);\">-</p></div><div class=\"card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-secondary);\">Pages Read</h3><p id=\"total-pages\" class=\"text-3xl font-bold\" style=\"color: var(--text-primary);\">-</p></div><div class=\"card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-secondary);\">Reading Time</h3><p id=\"reading-time\" class=\"text-3xl font-bold\" style=\"color: var(--text-primary);\">-</p></div><div class=\"card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-secondary);\">Completion Rate</h3><p id=\"completion-rate\" class=\"text-3xl font-bold\" style=\"color: var(--text-primary);\">-</p></div></div><!-- Charts Row --><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8\"><!-- Daily Reading Chart --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary);\">Daily Reading Minutes</h3><div class=\"h-80\"><canvas id=\"daily-reading-chart\"></canvas></div></div><!-- Device Usage Chart --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary);\">Device Usage</h3><div class=\"h-80\"><canvas id=\"device-usage-chart\"></canvas></div></div></div><!-- Popular Books --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary);\">Most Read Books</h3><div id=\"popular-books\" class=\"space-y-3\"><div class=\"text-center py-8\" style=\"color: var(--text-secondary);\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading analytics...</p></div></div></div></div></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("chart", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Reading Analytics</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Track your reading habits and device usage</p></div><div class=\"card p-4 mb-6\"><div class=\"flex flex-wrap gap-4 items-end\"><div class=\"flex-1 min-w-[200px]\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Start Date</label> <input type=\"date\" id=\"start-date\" onchange=\"loadAnalytics()\" class=\"input\"></div><div class=\"flex-1 min-w-[200px]\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">End Date</label> <input type=\"date\" id=\"end-date\" onchange=\"loadAnalytics()\" class=\"input\"></div><div><button @click=\"loadAnalytics()\" class=\"btn btn-primary\">Update</button></div></div></div><div class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8\" id=\"stats-container\"><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Books Read</h3><p id=\"total-books\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Pages Read</h3><p id=\"total-pages\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Reading Time</h3><p id=\"reading-time\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Completion Rate</h3><p id=\"completion-rate\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div></div><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary);\">Daily Reading Minutes</h3><div class=\"h-80\"><canvas id=\"daily-reading-chart\"></canvas></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary);\">Device Usage</h3><div class=\"h-80\"><canvas id=\"device-usage-chart\"></canvas></div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary);\">Most Read Books</h3><div id=\"popular-books\" class=\"space-y-3\"><div class=\"text-center py-8\" style=\"color: var(--text-secondary);\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading analytics...</p></div></div></div></div></body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,64 +2,97 @@ package templates
|
|||||||
|
|
||||||
templ APIExplorer(explorer APIExplorerData) {
|
templ APIExplorer(explorer APIExplorerData) {
|
||||||
if !explorer.IsLoggedIn {
|
if !explorer.IsLoggedIn {
|
||||||
// Show mode toggle and mock data
|
<div x-data="devices" class="card p-6 mt-8 space-y-5">
|
||||||
<div x-data="devices" class="border border-border rounded-lg p-6 mt-8 bg-background-secondary">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<div class="flex gap-2 mb-4">
|
if explorer.Endpoint.Method == "GET" {
|
||||||
<button class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary cursor-pointer bg-accent text-white">Mock Data</button>
|
<span class="badge status-completed">{ explorer.Endpoint.Method }</span>
|
||||||
<button class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary opacity-50 cursor-not-allowed" disabled>Login to Try Real</button>
|
} else if explorer.Endpoint.Method == "DELETE" {
|
||||||
|
<span class="badge status-failed">{ explorer.Endpoint.Method }</span>
|
||||||
|
} else if explorer.Endpoint.Method == "POST" {
|
||||||
|
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ explorer.Endpoint.Method }</span>
|
||||||
|
} else {
|
||||||
|
<span class="badge status-processing">{ explorer.Endpoint.Method }</span>
|
||||||
|
}
|
||||||
|
<code class="text-sm" style="color: var(--text-secondary);">{ explorer.Endpoint.Path }</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="flex gap-2">
|
||||||
<div class="mb-4">
|
<button class="btn btn-primary">Mock Data</button>
|
||||||
<select disabled class="w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent">
|
<button class="btn btn-secondary opacity-50 cursor-not-allowed" disabled>Login to Try Real</button>
|
||||||
<option value="GET" selected?={ explorer.Endpoint.Method == "GET" }>GET</option>
|
|
||||||
<option value="POST" selected?={ explorer.Endpoint.Method == "POST" }>POST</option>
|
|
||||||
<option value="PUT" selected?={ explorer.Endpoint.Method == "PUT" }>PUT</option>
|
|
||||||
<option value="DELETE" selected?={ explorer.Endpoint.Method == "DELETE" }>DELETE</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<h4 class="text-text-secondary mb-2 text-sm font-medium">Request Body (Example)</h4>
|
|
||||||
<pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code class="text-text-primary text-sm">{ explorer.Endpoint.RequestBody }</code></pre>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div>
|
||||||
<h4 class="text-text-secondary mb-2 text-sm font-medium">Response (Mock)</h4>
|
<p class="text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Request Body (Example)</p>
|
||||||
<pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code class="text-text-primary text-sm">{ explorer.Endpoint.Response }</code></pre>
|
<pre class="card p-4 overflow-x-auto"><code class="text-sm" style="color: var(--text-primary);">{ explorer.Endpoint.RequestBody }</code></pre>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 mt-4">
|
<div>
|
||||||
<button @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Request</button>
|
<p class="text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Response (Mock)</p>
|
||||||
<button @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Response</button>
|
<pre class="card p-4 overflow-x-auto"><code class="text-sm" style="color: var(--text-primary);">{ explorer.Endpoint.Response }</code></pre>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))" class="btn btn-secondary text-sm">
|
||||||
|
@Icon("copy", "h-4 w-4")
|
||||||
|
Copy Request
|
||||||
|
</button>
|
||||||
|
<button @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))" class="btn btn-secondary text-sm">
|
||||||
|
@Icon("copy", "h-4 w-4")
|
||||||
|
Copy Response
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
// Show interactive explorer with real execution
|
<div class="card p-6 mt-8 space-y-5" x-data="apiExplorerDoc" x-init="initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')">
|
||||||
<div class="border border-border rounded-lg p-6 mt-8 bg-background-secondary" x-data="apiExplorerDoc" x-init="initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<div class="flex gap-2 mb-4">
|
if explorer.Endpoint.Method == "GET" {
|
||||||
<button class="mode-btn px-4 py-2 border border-border rounded bg-background-primary text-text-primary cursor-pointer hover:bg-background-secondary text-sm" id="mock-btn" @click="showDocMode('mock')">Mock Data</button>
|
<span class="badge status-completed">{ explorer.Endpoint.Method }</span>
|
||||||
<button class="mode-btn px-4 py-2 bg-accent text-white rounded cursor-pointer text-sm" id="real-btn" @click="showDocMode('real')">Try It Out</button>
|
} else if explorer.Endpoint.Method == "DELETE" {
|
||||||
|
<span class="badge status-failed">{ explorer.Endpoint.Method }</span>
|
||||||
|
} else if explorer.Endpoint.Method == "POST" {
|
||||||
|
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ explorer.Endpoint.Method }</span>
|
||||||
|
} else {
|
||||||
|
<span class="badge status-processing">{ explorer.Endpoint.Method }</span>
|
||||||
|
}
|
||||||
|
<code class="text-sm" style="color: var(--text-secondary);">{ explorer.Endpoint.Path }</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="flex gap-2">
|
||||||
<div class="mb-4">
|
<button class="mode-btn btn btn-secondary text-sm" id="mock-btn" @click="showDocMode('mock')">Mock Data</button>
|
||||||
<select id="http-method" class="w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent">
|
<button class="mode-btn btn btn-primary text-sm" id="real-btn" @click="showDocMode('real')">Try It Out</button>
|
||||||
<option value="GET" selected?={ explorer.Endpoint.Method == "GET" }>GET</option>
|
</div>
|
||||||
<option value="POST" selected?={ explorer.Endpoint.Method == "POST" }>POST</option>
|
<div>
|
||||||
<option value="PUT" selected?={ explorer.Endpoint.Method == "PUT" }>PUT</option>
|
<label for="http-method" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Method</label>
|
||||||
<option value="DELETE" selected?={ explorer.Endpoint.Method == "DELETE" }>DELETE</option>
|
<select id="http-method" class="input">
|
||||||
</select>
|
<option value="GET" selected?={ explorer.Endpoint.Method == "GET" }>GET</option>
|
||||||
|
<option value="POST" selected?={ explorer.Endpoint.Method == "POST" }>POST</option>
|
||||||
|
<option value="PUT" selected?={ explorer.Endpoint.Method == "PUT" }>PUT</option>
|
||||||
|
<option value="DELETE" selected?={ explorer.Endpoint.Method == "DELETE" }>DELETE</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="request-body" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Request Body</label>
|
||||||
|
<textarea id="request-body" placeholder="Edit request body…" class="input min-h-[150px] font-mono">{ explorer.Endpoint.RequestBody }</textarea>
|
||||||
|
</div>
|
||||||
|
<button id="try-it-out" @click="tryDocEndpoint()" class="btn btn-primary">
|
||||||
|
@Icon("play", "h-4 w-4")
|
||||||
|
Try It Out
|
||||||
|
</button>
|
||||||
|
<div class="api-response hidden space-y-2">
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span id="response-status" class="font-semibold" style="color: var(--text-primary);"></span>
|
||||||
|
<span id="response-time" style="color: var(--text-secondary);"></span>
|
||||||
</div>
|
</div>
|
||||||
<h4 class="text-text-secondary mb-2 text-sm font-medium">Request Body</h4>
|
<pre class="card p-4 overflow-x-auto"><code id="response-body" class="text-sm" style="color: var(--text-primary);"></code></pre>
|
||||||
<textarea id="request-body" placeholder="Edit request body..." class="w-full min-h-[150px] px-3 py-2 font-mono text-sm bg-background-primary text-text-primary border border-border rounded focus:outline-none focus:ring-2 focus:ring-accent">{ explorer.Endpoint.RequestBody }</textarea>
|
|
||||||
</div>
|
</div>
|
||||||
<button id="try-it-out" @click="tryDocEndpoint()" class="bg-accent text-white px-6 py-3 rounded cursor-pointer mt-4 hover:opacity-90">Try It Out</button>
|
<div class="flex gap-2 flex-wrap">
|
||||||
<div class="api-response mt-4 hidden">
|
<button @click="copyDocRequest()" class="btn btn-secondary text-sm">
|
||||||
<div class="flex justify-between mb-2 text-sm">
|
@Icon("copy", "h-4 w-4")
|
||||||
<span id="response-status" class="font-semibold"></span>
|
Copy Request
|
||||||
<span id="response-time" class="text-text-secondary"></span>
|
</button>
|
||||||
</div>
|
<button @click="copyDocResponse()" class="btn btn-secondary text-sm">
|
||||||
<pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code id="response-body" class="text-text-primary text-sm"></code></pre>
|
@Icon("copy", "h-4 w-4")
|
||||||
</div>
|
Copy Response
|
||||||
<div class="flex gap-2 mt-4 flex-wrap">
|
</button>
|
||||||
<button @click="copyDocRequest()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Request</button>
|
<button @click="generateDocCURL()" class="btn btn-secondary text-sm">
|
||||||
<button @click="copyDocResponse()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Response</button>
|
@Icon("external", "h-4 w-4")
|
||||||
<button @click="generateDocCURL()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Generate cURL</button>
|
Generate cURL
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
+238
-50
@@ -30,131 +30,319 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
|||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
if !explorer.IsLoggedIn {
|
if !explorer.IsLoggedIn {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, " <div x-data=\"devices\" class=\"border border-border rounded-lg p-6 mt-8 bg-background-secondary\"><div class=\"flex gap-2 mb-4\"><button class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary cursor-pointer bg-accent text-white\">Mock Data</button> <button class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary opacity-50 cursor-not-allowed\" disabled>Login to Try Real</button></div><div class=\"mb-4\"><div class=\"mb-4\"><select disabled class=\"w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent\"><option value=\"GET\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"devices\" class=\"card p-6 mt-8 space-y-5\"><div class=\"flex items-center gap-2 flex-wrap\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if explorer.Endpoint.Method == "GET" {
|
if explorer.Endpoint.Method == "GET" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span class=\"badge status-completed\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var2 string
|
||||||
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 8, Col: 68}
|
||||||
|
}
|
||||||
|
_, 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, 3, "</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else if explorer.Endpoint.Method == "DELETE" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"badge status-failed\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 10, Col: 65}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else if explorer.Endpoint.Method == "POST" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 string
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 12, Col: 120}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"badge status-processing\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 14, Col: 69}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, ">GET</option> <option value=\"POST\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<code class=\"text-sm\" style=\"color: var(--text-secondary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if explorer.Endpoint.Method == "POST" {
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " selected")
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 16, Col: 88}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, ">POST</option> <option value=\"PUT\"")
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if explorer.Endpoint.Method == "PUT" {
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</code></div><div class=\"flex gap-2\"><button class=\"btn btn-primary\">Mock Data</button> <button class=\"btn btn-secondary opacity-50 cursor-not-allowed\" disabled>Login to Try Real</button></div><div><p class=\"text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary);\">Request Body (Example)</p><pre class=\"card p-4 overflow-x-auto\"><code class=\"text-sm\" style=\"color: var(--text-primary);\">")
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, ">PUT</option> <option value=\"DELETE\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if explorer.Endpoint.Method == "DELETE" {
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " selected")
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 24, Col: 131}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, ">DELETE</option></select></div><h4 class=\"text-text-secondary mb-2 text-sm font-medium\">Request Body (Example)</h4><pre class=\"bg-background-primary p-4 rounded-lg overflow-x-auto\"><code class=\"text-text-primary text-sm\">")
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</code></pre></div><div><p class=\"text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary);\">Response (Mock)</p><pre class=\"card p-4 overflow-x-auto\"><code class=\"text-sm\" style=\"color: var(--text-primary);\">")
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 21, Col: 141}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</code></pre></div><div class=\"mt-4\"><h4 class=\"text-text-secondary mb-2 text-sm font-medium\">Response (Mock)</h4><pre class=\"bg-background-primary p-4 rounded-lg overflow-x-auto\"><code class=\"text-text-primary text-sm\">")
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 28, Col: 128}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</code></pre></div><div class=\"flex gap-2\"><button @click=\"copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))\" class=\"btn btn-secondary text-sm\">")
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 25, Col: 138}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(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, 11, "</code></pre></div><div class=\"flex gap-2 mt-4\"><button @click=\"copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))\" class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm\">Copy Request</button> <button @click=\"copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))\" class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm\">Copy Response</button></div></div>")
|
templ_7745c5c3_Err = Icon("copy", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Copy Request</button> <button @click=\"copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))\" class=\"btn btn-secondary text-sm\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("copy", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Copy Response</button></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " <div class=\"border border-border rounded-lg p-6 mt-8 bg-background-secondary\" x-data=\"apiExplorerDoc\" x-init=\"initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')\"><div class=\"flex gap-2 mb-4\"><button class=\"mode-btn px-4 py-2 border border-border rounded bg-background-primary text-text-primary cursor-pointer hover:bg-background-secondary text-sm\" id=\"mock-btn\" @click=\"showDocMode('mock')\">Mock Data</button> <button class=\"mode-btn px-4 py-2 bg-accent text-white rounded cursor-pointer text-sm\" id=\"real-btn\" @click=\"showDocMode('real')\">Try It Out</button></div><div class=\"mb-4\"><div class=\"mb-4\"><select id=\"http-method\" class=\"w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent\"><option value=\"GET\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"card p-6 mt-8 space-y-5\" x-data=\"apiExplorerDoc\" x-init=\"initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')\"><div class=\"flex items-center gap-2 flex-wrap\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if explorer.Endpoint.Method == "GET" {
|
if explorer.Endpoint.Method == "GET" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"badge status-completed\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 45, Col: 68}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else if explorer.Endpoint.Method == "DELETE" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"badge status-failed\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 47, Col: 65}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else if explorer.Endpoint.Method == "POST" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var11 string
|
||||||
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 49, Col: 120}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"badge status-processing\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 51, Col: 69}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ">GET</option> <option value=\"POST\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<code class=\"text-sm\" style=\"color: var(--text-secondary);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 string
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 53, Col: 88}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</code></div><div class=\"flex gap-2\"><button class=\"mode-btn btn btn-secondary text-sm\" id=\"mock-btn\" @click=\"showDocMode('mock')\">Mock Data</button> <button class=\"mode-btn btn btn-primary text-sm\" id=\"real-btn\" @click=\"showDocMode('real')\">Try It Out</button></div><div><label for=\"http-method\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary);\">Method</label> <select id=\"http-method\" class=\"input\"><option value=\"GET\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if explorer.Endpoint.Method == "GET" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, ">GET</option> <option value=\"POST\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if explorer.Endpoint.Method == "POST" {
|
if explorer.Endpoint.Method == "POST" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, ">POST</option> <option value=\"PUT\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, ">POST</option> <option value=\"PUT\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if explorer.Endpoint.Method == "PUT" {
|
if explorer.Endpoint.Method == "PUT" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ">PUT</option> <option value=\"DELETE\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ">PUT</option> <option value=\"DELETE\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if explorer.Endpoint.Method == "DELETE" {
|
if explorer.Endpoint.Method == "DELETE" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, ">DELETE</option></select></div><h4 class=\"text-text-secondary mb-2 text-sm font-medium\">Request Body</h4><textarea id=\"request-body\" placeholder=\"Edit request body...\" class=\"w-full min-h-[150px] px-3 py-2 font-mono text-sm bg-background-primary text-text-primary border border-border rounded focus:outline-none focus:ring-2 focus:ring-accent\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, ">DELETE</option></select></div><div><label for=\"request-body\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary);\">Request Body</label> <textarea id=\"request-body\" placeholder=\"Edit request body…\" class=\"input min-h-[150px] font-mono\">")
|
||||||
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_Var14 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 49, Col: 274}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 70, Col: 136}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</textarea></div><button id=\"try-it-out\" @click=\"tryDocEndpoint()\" class=\"bg-accent text-white px-6 py-3 rounded cursor-pointer mt-4 hover:opacity-90\">Try It Out</button><div class=\"api-response mt-4 hidden\"><div class=\"flex justify-between mb-2 text-sm\"><span id=\"response-status\" class=\"font-semibold\"></span> <span id=\"response-time\" class=\"text-text-secondary\"></span></div><pre class=\"bg-background-primary p-4 rounded-lg overflow-x-auto\"><code id=\"response-body\" class=\"text-text-primary text-sm\"></code></pre></div><div class=\"flex gap-2 mt-4 flex-wrap\"><button @click=\"copyDocRequest()\" class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm\">Copy Request</button> <button @click=\"copyDocResponse()\" class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm\">Copy Response</button> <button @click=\"generateDocCURL()\" class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm\">Generate cURL</button></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</textarea></div><button id=\"try-it-out\" @click=\"tryDocEndpoint()\" class=\"btn btn-primary\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("play", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "Try It Out</button><div class=\"api-response hidden space-y-2\"><div class=\"flex justify-between text-sm\"><span id=\"response-status\" class=\"font-semibold\" style=\"color: var(--text-primary);\"></span> <span id=\"response-time\" style=\"color: var(--text-secondary);\"></span></div><pre class=\"card p-4 overflow-x-auto\"><code id=\"response-body\" class=\"text-sm\" style=\"color: var(--text-primary);\"></code></pre></div><div class=\"flex gap-2 flex-wrap\"><button @click=\"copyDocRequest()\" class=\"btn btn-secondary text-sm\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("copy", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "Copy Request</button> <button @click=\"copyDocResponse()\" class=\"btn btn-secondary text-sm\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("copy", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "Copy Response</button> <button @click=\"generateDocCURL()\" class=\"btn btn-secondary text-sm\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("external", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "Generate cURL</button></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+267
-374
@@ -18,511 +18,405 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
<title>{ book.Title } - Bookhoard</title>
|
<title>{ book.Title } - Bookhoard</title>
|
||||||
<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"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</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);">
|
<!-- Hero with blurred cover backdrop -->
|
||||||
<div class="w-full px-4 py-3 flex items-center gap-4">
|
<div class="relative overflow-hidden">
|
||||||
<a id="back-link" href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
||||||
← Back
|
<div class="hero-backdrop" style={ "background-image: url('" + book.CoverImagePath.String + "')" }></div>
|
||||||
|
}
|
||||||
|
<div class="relative z-10 xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pt-6 pb-8">
|
||||||
|
<a id="back-link" href="/dashboard" class="inline-flex items-center gap-1.5 text-sm font-medium hover:underline transition-colors mb-6" style="color: var(--text-secondary);">
|
||||||
|
@Icon("arrow-left", "h-4 w-4")
|
||||||
|
<span>Back</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
<div class="flex flex-col md:flex-row gap-8">
|
||||||
</div>
|
<!-- Cover -->
|
||||||
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 py-8">
|
<div class="flex-shrink-0 mx-auto md:mx-0">
|
||||||
<!-- Top Section: Cover + Basic Info + Actions -->
|
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
||||||
<div class="flex flex-col md:flex-row gap-8 mb-8">
|
<img
|
||||||
<!-- Left: Cover Image (256x384px) -->
|
src={ book.CoverImagePath.String }
|
||||||
<div class="flex-shrink-0">
|
alt={ book.Title }
|
||||||
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
class="w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl"
|
||||||
<img
|
style="box-shadow: var(--shadow-pop);"
|
||||||
src={ book.CoverImagePath.String }
|
onerror="this.src='/static/placeholder-book.svg'"
|
||||||
alt={ book.Title }
|
/>
|
||||||
class="w-64 h-96 object-cover rounded-lg shadow-xl"
|
} else {
|
||||||
onerror="this.src='/static/placeholder-book.svg'"
|
<img
|
||||||
/>
|
src="/static/placeholder-book.svg"
|
||||||
} else {
|
alt={ book.Title }
|
||||||
<img
|
class="w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl"
|
||||||
src="/static/placeholder-book.svg"
|
style="box-shadow: var(--shadow-pop);"
|
||||||
alt="{ book.Title }"
|
/>
|
||||||
class="w-64 h-96 object-cover rounded-lg shadow-xl"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<!-- Right: Details -->
|
|
||||||
<div class="flex-1">
|
|
||||||
<!-- Title & Author -->
|
|
||||||
<h1 class="text-4xl font-bold mb-2" style="color: var(--text-primary)">{ book.Title }</h1>
|
|
||||||
if book.Author.Valid && book.Author.String != "" {
|
|
||||||
<h2 class="text-2xl mb-6" style="color: var(--text-secondary)">
|
|
||||||
by { book.Author.String }
|
|
||||||
</h2>
|
|
||||||
}
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div class="flex flex-wrap gap-3 mb-6">
|
|
||||||
<!-- Read Now (placeholder) -->
|
|
||||||
<button
|
|
||||||
@click={ "window.location.href = '/readers/" + uuidToString(book.ID) + "'" }
|
|
||||||
class="px-6 py-3 rounded-lg font-semibold"
|
|
||||||
style="background-color: var(--accent); color: white;"
|
|
||||||
>
|
|
||||||
📖 Read Now
|
|
||||||
</button>
|
|
||||||
<!-- Sync Progress -->
|
|
||||||
if book.ActiveConflict != nil || book.ReadingProgress != nil {
|
|
||||||
<button
|
|
||||||
@click="showProgressSyncModal()"
|
|
||||||
class="px-6 py-3 rounded-lg border"
|
|
||||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
|
||||||
>
|
|
||||||
🔄 Sync Progress
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
<!-- View Notes/Highlights -->
|
</div>
|
||||||
<button
|
<!-- Details -->
|
||||||
@click="showNotesModal()"
|
<div class="flex-1 min-w-0">
|
||||||
class="px-6 py-3 rounded-lg border"
|
<h1 class="text-3xl md:text-4xl font-bold tracking-tight mb-2" style="color: var(--text-primary)">{ book.Title }</h1>
|
||||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
if book.Author.Valid && book.Author.String != "" {
|
||||||
>
|
<h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)">
|
||||||
📝 Notes & Highlights
|
by { book.Author.String }
|
||||||
if book.NotesCount + book.HighlightsCount > 0 {
|
</h2>
|
||||||
<span
|
}
|
||||||
class="ml-2 px-2 py-0.5 rounded text-xs font-semibold"
|
<!-- Action buttons -->
|
||||||
style="background-color: var(--accent); color: white;"
|
<div class="flex flex-wrap gap-2.5 mb-6">
|
||||||
>
|
<button
|
||||||
{ book.NotesCount + book.HighlightsCount }
|
@click={ "window.location.href = '/readers/" + uuidToString(book.ID) + "'" }
|
||||||
</span>
|
class="btn btn-primary px-5 py-2.5"
|
||||||
|
>
|
||||||
|
@Icon("book-open", "h-4 w-4")
|
||||||
|
<span>Read Now</span>
|
||||||
|
</button>
|
||||||
|
if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 {
|
||||||
|
<button @click="toggleRead(false)" :disabled="readSaving" class="btn btn-secondary px-5 py-2.5">
|
||||||
|
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check-circle", "h-4 w-4")<span>Mark Unread</span></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="btn btn-secondary px-5 py-2.5">
|
||||||
|
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check", "h-4 w-4")<span>Mark Read</span></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>
|
||||||
}
|
}
|
||||||
</button>
|
if book.ActiveConflict != nil || book.ReadingProgress != nil {
|
||||||
<!-- Edit Metadata (placeholder) -->
|
<button @click="showProgressSyncModal()" class="btn btn-secondary px-5 py-2.5">
|
||||||
<button
|
@Icon("sync", "h-4 w-4")
|
||||||
@click="showMetadataEditor()"
|
<span>Sync Progress</span>
|
||||||
class="px-6 py-3 rounded-lg border"
|
</button>
|
||||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
}
|
||||||
>
|
<button @click="showNotesModal()" class="btn btn-secondary px-5 py-2.5">
|
||||||
✏️ Edit Metadata
|
@Icon("edit", "h-4 w-4")
|
||||||
</button>
|
<span>Notes</span>
|
||||||
</div>
|
if book.NotesCount + book.HighlightsCount > 0 {
|
||||||
<!-- Rating Display -->
|
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
|
||||||
<div class="mb-6">
|
}
|
||||||
<span class="text-2xl">
|
</button>
|
||||||
@templ.Raw(renderStars(getBookRating(book.Rating)))
|
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
|
||||||
</span>
|
@Icon("edit", "h-4 w-4")
|
||||||
<span class="ml-2 text-sm" style="color: var(--text-secondary);">
|
<span>Edit</span>
|
||||||
({ fmt.Sprintf("%.1f", float64(getBookRating(book.Rating))/2.0) } / 5)
|
</button>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
<!-- Rating -->
|
||||||
<!-- Community Rating Display (from metadata) -->
|
<div class="mb-5" @mouseleave="ratingHover = 0">
|
||||||
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
|
<span class="text-2xl">
|
||||||
<div class="mb-2">
|
<template x-for="i in 5" :key="i">
|
||||||
<span class="text-lg" style="color: var(--text-secondary);">
|
<span style="position: relative; display: inline-block;">
|
||||||
Community Rating:
|
<span :style="starFill(i)">★</span>
|
||||||
<span class="font-bold" style="color: var(--text-primary);">
|
<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 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>
|
||||||
|
<!-- Community rating -->
|
||||||
|
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
|
||||||
|
<div class="mb-3 text-sm" style="color: var(--text-secondary);">
|
||||||
|
Community:
|
||||||
|
<span class="font-bold ml-1" style="color: var(--text-primary);">
|
||||||
@templ.Raw(renderStars(int32(book.CommunityRating.Float64)))
|
@templ.Raw(renderStars(int32(book.CommunityRating.Float64)))
|
||||||
</span>
|
</span>
|
||||||
<span class="ml-2 text-sm" style="color: var(--text-secondary);">
|
<span class="ml-2">{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }</span>
|
||||||
{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }
|
</div>
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<!-- Series Badge -->
|
|
||||||
if book.Series.Valid && book.Series.String != "" {
|
|
||||||
<div class="mb-4">
|
|
||||||
<a
|
|
||||||
href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID) }
|
|
||||||
class="px-3 py-1 rounded-full text-sm font-semibold inline-block hover:opacity-80 transition-opacity"
|
|
||||||
style="background-color: var(--accent); color: white; text-decoration: none;"
|
|
||||||
>
|
|
||||||
{ book.Series.String }
|
|
||||||
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
|
|
||||||
#{ book.SeriesNumber.Int32 }
|
|
||||||
}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<!-- Reading Direction Badge (for manga/comics) -->
|
|
||||||
if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" {
|
|
||||||
<div class="mb-4">
|
|
||||||
<span
|
|
||||||
class="px-3 py-1 rounded-full text-sm font-semibold"
|
|
||||||
style="background-color: var(--accent); color: white;"
|
|
||||||
>
|
|
||||||
📖 { strings.ToUpper(book.ReadingDirection.String) }
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<!-- Comic-Specific Badges -->
|
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
|
||||||
<!-- Age Rating Badge -->
|
|
||||||
if book.AgeRating.Valid && book.AgeRating.String != "" {
|
|
||||||
<span
|
|
||||||
class="px-2 py-1 rounded-full text-xs font-semibold"
|
|
||||||
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
|
|
||||||
>
|
|
||||||
{ book.AgeRating.String }
|
|
||||||
</span>
|
|
||||||
}
|
}
|
||||||
<!-- Black and White Badge -->
|
<!-- Badges: series, direction, tags -->
|
||||||
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
|
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||||
<span
|
if book.Series.Valid && book.Series.String != "" {
|
||||||
class="px-2 py-1 rounded-full text-xs font-semibold"
|
|
||||||
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
|
|
||||||
>
|
|
||||||
B&W
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<!-- Story Arc Badge -->
|
|
||||||
if book.StoryArc.Valid && book.StoryArc.String != "" {
|
|
||||||
<span
|
|
||||||
class="px-2 py-1 rounded-full text-xs font-semibold"
|
|
||||||
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
|
|
||||||
>
|
|
||||||
📚 { book.StoryArc.String }
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<!-- Tags -->
|
|
||||||
if len(book.Tags) > 0 {
|
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
|
||||||
for _, tag := range book.Tags {
|
|
||||||
<a
|
<a
|
||||||
href={ "/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID) }
|
href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID) }
|
||||||
class="px-2 py-1 rounded-full text-xs font-semibold hover:opacity-80 transition-opacity"
|
class="chip hover:opacity-90"
|
||||||
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary); text-decoration: none;"
|
style="background-color: var(--accent); color: var(--bg-primary); text-decoration: none;"
|
||||||
>
|
>
|
||||||
{ tag }
|
{ book.Series.String }
|
||||||
|
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
|
||||||
|
#{ book.SeriesNumber.Int32 }
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" {
|
||||||
|
<span class="chip" style="background-color: var(--accent); color: var(--bg-primary);">{ strings.ToUpper(book.ReadingDirection.String) }</span>
|
||||||
|
}
|
||||||
|
if book.AgeRating.Valid && book.AgeRating.String != "" {
|
||||||
|
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">{ book.AgeRating.String }</span>
|
||||||
|
}
|
||||||
|
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
|
||||||
|
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">B&W</span>
|
||||||
|
}
|
||||||
|
if book.StoryArc.Valid && book.StoryArc.String != "" {
|
||||||
|
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">{ book.StoryArc.String }</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
if len(book.Tags) > 0 {
|
||||||
<!-- Description/Synopsis -->
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
if book.Description.Valid && book.Description.String != "" {
|
for _, tag := range book.Tags {
|
||||||
<div class="mb-6">
|
<a
|
||||||
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Synopsis</h3>
|
href={ "/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID) }
|
||||||
<div class="max-h-[20rem] overflow-y-auto pr-2" style="color: var(--text-secondary)">
|
class="badge hover:opacity-80 transition-opacity"
|
||||||
@UnsafeHTML(
|
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary); text-decoration: none;"
|
||||||
bluemonday.UGCPolicy().Sanitize(book.Description.String),
|
>
|
||||||
).ToComponent()
|
{ tag }
|
||||||
|
</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Summary (from ComicInfo.xml, if different from description) -->
|
</div>
|
||||||
|
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pb-10">
|
||||||
|
<!-- Synopsis -->
|
||||||
|
if book.Description.Valid && book.Description.String != "" {
|
||||||
|
<div class="card p-6 mb-6">
|
||||||
|
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Synopsis</h3>
|
||||||
|
<div class="max-h-[20rem] overflow-y-auto pr-2 text-sm leading-relaxed" style="color: var(--text-secondary)">
|
||||||
|
@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Description.String)).ToComponent()
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String {
|
if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String {
|
||||||
<div class="mb-6">
|
<div class="card p-6 mb-6">
|
||||||
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Comic Summary</h3>
|
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Comic Summary</h3>
|
||||||
<div class="max-h-[20rem] overflow-y-auto pr-2" style="color: var(--text-secondary)">
|
<div class="max-h-[20rem] overflow-y-auto pr-2 text-sm leading-relaxed" style="color: var(--text-secondary)">
|
||||||
@UnsafeHTML(
|
@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Summary.String)).ToComponent()
|
||||||
bluemonday.UGCPolicy().Sanitize(book.Summary.String),
|
|
||||||
).ToComponent()
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Metadata Notes (technical notes from metadata files) -->
|
|
||||||
if book.MetadataNotes.Valid && book.MetadataNotes.String != "" {
|
if book.MetadataNotes.Valid && book.MetadataNotes.String != "" {
|
||||||
<div
|
<div class="card p-6 mb-6">
|
||||||
class="card p-6 rounded-lg border mb-6"
|
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
|
||||||
>
|
|
||||||
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Metadata Notes</h3>
|
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Metadata Notes</h3>
|
||||||
<div class="text-sm" style="color: var(--text-secondary);">
|
<div class="text-sm" style="color: var(--text-secondary);">{ book.MetadataNotes.String }</div>
|
||||||
{ book.MetadataNotes.String }
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Progress Section -->
|
<!-- Progress -->
|
||||||
if book.ReadingProgress != nil {
|
if book.ReadingProgress != nil {
|
||||||
<div
|
<div class="card p-6 mb-6">
|
||||||
class="card p-6 rounded-lg border mb-6"
|
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
|
||||||
>
|
|
||||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Reading Progress</h3>
|
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Reading Progress</h3>
|
||||||
if book.ActiveConflict != nil {
|
if book.ActiveConflict != nil {
|
||||||
<div
|
<div class="mb-4 p-3 rounded-xl flex items-start gap-2" style="background-color: color-mix(in srgb, var(--status-warning) 14%, transparent); border: 1px solid var(--status-warning);">
|
||||||
class="mb-4 p-3 rounded-lg border"
|
@Icon("alert", "h-5 w-5 shrink-0")
|
||||||
style="background-color: #f59e0b20; border-color: #f59e0b;"
|
<p class="text-sm" style="color: var(--text-primary);">Progress conflict detected — click "Sync Progress" to review and resolve.</p>
|
||||||
>
|
|
||||||
<p style="color: var(--text-primary);">
|
|
||||||
⚠️ Progress conflict detected - Click "Sync Progress" to review and resolve
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Progress Bar -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="w-full rounded-full h-3" style="background-color: var(--bg-primary);">
|
<div class="w-full rounded-full h-2.5" style="background-color: var(--bg-primary);">
|
||||||
<div
|
<div
|
||||||
class="h-3 rounded-full transition-all"
|
class="h-2.5 rounded-full transition-all"
|
||||||
style={ fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", book.ReadingProgress.Percentage.Float64*100) }
|
style={ fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", book.ReadingProgress.Percentage.Float64*100) }
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Progress Stats Grid -->
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Progress</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Progress</p>
|
||||||
<p class="text-2xl font-bold" style="color: var(--accent);">
|
<p class="text-2xl font-bold" style="color: var(--accent);">{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100) }%</p>
|
||||||
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64 * 100) }%
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
|
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Page</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Page</p>
|
||||||
<p>{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p>
|
<p style="color: var(--text-primary)">{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if book.ReadingProgress.LastReadAt.Valid {
|
if book.ReadingProgress.LastReadAt.Valid {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Last Read</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Last Read</p>
|
||||||
<p>{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p>
|
<p class="text-sm" style="color: var(--text-primary)">{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if book.ReadingProgress.LastSyncSource.Valid {
|
if book.ReadingProgress.LastSyncSource.Valid {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Source</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Source</p>
|
||||||
<p class="capitalize">{ book.ReadingProgress.LastSyncSource.String }</p>
|
<p class="capitalize" style="color: var(--text-primary)">{ book.ReadingProgress.LastSyncSource.String }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Metadata Grid -->
|
<!-- Metadata grid -->
|
||||||
<div
|
<div class="card p-6 mb-6">
|
||||||
class="card p-6 rounded-lg border mb-6"
|
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
|
||||||
>
|
|
||||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Metadata</h3>
|
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Metadata</h3>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-4 text-sm">
|
||||||
<!-- Publication Info -->
|
|
||||||
if book.Publisher.Valid && book.Publisher.String != "" {
|
if book.Publisher.Valid && book.Publisher.String != "" {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Publisher</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Publisher</p>
|
||||||
<p>{ book.Publisher.String }</p>
|
<p style="color: var(--text-primary)">{ book.Publisher.String }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if book.DatePublished.Valid {
|
if book.DatePublished.Valid {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Published</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Published</p>
|
||||||
<p>{ book.DatePublished.Time.Format("01-02-2006") }</p>
|
<p style="color: var(--text-primary)">{ book.DatePublished.Time.Format("01-02-2006") }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if book.Isbn.Valid && book.Isbn.String != "" {
|
if book.Isbn.Valid && book.Isbn.String != "" {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Isbn</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">ISBN</p>
|
||||||
<p>{ book.Isbn.String }</p>
|
<p style="color: var(--text-primary)">{ book.Isbn.String }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if book.Language.Valid && book.Language.String != "" {
|
if book.Language.Valid && book.Language.String != "" {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Language</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Language</p>
|
||||||
<p class="capitalize">{ book.Language.String }</p>
|
<p class="capitalize" style="color: var(--text-primary)">{ book.Language.String }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if book.Edition.Valid && book.Edition.String != "" {
|
if book.Edition.Valid && book.Edition.String != "" {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Edition</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Edition</p>
|
||||||
<p>{ book.Edition.String }</p>
|
<p style="color: var(--text-primary)">{ book.Edition.String }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if book.PageCount.Valid && book.PageCount.Int32 > 0 {
|
if book.PageCount.Valid && book.PageCount.Int32 > 0 {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Pages</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Pages</p>
|
||||||
<p>{ book.PageCount.Int32 }</p>
|
<p style="color: var(--text-primary)">{ book.PageCount.Int32 }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if book.Genre.Valid && book.Genre.String != "" {
|
if book.Genre.Valid && book.Genre.String != "" {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Genre</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Genre</p>
|
||||||
<p>{ book.Genre.String }</p>
|
<p style="color: var(--text-primary)">{ book.Genre.String }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Series Information (if not shown in badge) -->
|
|
||||||
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
|
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Series Count</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Series Count</p>
|
||||||
<p>{ book.SeriesCount.Int32 } items</p>
|
<p style="color: var(--text-primary)">{ book.SeriesCount.Int32 } items</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if book.Volume.Valid && book.Volume.Int32 > 0 {
|
if book.Volume.Valid && book.Volume.Int32 > 0 {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Volume</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Volume</p>
|
||||||
<p>Vol. { book.Volume.Int32 }</p>
|
<p style="color: var(--text-primary)">Vol. { book.Volume.Int32 }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if book.Imprint.Valid && book.Imprint.String != "" {
|
if book.Imprint.Valid && book.Imprint.String != "" {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Imprint</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Imprint</p>
|
||||||
<p>{ book.Imprint.String }</p>
|
<p style="color: var(--text-primary)">{ book.Imprint.String }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {
|
if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Copyright Year</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Copyright</p>
|
||||||
<p>{ book.CopyrightYear.Int32 }</p>
|
<p style="color: var(--text-primary)">{ book.CopyrightYear.Int32 }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Comic-Specific Fields -->
|
|
||||||
if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" {
|
if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Manga Type</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Manga Type</p>
|
||||||
<p class="capitalize">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</p>
|
<p class="capitalize" style="color: var(--text-primary)">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if book.ScanInformation.Valid && book.ScanInformation.String != "" {
|
if book.ScanInformation.Valid && book.ScanInformation.String != "" {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Scan Info</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Scan Info</p>
|
||||||
<p class="text-sm" style="color: var(--text-secondary);">{ book.ScanInformation.String }</p>
|
<p class="text-sm" style="color: var(--text-secondary);">{ book.ScanInformation.String }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if len(book.AlternateInfo) > 0 {
|
|
||||||
<div>
|
|
||||||
<p style="color: var(--text-secondary)">Alternate Series</p>
|
|
||||||
<p class="text-sm">{ string(book.AlternateInfo) }</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" {
|
if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Alternate Series</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Alternate Series</p>
|
||||||
<p>{ altSeries }</p>
|
<p style="color: var(--text-primary)">{ altSeries }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if len(book.Contributors) > 0 {
|
if len(book.Contributors) > 0 {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Contributors</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Contributors</p>
|
||||||
<p>{ strings.Join(book.Contributors, ", ") }</p>
|
<p style="color: var(--text-primary)">{ strings.Join(book.Contributors, ", ") }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Technical Info -->
|
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">Format</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Format</p>
|
||||||
<p>{ book.MimeType.String }</p>
|
<p style="color: var(--text-primary)">{ book.MimeType.String }</p>
|
||||||
</div>
|
</div>
|
||||||
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
|
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-secondary)">File Size</p>
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">File Size</p>
|
||||||
<p>{ formatFileSize(book.FileSize.Int64) }</p>
|
<p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- External IDs Section -->
|
|
||||||
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
|
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
|
||||||
<div class="mt-4 pt-4 border-t" style="border-color: var(--border);">
|
<div class="mt-5 pt-4 border-t flex flex-wrap gap-2" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
||||||
<h4 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">External Links</h4>
|
if book.GoodreadsID.Valid && book.GoodreadsID.String != "" {
|
||||||
<div class="flex flex-wrap gap-3">
|
<a href={ getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Goodreads</a>
|
||||||
if book.GoodreadsID.Valid && book.GoodreadsID.String != "" {
|
} else {
|
||||||
<a
|
<a href={ getExternalURL("goodreads", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Goodreads</a>
|
||||||
href={ getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author) }
|
}
|
||||||
target="_blank"
|
if book.OpenlibraryID.Valid && book.OpenlibraryID.String != "" {
|
||||||
rel="noopener noreferrer"
|
<a href={ getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Open Library</a>
|
||||||
class="text-sm hover:underline flex items-center gap-1"
|
} else {
|
||||||
style="color: var(--accent);"
|
<a href={ getExternalURL("openlibrary", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Open Library</a>
|
||||||
>
|
}
|
||||||
📚 Goodreads
|
if book.GoogleBooksID.Valid && book.GoogleBooksID.String != "" {
|
||||||
</a>
|
<a href={ getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Google Books</a>
|
||||||
} else {
|
} else {
|
||||||
<a
|
<a href={ getExternalURL("googlebooks", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Google Books</a>
|
||||||
href={ getExternalURL("goodreads", "", book.Isbn, book.Title, book.Author) }
|
}
|
||||||
target="_blank"
|
if book.Asin.Valid && book.Asin.String != "" {
|
||||||
rel="noopener noreferrer"
|
<a href={ getExternalURL("amazon", book.Asin.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Amazon</a>
|
||||||
class="text-sm hover:underline flex items-center gap-1"
|
} else if book.Isbn.Valid && book.Isbn.String != "" {
|
||||||
style="color: var(--accent);"
|
<a href={ getExternalURL("amazon", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Amazon</a>
|
||||||
>
|
}
|
||||||
📚 Goodreads
|
if book.WebUrl.Valid && book.WebUrl.String != "" {
|
||||||
</a>
|
<a href={ book.WebUrl.String } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">{ getDomainName(book.WebUrl.String) }</a>
|
||||||
}
|
}
|
||||||
if book.OpenlibraryID.Valid && book.OpenlibraryID.String != "" {
|
|
||||||
<a
|
|
||||||
href={ getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author) }
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-sm hover:underline flex items-center gap-1"
|
|
||||||
style="color: var(--accent);"
|
|
||||||
>
|
|
||||||
📖 Open Library
|
|
||||||
</a>
|
|
||||||
} else {
|
|
||||||
<a
|
|
||||||
href={ getExternalURL("openlibrary", "", book.Isbn, book.Title, book.Author) }
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-sm hover:underline flex items-center gap-1"
|
|
||||||
style="color: var(--accent);"
|
|
||||||
>
|
|
||||||
📖 Open Library
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
if book.GoogleBooksID.Valid && book.GoogleBooksID.String != "" {
|
|
||||||
<a
|
|
||||||
href={ getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author) }
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-sm hover:underline flex items-center gap-1"
|
|
||||||
style="color: var(--accent);"
|
|
||||||
>
|
|
||||||
🔍 Google Books
|
|
||||||
</a>
|
|
||||||
} else {
|
|
||||||
<a
|
|
||||||
href={ getExternalURL("googlebooks", "", book.Isbn, book.Title, book.Author) }
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-sm hover:underline flex items-center gap-1"
|
|
||||||
style="color: var(--accent);"
|
|
||||||
>
|
|
||||||
🔍 Google Books
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
if book.Asin.Valid && book.Asin.String != "" {
|
|
||||||
<a
|
|
||||||
href={ getExternalURL("amazon", book.Asin.String, book.Isbn, book.Title, book.Author) }
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-sm hover:underline flex items-center gap-1"
|
|
||||||
style="color: var(--accent);"
|
|
||||||
>
|
|
||||||
🛒 Amazon
|
|
||||||
</a>
|
|
||||||
} else if book.Isbn.Valid && book.Isbn.String != "" {
|
|
||||||
<a
|
|
||||||
href={ getExternalURL("amazon", "", book.Isbn, book.Title, book.Author) }
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-sm hover:underline flex items-center gap-1"
|
|
||||||
style="color: var(--accent);"
|
|
||||||
>
|
|
||||||
🛒 Amazon
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
if book.WebUrl.Valid && book.WebUrl.String != "" {
|
|
||||||
<a
|
|
||||||
href={ book.WebUrl.String }
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-sm hover:underline flex items-center gap-1"
|
|
||||||
style="color: var(--accent);"
|
|
||||||
>
|
|
||||||
🔗 { getDomainName(book.WebUrl.String) }
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- Collections Section -->
|
<!-- Collections -->
|
||||||
if len(book.Collections) > 0 {
|
if len(book.Collections) > 0 {
|
||||||
<div
|
<div class="card p-6 mb-6">
|
||||||
class="card p-6 rounded-lg border mb-6"
|
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
|
||||||
>
|
|
||||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Collections</h3>
|
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Collections</h3>
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-2.5">
|
||||||
for _, col := range book.Collections {
|
for _, col := range book.Collections {
|
||||||
<a
|
<a
|
||||||
href={ "/collections/" + uuidToString(col.ID) }
|
href={ "/collections/" + uuidToString(col.ID) }
|
||||||
class="px-3 py-2 rounded-lg border flex items-center gap-2 hover:opacity-80 transition-opacity"
|
class="inline-flex items-center gap-2 px-3 py-2 rounded-xl border hover:opacity-90 transition-opacity"
|
||||||
style="border-color: { col.Color.String }; background-color: var(--bg-primary); text-decoration: none;"
|
style={ "border-color: " + col.Color.String + "; background-color: var(--bg-primary); text-decoration: none;" }
|
||||||
>
|
>
|
||||||
<span style="color: { col.Color.String };">{ col.Icon.String }</span>
|
<span style={ "color: " + col.Color.String }>{ col.Icon.String }</span>
|
||||||
<span style="color: var(--text-primary);">{ col.Name }</span>
|
<span style="color: var(--text-primary);">{ col.Name }</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
@@ -530,7 +424,6 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- Modals -->
|
|
||||||
@ProgressSyncModal(user, book)
|
@ProgressSyncModal(user, book)
|
||||||
@NotesHighlightsModal(book)
|
@NotesHighlightsModal(book)
|
||||||
@MetadataEditorModal(book)
|
@MetadataEditorModal(book)
|
||||||
|
|||||||
+182
-230
@@ -8,50 +8,52 @@ import (
|
|||||||
templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
||||||
<div
|
<div
|
||||||
id="progress-sync-modal"
|
id="progress-sync-modal"
|
||||||
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
|
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
||||||
style="background-color: rgba(0, 0, 0, 0.7);"
|
style="background-color: var(--surface-overlay);"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="card rounded-lg p-6 w-full max-w-4xl mx-4 my-8"
|
class="card w-full max-w-4xl my-8 p-6"
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
style="box-shadow: var(--shadow-pop);"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div class="flex items-center gap-3">
|
||||||
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Sync Progress</h2>
|
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title }</p>
|
@Icon("sync", "h-5 w-5")
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Sync Progress</h2>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title }</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="hideProgressSyncModal()"
|
@click="hideProgressSyncModal()"
|
||||||
class="p-2 hover:opacity-80 rounded-lg"
|
class="icon-btn"
|
||||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
✕
|
@Icon("close", "h-5 w-5")
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
if book.ActiveConflict != nil {
|
if book.ActiveConflict != nil {
|
||||||
<div
|
<div
|
||||||
class="mb-6 p-4 rounded-lg border"
|
class="mb-6 p-4 rounded-xl flex items-start gap-3"
|
||||||
style="background-color: #f59e0b20; border-color: #f59e0b;"
|
style="background-color: color-mix(in srgb, var(--status-warning) 15%, transparent); border: 1px solid var(--status-warning);"
|
||||||
>
|
>
|
||||||
<p class="font-semibold mb-2" style="color: var(--text-primary);">
|
<span class="shrink-0 mt-0.5" style="color: var(--status-warning);">@Icon("alert", "h-5 w-5")</span>
|
||||||
⚠️ Conflict Detected
|
<div>
|
||||||
</p>
|
<p class="font-semibold mb-1" style="color: var(--text-primary);">
|
||||||
<p class="text-sm" style="color: var(--text-secondary);">
|
Conflict Detected
|
||||||
Progress differs between devices. Choose which version to keep.
|
</p>
|
||||||
</p>
|
<p class="text-sm" style="color: var(--text-secondary);">
|
||||||
|
Progress differs between devices. Choose which version to keep.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
for source, data := range book.ActiveConflict.ConflictData {
|
for source, data := range book.ActiveConflict.ConflictData {
|
||||||
<div
|
<div class="card p-4">
|
||||||
class="card p-4 rounded-lg border"
|
|
||||||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
|
||||||
>
|
|
||||||
<div class="flex justify-between items-start mb-3">
|
<div class="flex justify-between items-start mb-3">
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span class="badge mb-2" style="background-color: var(--accent); color: #fff;">
|
||||||
class="inline-block px-2 py-1 rounded text-xs font-semibold capitalize mb-2"
|
|
||||||
style="background-color: var(--accent); color: white;"
|
|
||||||
>
|
|
||||||
{ data.Source }
|
{ data.Source }
|
||||||
</span>
|
</span>
|
||||||
<p class="text-xs" style="color: var(--text-secondary);">
|
<p class="text-xs" style="color: var(--text-secondary);">
|
||||||
@@ -83,8 +85,7 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
|||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<button
|
<button
|
||||||
@click={"resolveConflict('" + book.ActiveConflict.ID + "', '" + source + "')"}
|
@click={"resolveConflict('" + book.ActiveConflict.ID + "', '" + source + "')"}
|
||||||
class="px-3 py-1.5 rounded-lg text-sm font-semibold"
|
class="btn btn-primary"
|
||||||
style="background-color: var(--accent); color: white;"
|
|
||||||
>
|
>
|
||||||
Keep This
|
Keep This
|
||||||
</button>
|
</button>
|
||||||
@@ -96,10 +97,7 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
|||||||
<div class="mb-4" style="color: var(--text-secondary);">
|
<div class="mb-4" style="color: var(--text-secondary);">
|
||||||
<p>No conflicts detected. Current progress from <strong>{ book.ReadingProgress.LastSyncSource.String }</strong>:</p>
|
<p>No conflicts detected. Current progress from <strong>{ book.ReadingProgress.LastSyncSource.String }</strong>:</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="card p-6 text-center">
|
||||||
class="card p-6 rounded-lg border text-center"
|
|
||||||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
|
||||||
>
|
|
||||||
<div class="text-5xl font-bold mb-4" style="color: var(--accent);">
|
<div class="text-5xl font-bold mb-4" style="color: var(--accent);">
|
||||||
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64 * 100) }%
|
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64 * 100) }%
|
||||||
</div>
|
</div>
|
||||||
@@ -121,8 +119,7 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
|||||||
<div class="flex justify-end space-x-3 mt-6">
|
<div class="flex justify-end space-x-3 mt-6">
|
||||||
<button
|
<button
|
||||||
@click="hideProgressSyncModal()"
|
@click="hideProgressSyncModal()"
|
||||||
class="px-4 py-2 rounded-lg"
|
class="btn btn-secondary"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
|
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
@@ -134,14 +131,16 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
|||||||
templ NotesHighlightsModal(book handlers.MediaDetail) {
|
templ NotesHighlightsModal(book handlers.MediaDetail) {
|
||||||
<div
|
<div
|
||||||
id="notes-modal"
|
id="notes-modal"
|
||||||
class="hidden fixed inset-0 z-50 flex items-center justify-center"
|
class="hidden fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
style="background-color: rgba(0, 0, 0, 0.7);"
|
style="background-color: var(--surface-overlay);"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="card rounded-lg p-8 w-full max-w-2xl mx-4 text-center"
|
class="card w-full max-w-2xl p-8 text-center"
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
style="box-shadow: var(--shadow-pop);"
|
||||||
>
|
>
|
||||||
<div class="text-6xl mb-4">📝</div>
|
<span class="inline-grid place-items-center h-14 w-14 rounded-2xl mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
@Icon("edit", "h-7 w-7")
|
||||||
|
</span>
|
||||||
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Notes & Highlights</h2>
|
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Notes & Highlights</h2>
|
||||||
<p class="mb-2" style="color: var(--text-secondary);">
|
<p class="mb-2" style="color: var(--text-secondary);">
|
||||||
This book has <strong>{ book.NotesCount }</strong> notes and <strong>{ book.HighlightsCount }</strong> highlights.
|
This book has <strong>{ book.NotesCount }</strong> notes and <strong>{ book.HighlightsCount }</strong> highlights.
|
||||||
@@ -150,8 +149,7 @@ templ NotesHighlightsModal(book handlers.MediaDetail) {
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
@click="hideNotesModal()"
|
@click="hideNotesModal()"
|
||||||
class="px-6 py-2 rounded-lg font-semibold"
|
class="btn btn-primary"
|
||||||
style="background-color: var(--accent); color: white;"
|
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
@@ -163,27 +161,31 @@ templ NotesHighlightsModal(book handlers.MediaDetail) {
|
|||||||
templ MetadataEditorModal(book handlers.MediaDetail) {
|
templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||||
<div
|
<div
|
||||||
id="metadata-editor-modal"
|
id="metadata-editor-modal"
|
||||||
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
|
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
||||||
style="background-color: rgba(0, 0, 0, 0.7);"
|
style="background-color: var(--surface-overlay);"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="rounded-lg w-full max-w-5xl mx-4 my-8 flex flex-col max-h-[90vh]"
|
class="card w-full max-w-5xl my-8 flex flex-col max-h-[90vh]"
|
||||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border);"
|
style="box-shadow: var(--shadow-pop);"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between items-center p-6 border-b flex-shrink-0" style="border-color: var(--border);">
|
<div class="flex justify-between items-center p-6 border-b flex-shrink-0" style="border-color: var(--border);">
|
||||||
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Edit Metadata</h2>
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
@Icon("edit", "h-5 w-5")
|
||||||
|
</span>
|
||||||
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit Metadata</h2>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="hideMetadataEditor()"
|
@click="hideMetadataEditor()"
|
||||||
class="p-2 hover:opacity-80 rounded-lg"
|
class="icon-btn"
|
||||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
✕
|
@Icon("close", "h-5 w-5")
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col md:flex-row gap-8 p-6 flex-1 min-h-0 overflow-y-auto">
|
<div class="flex flex-col md:flex-row gap-8 p-6 flex-1 min-h-0 overflow-y-auto">
|
||||||
<!-- Cover Section (Left) -->
|
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<div class="w-64 h-96 rounded-lg overflow-hidden shadow-lg mb-3 cursor-pointer relative group"
|
<div class="w-64 h-96 rounded-xl overflow-hidden mb-3 cursor-pointer relative group"
|
||||||
style="background-color: var(--bg-primary);"
|
style="background-color: var(--bg-primary);"
|
||||||
@click="document.getElementById('cover-upload-input').click()"
|
@click="document.getElementById('cover-upload-input').click()"
|
||||||
>
|
>
|
||||||
@@ -209,27 +211,26 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
<div class="w-64 space-y-2">
|
<div class="w-64 space-y-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full px-3 py-2 text-sm rounded-lg font-semibold"
|
class="btn btn-primary w-full"
|
||||||
style="background-color: var(--accent); color: white;"
|
|
||||||
@click="generateCover()"
|
@click="generateCover()"
|
||||||
x-show="coverGenerating"
|
x-show="coverGenerating"
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
|
@Icon("refresh", "h-4 w-4")
|
||||||
Generating...
|
Generating...
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full px-3 py-2 text-sm rounded-lg font-semibold"
|
class="btn btn-primary w-full"
|
||||||
style="background-color: var(--accent); color: white;"
|
|
||||||
@click="generateCover()"
|
@click="generateCover()"
|
||||||
x-show="!coverGenerating"
|
x-show="!coverGenerating"
|
||||||
>
|
>
|
||||||
|
@Icon("refresh", "h-4 w-4")
|
||||||
Generate Cover
|
Generate Cover
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full px-3 py-2 text-sm rounded-lg"
|
class="btn btn-ghost w-full"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border);"
|
|
||||||
@click="removeCover()"
|
@click="removeCover()"
|
||||||
x-show="hasExistingCover || newCoverPreview"
|
x-show="hasExistingCover || newCoverPreview"
|
||||||
>
|
>
|
||||||
@@ -237,53 +238,43 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Accordion Fields (Right) -->
|
|
||||||
<div class="flex-1 min-w-0 space-y-2">
|
<div class="flex-1 min-w-0 space-y-2">
|
||||||
<!-- Basic Info -->
|
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||||
<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 hover:bg-surface-hover"
|
||||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
style="color: var(--text-primary);"
|
||||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
|
||||||
@click="toggleSection('basic')">
|
@click="toggleSection('basic')">
|
||||||
<span class="font-semibold">Basic Info</span>
|
<span class="font-semibold">Basic Info</span>
|
||||||
<span x-text="openSections.basic ? '▾' : '▸'">▸</span>
|
<span class="inline-flex">
|
||||||
|
<span x-show="openSections.basic">@Icon("chevron-down", "h-4 w-4")</span>
|
||||||
|
<span x-show="!openSections.basic" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div x-show="openSections.basic" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
<div x-show="openSections.basic" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Title</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Title</label>
|
||||||
<input type="text" name="title" value={ book.Title }
|
<input type="text" name="title" value={ book.Title } class="input" />
|
||||||
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>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Author</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Author</label>
|
||||||
<input type="text" name="author" value={ textToString(book.Author) }
|
<input type="text" name="author" value={ textToString(book.Author) } class="input" />
|
||||||
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>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Description</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Description</label>
|
||||||
<textarea name="description" rows="3"
|
<textarea name="description" rows="3" class="input"
|
||||||
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);"
|
|
||||||
>{ textToString(book.Description) }</textarea>
|
>{ textToString(book.Description) }</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Summary</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Summary</label>
|
||||||
<textarea name="summary" rows="2"
|
<textarea name="summary" rows="2" class="input"
|
||||||
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);"
|
|
||||||
>{ textToString(book.Summary) }</textarea>
|
>{ textToString(book.Summary) }</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Tags</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Tags</label>
|
||||||
<div class="flex flex-wrap gap-1.5 mb-2">
|
<div class="flex flex-wrap gap-1.5 mb-2">
|
||||||
<template x-for="(tag, idx) in editorTags" :key="idx">
|
<template x-for="(tag, idx) in editorTags" :key="idx">
|
||||||
<span
|
<span class="chip">
|
||||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium"
|
|
||||||
style="background-color: var(--accent); color: white;"
|
|
||||||
>
|
|
||||||
<span x-text="tag"></span>
|
<span x-text="tag"></span>
|
||||||
<button type="button" class="hover:opacity-70 leading-none" @click="removeEditorTag(idx)">✕</button>
|
<button type="button" class="hover:bg-surface-hover rounded leading-none" @click="removeEditorTag(idx)">@Icon("close", "h-3 w-3")</button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -297,20 +288,19 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
@keydown="onTagKeydown($event)"
|
@keydown="onTagKeydown($event)"
|
||||||
@blur="hideEditorTagDropdown()"
|
@blur="hideEditorTagDropdown()"
|
||||||
placeholder="Add tag..."
|
placeholder="Add tag..."
|
||||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
class="input" />
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
|
||||||
<div
|
<div
|
||||||
x-show="showTagDropdown"
|
x-show="showTagDropdown"
|
||||||
x-transition
|
x-transition
|
||||||
class="mt-1 w-full rounded-lg shadow-lg border max-h-48 overflow-y-auto"
|
x-cloak
|
||||||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
class="mt-1 w-full rounded-lg border max-h-48 overflow-y-auto"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); box-shadow: var(--shadow-card);"
|
||||||
>
|
>
|
||||||
<template x-for="sug in tagSuggestions" :key="sug.value">
|
<template x-for="sug in tagSuggestions" :key="sug.value">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center"
|
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center hover:bg-surface-hover"
|
||||||
:class="highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''"
|
:class="highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'bg-surface-hover' : ''"
|
||||||
:style="highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'"
|
|
||||||
@click="selectEditorTagSuggestion(sug.value)"
|
@click="selectEditorTagSuggestion(sug.value)"
|
||||||
>
|
>
|
||||||
<span x-text="sug.value"></span>
|
<span x-text="sug.value"></span>
|
||||||
@@ -321,164 +311,142 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Community Rating (0-10)</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Community Rating (0-10)</label>
|
||||||
<input type="number" name="community_rating" min="0" max="10" step="0.1"
|
<input type="number" name="community_rating" min="0" max="10" step="0.1"
|
||||||
value={ fmt.Sprintf("%.1f", book.CommunityRating.Float64) }
|
value={ fmt.Sprintf("%.1f", book.CommunityRating.Float64) }
|
||||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
class="input" />
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Publication -->
|
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||||
<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 hover:bg-surface-hover"
|
||||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
style="color: var(--text-primary);"
|
||||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
|
||||||
@click="toggleSection('publication')">
|
@click="toggleSection('publication')">
|
||||||
<span class="font-semibold">Publication</span>
|
<span class="font-semibold">Publication</span>
|
||||||
<span x-text="openSections.publication ? '▾' : '▸'">▸</span>
|
<span class="inline-flex">
|
||||||
|
<span x-show="openSections.publication">@Icon("chevron-down", "h-4 w-4")</span>
|
||||||
|
<span x-show="!openSections.publication" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div x-show="openSections.publication" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
<div x-show="openSections.publication" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Publisher</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Publisher</label>
|
||||||
<input type="text" name="publisher" value={ textToString(book.Publisher) }
|
<input type="text" name="publisher" value={ textToString(book.Publisher) } class="input" />
|
||||||
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>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Date Published</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Date Published</label>
|
||||||
<input type="date" name="date_published"
|
<input type="date" name="date_published"
|
||||||
value={ formatDateForInput(book.DatePublished) }
|
value={ formatDateForInput(book.DatePublished) }
|
||||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
class="input" />
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Edition</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Edition</label>
|
||||||
<input type="text" name="edition" value={ textToString(book.Edition) }
|
<input type="text" name="edition" value={ textToString(book.Edition) } class="input" />
|
||||||
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>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Language</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Language</label>
|
||||||
<input type="text" name="language" value={ textToString(book.Language) }
|
<input type="text" name="language" value={ textToString(book.Language) } class="input" />
|
||||||
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>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Genre</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Genre</label>
|
||||||
<input type="text" name="genre" value={ textToString(book.Genre) }
|
<input type="text" name="genre" value={ textToString(book.Genre) } class="input" />
|
||||||
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>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Copyright Year</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Copyright Year</label>
|
||||||
<input type="number" name="copyright_year"
|
<input type="number" name="copyright_year"
|
||||||
value={ fmt.Sprintf("%d", book.CopyrightYear.Int32) }
|
value={ fmt.Sprintf("%d", book.CopyrightYear.Int32) }
|
||||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
class="input" />
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Series -->
|
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||||
<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 hover:bg-surface-hover"
|
||||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
style="color: var(--text-primary);"
|
||||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
|
||||||
@click="toggleSection('series')">
|
@click="toggleSection('series')">
|
||||||
<span class="font-semibold">Series</span>
|
<span class="font-semibold">Series</span>
|
||||||
<span x-text="openSections.series ? '▾' : '▸'">▸</span>
|
<span class="inline-flex">
|
||||||
|
<span x-show="openSections.series">@Icon("chevron-down", "h-4 w-4")</span>
|
||||||
|
<span x-show="!openSections.series" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div x-show="openSections.series" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
<div x-show="openSections.series" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Series</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Series</label>
|
||||||
<input type="text" name="series" value={ textToString(book.Series) }
|
<input type="text" name="series" value={ textToString(book.Series) } class="input" />
|
||||||
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 class="grid grid-cols-3 gap-3">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Number</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Number</label>
|
||||||
<input type="number" name="series_number"
|
<input type="number" name="series_number"
|
||||||
value={ fmt.Sprintf("%d", book.SeriesNumber.Int32) }
|
value={ fmt.Sprintf("%d", book.SeriesNumber.Int32) }
|
||||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
class="input" />
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Count</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Count</label>
|
||||||
<input type="number" name="series_count"
|
<input type="number" name="series_count"
|
||||||
value={ fmt.Sprintf("%d", book.SeriesCount.Int32) }
|
value={ fmt.Sprintf("%d", book.SeriesCount.Int32) }
|
||||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
class="input" />
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Volume</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Volume</label>
|
||||||
<input type="number" name="volume"
|
<input type="number" name="volume"
|
||||||
value={ fmt.Sprintf("%d", book.Volume.Int32) }
|
value={ fmt.Sprintf("%d", book.Volume.Int32) }
|
||||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
class="input" />
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Identifiers -->
|
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||||
<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 hover:bg-surface-hover"
|
||||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
style="color: var(--text-primary);"
|
||||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
|
||||||
@click="toggleSection('identifiers')">
|
@click="toggleSection('identifiers')">
|
||||||
<span class="font-semibold">Identifiers</span>
|
<span class="font-semibold">Identifiers</span>
|
||||||
<span x-text="openSections.identifiers ? '▾' : '▸'">▸</span>
|
<span class="inline-flex">
|
||||||
|
<span x-show="openSections.identifiers">@Icon("chevron-down", "h-4 w-4")</span>
|
||||||
|
<span x-show="!openSections.identifiers" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div x-show="openSections.identifiers" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
<div x-show="openSections.identifiers" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">ISBN</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">ISBN</label>
|
||||||
<input type="text" name="isbn" value={ textToString(book.Isbn) }
|
<input type="text" name="isbn" value={ textToString(book.Isbn) } class="input" />
|
||||||
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>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">ASIN</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">ASIN</label>
|
||||||
<input type="text" name="asin" value={ textToString(book.Asin) }
|
<input type="text" name="asin" value={ textToString(book.Asin) } class="input" />
|
||||||
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>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Goodreads ID</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Goodreads ID</label>
|
||||||
<input type="text" name="goodreads_id" value={ textToString(book.GoodreadsID) }
|
<input type="text" name="goodreads_id" value={ textToString(book.GoodreadsID) } class="input" />
|
||||||
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>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">OpenLibrary ID</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">OpenLibrary ID</label>
|
||||||
<input type="text" name="openlibrary_id" value={ textToString(book.OpenlibraryID) }
|
<input type="text" name="openlibrary_id" value={ textToString(book.OpenlibraryID) } class="input" />
|
||||||
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>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Google Books ID</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Google Books ID</label>
|
||||||
<input type="text" name="google_books_id" value={ textToString(book.GoogleBooksID) }
|
<input type="text" name="google_books_id" value={ textToString(book.GoogleBooksID) } class="input" />
|
||||||
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>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Web URL</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Web URL</label>
|
||||||
<input type="url" name="web_url" value={ textToString(book.WebUrl) }
|
<input type="url" name="web_url" value={ textToString(book.WebUrl) } class="input" />
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Comic/Manga -->
|
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||||
<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 hover:bg-surface-hover"
|
||||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
style="color: var(--text-primary);"
|
||||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
|
||||||
@click="toggleSection('comic')">
|
@click="toggleSection('comic')">
|
||||||
<span class="font-semibold">Comic/Manga</span>
|
<span class="font-semibold">Comic/Manga</span>
|
||||||
<span x-text="openSections.comic ? '▾' : '▸'">▸</span>
|
<span class="inline-flex">
|
||||||
|
<span x-show="openSections.comic">@Icon("chevron-down", "h-4 w-4")</span>
|
||||||
|
<span x-show="!openSections.comic" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div x-show="openSections.comic" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
<div x-show="openSections.comic" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
||||||
<div>
|
<div>
|
||||||
<label for="manga_type" class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Manga Type</label>
|
<label for="manga_type" class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Manga Type</label>
|
||||||
<select id="manga_type" name="manga_type"
|
<select id="manga_type" name="manga_type" class="input">
|
||||||
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={ textToString(book.MangaType) == "unknown" }>Unknown</option>
|
<option value="unknown" selected={ textToString(book.MangaType) == "unknown" }>Unknown</option>
|
||||||
<option value="no" selected={ textToString(book.MangaType) == "no" }>No</option>
|
<option value="no" selected={ textToString(book.MangaType) == "no" }>No</option>
|
||||||
<option value="yes" selected={ textToString(book.MangaType) == "yes" }>Yes</option>
|
<option value="yes" selected={ textToString(book.MangaType) == "yes" }>Yes</option>
|
||||||
@@ -486,10 +454,8 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="reading_direction" class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Reading Direction</label>
|
<label for="reading_direction" class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Reading Direction</label>
|
||||||
<select id="reading_direction" name="reading_direction"
|
<select id="reading_direction" name="reading_direction" class="input">
|
||||||
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={ textToString(book.ReadingDirection) == "auto" }>Auto</option>
|
<option value="auto" selected={ textToString(book.ReadingDirection) == "auto" }>Auto</option>
|
||||||
<option value="ltr" selected={ textToString(book.ReadingDirection) == "ltr" }>Left to Right</option>
|
<option value="ltr" selected={ textToString(book.ReadingDirection) == "ltr" }>Left to Right</option>
|
||||||
<option value="rtl" selected={ textToString(book.ReadingDirection) == "rtl" }>Right to Left</option>
|
<option value="rtl" selected={ textToString(book.ReadingDirection) == "rtl" }>Right to Left</option>
|
||||||
@@ -497,34 +463,24 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Age Rating</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Age Rating</label>
|
||||||
<input type="text" name="age_rating" value={ textToString(book.AgeRating) }
|
<input type="text" name="age_rating" value={ textToString(book.AgeRating) } class="input" />
|
||||||
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>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Story Arc</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Story Arc</label>
|
||||||
<input type="text" name="story_arc" value={ textToString(book.StoryArc) }
|
<input type="text" name="story_arc" value={ textToString(book.StoryArc) } class="input" />
|
||||||
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>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Imprint</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Imprint</label>
|
||||||
<input type="text" name="imprint" value={ textToString(book.Imprint) }
|
<input type="text" name="imprint" value={ textToString(book.Imprint) } class="input" />
|
||||||
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>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Scan Information</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Scan Information</label>
|
||||||
<input type="text" name="scan_information" value={ textToString(book.ScanInformation) }
|
<input type="text" name="scan_information" value={ textToString(book.ScanInformation) } class="input" />
|
||||||
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>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Metadata Notes</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Metadata Notes</label>
|
||||||
<textarea name="metadata_notes" rows="2"
|
<textarea name="metadata_notes" rows="2" class="input"
|
||||||
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);"
|
|
||||||
>{ textToString(book.MetadataNotes) }</textarea>
|
>{ textToString(book.MetadataNotes) }</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -535,40 +491,37 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Technical -->
|
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||||
<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 hover:bg-surface-hover"
|
||||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
style="color: var(--text-primary);"
|
||||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
|
||||||
@click="toggleSection('technical')">
|
@click="toggleSection('technical')">
|
||||||
<span class="font-semibold">Technical</span>
|
<span class="font-semibold">Technical</span>
|
||||||
<span x-text="openSections.technical ? '▾' : '▸'">▸</span>
|
<span class="inline-flex">
|
||||||
|
<span x-show="openSections.technical">@Icon("chevron-down", "h-4 w-4")</span>
|
||||||
|
<span x-show="!openSections.technical" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div x-show="openSections.technical" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
<div x-show="openSections.technical" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Page Count</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Page Count</label>
|
||||||
<input type="number" name="page_count"
|
<input type="number" name="page_count"
|
||||||
value={ fmt.Sprintf("%d", book.PageCount.Int32) }
|
value={ fmt.Sprintf("%d", book.PageCount.Int32) }
|
||||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
class="input" />
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Contributors (comma-separated)</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Contributors (comma-separated)</label>
|
||||||
<input type="text" name="contributors" value={ stringSliceToString(book.Contributors) }
|
<input type="text" name="contributors" value={ stringSliceToString(book.Contributors) } class="input" />
|
||||||
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 class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Format</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Format</label>
|
||||||
<input type="text" value={ textToString(book.MimeType) } readonly
|
<input type="text" value={ textToString(book.MimeType) } readonly
|
||||||
class="w-full px-3 py-2 rounded-lg border text-sm opacity-60"
|
class="input opacity-60" />
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">File Size</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">File Size</label>
|
||||||
<input type="text" value={ fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024) } readonly
|
<input type="text" value={ fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024) } readonly
|
||||||
class="w-full px-3 py-2 rounded-lg border text-sm opacity-60"
|
class="input opacity-60" />
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -578,16 +531,15 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
<div class="flex justify-end space-x-3 p-6 border-t flex-shrink-0" style="border-color: var(--border);">
|
<div class="flex justify-end space-x-3 p-6 border-t flex-shrink-0" style="border-color: var(--border);">
|
||||||
<button
|
<button
|
||||||
@click="hideMetadataEditor()"
|
@click="hideMetadataEditor()"
|
||||||
class="px-4 py-2 rounded-lg"
|
class="btn btn-secondary"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="saveMetadata()"
|
@click="saveMetadata()"
|
||||||
class="px-6 py-2 rounded-lg font-semibold"
|
class="btn btn-primary"
|
||||||
style="background-color: var(--accent); color: white;"
|
|
||||||
>
|
>
|
||||||
|
@Icon("save", "h-4 w-4")
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+714
-614
File diff suppressed because it is too large
Load Diff
+251
-348
@@ -3,6 +3,7 @@ package templates
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
templ BookShelf(
|
templ BookShelf(
|
||||||
@@ -11,10 +12,10 @@ templ BookShelf(
|
|||||||
currentLibraryID string,
|
currentLibraryID string,
|
||||||
errorMessage string,
|
errorMessage string,
|
||||||
savedFilters []database.SavedFilters,
|
savedFilters []database.SavedFilters,
|
||||||
books []handlers.BookInfo, // NEW
|
books []handlers.BookInfo,
|
||||||
limit int, // NEW
|
limit int,
|
||||||
offset int, // NEW
|
offset int,
|
||||||
count int, // NEW
|
count int,
|
||||||
) {
|
) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -24,6 +25,7 @@ templ BookShelf(
|
|||||||
<title>Library - Bookhoard</title>
|
<title>Library - Bookhoard</title>
|
||||||
<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"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
class="theme-{ user.Theme }"
|
class="theme-{ user.Theme }"
|
||||||
@@ -31,363 +33,287 @@ templ BookShelf(
|
|||||||
x-init="initBookshelf()"
|
x-init="initBookshelf()"
|
||||||
>
|
>
|
||||||
@Header(user, "/bookshelf")
|
@Header(user, "/bookshelf")
|
||||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
<div class="w-full px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<!-- Filter Bar -->
|
<form id="filter-form" hx-get="/api/media-items/search" hx-target="#books-grid">
|
||||||
<div
|
<!-- Toolbar -->
|
||||||
class="mb-6 card p-4 rounded-lg border"
|
<div class="flex flex-wrap items-center gap-3 mb-6">
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
<div class="relative flex-1 min-w-[200px]">
|
||||||
>
|
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
|
||||||
<form id="filter-form" hx-get="/api/media-items/search" hx-target="#books-grid">
|
@Icon("search", "h-4 w-4")
|
||||||
<div class="flex flex-wrap gap-4 items-center">
|
</span>
|
||||||
<!-- Library Selector -->
|
<input
|
||||||
<div class="flex-1 min-w-[200px]">
|
name="q"
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
type="text"
|
||||||
Library
|
class="input pl-10"
|
||||||
</label>
|
placeholder="Search all fields…"
|
||||||
<select
|
/>
|
||||||
id="library-select"
|
</div>
|
||||||
name="library_id"
|
<select
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
name="sort"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
class="input w-auto cursor-pointer"
|
||||||
>
|
hx-get="/api/media-items/search"
|
||||||
if len(libraries) == 0 {
|
hx-target="#books-grid"
|
||||||
<option value="">No libraries available</option>
|
hx-trigger="change"
|
||||||
} else {
|
hx-include="#filter-form"
|
||||||
if currentLibraryID == "" {
|
>
|
||||||
<option value="" selected>All Books</option>
|
<option value="title ASC">Title (A-Z)</option>
|
||||||
|
<option value="title DESC">Title (Z-A)</option>
|
||||||
|
<option value="author ASC">Author (A-Z)</option>
|
||||||
|
<option value="created_at DESC">Date Added</option>
|
||||||
|
<option value="page_count DESC">Page Count</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" @click="filtersOpen = true" class="btn btn-secondary">
|
||||||
|
@Icon("filter", "h-4 w-4")
|
||||||
|
<span>Filters</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="showSaveFilterModal()" class="btn btn-secondary">
|
||||||
|
@Icon("save", "h-4 w-4")
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
|
<div class="relative">
|
||||||
|
<button type="button" @click="toggleFiltersDropdown()" data-load-filter-btn class="btn btn-secondary">
|
||||||
|
@Icon("folder", "h-4 w-4")
|
||||||
|
<span>Load</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
x-show="showFiltersDropdown"
|
||||||
|
@click.outside="showFiltersDropdown = false"
|
||||||
|
x-transition:enter="transition ease-out duration-200"
|
||||||
|
x-transition:enter-start="opacity-0 scale-95"
|
||||||
|
x-transition:enter-end="opacity-100 scale-100"
|
||||||
|
x-transition:leave="transition ease-in duration-150"
|
||||||
|
x-transition:leave-start="opacity-100 scale-100"
|
||||||
|
x-transition:leave-end="opacity-0 scale-95"
|
||||||
|
class="absolute top-full right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl shadow-lg z-50 card p-4"
|
||||||
|
x-cloak
|
||||||
|
>
|
||||||
|
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">Saved Filters</h3>
|
||||||
|
<div class="space-y-2" id="saved-filters-list">
|
||||||
|
for _, filter := range savedFilters {
|
||||||
|
<div class="flex items-center justify-between p-2 rounded-lg hover:bg-surface-hover" data-filter-id={ uuidToString(filter.ID) }>
|
||||||
|
<button data-action="load-filter" @click="loadFilter($event)" class="flex-1 text-left px-2 py-1 rounded text-sm" style="color: var(--text-primary);">
|
||||||
|
{ filter.Name }
|
||||||
|
</button>
|
||||||
|
<button data-action="delete-filter" @click="deleteFilter($event)" class="icon-btn h-8 w-8" title="Delete filter">
|
||||||
|
@Icon("trash", "h-4 w-4")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if len(savedFilters) == 0 {
|
||||||
|
<div class="text-sm py-4 text-center" style="color: var(--text-secondary);">
|
||||||
|
No saved filters yet
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" @click="clearFilters()" class="btn btn-ghost">
|
||||||
|
@Icon("close", "h-4 w-4")
|
||||||
|
<span>Clear</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Filter drawer -->
|
||||||
|
<div
|
||||||
|
x-show="filtersOpen"
|
||||||
|
x-cloak
|
||||||
|
class="fixed inset-0 z-[65]"
|
||||||
|
@keydown.escape.window="filtersOpen = false"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0" style="background-color: var(--surface-overlay);" @click="filtersOpen = false"></div>
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-0 bottom-0 w-full max-w-md overflow-y-auto"
|
||||||
|
style="background-color: var(--bg-secondary); box-shadow: var(--shadow-pop);"
|
||||||
|
x-transition:enter="transition ease-out duration-200"
|
||||||
|
x-transition:enter-start="translate-x-full"
|
||||||
|
x-transition:enter-end="translate-x-0"
|
||||||
|
x-transition:leave="transition ease-in duration-150"
|
||||||
|
x-transition:leave-start="translate-x-0"
|
||||||
|
x-transition:leave-end="translate-x-full"
|
||||||
|
>
|
||||||
|
<div class="sticky top-0 flex items-center justify-between px-6 py-4 border-b" style="background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
||||||
|
<h2 class="text-lg font-bold" style="color: var(--text-primary)">Filters</h2>
|
||||||
|
<button type="button" @click="filtersOpen = false" class="icon-btn" aria-label="Close filters">
|
||||||
|
@Icon("close", "h-5 w-5")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library</label>
|
||||||
|
<select id="library-select" name="library_id" class="input">
|
||||||
|
if len(libraries) == 0 {
|
||||||
|
<option value="">No libraries available</option>
|
||||||
} else {
|
} else {
|
||||||
<option value="">All Books</option>
|
if currentLibraryID == "" {
|
||||||
}
|
<option value="" selected>All Books ({ TotalMediaCount(libraries) })</option>
|
||||||
for _, lib := range libraries {
|
|
||||||
if lib.ID == currentLibraryID {
|
|
||||||
<option value={ lib.ID } selected>{ lib.Name }</option>
|
|
||||||
} else {
|
} else {
|
||||||
<option value={ lib.ID }>{ lib.Name }</option>
|
<option value="">All Books ({ TotalMediaCount(libraries) })</option>
|
||||||
|
}
|
||||||
|
for _, lib := range libraries {
|
||||||
|
if lib.ID == currentLibraryID {
|
||||||
|
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
|
||||||
|
} else {
|
||||||
|
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
</select>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<!-- Search Input -->
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Author</label>
|
||||||
<div class="flex-1 min-w-[150px]">
|
<input
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
type="text"
|
||||||
Search
|
name="author_filter"
|
||||||
</label>
|
placeholder="Filter by author"
|
||||||
<input
|
class="input"
|
||||||
name="q"
|
list="author-datalist"
|
||||||
type="text"
|
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
/>
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
<datalist id="author-datalist"></datalist>
|
||||||
placeholder="Search all fields..."
|
</div>
|
||||||
/>
|
<div class="relative">
|
||||||
</div>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Tags</label>
|
||||||
<!-- Author Filter with Autocomplete -->
|
<input
|
||||||
<div class="flex-1 min-w-[150px]">
|
type="text"
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
name="tags_filter"
|
||||||
Author
|
placeholder="Filter by tags"
|
||||||
</label>
|
class="input"
|
||||||
<input
|
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
|
||||||
type="text"
|
@keydown="onTagFilterKeydown($event)"
|
||||||
name="author_filter"
|
@blur="hideTagDropdown()"
|
||||||
placeholder="Filter by author"
|
/>
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
<div
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
x-show="showTagDropdown"
|
||||||
list="author-datalist"
|
x-transition
|
||||||
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
|
x-cloak
|
||||||
/>
|
class="absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||||
<datalist id="author-datalist"></datalist>
|
style="background-color: var(--bg-primary); border: 1px solid var(--border);"
|
||||||
</div>
|
>
|
||||||
<!-- Tags Filter with Autocomplete -->
|
<template x-for="sug in tagSuggestions" :key="sug.value">
|
||||||
<div class="flex-1 min-w-[150px] relative">
|
<button
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Tags</label>
|
type="button"
|
||||||
<input
|
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center"
|
||||||
type="text"
|
:class="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''"
|
||||||
name="tags_filter"
|
:style="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'"
|
||||||
placeholder="Filter by tags"
|
@click="selectTagSuggestion(sug.value)"
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
>
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
<span x-text="sug.value"></span>
|
||||||
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
|
<span class="text-xs" style="color: var(--text-secondary);" x-text="sug.count + ' books'"></span>
|
||||||
@keydown="onTagFilterKeydown($event)"
|
</button>
|
||||||
@blur="hideTagDropdown()"
|
</template>
|
||||||
/>
|
</div>
|
||||||
<div
|
</div>
|
||||||
x-show="showTagDropdown"
|
<div>
|
||||||
x-transition
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Series</label>
|
||||||
class="absolute z-50 mt-1 w-full rounded-lg shadow-lg border max-h-48 overflow-y-auto"
|
<input
|
||||||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
type="text"
|
||||||
>
|
name="series_filter"
|
||||||
<template x-for="sug in tagSuggestions" :key="sug.value">
|
placeholder="Filter by series"
|
||||||
<button
|
class="input"
|
||||||
type="button"
|
list="series-datalist"
|
||||||
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center"
|
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
|
||||||
:class="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''"
|
/>
|
||||||
:style="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'"
|
<datalist id="series-datalist"></datalist>
|
||||||
@click="selectTagSuggestion(sug.value)"
|
</div>
|
||||||
>
|
<div>
|
||||||
<span x-text="sug.value"></span>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Language</label>
|
||||||
<span class="text-xs" style="color: var(--text-secondary);" x-text="sug.count + ' books'"></span>
|
<input
|
||||||
</button>
|
type="text"
|
||||||
|
name="language_filter"
|
||||||
|
placeholder="Filter by language"
|
||||||
|
class="input"
|
||||||
|
list="language-datalist"
|
||||||
|
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
|
||||||
|
/>
|
||||||
|
<datalist id="language-datalist"></datalist>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Year Range</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="number" name="year_min" placeholder="From" class="input"/>
|
||||||
|
<input type="number" name="year_max" placeholder="To" class="input"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Cover</label>
|
||||||
|
<input
|
||||||
|
type="button"
|
||||||
|
id="has-cover-tristate"
|
||||||
|
class="tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm"
|
||||||
|
:class="{
|
||||||
|
'state-null': hasCoverState === null,
|
||||||
|
'state-true': hasCoverState === true,
|
||||||
|
'state-false': hasCoverState === false
|
||||||
|
}"
|
||||||
|
:value="hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'"
|
||||||
|
@click="cycleHasCover()"
|
||||||
|
/>
|
||||||
|
<template x-if="hasCoverState !== null">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="has_cover"
|
||||||
|
:value="hasCoverState ? 'true' : 'false'"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
// <!-- Genre Filter with Autocomplete -->
|
<div class="sticky bottom-0 px-6 py-4 border-t" style="background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
||||||
// <div class="flex-1 min-w-[150px]">
|
<button type="submit" @click="filtersOpen = false" class="btn btn-primary w-full">Apply Filters</button>
|
||||||
// <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
|
||||||
// Genre
|
|
||||||
// </label>
|
|
||||||
// <input
|
|
||||||
// type="text"
|
|
||||||
// name="genre_filter"
|
|
||||||
// placeholder="Filter by genre"
|
|
||||||
// class="w-full px-3 py-2 border rounded-lg"
|
|
||||||
// style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
// list="genre-datalist"
|
|
||||||
// @input.debounce.300ms="if($el.value.length >= 2) fetchGenreValues($el)"
|
|
||||||
// />
|
|
||||||
// <datalist id="genre-datalist"></datalist>
|
|
||||||
// </div>
|
|
||||||
<!-- Series Filter with Autocomplete (NEW) -->
|
|
||||||
<div class="flex-1 min-w-[150px]">
|
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
|
||||||
Series
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="series_filter"
|
|
||||||
placeholder="Filter by series"
|
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
list="series-datalist"
|
|
||||||
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
|
|
||||||
/>
|
|
||||||
<datalist id="series-datalist"></datalist>
|
|
||||||
</div>
|
|
||||||
<!-- Language Filter with Autocomplete (NEW) -->
|
|
||||||
<div class="flex-1 min-w-[150px]">
|
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
|
||||||
Language
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="language_filter"
|
|
||||||
placeholder="Filter by language"
|
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
list="language-datalist"
|
|
||||||
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
|
|
||||||
/>
|
|
||||||
<datalist id="language-datalist"></datalist>
|
|
||||||
</div>
|
|
||||||
<!-- Year Range -->
|
|
||||||
<div class="flex-1 min-w-[200px]">
|
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
|
||||||
Year Range
|
|
||||||
</label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name="year_min"
|
|
||||||
placeholder="From"
|
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name="year_max"
|
|
||||||
placeholder="To"
|
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Has Cover Filter - TriState Button -->
|
|
||||||
<div class="flex-1 min-w-[150px]">
|
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
|
||||||
Cover Filter
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="button"
|
|
||||||
id="has-cover-tristate"
|
|
||||||
class="tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm"
|
|
||||||
:class="{
|
|
||||||
'state-null': hasCoverState === null,
|
|
||||||
'state-true': hasCoverState === true,
|
|
||||||
'state-false': hasCoverState === false
|
|
||||||
}"
|
|
||||||
:value="hasCoverState === null ? '○ Cover: Any' : hasCoverState === true ? '✓ Has Cover' : '✗ No Cover'"
|
|
||||||
@click="cycleHasCover()"
|
|
||||||
/>
|
|
||||||
<!-- Hidden input for HTMX form submission - conditionally rendered -->
|
|
||||||
<template x-if="hasCoverState !== null">
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="has_cover"
|
|
||||||
:value="hasCoverState ? 'true' : 'false'"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<!-- Sort By Dropdown (PRESERVED) -->
|
|
||||||
<div class="flex-1 min-w-[150px]">
|
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
|
||||||
Sort By
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
name="sort"
|
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
hx-get="/api/media-items/search"
|
|
||||||
hx-target="#books-grid"
|
|
||||||
hx-trigger="change"
|
|
||||||
hx-include="#filter-form"
|
|
||||||
>
|
|
||||||
<option value="title ASC">Title (A-Z)</option>
|
|
||||||
<option value="title DESC">Title (Z-A)</option>
|
|
||||||
<option value="author ASC">Author (A-Z)</option>
|
|
||||||
<option value="created_at DESC">Date Added</option>
|
|
||||||
<option value="page_count DESC">Page Count</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<!-- Submit Button -->
|
|
||||||
<div class="flex items-end">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="px-6 py-2 rounded-lg font-medium"
|
|
||||||
style="background-color: var(--accent); color: var(--bg-primary);"
|
|
||||||
>
|
|
||||||
🔍 Search
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Save Filter Button (PRESERVED) -->
|
|
||||||
<div class="flex items-end">
|
|
||||||
<button
|
|
||||||
@click="showSaveFilterModal()"
|
|
||||||
class="px-4 py-2 rounded-lg font-medium"
|
|
||||||
style="background-color: var(--accent); color: var(--bg-primary);"
|
|
||||||
>
|
|
||||||
💾 Save Filter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Load Filter Button (PRESERVED) -->
|
|
||||||
<div class="flex items-end relative">
|
|
||||||
<button
|
|
||||||
@click="toggleFiltersDropdown()"
|
|
||||||
data-load-filter-btn
|
|
||||||
class="px-4 py-2 rounded-lg font-medium border"
|
|
||||||
style="border-color: var(--border); color: var(--text-primary);"
|
|
||||||
>
|
|
||||||
📂 Load Filter
|
|
||||||
</button>
|
|
||||||
<!-- Saved Filters Dropdown (PRESERVED) -->
|
|
||||||
<div
|
|
||||||
x-show="showFiltersDropdown"
|
|
||||||
@click.outside="showFiltersDropdown = false"
|
|
||||||
x-transition:enter="transition ease-out duration-200"
|
|
||||||
x-transition:enter-start="opacity-0 scale-95"
|
|
||||||
x-transition:enter-end="opacity-100 scale-100"
|
|
||||||
x-transition:leave="transition ease-in duration-150"
|
|
||||||
x-transition:leave-start="opacity-100 scale-100"
|
|
||||||
x-transition:leave-end="opacity-0 scale-95"
|
|
||||||
class="absolute top-full mt-2 w-80 rounded-lg shadow-lg z-50"
|
|
||||||
:class="dropdownAlign === 'left' ? 'left-0' : 'right-0'"
|
|
||||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
|
|
||||||
>
|
|
||||||
<div class="p-4">
|
|
||||||
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">
|
|
||||||
Saved Filters
|
|
||||||
</h3>
|
|
||||||
<!-- Filter List -->
|
|
||||||
<div class="space-y-2" id="saved-filters-list">
|
|
||||||
for _, filter := range savedFilters {
|
|
||||||
<div class="flex items-center justify-between p-2 rounded hover:opacity-80" style="background-color: var(--bg-primary);" data-filter-id={ uuidToString(filter.ID) }>
|
|
||||||
<button data-action="load-filter" @click="loadFilter($event)" class="flex-1 text-left px-2 py-1 rounded" style="color: var(--text-primary);">
|
|
||||||
{ filter.Name }
|
|
||||||
</button>
|
|
||||||
<button data-action="delete-filter" @click="deleteFilter($event)" class="p-1 hover:opacity-70 rounded" style="color: var(--text-secondary);" title="Delete filter">🗑️</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<!-- Empty State -->
|
|
||||||
if len(savedFilters) == 0 {
|
|
||||||
<div class="text-sm py-4 text-center" style="color: var(--text-secondary);">
|
|
||||||
No saved filters yet
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Clear Filters Button (PRESERVED) -->
|
|
||||||
<div class="flex items-end">
|
|
||||||
<button
|
|
||||||
@click="clearFilters()"
|
|
||||||
class="px-4 py-2 rounded-lg font-medium border"
|
|
||||||
style="border-color: var(--border); color: var(--text-primary);"
|
|
||||||
>
|
|
||||||
✕ Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
<!-- Books Grid -->
|
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||||
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
|
|
||||||
@BooksGrid(books, limit, offset, count, currentLibraryID)
|
@BooksGrid(books, limit, offset, count, currentLibraryID)
|
||||||
<!-- Error Message -->
|
|
||||||
if errorMessage != "" {
|
if errorMessage != "" {
|
||||||
<div class="mt-6 p-4 rounded-lg border bg-red-500/10 border-red-500">
|
<div class="col-span-full mt-2 p-4 rounded-xl border" style="background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);">
|
||||||
<p style="color: var(--text-primary)">{ errorMessage }</p>
|
<p style="color: var(--text-primary)">{ errorMessage }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- Pagination -->
|
<div id="pagination" class="mt-6 flex justify-center items-center gap-2">
|
||||||
<div id="pagination" class="mt-6 flex justify-center gap-2">
|
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-4 py-2 rounded-lg border disabled:opacity-50"
|
class="btn btn-secondary disabled:opacity-40"
|
||||||
style="border-color: var(--border); color: var(--text-primary);"
|
hx-get={ fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit) }
|
||||||
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }"
|
|
||||||
hx-target="#books-grid"
|
hx-target="#books-grid"
|
||||||
hx-include="#filter-form"
|
hx-include="#filter-form"
|
||||||
disabled?={ offset <= 0 }
|
disabled?={ offset <= 0 }
|
||||||
>
|
>
|
||||||
← Previous
|
@Icon("chevron-left", "h-4 w-4")
|
||||||
|
<span>Prev</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="px-4 py-2" style="color: var(--text-secondary);">
|
<span class="px-4 py-2 text-sm" style="color: var(--text-secondary);">
|
||||||
Page { offset / limit + 1 }
|
Page { offset / limit + 1 }
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 rounded-lg border disabled:opacity-50"
|
type="button"
|
||||||
style="border-color: var(--border); color: var(--text-primary);"
|
class="btn btn-secondary disabled:opacity-40"
|
||||||
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset + limit }"
|
hx-get={ fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit) }
|
||||||
hx-target="#books-grid"
|
hx-target="#books-grid"
|
||||||
hx-include="#filter-form"
|
hx-include="#filter-form"
|
||||||
disabled?={ offset+limit >= count }
|
disabled?={ offset+limit >= count }
|
||||||
>
|
>
|
||||||
Next →
|
<span>Next</span>
|
||||||
|
@Icon("chevron-right", "h-4 w-4")
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Save Filter Modal -->
|
|
||||||
<div
|
<div
|
||||||
x-show="showSaveModal"
|
x-show="showSaveModal"
|
||||||
@click.self="showSaveModal = false"
|
@click.self="showSaveModal = false"
|
||||||
x-transition
|
x-transition
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
x-cloak
|
||||||
style="background-color: rgba(0, 0, 0, 0.7); display: none;"
|
class="fixed inset-0 z-[70] flex items-center justify-center p-4"
|
||||||
|
style="background-color: var(--surface-overlay);"
|
||||||
>
|
>
|
||||||
<div
|
<div @click.stop class="card rounded-2xl p-6 w-full max-w-md">
|
||||||
@click.stop
|
|
||||||
class="card rounded-lg p-6 w-full max-w-md mx-4"
|
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
|
||||||
>
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Save Filter</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Save Filter</h2>
|
||||||
<button
|
<button @click="showSaveModal = false" class="icon-btn" aria-label="Close">
|
||||||
@click="showSaveModal = false"
|
@Icon("close", "h-5 w-5")
|
||||||
class="p-2 hover:opacity-80 rounded"
|
</button>
|
||||||
style="color: var(--text-primary)"
|
|
||||||
>✕</button>
|
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
hx-post="/api/saved-filters"
|
hx-post="/api/saved-filters"
|
||||||
@@ -397,38 +323,15 @@ templ BookShelf(
|
|||||||
@htmx:afterRequest="if(event.detail.xhr.status < 400) { afterFilterSave() }"
|
@htmx:afterRequest="if(event.detail.xhr.status < 400) { afterFilterSave() }"
|
||||||
>
|
>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Filter Name</label>
|
||||||
Filter Name
|
<input type="text" name="filter_name" placeholder="My Custom Filter" class="input" required/>
|
||||||
</label>
|
<div id="filter-save-error" style="display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;"></div>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="filter_name"
|
|
||||||
placeholder="My Custom Filter"
|
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<div id="filter-save-error" style="display: none; color: #ef4444; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;"></div>
|
|
||||||
<input type="hidden" name="resource_type" value="media-items"/>
|
<input type="hidden" name="resource_type" value="media-items"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button
|
<button type="button" @click="showSaveModal = false" class="btn btn-secondary">Cancel</button>
|
||||||
type="button"
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
@click="showSaveModal = false"
|
|
||||||
class="px-4 py-2 rounded-lg border"
|
|
||||||
style="border-color: var(--border); color: var(--text-primary);"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="px-4 py-2 rounded-lg font-medium"
|
|
||||||
style="background-color: var(--accent); color: var(--bg-primary);"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Hidden pagination state -->
|
|
||||||
<input type="hidden" name="limit" value="50"/>
|
<input type="hidden" name="limit" value="50"/>
|
||||||
<input type="hidden" name="offset" value="0"/>
|
<input type="hidden" name="offset" value="0"/>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+257
-102
File diff suppressed because one or more lines are too long
@@ -13,23 +13,25 @@ templ BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>{ pageTitle } - Bookhoard</title>
|
<title>{ pageTitle } - Bookhoard</title>
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body class="theme-{ user.Theme }">
|
<body class="theme-{ user.Theme }">
|
||||||
@Header(user, backUrl)
|
@Header(user, backUrl)
|
||||||
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
|
<div class="app-subbar border-b" style="background-color: color-mix(in srgb, var(--bg-secondary) 82%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);">
|
||||||
<div class="w-full px-4 py-3 flex items-center gap-4">
|
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-3">
|
||||||
<a href={ backUrl } class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
<a href={ backUrl } class="btn btn-ghost">
|
||||||
← { backLabel }
|
@Icon("arrow-left", "h-4 w-4")
|
||||||
|
<span>{ backLabel }</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full px-4 py-8">
|
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="flex items-center gap-4 mb-6">
|
<div class="flex flex-wrap items-center gap-3 mb-6">
|
||||||
<span class="px-3 py-1 rounded-full text-sm font-semibold" style="background-color: var(--accent); color: white;">
|
<span class="chip" style="background-color: var(--accent); color: var(--bg-primary);">
|
||||||
{ badgeIcon } { badgeLabel }
|
{ badgeIcon } { badgeLabel }
|
||||||
</span>
|
</span>
|
||||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ title }</h1>
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">{ title }</h1>
|
||||||
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d", len(books)) } books</span>
|
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ fmt.Sprintf("%d", len(books)) } books</span>
|
||||||
</div>
|
</div>
|
||||||
if len(books) > 0 {
|
if len(books) > 0 {
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
|
||||||
@@ -38,10 +40,10 @@ templ BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<div class="text-center py-16" style="color: var(--text-secondary)">
|
<div class="card text-center py-16 px-6">
|
||||||
<div class="text-6xl mb-4">{ emptyIcon }</div>
|
<div class="text-5xl mb-4">{ emptyIcon }</div>
|
||||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Books Found</h3>
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Books Found</h3>
|
||||||
<p>{ emptyMessage }</p>
|
<p style="color: var(--text-secondary)">{ emptyMessage }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-{ user.Theme }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -55,90 +55,98 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<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\"><a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"app-subbar border-b\" style=\"background-color: color-mix(in srgb, var(--bg-secondary) 82%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);\"><div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-3\"><a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 templ.SafeURL
|
var templ_7745c5c3_Var3 templ.SafeURL
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(backUrl)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(backUrl)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 21, Col: 22}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 22, Col: 22}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(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, "\" class=\"text-sm hover:opacity-80 transition-opacity\" style=\"color: var(--text-secondary); text-decoration: none;\">← ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"btn btn-ghost\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("arrow-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<span>")
|
||||||
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.JoinStringErrs(backLabel)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 22, Col: 21}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 24, Col: 23}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(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, 5, "</a></div></div><div class=\"w-full px-4 py-8\"><div class=\"flex items-center gap-4 mb-6\"><span class=\"px-3 py-1 rounded-full text-sm font-semibold\" style=\"background-color: var(--accent); color: white;\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span></a></div></div><div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"flex flex-wrap items-center gap-3 mb-6\"><span class=\"chip\" style=\"background-color: var(--accent); color: var(--bg-primary);\">")
|
||||||
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(badgeIcon)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 29, Col: 17}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 17}
|
||||||
}
|
}
|
||||||
_, 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, 6, " ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 29, Col: 32}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 32}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 78}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 33, Col: 93}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</h1><span class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</h1><span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books)))
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books)))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 32, Col: 95}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 34, Col: 125}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " books</span></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " books</span></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(books) > 0 {
|
if len(books) > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -148,43 +156,43 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"text-6xl mb-4\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"card text-center py-16 px-6\"><div class=\"text-5xl mb-4\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 42, Col: 44}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 44, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Books Found</h3><p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Books Found</h3><p style=\"color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 44, Col: 23}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 46, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</p></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -192,7 +200,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
templ CollectionModal(collection CollectionData) {
|
templ CollectionModal(collection CollectionData) {
|
||||||
<div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
|
<div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||||
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
if collection.ID != "" {
|
<div class="flex items-center gap-3">
|
||||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit Collection</h2>
|
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
} else {
|
@Icon("folder", "h-5 w-5")
|
||||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Collection</h2>
|
</span>
|
||||||
}
|
if collection.ID != "" {
|
||||||
<button type="button" @click="closeCollectionModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit Collection</h2>
|
||||||
|
} else {
|
||||||
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Collection</h2>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button type="button" @click="closeCollectionModal()" class="icon-btn" aria-label="Close">
|
||||||
|
@Icon("close", "h-5 w-5")
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
if collection.ID != "" {
|
if collection.ID != "" {
|
||||||
<form
|
<form
|
||||||
@@ -18,65 +25,57 @@ templ CollectionModal(collection CollectionData) {
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={ collection.ID }/>
|
<input type="hidden" name="id" value={ collection.ID }/>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
value={ collection.Name }
|
value={ collection.Name }
|
||||||
required
|
required
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
placeholder="My Reading List"
|
placeholder="My Reading List"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
name="description"
|
name="description"
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
placeholder="Optional description"
|
placeholder="Optional description"
|
||||||
rows="3"
|
rows="3"
|
||||||
>{ collection.Description }</textarea>
|
>{ collection.Description }</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Icon</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Icon</label>
|
||||||
<!-- Search/Text Input -->
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="icon-search"
|
id="icon-search"
|
||||||
class="w-full px-4 py-2 border rounded-lg mb-2"
|
class="input mb-2"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
placeholder="Search or type emoji..."
|
||||||
placeholder="🔍 Search or type emoji..."
|
|
||||||
maxlength="4"
|
maxlength="4"
|
||||||
oninput="filterIcons(this.value)"
|
@input="filterIcons($event.target.value)"
|
||||||
onfocus="showAllIcons()"
|
@focus="showAllIcons()"
|
||||||
/>
|
/>
|
||||||
<!-- Hidden input for form submission -->
|
|
||||||
<input type="hidden" name="icon" id="collection-icon" value={ collection.Icon }/>
|
<input type="hidden" name="icon" id="collection-icon" value={ collection.Icon }/>
|
||||||
<!-- Emoji Grid -->
|
|
||||||
<div
|
<div
|
||||||
id="icon-grid"
|
id="icon-grid"
|
||||||
class="grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 border rounded-lg"
|
class="grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border"
|
||||||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||||
>
|
></div>
|
||||||
<!-- Populated dynamically by JavaScript -->
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Color</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button type="button" @click="selectColor('blue')" class="w-8 h-8 rounded-full color-option bg-blue-500"></button>
|
<button type="button" @click="selectColor('blue')" data-color="blue" class="w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400" aria-label="Blue"></button>
|
||||||
<button type="button" @click="selectColor('red')" class="w-8 h-8 rounded-full color-option bg-red-500"></button>
|
<button type="button" @click="selectColor('red')" data-color="red" class="w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400" aria-label="Red"></button>
|
||||||
<button type="button" @click="selectColor('yellow')" class="w-8 h-8 rounded-full color-option bg-yellow-500"></button>
|
<button type="button" @click="selectColor('yellow')" data-color="yellow" class="w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400" aria-label="Yellow"></button>
|
||||||
<button type="button" @click="selectColor('green')" class="w-8 h-8 rounded-full color-option bg-green-500"></button>
|
<button type="button" @click="selectColor('green')" data-color="green" class="w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400" aria-label="Green"></button>
|
||||||
<button type="button" @click="selectColor('purple')" class="w-8 h-8 rounded-full color-option bg-purple-500"></button>
|
<button type="button" @click="selectColor('purple')" data-color="purple" class="w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400" aria-label="Purple"></button>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="color" id="collection-color" value={ collection.Color }/>
|
<input type="hidden" name="color" id="collection-color" value={ collection.Color }/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" @click="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
|
<button type="button" @click="closeCollectionModal()" class="btn btn-secondary">Cancel</button>
|
||||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Update Collection</button>
|
<button type="submit" class="btn btn-primary">Update Collection</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
} else {
|
} else {
|
||||||
@@ -85,64 +84,56 @@ templ CollectionModal(collection CollectionData) {
|
|||||||
hx-redirect="/collections"
|
hx-redirect="/collections"
|
||||||
>
|
>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
required
|
required
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
placeholder="My Reading List"
|
placeholder="My Reading List"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
name="description"
|
name="description"
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
placeholder="Optional description"
|
placeholder="Optional description"
|
||||||
rows="3"
|
rows="3"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Icon</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Icon</label>
|
||||||
<!-- Search/Text Input -->
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="icon-search"
|
id="icon-search"
|
||||||
class="w-full px-4 py-2 border rounded-lg mb-2"
|
class="input mb-2"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
placeholder="Search or type emoji..."
|
||||||
placeholder="🔍 Search or type emoji..."
|
|
||||||
maxlength="4"
|
maxlength="4"
|
||||||
oninput="filterIcons(this.value)"
|
@input="filterIcons($event.target.value)"
|
||||||
onfocus="showAllIcons()"
|
@focus="showAllIcons()"
|
||||||
/>
|
/>
|
||||||
<!-- Hidden input for form submission -->
|
|
||||||
<input type="hidden" name="icon" id="collection-icon" value={ collection.Icon }/>
|
<input type="hidden" name="icon" id="collection-icon" value={ collection.Icon }/>
|
||||||
<!-- Emoji Grid -->
|
|
||||||
<div
|
<div
|
||||||
id="icon-grid"
|
id="icon-grid"
|
||||||
class="grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 border rounded-lg"
|
class="grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border"
|
||||||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||||
>
|
></div>
|
||||||
<!-- Populated dynamically by JavaScript -->
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Color</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button type="button" @click="selectColor('blue')" class="w-8 h-8 rounded-full color-option bg-blue-500"></button>
|
<button type="button" @click="selectColor('blue')" data-color="blue" class="w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400" aria-label="Blue"></button>
|
||||||
<button type="button" @click="selectColor('red')" class="w-8 h-8 rounded-full color-option bg-red-500"></button>
|
<button type="button" @click="selectColor('red')" data-color="red" class="w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400" aria-label="Red"></button>
|
||||||
<button type="button" @click="selectColor('yellow')" class="w-8 h-8 rounded-full color-option bg-yellow-500"></button>
|
<button type="button" @click="selectColor('yellow')" data-color="yellow" class="w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400" aria-label="Yellow"></button>
|
||||||
<button type="button" @click="selectColor('green')" class="w-8 h-8 rounded-full color-option bg-green-500"></button>
|
<button type="button" @click="selectColor('green')" data-color="green" class="w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400" aria-label="Green"></button>
|
||||||
<button type="button" @click="selectColor('purple')" class="w-8 h-8 rounded-full color-option bg-purple-500"></button>
|
<button type="button" @click="selectColor('purple')" data-color="purple" class="w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400" aria-label="Purple"></button>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="color" id="collection-color" value="blue"/>
|
<input type="hidden" name="color" id="collection-color" value="blue"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" @click="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
|
<button type="button" @click="closeCollectionModal()" class="btn btn-secondary">Cancel</button>
|
||||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Create Collection</button>
|
<button type="submit" class="btn btn-primary">Create Collection</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,128 +29,144 @@ func CollectionModal(collection CollectionData) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"collections\" class=\"fixed inset-0 z-50 flex items-center justify-center bg-black/70\"><div class=\"card rounded-lg p-6 w-full max-w-md mx-4\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-6\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"collections\" class=\"fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("folder", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if collection.ID != "" {
|
if collection.ID != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Edit Collection</h2>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Edit Collection</h2>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Create Collection</h2>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Create Collection</h2>")
|
||||||
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, "<button type=\"button\" @click=\"closeCollectionModal()\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><button type=\"button\" @click=\"closeCollectionModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</button></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if collection.ID != "" {
|
if collection.ID != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<form hx-put=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<form hx-put=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 16, Col: 49}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 23, Col: 49}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" hx-redirect=\"/collections\"><input type=\"hidden\" name=\"id\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" hx-redirect=\"/collections\"><input type=\"hidden\" name=\"id\" value=\"")
|
||||||
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.ResolveAttributeValue(collection.ID)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 19, Col: 57}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 26, Col: 57}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 7, "\"><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" 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(collection.Name)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 25, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 32, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 8, "\" required class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"Optional description\" rows=\"3\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\">")
|
||||||
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(collection.Description)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 40, Col: 31}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 45, Col: 31}
|
||||||
}
|
}
|
||||||
_, 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, 9, "</textarea></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Icon</label><!-- Search/Text Input --><input type=\"text\" id=\"icon-search\" class=\"w-full px-4 py-2 border rounded-lg mb-2\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"🔍 Search or type emoji...\" maxlength=\"4\" oninput=\"filterIcons(this.value)\" onfocus=\"showAllIcons()\"><!-- Hidden input for form submission --><input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 56, Col: 83}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 58, Col: 83}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"><!-- Emoji Grid --><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 border rounded-lg\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><!-- Populated dynamically by JavaScript --></div></div><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" class=\"w-8 h-8 rounded-full color-option bg-blue-500\"></button> <button type=\"button\" @click=\"selectColor('red')\" class=\"w-8 h-8 rounded-full color-option bg-red-500\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500\"></button> <button type=\"button\" @click=\"selectColor('green')\" class=\"w-8 h-8 rounded-full color-option bg-green-500\"></button> <button type=\"button\" @click=\"selectColor('purple')\" class=\"w-8 h-8 rounded-full color-option bg-purple-500\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 75, Col: 86}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 74, Col: 86}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Cancel</button> <button type=\"submit\" class=\"btn-primary px-4 py-2 rounded-lg\">Update Collection</button></div></form>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Update Collection</button></div></form>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Icon</label><!-- Search/Text Input --><input type=\"text\" id=\"icon-search\" class=\"w-full px-4 py-2 border rounded-lg mb-2\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"🔍 Search or type emoji...\" maxlength=\"4\" oninput=\"filterIcons(this.value)\" onfocus=\"showAllIcons()\"><!-- Hidden input for form submission --><input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 122, Col: 83}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 116, Col: 83}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"><!-- Emoji Grid --><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 border rounded-lg\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><!-- Populated dynamically by JavaScript --></div></div><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" class=\"w-8 h-8 rounded-full color-option bg-blue-500\"></button> <button type=\"button\" @click=\"selectColor('red')\" class=\"w-8 h-8 rounded-full color-option bg-red-500\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500\"></button> <button type=\"button\" @click=\"selectColor('green')\" class=\"w-8 h-8 rounded-full color-option bg-green-500\"></button> <button type=\"button\" @click=\"selectColor('purple')\" class=\"w-8 h-8 rounded-full color-option bg-purple-500\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Cancel</button> <button type=\"submit\" class=\"btn-primary px-4 py-2 rounded-lg\">Create Collection</button></div></form>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,18 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
<title>Collection Rules - { collection.Name } - Bookhoard</title>
|
<title>Collection Rules - { collection.Name } - Bookhoard</title>
|
||||||
<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"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body class="theme-{ user.Theme }" x-data="collectionRules" x-init="initCollectionRules('{ collection.ID }')">
|
<body class="theme-{ user.Theme }" x-data="collectionRules" x-init="initCollectionRules('{ collection.ID }')">
|
||||||
@Header(user, "/collections")
|
@Header(user, "/collections")
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<button @click="backToCollection()" class="btn-secondary px-4 py-2 rounded-lg mb-4">
|
<button @click="backToCollection()" class="btn btn-secondary mb-4">
|
||||||
← Back to Collection
|
@Icon("arrow-left", "h-4 w-4")
|
||||||
|
Back to Collection
|
||||||
</button>
|
</button>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
|
<span class="grid place-items-center h-12 w-12 rounded-2xl shrink-0 text-2xl" style="background-color: var(--accent-muted); color: { collection.Color }">{ collection.Icon }</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
|
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||||||
<p style="color: var(--text-secondary)">Auto-Assign Rules</p>
|
<p style="color: var(--text-secondary)">Auto-Assign Rules</p>
|
||||||
@@ -28,8 +30,10 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Existing Rules</h2>
|
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Existing Rules</h2>
|
||||||
<div id="rules-container" class="space-y-4">
|
<div id="rules-container" class="space-y-4">
|
||||||
<div id="no-rules" class="card p-6 rounded-lg border text-center" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div id="no-rules" class="card p-8 text-center">
|
||||||
<div class="text-4xl mb-2">📋</div>
|
<span class="inline-grid place-items-center h-12 w-12 rounded-xl mb-3" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
@Icon("filter", "h-6 w-6")
|
||||||
|
</span>
|
||||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">No Rules Yet</h3>
|
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">No Rules Yet</h3>
|
||||||
<p style="color: var(--text-secondary)">Create auto-assign rules to automatically add books to this collection</p>
|
<p style="color: var(--text-secondary)">Create auto-assign rules to automatically add books to this collection</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,16 +41,15 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Create New Rule</h2>
|
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Create New Rule</h2>
|
||||||
<form id="rule-form" @submit="handleCreateRule($event)">
|
<form id="rule-form" @submit="handleCreateRule($event)" class="card p-6 space-y-6">
|
||||||
<input type="hidden" id="collection-id" value="{ collection.ID }"/>
|
<input type="hidden" id="collection-id" value={ collection.ID }/>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Field</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Field</label>
|
||||||
<select
|
<select
|
||||||
id="rule-field"
|
id="rule-field"
|
||||||
required
|
required
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
>
|
>
|
||||||
<option value="">Select field...</option>
|
<option value="">Select field...</option>
|
||||||
<option value="genre">Genre</option>
|
<option value="genre">Genre</option>
|
||||||
@@ -59,12 +62,11 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Operator</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Operator</label>
|
||||||
<select
|
<select
|
||||||
id="rule-operator"
|
id="rule-operator"
|
||||||
required
|
required
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
>
|
>
|
||||||
<option value="">Select operator...</option>
|
<option value="">Select operator...</option>
|
||||||
<option value="equals">equals</option>
|
<option value="equals">equals</option>
|
||||||
@@ -78,18 +80,17 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Value</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Value</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="rule-value"
|
id="rule-value"
|
||||||
required
|
required
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
placeholder="e.g., Science Fiction"
|
placeholder="e.g., Science Fiction"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div>
|
||||||
<label class="flex items-center space-x-3 cursor-pointer">
|
<label class="flex items-center space-x-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -97,16 +98,16 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
checked
|
checked
|
||||||
class="w-5 h-5 rounded"
|
class="w-5 h-5 rounded"
|
||||||
/>
|
/>
|
||||||
<span style="color: var(--text-primary)">Enable Rule</span>
|
<span class="text-sm font-medium" style="color: var(--text-primary)">Enable Rule</span>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Uncheck to disable without deleting</p>
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Uncheck to disable without deleting</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Priority</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Priority</label>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<label class="flex items-center space-x-2 cursor-pointer">
|
<label class="flex items-center space-x-2 cursor-pointer">
|
||||||
<input type="radio" name="priority" value="1" class="w-4 h-4"/>
|
<input type="radio" name="priority" value="1" class="w-4 h-4"/>
|
||||||
<span style="color: var(--text-primary)">High</span>
|
<span class="text-sm" style="color: var(--text-primary)">High</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center space-x-2 cursor-pointer">
|
<label class="flex items-center space-x-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -116,7 +117,7 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
checked
|
checked
|
||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
/>
|
/>
|
||||||
<span style="color: var(--text-primary)">Medium</span>
|
<span class="text-sm" style="color: var(--text-primary)">Medium</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center space-x-2 cursor-pointer">
|
<label class="flex items-center space-x-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -125,36 +126,38 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
value="3"
|
value="3"
|
||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
/>
|
/>
|
||||||
<span style="color: var(--text-primary)">Low</span>
|
<span class="text-sm" style="color: var(--text-primary)">Low</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" @click="testRule()" class="btn-secondary px-4 py-2 rounded-lg">
|
<button type="button" @click="testRule()" class="btn btn-secondary">
|
||||||
🧪 Test Rule
|
@Icon("search", "h-4 w-4")
|
||||||
|
Test Rule
|
||||||
</button>
|
</button>
|
||||||
<button type="button" @click="clearForm()" class="btn-secondary px-4 py-2 rounded-lg">
|
<button type="button" @click="clearForm()" class="btn btn-ghost">
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
|
<button type="submit" class="btn btn-primary">
|
||||||
➕ Add Rule
|
@Icon("plus", "h-4 w-4")
|
||||||
|
Add Rule
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div id="test-results" class="hidden card p-6 rounded-lg border mb-6" style="background-color: var(--bg-primary); border-color: var(--border);">
|
<div id="test-results" class="hidden card p-6 mt-4 mb-6">
|
||||||
<h3 class="font-semibold mb-3" style="color: var(--text-primary)">Rule Test Results</h3>
|
<h3 class="font-semibold mb-3" style="color: var(--text-primary)">Rule Test Results</h3>
|
||||||
<p class="text-sm mb-2" style="color: var(--text-secondary)">Books that would be added by this rule:</p>
|
<p class="text-sm mb-2" style="color: var(--text-secondary)">Books that would be added by this rule:</p>
|
||||||
<div id="test-results-list" class="max-h-64 overflow-y-auto space-y-2"></div>
|
<div id="test-results-list" class="max-h-64 overflow-y-auto space-y-2"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border);">
|
<div class="card p-6">
|
||||||
<h2 class="text-xl font-bold mb-4" style="color: var(--text-primary)">Rule Examples</h2>
|
<h2 class="text-xl font-bold mb-4" style="color: var(--text-primary)">Rule Examples</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold" style="background-color: var(--accent);">1</div>
|
<span class="grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold" style="background-color: var(--accent-muted); color: var(--accent);">1</span>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium" style="color: var(--text-primary)">Add all Science Fiction books</p>
|
<p class="font-medium" style="color: var(--text-primary)">Add all Science Fiction books</p>
|
||||||
<code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;">
|
<code class="block mt-1 text-sm rounded-lg" style="color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;">
|
||||||
Field: genre
|
Field: genre
|
||||||
<br/>
|
<br/>
|
||||||
Operator: equals
|
Operator: equals
|
||||||
@@ -164,10 +167,10 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold" style="background-color: var(--accent);">2</div>
|
<span class="grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold" style="background-color: var(--accent-muted); color: var(--accent);">2</span>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium" style="color: var(--text-primary)">Add books from a specific series</p>
|
<p class="font-medium" style="color: var(--text-primary)">Add books from a specific series</p>
|
||||||
<code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;">
|
<code class="block mt-1 text-sm rounded-lg" style="color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;">
|
||||||
Field: series
|
Field: series
|
||||||
<br/>
|
<br/>
|
||||||
Operator: starts with
|
Operator: starts with
|
||||||
@@ -177,10 +180,10 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold" style="background-color: var(--accent);">3</div>
|
<span class="grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold" style="background-color: var(--accent-muted); color: var(--accent);">3</span>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium" style="color: var(--text-primary)">Add books published in a year range</p>
|
<p class="font-medium" style="color: var(--text-primary)">Add books published in a year range</p>
|
||||||
<code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;">
|
<code class="block mt-1 text-sm rounded-lg" style="color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;">
|
||||||
Field: copyright_year
|
Field: copyright_year
|
||||||
<br/>
|
<br/>
|
||||||
Operator: greater than
|
Operator: greater than
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+198
-134
@@ -11,85 +11,93 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
|||||||
<title>Collections - Bookhoard</title>
|
<title>Collections - Bookhoard</title>
|
||||||
<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"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
|
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
|
||||||
@Header(user, "/collections")
|
@Header(user, "/collections")
|
||||||
<!-- Modal Container -->
|
|
||||||
<div id="modal-container"></div>
|
<div id="modal-container"></div>
|
||||||
<!-- Actual container page -->
|
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
<div class="mb-8 flex flex-wrap justify-between items-center gap-4">
|
||||||
<div class="mb-8 flex justify-between items-center">
|
<div class="flex items-center gap-3">
|
||||||
<div>
|
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">My Collections</h1>
|
@Icon("folder", "h-5 w-5")
|
||||||
<p style="color: var(--text-secondary)">Organize your books into custom collections</p>
|
</span>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">My Collections</h1>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Organize your books into custom collections</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
hx-get="/collections/restore-modal"
|
hx-get="/collections/restore-modal"
|
||||||
hx-target="#modal-container"
|
hx-target="#modal-container"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
class="btn-secondary px-4 py-2 rounded-lg"
|
class="btn btn-secondary"
|
||||||
>
|
>
|
||||||
🔄 Restore System
|
@Icon("refresh", "h-4 w-4")
|
||||||
|
<span>Restore System</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
hx-get="/collections/create-modal"
|
hx-get="/collections/create-modal"
|
||||||
hx-target="#modal-container"
|
hx-target="#modal-container"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
class="btn-primary px-4 py-2 rounded-lg"
|
class="btn btn-primary"
|
||||||
>
|
>
|
||||||
➕ New Collection
|
@Icon("plus", "h-4 w-4")
|
||||||
|
<span>New Collection</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="collections-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div id="collections-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
if len(collections) == 0 {
|
if len(collections) == 0 {
|
||||||
<div class="text-center py-16 col-span-full" style="color: var(--text-secondary)">
|
<div class="card text-center py-16 px-6 col-span-full">
|
||||||
<div class="text-6xl mb-4">📚</div>
|
<div class="grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
@Icon("folder", "h-7 w-7")
|
||||||
|
</div>
|
||||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3>
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3>
|
||||||
<p class="mb-4">Create collections to organize your books</p>
|
<p class="mb-5" style="color: var(--text-secondary)">Create collections to organize your books</p>
|
||||||
<button
|
<button
|
||||||
hx-get="/collections/create-modal"
|
hx-get="/collections/create-modal"
|
||||||
hx-target="#modal-container"
|
hx-target="#modal-container"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
class="btn-primary px-4 py-2 rounded-lg"
|
class="btn btn-primary"
|
||||||
>
|
>
|
||||||
Create Your First Collection
|
@Icon("plus", "h-4 w-4")
|
||||||
|
<span>Create Your First Collection</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
for _, col := range collections {
|
for _, col := range collections {
|
||||||
<div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block">
|
<div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block">
|
||||||
<div
|
<div
|
||||||
class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
|
class="card p-6 rounded-2xl border-l-4 cursor-pointer"
|
||||||
style="background-color: var(--bg-secondary);"
|
|
||||||
data-color={ col.Color }
|
data-color={ col.Color }
|
||||||
>
|
>
|
||||||
<div class="flex justify-between items-start mb-4">
|
<div class="flex justify-between items-start mb-4">
|
||||||
<div class="text-3xl">{ col.Icon }</div>
|
<div class="text-3xl">{ col.Icon }</div>
|
||||||
<div class="flex space-x-2">
|
<div class="flex gap-1">
|
||||||
<button
|
<button
|
||||||
hx-get={ "/collections/" + col.ID + "/edit-modal" }
|
hx-get={ "/collections/" + col.ID + "/edit-modal" }
|
||||||
hx-target="#modal-container"
|
hx-target="#modal-container"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
class="p-2 hover:opacity-80 rounded"
|
class="icon-btn"
|
||||||
style="color: var(--text-secondary); background-color: var(--bg-primary);"
|
aria-label="Edit collection"
|
||||||
>
|
>
|
||||||
✏️
|
@Icon("edit", "h-4 w-4")
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
hx-delete={ "/api/collections/" + col.ID + "" }
|
hx-delete={ "/api/collections/" + col.ID + "" }
|
||||||
hx-redirect="/collections"
|
hx-redirect="/collections"
|
||||||
hx-confirm="Are you sure you want to delete this collection?"
|
hx-confirm="Are you sure you want to delete this collection?"
|
||||||
class="p-2 hover:opacity-80 rounded"
|
class="icon-btn"
|
||||||
style="color: var(--text-secondary); background-color: var(--bg-primary);"
|
aria-label="Delete collection"
|
||||||
>
|
>
|
||||||
🗑️
|
@Icon("trash", "h-4 w-4")
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ col.Name }</h3>
|
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ col.Name }</h3>
|
||||||
<p class="text-sm mb-4" style="color: var(--text-secondary)">{ col.Description }</p>
|
<p class="text-sm" style="color: var(--text-secondary)">{ col.Description }</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -109,127 +117,133 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
<title>{ collection.Name } - Bookhoard</title>
|
<title>{ collection.Name } - Bookhoard</title>
|
||||||
<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"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
|
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
|
||||||
@Header(user, "/collections")
|
@Header(user, "/collections")
|
||||||
@LibrarySwitcher(libData, libraryID)
|
@LibrarySwitcher(libData, libraryID)
|
||||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/collections" class="btn-secondary px-4 py-2 rounded-lg mb-4 inline-block">
|
<a href="/collections" class="btn btn-secondary mb-4">
|
||||||
← Back to Collections
|
@Icon("arrow-left", "h-4 w-4")
|
||||||
|
<span>Back to Collections</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
|
<div class="grid place-items-center h-14 w-14 rounded-2xl text-3xl" style="background-color: var(--accent-muted); color: { collection.Color }">{ collection.Icon }</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||||||
<p style="color: var(--text-secondary)">{ collection.Description }</p>
|
<p class="text-sm" style="color: var(--text-secondary)">{ collection.Description }</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6 flex justify-between items-center">
|
<div class="mb-6 flex flex-wrap justify-between items-center gap-4">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-3">
|
||||||
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Books in this Collection</h2>
|
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">Books in this Collection</h2>
|
||||||
<span id="selected-count" class="hidden px-3 py-1 text-sm rounded" style="background-color: var(--accent); color: var(--bg-primary);">
|
<span x-show="selectedBooks.length > 0" x-text="selectedBooks.length + ' selected'" class="badge" style="display: none; background-color: var(--accent); color: var(--bg-primary);"></span>
|
||||||
0 selected
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
<div class="flex-1 max-w-md">
|
<div class="flex-1 min-w-[200px] max-w-md">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="collection-search"
|
id="collection-search"
|
||||||
placeholder="Search within collection..."
|
placeholder="Search within collection..."
|
||||||
onkeyup="filterCollectionBooks()"
|
@input="filterCollectionBooks()"
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
if !collection.IsSystem {
|
||||||
id="bulk-remove-btn"
|
<button
|
||||||
disabled
|
@click="requestBulkRemove()"
|
||||||
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
:disabled="selectedBooks.length === 0"
|
||||||
>
|
:class="selectedBooks.length === 0 ? 'opacity-50 cursor-not-allowed' : ''"
|
||||||
🗑️ Remove Selected
|
class="btn btn-danger"
|
||||||
</button>
|
>
|
||||||
<button
|
@Icon("trash", "h-4 w-4")
|
||||||
@click="$store.bookPicker.open()"
|
<span>Remove Selected</span>
|
||||||
class="btn-primary px-4 py-2 rounded-lg"
|
</button>
|
||||||
>
|
<button
|
||||||
➕ Add Books
|
@click="$store.bookPicker.open()"
|
||||||
</button>
|
class="btn btn-primary"
|
||||||
|
>
|
||||||
|
@Icon("plus", "h-4 w-4")
|
||||||
|
<span>Add Books</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
if len(books) == 0 {
|
if len(books) == 0 {
|
||||||
<div id="empty-state" class="col-span-full text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
|
<div id="empty-state" class="col-span-full card text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
|
||||||
}
|
}
|
||||||
for _, book := range books {
|
for _, book := range books {
|
||||||
<a href={ "/media/" + book.MediaItemID }>
|
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
|
||||||
<div
|
<div class="flex gap-4">
|
||||||
class="card p-4 rounded-lg border hover:shadow-lg transition-shadow"
|
if !collection.IsSystem {
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
|
||||||
>
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<div class="flex-shrink-0 pt-1">
|
<div class="flex-shrink-0 pt-1">
|
||||||
<input
|
<label class="flex items-center cursor-pointer p-2 -m-2">
|
||||||
type="checkbox"
|
<input
|
||||||
onchange="toggleBookForRemoval('{ book.MediaItemID }')"
|
type="checkbox"
|
||||||
class="w-5 h-5"
|
class="w-5 h-5"
|
||||||
/>
|
:checked="selectedBooks.includes('{ book.MediaItemID }')"
|
||||||
|
@change="toggleSelection('{ book.MediaItemID }')"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
}
|
||||||
<h3
|
<div class="flex-1 min-w-0">
|
||||||
class="font-semibold text-lg mb-1 line-clamp-2"
|
<a href={ "/media/" + book.MediaItemID }>
|
||||||
style="color: var(--text-primary)"
|
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">
|
||||||
>
|
|
||||||
{ book.Title }
|
{ book.Title }
|
||||||
</h3>
|
</h3>
|
||||||
if book.Author != "" {
|
</a>
|
||||||
<p
|
if book.Author != "" {
|
||||||
class="text-sm line-clamp-1"
|
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">
|
||||||
style="color: var(--text-secondary)"
|
by { book.Author }
|
||||||
>
|
</p>
|
||||||
by { book.Author }
|
}
|
||||||
</p>
|
</div>
|
||||||
}
|
<div class="flex-shrink-0 w-16 sm:w-20">
|
||||||
</div>
|
<a href={ "/media/" + book.MediaItemID }>
|
||||||
<div class="flex-shrink-0 w-16 sm:w-20">
|
|
||||||
if book.CoverImagePath != "" {
|
if book.CoverImagePath != "" {
|
||||||
<img
|
<img
|
||||||
src={ book.CoverImagePath }
|
src={ book.CoverImagePath }
|
||||||
alt="Cover"
|
alt="Cover"
|
||||||
class="w-full aspect-[3/4] object-cover rounded shadow-md"
|
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
|
||||||
onerror="this.src='/static/placeholder-book.svg'"
|
onerror="this.src='/static/placeholder-book.svg'"
|
||||||
/>
|
/>
|
||||||
} else {
|
} else {
|
||||||
<img
|
<img
|
||||||
src="/static/placeholder-book.svg"
|
src="/static/placeholder-book.svg"
|
||||||
alt="Cover"
|
alt="Cover"
|
||||||
class="w-full aspect-[3/4] object-cover rounded shadow-md"
|
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</a>
|
||||||
</div>
|
|
||||||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
|
||||||
<button
|
|
||||||
@click="removeBook('{ book.MediaItemID }')"
|
|
||||||
class="px-3 py-1 text-sm border rounded hover:opacity-80"
|
|
||||||
style="border-color: var(--border); color: var(--text-secondary);"
|
|
||||||
>
|
|
||||||
🗑️ Remove from Collection
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
if !collection.IsSystem {
|
||||||
|
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||||||
|
<button
|
||||||
|
@click="requestRemoveBook('{ book.MediaItemID }')"
|
||||||
|
class="btn btn-secondary w-full"
|
||||||
|
>
|
||||||
|
@Icon("trash", "h-4 w-4")
|
||||||
|
<span>Remove from Collection</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Book Picker Modal -->
|
if !collection.IsSystem {
|
||||||
<div
|
<div
|
||||||
x-data="bookPicker"
|
x-data="bookPicker"
|
||||||
|
x-show="$store.bookPicker.isOpen"
|
||||||
@keyup.escape.window="$store.bookPicker.close()"
|
@keyup.escape.window="$store.bookPicker.close()"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
@click.self="$store.bookPicker.close()"
|
||||||
style="display: none;"
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
style="display: none; background-color: var(--surface-overlay);"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@click.stop
|
@click.stop
|
||||||
@@ -240,8 +254,8 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
x-transition:leave="transition ease-in duration-150"
|
x-transition:leave="transition ease-in duration-150"
|
||||||
x-transition:leave-start="opacity-100 scale-100"
|
x-transition:leave-start="opacity-100 scale-100"
|
||||||
x-transition:leave-end="opacity-0 scale-95"
|
x-transition:leave-end="opacity-0 scale-95"
|
||||||
class="card rounded-lg w-full max-w-6xl mx-4 my-8"
|
class="card rounded-2xl w-full max-w-6xl mx-4 my-8"
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border); display: none;"
|
style="display: none; box-shadow: var(--shadow-pop);"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex justify-between items-center p-6 border-b"
|
class="flex justify-between items-center p-6 border-b"
|
||||||
@@ -252,23 +266,24 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
@click="$store.bookPicker.close()"
|
@click="$store.bookPicker.close()"
|
||||||
class="p-2 hover:opacity-80 rounded"
|
class="icon-btn"
|
||||||
style="color: var(--text-primary)"
|
aria-label="Close"
|
||||||
>✕</button>
|
>
|
||||||
|
@Icon("close", "h-5 w-5")
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="p-4 border-b"
|
class="p-4 border-b"
|
||||||
style="border-color: var(--border);"
|
style="border-color: var(--border);"
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap gap-4 items-center">
|
<div id="book-picker-filters" class="flex flex-wrap gap-3 items-center">
|
||||||
<div class="flex-1 min-w-[200px]">
|
<div class="flex-1 min-w-[200px]">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="search"
|
name="q"
|
||||||
placeholder="Search books..."
|
placeholder="Search books..."
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
hx-get="/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }"
|
||||||
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
|
|
||||||
hx-target="#book-picker-grid"
|
hx-target="#book-picker-grid"
|
||||||
hx-trigger="keyup changed delay:300ms"
|
hx-trigger="keyup changed delay:300ms"
|
||||||
hx-include="#book-picker-filters"
|
hx-include="#book-picker-filters"
|
||||||
@@ -279,9 +294,8 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
type="text"
|
type="text"
|
||||||
name="author_filter"
|
name="author_filter"
|
||||||
placeholder="Author"
|
placeholder="Author"
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
hx-get="/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }"
|
||||||
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
|
|
||||||
hx-target="#book-picker-grid"
|
hx-target="#book-picker-grid"
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-include="#book-picker-filters"
|
hx-include="#book-picker-filters"
|
||||||
@@ -292,32 +306,30 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
type="text"
|
type="text"
|
||||||
name="genre_filter"
|
name="genre_filter"
|
||||||
placeholder="Genre"
|
placeholder="Genre"
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
hx-get="/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }"
|
||||||
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
|
|
||||||
hx-target="#book-picker-grid"
|
hx-target="#book-picker-grid"
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-include="#book-picker-filters"
|
hx-include="#book-picker-filters"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<button
|
||||||
<button
|
@click="$store.bookPicker.clearFilters()"
|
||||||
@click="$store.bookPicker.clearFilters()"
|
class="btn btn-ghost"
|
||||||
class="px-4 py-2 rounded-lg border"
|
>
|
||||||
style="border-color: var(--border); color: var(--text-primary);"
|
@Icon("close", "h-4 w-4")
|
||||||
>
|
<span>Clear</span>
|
||||||
✕ Clear
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form id="filter-form" class="hidden">
|
|
||||||
<input type="hidden" name="limit" value="50"/>
|
<input type="hidden" name="limit" value="50"/>
|
||||||
<input type="hidden" name="offset" value="0"/>
|
<input type="hidden" name="offset" value="0"/>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="book-picker-grid"
|
id="book-picker-grid"
|
||||||
class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 max-h-96 overflow-y-auto"
|
class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 max-h-96 overflow-y-auto"
|
||||||
|
hx-get="/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }"
|
||||||
|
hx-trigger="loadBooks"
|
||||||
|
hx-include="#book-picker-filters"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -330,15 +342,13 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@click="$store.bookPicker.close()"
|
@click="$store.bookPicker.close()"
|
||||||
class="px-4 py-2 rounded-lg border"
|
class="btn btn-secondary"
|
||||||
style="border-color: var(--border); color: var(--text-primary);"
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="$store.bookPicker.submit()"
|
@click="$store.bookPicker.submit()"
|
||||||
class="px-4 py-2 rounded-lg font-medium"
|
class="btn btn-primary"
|
||||||
style="background-color: var(--accent); color: var(--bg-primary);"
|
|
||||||
>
|
>
|
||||||
Add Selected Books
|
Add Selected Books
|
||||||
</button>
|
</button>
|
||||||
@@ -346,12 +356,66 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
<div
|
||||||
|
x-show="showConfirm"
|
||||||
|
@click.self="closeConfirm()"
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||||
|
style="display: none; background-color: var(--surface-overlay);"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
@click.stop
|
||||||
|
class="card p-6 w-full max-w-md mx-4"
|
||||||
|
style="box-shadow: var(--shadow-pop);"
|
||||||
|
>
|
||||||
|
<h3 class="text-lg font-bold mb-2" style="color: var(--text-primary)">Remove from Collection</h3>
|
||||||
|
<p x-text="confirmMessage" class="text-sm mb-6" style="color: var(--text-secondary)"></p>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button @click="closeConfirm()" class="btn btn-secondary">Cancel</button>
|
||||||
|
<button @click="executeConfirmed()" class="btn btn-danger">
|
||||||
|
@Icon("trash", "h-4 w-4")
|
||||||
|
<span x-text="confirmLabel"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
<div
|
<div
|
||||||
id="collection-data"
|
id="collection-data"
|
||||||
data-id={ collection.ID }
|
data-id={ collection.ID }
|
||||||
data-library-id={ libraryID }
|
data-library-id={ libraryID }
|
||||||
|
data-is-system={ collection.IsSystem }
|
||||||
style="display: none;"
|
style="display: none;"
|
||||||
></div>
|
></div>
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ BookPickerGrid(books []handlers.BookInfo) {
|
||||||
|
for _, book := range books {
|
||||||
|
<div
|
||||||
|
class="relative cursor-pointer rounded-lg overflow-hidden"
|
||||||
|
@click={ "$store.bookPicker.toggleBook('" + book.MediaItemID + "')" }
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
if book.CoverImagePath != "" {
|
||||||
|
<img src={ book.CoverImagePath } alt={ book.Title } class="w-full aspect-[2/3] object-cover" loading="lazy" onerror="this.src='/static/placeholder-book.svg'"/>
|
||||||
|
} else {
|
||||||
|
<img src="/static/placeholder-book.svg" alt={ book.Title } class="w-full aspect-[2/3] object-cover" loading="lazy"/>
|
||||||
|
}
|
||||||
|
<div
|
||||||
|
x-show={ "$store.bookPicker.isSelected('" + book.MediaItemID + "')" }
|
||||||
|
class="absolute inset-0 border-4 rounded-lg pointer-events-none"
|
||||||
|
style="border-color: var(--accent); background-color: color-mix(in srgb, var(--accent) 20%, transparent);"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
x-show={ "$store.bookPicker.isSelected('" + book.MediaItemID + "')" }
|
||||||
|
class="absolute top-1 right-1 w-6 h-6 rounded-full flex items-center justify-center text-sm font-bold pointer-events-none"
|
||||||
|
style="background-color: var(--accent); color: var(--bg-primary);"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs mt-1 line-clamp-2" style="color: var(--text-primary)">{ book.Title }</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+424
-70
@@ -31,7 +31,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Collections - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"collections\" x-init=\"initCollectionsPage()\" class=\"theme-{ user.Theme }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Collections - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"collections\" x-init=\"initCollectionsPage()\" class=\"theme-{ user.Theme }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -39,114 +39,170 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!-- Modal Container --><div id=\"modal-container\"></div><!-- Actual container page --><div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8 flex justify-between items-center\"><div><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">My Collections</h1><p style=\"color: var(--text-secondary)\">Organize your books into custom collections</p></div><div class=\"flex gap-3\"><button hx-get=\"/collections/restore-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn-secondary px-4 py-2 rounded-lg\">🔄 Restore System</button> <button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn-primary px-4 py-2 rounded-lg\">➕ New Collection</button></div></div><div id=\"collections-list\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div id=\"modal-container\"></div><div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8 flex flex-wrap justify-between items-center gap-4\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("folder", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><div><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">My Collections</h1><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Organize your books into custom collections</p></div></div><div class=\"flex gap-2\"><button hx-get=\"/collections/restore-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-secondary\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span>Restore System</span></button> <button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-primary\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<span>New Collection</span></button></div></div><div id=\"collections-list\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(collections) == 0 {
|
if len(collections) == 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"text-center py-16 col-span-full\" style=\"color: var(--text-secondary)\"><div class=\"text-6xl mb-4\">📚</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Collections Yet</h3><p class=\"mb-4\">Create collections to organize your books</p><button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn-primary px-4 py-2 rounded-lg\">Create Your First Collection</button></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"card text-center py-16 px-6 col-span-full\"><div class=\"grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("folder", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Collections Yet</h3><p class=\"mb-5\" style=\"color: var(--text-secondary)\">Create collections to organize your books</p><button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-primary\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span>Create Your First Collection</span></button></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, col := range collections {
|
for _, col := range collections {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div @click=\"navigateToCollection($el)\" data-href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div @click=\"navigateToCollection($el)\" data-href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 62, Col: 82}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 71, Col: 82}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"block\"><div class=\"card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow\" style=\"background-color: var(--bg-secondary);\" data-color=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"block\"><div class=\"card p-6 rounded-2xl border-l-4 cursor-pointer\" data-color=\"")
|
||||||
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.ResolveAttributeValue(col.Color)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 66, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 74, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 6, "\"><div class=\"flex justify-between items-start mb-4\"><div class=\"text-3xl\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"><div class=\"flex justify-between items-start mb-4\"><div class=\"text-3xl\">")
|
||||||
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.JoinStringErrs(col.Icon)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 69, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 77, Col: 41}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(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, 7, "</div><div class=\"flex space-x-2\"><button hx-get=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><div class=\"flex gap-1\"><button hx-get=\"")
|
||||||
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.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 72, Col: 60}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 80, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 8, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-secondary); background-color: var(--bg-primary);\">✏️</button> <button hx-delete=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"icon-btn\" aria-label=\"Edit collection\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("edit", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</button> <button hx-delete=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 81, Col: 56}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 89, Col: 56}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" hx-redirect=\"/collections\" hx-confirm=\"Are you sure you want to delete this collection?\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-secondary); background-color: var(--bg-primary);\">🗑️</button></div></div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" hx-redirect=\"/collections\" hx-confirm=\"Are you sure you want to delete this collection?\" class=\"icon-btn\" aria-label=\"Delete collection\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button></div></div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 91, Col: 92}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 99, Col: 92}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</h3><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 92, Col: 86}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 100, Col: 81}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -154,7 +210,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -183,20 +239,20 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
templ_7745c5c3_Var9 = templ.NopComponent
|
templ_7745c5c3_Var9 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 109, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 117, Col: 27}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"collections\" x-init=\"initCollectionsPage()\" class=\"theme-{ user.Theme }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"collections\" x-init=\"initCollectionsPage()\" class=\"theme-{ user.Theme }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -208,165 +264,326 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6\"><a href=\"/collections\" class=\"btn-secondary px-4 py-2 rounded-lg mb-4 inline-block\">← Back to Collections</a><div class=\"flex items-center gap-4\"><div class=\"text-4xl\" style=\"color: { collection.Color }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6\"><a href=\"/collections\" class=\"btn btn-secondary mb-4\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("arrow-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<span>Back to Collections</span></a><div class=\"flex items-center gap-4\"><div class=\"grid place-items-center h-14 w-14 rounded-2xl text-3xl\" style=\"background-color: var(--accent-muted); color: { collection.Color }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 122, Col: 81}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 132, Col: 166}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div><div><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><div><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 124, Col: 90}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 134, Col: 105}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</h1><p style=\"color: var(--text-secondary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</h1><p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 125, Col: 71}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 135, Col: 87}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</p></div></div></div><div class=\"mb-6 flex justify-between items-center\"><div class=\"flex items-center gap-4\"><h2 class=\"text-xl font-semibold\" style=\"color: var(--text-primary)\">Books in this Collection</h2><span id=\"selected-count\" class=\"hidden px-3 py-1 text-sm rounded\" style=\"background-color: var(--accent); color: var(--bg-primary);\">0 selected</span></div><div class=\"flex gap-3\"><div class=\"flex-1 max-w-md\"><input type=\"text\" id=\"collection-search\" placeholder=\"Search within collection...\" onkeyup=\"filterCollectionBooks()\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><button id=\"bulk-remove-btn\" disabled class=\"btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed\">🗑️ Remove Selected</button> <button @click=\"$store.bookPicker.open()\" class=\"btn-primary px-4 py-2 rounded-lg\">➕ Add Books</button></div></div><div id=\"books-container\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p></div></div></div><div class=\"mb-6 flex flex-wrap justify-between items-center gap-4\"><div class=\"flex items-center gap-3\"><h2 class=\"text-lg font-bold tracking-tight\" style=\"color: var(--text-primary)\">Books in this Collection</h2><span x-show=\"selectedBooks.length > 0\" x-text=\"selectedBooks.length + ' selected'\" class=\"badge\" style=\"display: none; background-color: var(--accent); color: var(--bg-primary);\"></span></div><div class=\"flex flex-wrap gap-2 items-center\"><div class=\"flex-1 min-w-[200px] max-w-md\"><input type=\"text\" id=\"collection-search\" placeholder=\"Search within collection...\" @input=\"filterCollectionBooks()\" class=\"input\"></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if !collection.IsSystem {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<button @click=\"requestBulkRemove()\" :disabled=\"selectedBooks.length === 0\" :class=\"selectedBooks.length === 0 ? 'opacity-50 cursor-not-allowed' : ''\" class=\"btn btn-danger\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span>Remove Selected</span></button> <button @click=\"$store.bookPicker.open()\" class=\"btn btn-primary\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span>Add Books</span></button>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div></div><div id=\"books-container\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(books) == 0 {
|
if len(books) == 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div id=\"empty-state\" class=\"col-span-full text-center py-16\" style=\"color: var(--text-secondary)\">No books in this collection yet.</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div id=\"empty-state\" class=\"col-span-full card text-center py-16\" style=\"color: var(--text-secondary)\">No books in this collection yet.</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, book := range books {
|
for _, book := range books {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"card p-4 rounded-2xl collection-book-card\" data-title=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var14 templ.SafeURL
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 167, Col: 44}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 84}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"><div class=\"card p-4 rounded-lg border hover:shadow-lg transition-shadow\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex gap-4\"><div class=\"flex-shrink-0 pt-1\"><input type=\"checkbox\" onchange=\"toggleBookForRemoval('{ book.MediaItemID }')\" class=\"w-5 h-5\"></div><div class=\"flex-1 min-w-0\"><h3 class=\"font-semibold text-lg mb-1 line-clamp-2\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" data-author=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Author)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 185, Col: 23}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 112}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</h3>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" data-media-id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var16 string
|
||||||
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.MediaItemID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 147}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"><div class=\"flex gap-4\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if !collection.IsSystem {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"flex-shrink-0 pt-1\"><label class=\"flex items-center cursor-pointer p-2 -m-2\"><input type=\"checkbox\" class=\"w-5 h-5\" :checked=\"selectedBooks.includes('{ book.MediaItemID }')\" @change=\"toggleSelection('{ book.MediaItemID }')\"></label></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"flex-1 min-w-0\"><a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var17 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 194, Col: 47}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\"><h3 class=\"font-semibold text-lg mb-1 line-clamp-2 hover:underline\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var18 string
|
||||||
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 196, Col: 23}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</h3></a> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if book.Author != "" {
|
if book.Author != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<p class=\"text-sm line-clamp-1\" style=\"color: var(--text-secondary)\">by ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<p class=\"text-sm line-clamp-1\" style=\"color: var(--text-secondary)\">by ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 192, Col: 28}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 201, Col: 27}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</p>")
|
||||||
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, 26, "</div><div class=\"flex-shrink-0 w-16 sm:w-20\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div><div class=\"flex-shrink-0 w-16 sm:w-20\"><a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var20 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 206, Col: 47}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if book.CoverImagePath != "" {
|
if book.CoverImagePath != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<img src=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<img src=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 199, Col: 37}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 209, Col: 37}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded shadow-md\" onerror=\"this.src='/static/placeholder-book.svg'\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded-lg shadow-md\" onerror=\"this.src='/static/placeholder-book.svg'\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<img src=\"/static/placeholder-book.svg\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded shadow-md\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<img src=\"/static/placeholder-book.svg\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded-lg shadow-md\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div></div><div class=\"mt-3 pt-3 border-t\" style=\"border-color: var(--border);\"><button @click=\"removeBook('{ book.MediaItemID }')\" class=\"px-3 py-1 text-sm border rounded hover:opacity-80\" style=\"border-color: var(--border); color: var(--text-secondary);\">🗑️ Remove from Collection</button></div></div></a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</a></div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if !collection.IsSystem {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"mt-3 pt-3 border-t\" style=\"border-color: var(--border);\"><button @click=\"requestRemoveBook('{ book.MediaItemID }')\" class=\"btn btn-secondary w-full\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span>Remove from Collection</span></button></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</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, 31, "</div></div><!-- Book Picker Modal --><div x-data=\"bookPicker\" @keyup.escape.window=\"$store.bookPicker.close()\" class=\"fixed inset-0 z-50 flex items-center justify-center\" style=\"display: none;\"><div @click.stop x-show=\"$store.bookPicker.isOpen\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" class=\"card rounded-lg w-full max-w-6xl mx-4 my-8\" style=\"background-color: var(--bg-secondary); border-color: var(--border); display: none;\"><div class=\"flex justify-between items-center p-6 border-b\" style=\"border-color: var(--border);\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Add Books to Collection</h2><button @click=\"$store.bookPicker.close()\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div><div class=\"p-4 border-b\" style=\"border-color: var(--border);\"><div class=\"flex flex-wrap gap-4 items-center\"><div class=\"flex-1 min-w-[200px]\"><input type=\"text\" name=\"search\" placeholder=\"Search books...\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" hx-get=\"/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"keyup changed delay:300ms\" hx-include=\"#book-picker-filters\"></div><div class=\"flex-1 min-w-[150px]\"><input type=\"text\" name=\"author_filter\" placeholder=\"Author\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" hx-get=\"/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><div class=\"flex-1 min-w-[150px]\"><input type=\"text\" name=\"genre_filter\" placeholder=\"Genre\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" hx-get=\"/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><div><button @click=\"$store.bookPicker.clearFilters()\" class=\"px-4 py-2 rounded-lg border\" style=\"border-color: var(--border); color: var(--text-primary);\">✕ Clear</button></div></div><form id=\"filter-form\" class=\"hidden\"><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div><div id=\"book-picker-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 max-h-96 overflow-y-auto\"></div><div class=\"p-4 border-t flex justify-between items-center\" style=\"border-color: var(--border);\"><div class=\"text-sm\" style=\"color: var(--text-secondary);\"><span x-text=\"$store.bookPicker.selectedCount\"></span> books selected</div><div class=\"flex gap-2\"><button @click=\"$store.bookPicker.close()\" class=\"px-4 py-2 rounded-lg border\" style=\"border-color: var(--border); color: var(--text-primary);\">Cancel</button> <button @click=\"$store.bookPicker.submit()\" class=\"px-4 py-2 rounded-lg font-medium\" style=\"background-color: var(--accent); color: var(--bg-primary);\">Add Selected Books</button></div></div></div></div></body><div id=\"collection-data\" data-id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var18 string
|
if !collection.IsSystem {
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<div x-data=\"bookPicker\" x-show=\"$store.bookPicker.isOpen\" @keyup.escape.window=\"$store.bookPicker.close()\" @click.self=\"$store.bookPicker.close()\" class=\"fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"display: none; background-color: var(--surface-overlay);\"><div @click.stop x-show=\"$store.bookPicker.isOpen\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" class=\"card rounded-2xl w-full max-w-6xl mx-4 my-8\" style=\"display: none; box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center p-6 border-b\" style=\"border-color: var(--border);\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Add Books to Collection</h2><button @click=\"$store.bookPicker.close()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 352, Col: 26}
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</button></div><div class=\"p-4 border-b\" style=\"border-color: var(--border);\"><div id=\"book-picker-filters\" class=\"flex flex-wrap gap-3 items-center\"><div class=\"flex-1 min-w-[200px]\"><input type=\"text\" name=\"q\" placeholder=\"Search books...\" class=\"input\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"keyup changed delay:300ms\" hx-include=\"#book-picker-filters\"></div><div class=\"flex-1 min-w-[150px]\"><input type=\"text\" name=\"author_filter\" placeholder=\"Author\" class=\"input\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><div class=\"flex-1 min-w-[150px]\"><input type=\"text\" name=\"genre_filter\" placeholder=\"Genre\" class=\"input\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><button @click=\"$store.bookPicker.clearFilters()\" class=\"btn btn-ghost\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("close", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span>Clear</span></button> <input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></div></div><div id=\"book-picker-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 max-h-96 overflow-y-auto\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-trigger=\"loadBooks\" hx-include=\"#book-picker-filters\"></div><div class=\"p-4 border-t flex justify-between items-center\" style=\"border-color: var(--border);\"><div class=\"text-sm\" style=\"color: var(--text-secondary);\"><span x-text=\"$store.bookPicker.selectedCount\"></span> books selected</div><div class=\"flex gap-2\"><button @click=\"$store.bookPicker.close()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"$store.bookPicker.submit()\" class=\"btn btn-primary\">Add Selected Books</button></div></div></div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div x-show=\"showConfirm\" @click.self=\"closeConfirm()\" class=\"fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"display: none; background-color: var(--surface-overlay);\"><div @click.stop class=\"card p-6 w-full max-w-md mx-4\" style=\"box-shadow: var(--shadow-pop);\"><h3 class=\"text-lg font-bold mb-2\" style=\"color: var(--text-primary)\">Remove from Collection</h3><p x-text=\"confirmMessage\" class=\"text-sm mb-6\" style=\"color: var(--text-secondary)\"></p><div class=\"flex justify-end gap-3\"><button @click=\"closeConfirm()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"executeConfirmed()\" class=\"btn btn-danger\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" data-library-id=\"")
|
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var19 string
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<span x-text=\"confirmLabel\"></span></button></div></div></div></body><div id=\"collection-data\" data-id=\"")
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 353, Col: 30}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" style=\"display: none;\"></div></html>")
|
var templ_7745c5c3_Var22 string
|
||||||
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 385, Col: 26}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" data-library-id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var23 string
|
||||||
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 386, Col: 30}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" data-is-system=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var24 string
|
||||||
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 387, Col: 39}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" style=\"display: none;\"></div></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -374,4 +591,141 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var25 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var25 == nil {
|
||||||
|
templ_7745c5c3_Var25 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
for _, book := range books {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<div class=\"relative cursor-pointer rounded-lg overflow-hidden\" @click=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var26 string
|
||||||
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 397, Col: 70}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\"><div class=\"relative\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if book.CoverImagePath != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<img src=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var27 string
|
||||||
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 401, Col: 35}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" alt=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var28 string
|
||||||
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 401, Col: 54}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" class=\"w-full aspect-[2/3] object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<img src=\"/static/placeholder-book.svg\" alt=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var29 string
|
||||||
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 403, Col: 61}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" class=\"w-full aspect-[2/3] object-cover\" loading=\"lazy\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div x-show=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var30 string
|
||||||
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 406, Col: 72}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" class=\"absolute inset-0 border-4 rounded-lg pointer-events-none\" style=\"border-color: var(--accent); background-color: color-mix(in srgb, var(--accent) 20%, transparent);\"></div><div x-show=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var31 string
|
||||||
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 411, Col: 72}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" class=\"absolute top-1 right-1 w-6 h-6 rounded-full flex items-center justify-center text-sm font-bold pointer-events-none\" style=\"background-color: var(--accent); color: var(--bg-primary);\">✓</div></div><p class=\"text-xs mt-1 line-clamp-2\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var32 string
|
||||||
|
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 418, Col: 87}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</p></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
var _ = templruntime.GeneratedTemplate
|
||||||
|
|||||||
+84
-70
@@ -11,58 +11,70 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
|||||||
<title>Sync Conflicts - Bookhoard</title>
|
<title>Sync Conflicts - Bookhoard</title>
|
||||||
<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"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="conflicts" class="theme-{ user.Theme }">
|
<body x-data="conflicts" class="theme-{ user.Theme }">
|
||||||
@Header(user, "/conflicts")
|
@Header(user, "/conflicts")
|
||||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Sync Conflicts</h1>
|
<div class="flex items-center gap-3 mb-1">
|
||||||
<p style="color: var(--text-secondary)">Resolve conflicts when reading progress differs across devices</p>
|
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: color-mix(in srgb, var(--status-warning) 16%, transparent); color: var(--status-warning);">
|
||||||
|
@Icon("alert", "h-5 w-5")
|
||||||
|
</span>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Sync Conflicts</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Resolve conflicts when reading progress differs across devices</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-3">
|
<div class="flex space-x-3">
|
||||||
<select
|
<select
|
||||||
id="status-filter"
|
id="status-filter"
|
||||||
onchange="filterConflicts()"
|
onchange="filterConflicts()"
|
||||||
class="px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
>
|
>
|
||||||
<option value="unresolved">Unresolved Only</option>
|
<option value="unresolved">Unresolved Only</option>
|
||||||
<option value="all">All Conflicts</option>
|
<option value="all">All Conflicts</option>
|
||||||
<option value="resolved">Resolved Only</option>
|
<option value="resolved">Resolved Only</option>
|
||||||
</select>
|
</select>
|
||||||
<button @click="dismissAllResolved()" class="btn-secondary px-4 py-2 rounded-lg">
|
<button @click="dismissAllResolved()" class="btn btn-secondary">Dismiss All Resolved</button>
|
||||||
Dismiss All Resolved
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="stats-bar" class="mt-4 flex space-x-6 text-sm">
|
<div id="stats-bar" class="mt-4 grid grid-cols-3 gap-4 max-w-lg">
|
||||||
<span style="color: var(--text-secondary)">Total: <strong style="color: var(--text-primary)">{ total }</strong></span>
|
<div class="stat-card text-center py-3">
|
||||||
<span style="color: var(--text-secondary)">Unresolved: <strong style="color: #f59e0b;">{ unresolved }</strong></span>
|
<div class="text-2xl font-bold" style="color: var(--text-primary);">{ total }</div>
|
||||||
<span style="color: var(--text-secondary)">Resolved: <strong style="color: #10b981;">{ total - unresolved }</strong></span>
|
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Total</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card text-center py-3">
|
||||||
|
<div class="text-2xl font-bold" style="color: var(--status-warning);">{ unresolved }</div>
|
||||||
|
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Unresolved</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card text-center py-3">
|
||||||
|
<div class="text-2xl font-bold" style="color: var(--status-success);">{ total - unresolved }</div>
|
||||||
|
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Resolved</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="bulk-actions" class="mt-4 hidden">
|
<div id="bulk-actions" class="mt-4 hidden">
|
||||||
<div class="flex items-center justify-between p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="card p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<span class="text-sm font-semibold" style="color: var(--text-primary);">
|
<span class="text-sm font-semibold" style="color: var(--text-primary);">
|
||||||
<span id="selected-count">0</span> conflicts selected
|
<span id="selected-count">0</span> conflicts selected
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
id="bulk-strategy"
|
id="bulk-strategy"
|
||||||
class="px-3 py-2 border rounded-lg text-sm"
|
class="input w-auto"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
>
|
>
|
||||||
<option value="most_recent">Most Recent</option>
|
<option value="most_recent">Most Recent</option>
|
||||||
<option value="highest_progress">Highest Progress</option>
|
<option value="highest_progress">Highest Progress</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<button @click="bulkResolve()" class="btn-primary px-4 py-2 rounded-lg text-sm">
|
<button @click="bulkResolve()" class="btn btn-primary text-sm">
|
||||||
✅ Resolve Selected
|
@Icon("check", "h-4 w-4")
|
||||||
|
Resolve Selected
|
||||||
</button>
|
</button>
|
||||||
<button @click="bulkDismiss()" class="btn-danger px-4 py-2 rounded-lg text-sm">
|
<button @click="bulkDismiss()" class="btn btn-danger text-sm">
|
||||||
❌ Dismiss Selected
|
Dismiss Selected
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,12 +84,14 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
|||||||
<div class="loading-spinner mx-auto mb-4"></div>
|
<div class="loading-spinner mx-auto mb-4"></div>
|
||||||
<p>Loading conflicts...</p>
|
<p>Loading conflicts...</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)">
|
<div id="empty-state" class="hidden card text-center py-16">
|
||||||
<div class="text-6xl mb-4">✅</div>
|
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
|
||||||
|
@Icon("check-circle", "h-7 w-7")
|
||||||
|
</span>
|
||||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
|
||||||
<p>Your devices are in sync! No conflicts to resolve.</p>
|
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="conflicts-list" class="space-y-6">
|
<div id="conflicts-list" class="space-y-4">
|
||||||
<div id="select-all-container" class="mb-4 hidden">
|
<div id="select-all-container" class="mb-4 hidden">
|
||||||
<label class="flex items-center space-x-2 cursor-pointer">
|
<label class="flex items-center space-x-2 cursor-pointer">
|
||||||
<input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" onchange="toggleAllConflicts()"/>
|
<input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" onchange="toggleAllConflicts()"/>
|
||||||
@@ -85,30 +99,35 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
if len(conflicts) == 0 {
|
if len(conflicts) == 0 {
|
||||||
<div class="text-center py-16" style="color: var(--text-secondary)">
|
<div class="card text-center py-16">
|
||||||
<div class="text-6xl mb-4">✅</div>
|
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
|
||||||
|
@Icon("check-circle", "h-7 w-7")
|
||||||
|
</span>
|
||||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
|
||||||
<p>Your devices are in sync! No conflicts to resolve.</p>
|
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
for _, conflict := range conflicts {
|
for _, conflict := range conflicts {
|
||||||
<div class="conflict-item card p-6 rounded-lg border" data-conflict-id="{ conflict.ID }" style="background-color: var(--bg-secondary); border-color: var(--border); border-left-width: 4px; border-left-style: solid; border-left-color: rgb(245, 158, 11);">
|
<div class="conflict-item card p-6" data-conflict-id={ conflict.ID } style="border-left-width: 4px; border-left-color: var(--status-warning);">
|
||||||
<div class="flex justify-between items-start mb-4">
|
<div class="flex justify-between items-start mb-4 gap-4">
|
||||||
<div class="flex items-start space-x-4">
|
<div class="flex items-start space-x-4">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="conflict-checkbox w-5 h-5 rounded mt-1"
|
class="conflict-checkbox w-5 h-5 rounded mt-1"
|
||||||
data-conflict-id="{ conflict.ID }"
|
data-conflict-id={ conflict.ID }
|
||||||
onchange="updateBulkActions()"
|
onchange="updateBulkActions()"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div class="flex items-start gap-3">
|
||||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3>
|
<span style="color: var(--status-warning);">
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p>
|
@Icon("alert", "h-5 w-5")
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button @click="showResolveModal('{ conflict.ID }')" class="btn-primary px-4 py-2 rounded-lg">
|
<button @click="showResolveModal('{ conflict.ID }')" class="btn btn-primary">Resolve</button>
|
||||||
Resolve
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm" style="color: var(--text-secondary)">
|
<div class="text-sm" style="color: var(--text-secondary)">
|
||||||
Created: { FormatInTimezone(conflict.CreatedAt, user.Timezone) }
|
Created: { FormatInTimezone(conflict.CreatedAt, user.Timezone) }
|
||||||
@@ -119,94 +138,90 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style="background-color: rgba(0, 0, 0, 0.7);">
|
<div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4" style="background-color: var(--surface-overlay);">
|
||||||
<div class="card rounded-lg p-6 w-full max-w-4xl mx-4 my-8" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="card p-6 w-full max-w-4xl my-8" style="box-shadow: var(--shadow-pop);">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Resolve Conflict</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Resolve Conflict</h2>
|
||||||
<p class="text-sm" id="conflict-book-title" style="color: var(--text-secondary);">Book Title</p>
|
<p class="text-sm" id="conflict-book-title" style="color: var(--text-secondary);">Book Title</p>
|
||||||
</div>
|
</div>
|
||||||
<button @click="hideResolveModal()" class="p-2 hover:opacity-80 rounded-lg" style="color: var(--text-primary); background-color: var(--bg-primary);">
|
<button @click="hideResolveModal()" class="icon-btn" aria-label="Close">
|
||||||
X
|
@Icon("close", "h-5 w-5")
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="conflict-resolution-form" @submit="handleResolveSubmit($event)">
|
<form id="conflict-resolution-form" @submit="handleResolveSubmit($event)">
|
||||||
<input type="hidden" id="conflict-id"/>
|
<input type="hidden" id="conflict-id"/>
|
||||||
<div id="conflict-details" class="mb-6"></div>
|
<div id="conflict-details" class="mb-6"></div>
|
||||||
<div class="card p-4 rounded-lg border mb-6" style="background-color: var(--bg-primary); border-color: var(--border);">
|
<div class="card p-4 mb-6">
|
||||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Resolution Options</h3>
|
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Resolution Options</h3>
|
||||||
<div class="space-y-3 mb-6">
|
<div class="space-y-3 mb-6">
|
||||||
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
|
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);">
|
||||||
<input type="radio" name="winner" value="koreader" class="mr-3" required/>
|
<input type="radio" name="winner" value="koreader" class="w-4 h-4" required/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="font-semibold" style="color: var(--text-primary);">Keep KOReader Progress</span>
|
<span class="font-semibold" style="color: var(--text-primary);">Keep KOReader Progress</span>
|
||||||
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your KOReader device</p>
|
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your KOReader device</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
|
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);">
|
||||||
<input type="radio" name="winner" value="kobo" class="mr-3"/>
|
<input type="radio" name="winner" value="kobo" class="w-4 h-4"/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="font-semibold" style="color: var(--text-primary);">Keep Kobo Progress</span>
|
<span class="font-semibold" style="color: var(--text-primary);">Keep Kobo Progress</span>
|
||||||
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your Kobo device</p>
|
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your Kobo device</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
|
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);">
|
||||||
<input type="radio" name="winner" value="web" class="mr-3"/>
|
<input type="radio" name="winner" value="web" class="w-4 h-4"/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="font-semibold" style="color: var(--text-primary);">Keep Web Progress</span>
|
<span class="font-semibold" style="color: var(--text-primary);">Keep Web Progress</span>
|
||||||
<p class="text-xs" style="color: var(--text-secondary);">Use progress from the web interface</p>
|
<p class="text-xs" style="color: var(--text-secondary);">Use progress from the web interface</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
|
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);">
|
||||||
<input type="radio" name="winner" value="manual" class="mr-3"/>
|
<input type="radio" name="winner" value="manual" class="w-4 h-4"/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="font-semibold" style="color: var(--text-primary);">Manual Override</span>
|
<span class="font-semibold" style="color: var(--text-primary);">Manual Override</span>
|
||||||
<p class="text-xs" style="color: var(--text-secondary);">Specify a custom progress value</p>
|
<p class="text-xs" style="color: var(--text-secondary);">Specify a custom progress value</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="manual-override" class="hidden mb-4 p-4 border rounded-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div id="manual-override" class="hidden mb-4 p-4 rounded-lg" style="background-color: var(--bg-primary); border: 1px solid var(--border);">
|
||||||
<h4 class="font-semibold mb-3" style="color: var(--text-primary);">Manual Progress</h4>
|
<h4 class="font-semibold mb-3" style="color: var(--text-primary);">Manual Progress</h4>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Percentage (0-100)</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Percentage (0-100)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="manual-percentage"
|
id="manual-percentage"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Page Number</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Page Number</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="manual-page"
|
id="manual-page"
|
||||||
min="1"
|
min="1"
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">EPUB CFI</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">EPUB CFI</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="manual-epubcfi"
|
id="manual-epubcfi"
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Chapter</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Chapter</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="manual-chapter"
|
id="manual-chapter"
|
||||||
min="1"
|
min="1"
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,20 +233,19 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Resolution Reason (optional)</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Resolution Reason (optional)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="resolution-reason"
|
id="resolution-reason"
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
placeholder="e.g., Device was offline, using most recent progress"
|
placeholder="e.g., Device was offline, using most recent progress"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3 flex-wrap">
|
||||||
<button type="button" @click="hideResolveModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
|
<button type="button" @click="hideResolveModal()" class="btn btn-secondary">Cancel</button>
|
||||||
<button type="button" @click="deleteConflict()" class="btn-danger px-4 py-2 rounded-lg">Dismiss</button>
|
<button type="button" @click="deleteConflict()" class="btn btn-danger">Dismiss</button>
|
||||||
<button type="submit" class="btn-primary px-6 py-2 rounded-lg">Resolve Conflict</button>
|
<button type="submit" class="btn btn-primary">Resolve Conflict</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+103
-29
File diff suppressed because one or more lines are too long
@@ -9,78 +9,86 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
|
|||||||
<title>Create Custom Section - Bookhoard</title>
|
<title>Create Custom Section - Bookhoard</title>
|
||||||
<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"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body class="theme-{ user.Theme }">
|
<body class="theme-{ user.Theme }">
|
||||||
@Header(user, "/custom-section")
|
@Header(user, "/custom-section")
|
||||||
<main class="max-w-4xl mx-auto px-4 py-8">
|
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8 max-w-4xl">
|
||||||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Create Custom Section</h1>
|
<div class="mb-8">
|
||||||
<p class="mb-6" style="color: var(--text-secondary)">Build a custom dashboard section by defining filter rules or manually selecting books.</p>
|
<div class="flex items-center gap-3 mb-1">
|
||||||
|
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
@Icon("layers", "h-5 w-5")
|
||||||
|
</span>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Create Custom Section</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Build a custom dashboard section by defining filter rules or manually selecting books.</p>
|
||||||
|
</div>
|
||||||
<form id="custom-section-form" class="space-y-6">
|
<form id="custom-section-form" class="space-y-6">
|
||||||
<!-- Section Details -->
|
<div class="card p-6">
|
||||||
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2" style="color: var(--text-primary)">
|
||||||
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Section Details</h2>
|
@Icon("info", "h-5 w-5")
|
||||||
|
Section Details
|
||||||
|
</h2>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name *</label>
|
<label for="section-name" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Name *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="section-name"
|
id="section-name"
|
||||||
name="name"
|
name="name"
|
||||||
required
|
required
|
||||||
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Icon (emoji)</label>
|
<label for="section-icon" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Icon (emoji)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="section-icon"
|
id="section-icon"
|
||||||
name="icon"
|
name="icon"
|
||||||
maxlength="4"
|
maxlength="4"
|
||||||
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
placeholder="📚"
|
placeholder="📚"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
|
<label for="section-description" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="section-description"
|
id="section-description"
|
||||||
name="description"
|
name="description"
|
||||||
rows="2"
|
rows="2"
|
||||||
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Library *</label>
|
<label for="section-library" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Library *</label>
|
||||||
<select
|
<select
|
||||||
id="section-library"
|
id="section-library"
|
||||||
name="library_id"
|
name="library_id"
|
||||||
required
|
required
|
||||||
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
>
|
>
|
||||||
<option value="">Select a library...</option>
|
<option value="">Select a library…</option>
|
||||||
for _, lib := range libraries {
|
for _, lib := range libraries {
|
||||||
<option value={ lib.ID }>{ lib.Name }</option>
|
<option value={ lib.ID }>{ lib.Name }</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Filter Rules -->
|
<div class="card p-6">
|
||||||
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Filter Rules</h2>
|
<h2 class="text-lg font-semibold flex items-center gap-2" style="color: var(--text-primary)">
|
||||||
|
@Icon("filter", "h-5 w-5")
|
||||||
|
Filter Rules
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="add-rule-btn"
|
id="add-rule-btn"
|
||||||
class="px-3 py-1 rounded-lg text-sm font-medium"
|
class="btn btn-primary text-sm"
|
||||||
style="background-color: var(--accent);"
|
|
||||||
>
|
>
|
||||||
+ Add Rule
|
@Icon("plus", "h-4 w-4")
|
||||||
|
Add Rule
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm mb-4" style="color: var(--text-secondary)">
|
<p class="text-sm mb-4" style="color: var(--text-secondary)">
|
||||||
@@ -88,101 +96,101 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
|
|||||||
</p>
|
</p>
|
||||||
<div id="rules-container" class="space-y-3"></div>
|
<div id="rules-container" class="space-y-3"></div>
|
||||||
<div class="mt-4 flex items-center gap-2">
|
<div class="mt-4 flex items-center gap-2">
|
||||||
<label class="text-sm font-medium" style="color: var(--text-secondary)">Match:</label>
|
<label for="match-type" class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">Match:</label>
|
||||||
<select
|
<select
|
||||||
id="match-type"
|
id="match-type"
|
||||||
name="match_type"
|
name="match_type"
|
||||||
class="px-3 py-1 rounded border"
|
class="input w-auto"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
>
|
>
|
||||||
<option value="all">ALL rules (AND)</option>
|
<option value="all">ALL rules (AND)</option>
|
||||||
<option value="any">ANY rule (OR)</option>
|
<option value="any">ANY rule (OR)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Manual Book Selection -->
|
<div class="card p-6">
|
||||||
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2" style="color: var(--text-primary)">
|
||||||
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Manual Book Selection</h2>
|
@Icon("book", "h-5 w-5")
|
||||||
|
Manual Book Selection
|
||||||
|
</h2>
|
||||||
<p class="text-sm mb-4" style="color: var(--text-secondary)">
|
<p class="text-sm mb-4" style="color: var(--text-secondary)">
|
||||||
Add specific books to this section. Use the search to find and select multiple books.
|
Add specific books to this section. Use the search to find and select multiple books.
|
||||||
</p>
|
</p>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Search Books</label>
|
<label for="book-search" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Search Books</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="book-search"
|
id="book-search"
|
||||||
name="book_search"
|
name="book_search"
|
||||||
class="flex-1 px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
class="input flex-1"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
placeholder="Search by title or author…"
|
||||||
placeholder="Search by title or author..."
|
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="search-books-btn"
|
id="search-books-btn"
|
||||||
class="px-4 py-2 rounded-lg font-medium"
|
class="btn btn-primary"
|
||||||
style="background-color: var(--accent);"
|
|
||||||
>
|
>
|
||||||
|
@Icon("search", "h-4 w-4")
|
||||||
Search
|
Search
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="search-results"
|
id="search-results"
|
||||||
class="hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto"
|
class="hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2"
|
||||||
style="background-color: var(--bg-primary);"
|
style="background-color: var(--bg-primary);"
|
||||||
></div>
|
></div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Selected Books</label>
|
<label class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Selected Books</label>
|
||||||
<div
|
<div
|
||||||
id="selected-books"
|
id="selected-books"
|
||||||
class="min-h-[60px] p-3 rounded-lg border-2 border-dashed"
|
class="min-h-[60px] p-3 rounded-xl border-2 border-dashed"
|
||||||
style="border-color: var(--border); background-color: var(--bg-primary);"
|
style="border-color: var(--border-strong); background-color: var(--bg-primary);"
|
||||||
>
|
>
|
||||||
<p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>
|
<p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Live Preview -->
|
<div class="card p-6">
|
||||||
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Live Preview</h2>
|
<h2 class="text-lg font-semibold flex items-center gap-2" style="color: var(--text-primary)">
|
||||||
|
@Icon("play", "h-5 w-5")
|
||||||
|
Live Preview
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="preview-btn"
|
id="preview-btn"
|
||||||
class="px-4 py-2 rounded-lg font-medium"
|
class="btn btn-secondary text-sm"
|
||||||
style="background-color: var(--accent);"
|
|
||||||
>
|
>
|
||||||
|
@Icon("refresh", "h-4 w-4")
|
||||||
Refresh Preview
|
Refresh Preview
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="preview-container"
|
id="preview-container"
|
||||||
class="p-4 rounded-lg"
|
class="card p-4"
|
||||||
style="background-color: var(--bg-primary); min-height: 200px;"
|
style="background-color: var(--bg-primary); min-height: 200px;"
|
||||||
>
|
>
|
||||||
<p class="text-center" style="color: var(--text-secondary);">
|
<p class="text-center text-sm" style="color: var(--text-secondary);">
|
||||||
Add filter rules or select books to see a preview of your custom section.
|
Add filter rules or select books to see a preview of your custom section.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Form Actions -->
|
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="cancel-btn"
|
id="cancel-btn"
|
||||||
class="px-6 py-2 rounded-lg font-medium border hover:opacity-80"
|
class="btn btn-secondary"
|
||||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
id="save-section-btn"
|
id="save-section-btn"
|
||||||
class="px-6 py-2 rounded-lg font-medium text-white hover:opacity-90"
|
class="btn btn-primary"
|
||||||
style="background-color: var(--accent);"
|
|
||||||
>
|
>
|
||||||
|
@Icon("save", "h-4 w-4")
|
||||||
Save Section
|
Save Section
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Create Custom Section - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-{ user.Theme }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Create Custom Section - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -37,43 +37,115 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"max-w-4xl mx-auto px-4 py-8\"><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">Create Custom Section</h1><p class=\"mb-6\" style=\"color: var(--text-secondary)\">Build a custom dashboard section by defining filter rules or manually selecting books.</p><form id=\"custom-section-form\" class=\"space-y-6\"><!-- Section Details --><div class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-secondary);\"><h2 class=\"text-xl font-semibold mb-4\" style=\"color: var(--text-primary)\">Section Details</h2><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Name *</label> <input type=\"text\" id=\"section-name\" name=\"name\" required class=\"w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Icon (emoji)</label> <input type=\"text\" id=\"section-icon\" name=\"icon\" maxlength=\"4\" class=\"w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"📚\"></div></div><div class=\"mt-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea id=\"section-description\" name=\"description\" rows=\"2\" class=\"w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></textarea></div><div class=\"mt-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Library *</label> <select id=\"section-library\" name=\"library_id\" required class=\"w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"\">Select a library...</option> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8 max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("layers", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Create Custom Section</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Build a custom dashboard section by defining filter rules or manually selecting books.</p></div><form id=\"custom-section-form\" class=\"space-y-6\"><div class=\"card p-6\"><h2 class=\"text-lg font-semibold mb-4 flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("info", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Section Details</h2><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label for=\"section-name\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Name *</label> <input type=\"text\" id=\"section-name\" name=\"name\" required class=\"input\"></div><div><label for=\"section-icon\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Icon (emoji)</label> <input type=\"text\" id=\"section-icon\" name=\"icon\" maxlength=\"4\" class=\"input\" placeholder=\"📚\"></div></div><div class=\"mt-4\"><label for=\"section-description\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea id=\"section-description\" name=\"description\" rows=\"2\" class=\"input\"></textarea></div><div class=\"mt-4\"><label for=\"section-library\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Library *</label> <select id=\"section-library\" name=\"library_id\" required class=\"input\"><option value=\"\">Select a library…</option> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, lib := range libraries {
|
for _, lib := range libraries {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<option value=\"")
|
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_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var2, 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/custom_section.templ`, Line: 68, Col: 31}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 31}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">")
|
||||||
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.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 68, Col: 44}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(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, 5, "</option>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</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, 6, "</select></div></div><!-- Filter Rules --><div class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-secondary);\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-xl font-semibold\" style=\"color: var(--text-primary)\">Filter Rules</h2><button type=\"button\" id=\"add-rule-btn\" class=\"px-3 py-1 rounded-lg text-sm font-medium\" style=\"background-color: var(--accent);\">+ Add Rule</button></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.</p><div id=\"rules-container\" class=\"space-y-3\"></div><div class=\"mt-4 flex items-center gap-2\"><label class=\"text-sm font-medium\" style=\"color: var(--text-secondary)\">Match:</label> <select id=\"match-type\" name=\"match_type\" class=\"px-3 py-1 rounded border\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"all\">ALL rules (AND)</option> <option value=\"any\">ANY rule (OR)</option></select></div></div><!-- Manual Book Selection --><div class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-secondary);\"><h2 class=\"text-xl font-semibold mb-4\" style=\"color: var(--text-primary)\">Manual Book Selection</h2><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Add specific books to this section. Use the search to find and select multiple books.</p><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Search Books</label><div class=\"flex gap-2\"><input type=\"text\" id=\"book-search\" name=\"book_search\" class=\"flex-1 px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"Search by title or author...\" autocomplete=\"off\"> <button type=\"button\" id=\"search-books-btn\" class=\"px-4 py-2 rounded-lg font-medium\" style=\"background-color: var(--accent);\">Search</button></div></div><div id=\"search-results\" class=\"hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto\" style=\"background-color: var(--bg-primary);\"></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Selected Books</label><div id=\"selected-books\" class=\"min-h-[60px] p-3 rounded-lg border-2 border-dashed\" style=\"border-color: var(--border); background-color: var(--bg-primary);\"><p class=\"text-sm text-center\" style=\"color: var(--text-secondary);\">No books selected</p></div></div></div><!-- Live Preview --><div class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-secondary);\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-xl font-semibold\" style=\"color: var(--text-primary)\">Live Preview</h2><button type=\"button\" id=\"preview-btn\" class=\"px-4 py-2 rounded-lg font-medium\" style=\"background-color: var(--accent);\">Refresh Preview</button></div><div id=\"preview-container\" class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-primary); min-height: 200px;\"><p class=\"text-center\" style=\"color: var(--text-secondary);\">Add filter rules or select books to see a preview of your custom section.</p></div></div><!-- Form Actions --><div class=\"flex justify-end gap-3\"><button type=\"button\" id=\"cancel-btn\" class=\"px-6 py-2 rounded-lg font-medium border hover:opacity-80\" style=\"border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);\">Cancel</button> <button type=\"submit\" id=\"save-section-btn\" class=\"px-6 py-2 rounded-lg font-medium text-white hover:opacity-90\" style=\"background-color: var(--accent);\">Save Section</button></div></form></main>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</select></div></div><div class=\"card p-6\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("filter", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Filter Rules</h2><button type=\"button\" id=\"add-rule-btn\" class=\"btn btn-primary text-sm\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Add Rule</button></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.</p><div id=\"rules-container\" class=\"space-y-3\"></div><div class=\"mt-4 flex items-center gap-2\"><label for=\"match-type\" class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Match:</label> <select id=\"match-type\" name=\"match_type\" class=\"input w-auto\"><option value=\"all\">ALL rules (AND)</option> <option value=\"any\">ANY rule (OR)</option></select></div></div><div class=\"card p-6\"><h2 class=\"text-lg font-semibold mb-4 flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("book", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Manual Book Selection</h2><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Add specific books to this section. Use the search to find and select multiple books.</p><div class=\"mb-4\"><label for=\"book-search\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Search Books</label><div class=\"flex gap-2\"><input type=\"text\" id=\"book-search\" name=\"book_search\" class=\"input flex-1\" placeholder=\"Search by title or author…\" autocomplete=\"off\"> <button type=\"button\" id=\"search-books-btn\" class=\"btn btn-primary\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Search</button></div></div><div id=\"search-results\" class=\"hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2\" style=\"background-color: var(--bg-primary);\"></div><div class=\"mb-4\"><label class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Selected Books</label><div id=\"selected-books\" class=\"min-h-[60px] p-3 rounded-xl border-2 border-dashed\" style=\"border-color: var(--border-strong); background-color: var(--bg-primary);\"><p class=\"text-sm text-center\" style=\"color: var(--text-secondary);\">No books selected</p></div></div></div><div class=\"card p-6\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("play", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "Live Preview</h2><button type=\"button\" id=\"preview-btn\" class=\"btn btn-secondary text-sm\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Refresh Preview</button></div><div id=\"preview-container\" class=\"card p-4\" style=\"background-color: var(--bg-primary); min-height: 200px;\"><p class=\"text-center text-sm\" style=\"color: var(--text-secondary);\">Add filter rules or select books to see a preview of your custom section.</p></div></div><div class=\"flex justify-end gap-3\"><button type=\"button\" id=\"cancel-btn\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" id=\"save-section-btn\" class=\"btn btn-primary\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("save", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Save Section</button></div></form></main>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -81,7 +153,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
|||||||
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, "</body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-90
@@ -14,17 +14,20 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
|
|||||||
<title>Dashboard - Bookhoard</title>
|
<title>Dashboard - Bookhoard</title>
|
||||||
<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"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="dashboard" x-init="initDashboard()" class="theme-{ user.Theme }">
|
<body x-data="dashboard" x-init="initDashboard()" class="theme-{ user.Theme }">
|
||||||
@Header(user, "/dashboard")
|
@Header(user, "/dashboard")
|
||||||
@LibrarySwitcher(libData, currentLibraryID, DashboardActions())
|
@LibrarySwitcher(libData, currentLibraryID, DashboardActions())
|
||||||
<!-- Collections Container -->
|
<main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<main id="collections-container" class="w-full px-4 py-8">
|
<div class="mb-8">
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Your Library</h1>
|
||||||
|
<p class="text-sm mt-1" style="color: var(--text-secondary)">Pick up where you left off, or explore something new.</p>
|
||||||
|
</div>
|
||||||
for _, section := range sections {
|
for _, section := range sections {
|
||||||
@CollectionCarousel(section)
|
@CollectionCarousel(section)
|
||||||
}
|
}
|
||||||
</main>
|
</main>
|
||||||
<!-- Dashboard Settings Modal -->
|
|
||||||
@DashboardSettingsModal(allSections, hiddenCollections, itemsPerSection)
|
@DashboardSettingsModal(allSections, hiddenCollections, itemsPerSection)
|
||||||
@ErrorToast(errorMessage)
|
@ErrorToast(errorMessage)
|
||||||
</body>
|
</body>
|
||||||
@@ -33,89 +36,79 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
|
|||||||
|
|
||||||
templ CollectionCarousel(section handlers.SectionData) {
|
templ CollectionCarousel(section handlers.SectionData) {
|
||||||
<div
|
<div
|
||||||
class="dashboard-collection mb-8"
|
class="dashboard-collection mb-10"
|
||||||
data-collection-id={ section.ID }
|
data-collection-id={ section.ID }
|
||||||
data-is-system={ section.IsSystem }
|
data-is-system={ section.IsSystem }
|
||||||
>
|
>
|
||||||
<!-- Collection Header -->
|
<div class="flex items-end justify-between mb-4">
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-2xl">{ section.Icon }</span>
|
<span class="grid place-items-center h-9 w-9 rounded-lg text-lg" style="background-color: var(--accent-muted);">{ section.Icon }</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">{ section.Title }</h2>
|
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">{ section.Title }</h2>
|
||||||
if section.Description != "" {
|
if section.Description != "" {
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">{ section.Description }</p>
|
<p class="text-xs" style="color: var(--text-secondary)">{ section.Description }</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
if section.ViewAllURL != "" {
|
if section.ViewAllURL != "" {
|
||||||
<a
|
<a
|
||||||
href={ section.ViewAllURL }
|
href={ section.ViewAllURL }
|
||||||
class="text-sm font-medium hover:underline transition-colors"
|
class="inline-flex items-center gap-1 text-sm font-medium hover:underline transition-colors"
|
||||||
style="color: var(--accent);"
|
style="color: var(--accent);"
|
||||||
>
|
>
|
||||||
View All →
|
View All
|
||||||
|
@Icon("arrow-right", "h-4 w-4")
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- Carousel -->
|
|
||||||
<div class="carousel-container relative group">
|
<div class="carousel-container relative group">
|
||||||
<button
|
<button
|
||||||
class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
|
class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10 h-full w-12 flex items-center justify-center opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 transition-opacity duration-200"
|
||||||
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
|
|
||||||
flex items-center justify-start opacity-0 group-hover:opacity-100
|
|
||||||
transition-opacity duration-200"
|
|
||||||
data-action="scroll-carousel"
|
data-action="scroll-carousel"
|
||||||
data-collection-id={ section.ID }
|
data-collection-id={ section.ID }
|
||||||
data-direction="-1"
|
data-direction="-1"
|
||||||
aria-label="Scroll left"
|
aria-label="Scroll left"
|
||||||
|
style="background: linear-gradient(to right, var(--bg-primary), transparent);"
|
||||||
>
|
>
|
||||||
<span class="text-3xl pl-2" style="color: var(--text-primary);">‹</span>
|
@Icon("chevron-left", "h-7 w-7")
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
id={ "carousel-track-" + section.ID }
|
id={ "carousel-track-" + section.ID }
|
||||||
class="carousel-track flex gap-4 overflow-x-auto
|
class="carousel-track flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory px-6 pb-2"
|
||||||
scroll-smooth snap-x snap-mandatory
|
|
||||||
px-12 pb-4"
|
|
||||||
style="scrollbar-width: none; -ms-overflow-style: none;"
|
style="scrollbar-width: none; -ms-overflow-style: none;"
|
||||||
>
|
>
|
||||||
for _, item := range section.Items {
|
for _, item := range section.Items {
|
||||||
|
<div class="flex-shrink-0 w-36 sm:w-40 snap-start" data-media-item-id={ item.MediaItemID }>
|
||||||
@BookCard(item)
|
@BookCard(item)
|
||||||
}
|
</div>
|
||||||
|
}
|
||||||
if len(section.Items) == 0 {
|
if len(section.Items) == 0 {
|
||||||
<div class="text-center py-8 w-full" style="color: var(--text-secondary);">
|
<div class="flex flex-col items-center justify-center text-center py-12 w-full gap-2" style="color: var(--text-secondary);">
|
||||||
<p>No items in this collection</p>
|
@Icon("book", "h-8 w-8 opacity-50")
|
||||||
|
<p class="text-sm">No items in this collection</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
|
class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10 h-full w-12 flex items-center justify-center opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 transition-opacity duration-200"
|
||||||
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
|
|
||||||
flex items-center justify-end opacity-0 group-hover:opacity-100
|
|
||||||
transition-opacity duration-200"
|
|
||||||
data-action="scroll-carousel"
|
data-action="scroll-carousel"
|
||||||
data-collection-id={ section.ID }
|
data-collection-id={ section.ID }
|
||||||
data-direction="1"
|
data-direction="1"
|
||||||
aria-label="Scroll right"
|
aria-label="Scroll right"
|
||||||
|
style="background: linear-gradient(to left, var(--bg-primary), transparent);"
|
||||||
>
|
>
|
||||||
<span class="text-3xl pr-2" style="color: var(--text-primary);">›</span>
|
@Icon("chevron-right", "h-7 w-7")
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ BookCard(item handlers.BookInfo) {
|
templ BookCard(item handlers.BookInfo) {
|
||||||
<a href={ "/media/" + item.MediaItemID } title={ item.Title }>
|
<div class="book-card relative w-full h-full rounded-xl overflow-hidden cursor-pointer">
|
||||||
<div
|
<a href={ "/media/" + item.MediaItemID } class="block h-full">
|
||||||
class="book-card flex-shrink-0 w-36 rounded-lg overflow-hidden snap-start cursor-pointer
|
|
||||||
transition-transform duration-200 hover:scale-105"
|
|
||||||
tabindex="0"
|
|
||||||
role="button"
|
|
||||||
aria-label={ "View " + item.Title }
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="aspect-[2/3] overflow-hidden shadow-lg
|
class="book-card-cover aspect-[2/3] overflow-hidden"
|
||||||
bg-gradient-to-br from-gray-700 to-gray-900"
|
style="background-color: color-mix(in srgb, var(--text-primary) 8%, transparent);"
|
||||||
>
|
>
|
||||||
if item.CoverImagePath != "" {
|
if item.CoverImagePath != "" {
|
||||||
<img
|
<img
|
||||||
@@ -133,60 +126,72 @@ templ BookCard(item handlers.BookInfo) {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="book-card-text px-2 py-1 bg-[color-mix(in_srgb,var(--wood-border)_40%,transparent)]">
|
<div class="book-card-meta">
|
||||||
<h3 class="font-semibold text-base line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
|
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
|
||||||
{ item.Title }
|
{ item.Title }
|
||||||
</h3>
|
</h3>
|
||||||
if item.Author != "" {
|
if item.Author != "" {
|
||||||
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)" title={ item.Author }>
|
<p class="text-xs mt-0.5 line-clamp-1" style="color: var(--text-secondary)" title={ item.Author }>
|
||||||
{ item.Author }
|
{ item.Author }
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="book-card-action">
|
||||||
|
if item.HasConflict {
|
||||||
|
<a
|
||||||
|
href={ "/media/" + item.MediaItemID }
|
||||||
|
class="book-card-action-btn"
|
||||||
|
aria-label={ "Resolve progress conflict for " + item.Title }
|
||||||
|
title="Resolve progress conflict"
|
||||||
|
>
|
||||||
|
@Icon("book-open", "h-5 w-5")
|
||||||
|
</a>
|
||||||
|
} else {
|
||||||
|
<a
|
||||||
|
href={ "/readers/" + item.MediaItemID }
|
||||||
|
class="book-card-action-btn"
|
||||||
|
aria-label={ "Read " + item.Title }
|
||||||
|
title="Read"
|
||||||
|
>
|
||||||
|
@Icon("book-open", "h-5 w-5")
|
||||||
|
</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) {
|
templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) {
|
||||||
<div
|
<div
|
||||||
id="dashboard-settings-modal"
|
id="dashboard-settings-modal"
|
||||||
class="hidden fixed inset-0 z-50 flex items-center justify-center"
|
class="hidden fixed inset-0 z-[70] flex items-center justify-center p-4"
|
||||||
style="background-color: rgba(0, 0, 0, 0.7);"
|
style="background-color: var(--surface-overlay);"
|
||||||
>
|
>
|
||||||
<div
|
<div class="card p-6 w-full max-w-2xl shadow-2xl" style="box-shadow: var(--shadow-pop);">
|
||||||
class="rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl"
|
|
||||||
style="background-color: var(--bg-secondary);"
|
|
||||||
>
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
|
<div>
|
||||||
<button
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
|
||||||
data-action="close-dashboard-settings"
|
<p class="text-sm mt-1" style="color: var(--text-secondary)">Drag to reorder, toggle to show or hide.</p>
|
||||||
class="p-2 hover:bg-gray-700 rounded transition-colors"
|
</div>
|
||||||
>
|
<button data-action="close-dashboard-settings" class="icon-btn" aria-label="Close">
|
||||||
✕
|
@Icon("close", "h-5 w-5")
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm mb-4" style="color: var(--text-secondary);">
|
|
||||||
Drag to reorder collections, toggle visibility with the switch.
|
|
||||||
</p>
|
|
||||||
<!-- Draggable Collection List -->
|
|
||||||
<div id="collection-list" class="space-y-2 mb-6">
|
<div id="collection-list" class="space-y-2 mb-6">
|
||||||
for _, section := range sections {
|
for _, section := range sections {
|
||||||
<div
|
<div
|
||||||
class="collection-item flex items-center justify-between p-3 rounded border
|
class="collection-item flex items-center justify-between p-3 rounded-xl cursor-move select-none card"
|
||||||
cursor-move select-none"
|
|
||||||
data-collection-id={ section.ID }
|
data-collection-id={ section.ID }
|
||||||
data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
|
data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
|
||||||
draggable="true"
|
draggable="true"
|
||||||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-xl" style="color: var(--text-secondary);">☰</span>
|
@Icon("grip", "h-5 w-5 shrink-0")
|
||||||
<span class="text-xl">{ section.Icon }</span>
|
<span class="text-xl">{ section.Icon }</span>
|
||||||
<div>
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span>
|
<span class="font-medium" style="color: var(--text-primary)">{ section.Title }</span>
|
||||||
if section.IsSystem {
|
if section.IsSystem {
|
||||||
<span class="text-xs ml-2 px-2 py-1 rounded" style="background-color: var(--accent);">System</span>
|
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">System</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,8 +200,7 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
|
|||||||
<button
|
<button
|
||||||
data-action="restore-system-collection"
|
data-action="restore-system-collection"
|
||||||
data-collection-name={ section.ID }
|
data-collection-name={ section.ID }
|
||||||
class="text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity"
|
class="btn btn-secondary py-1 px-2.5 text-xs"
|
||||||
style="border-color: var(--border); color: var(--text-secondary);"
|
|
||||||
title="Restore { section.Title } to defaults"
|
title="Restore { section.Title } to defaults"
|
||||||
>
|
>
|
||||||
Restore
|
Restore
|
||||||
@@ -211,21 +215,17 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
|
class="w-11 h-6 rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"
|
||||||
peer-checked:after:translate-x-full peer-checked:after:border-white
|
style="background-color: var(--border-strong);"
|
||||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
|
||||||
after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
|
|
||||||
peer-checked:bg-blue-600"
|
|
||||||
></div>
|
></div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- Items Per Section Slider -->
|
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
Items per Collection: <span id="items-count-display" class="font-bold">{ itemsPerSection }</span>
|
Items per Collection: <span id="items-count-display" class="font-bold" style="color: var(--text-primary)">{ itemsPerSection }</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -233,26 +233,15 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
|
|||||||
max="50"
|
max="50"
|
||||||
step="5"
|
step="5"
|
||||||
value={ itemsPerSection }
|
value={ itemsPerSection }
|
||||||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
|
class="w-full h-2 rounded-lg appearance-none cursor-pointer"
|
||||||
|
style="background-color: var(--border-strong);"
|
||||||
data-input-action="update-items-count"
|
data-input-action="update-items-count"
|
||||||
target="items-count-display"
|
target="items-count-display"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<button
|
<button data-action="close-dashboard-settings" class="btn btn-secondary">Cancel</button>
|
||||||
data-action="close-dashboard-settings"
|
<button data-action="save-dashboard-settings" class="btn btn-primary">Save Changes</button>
|
||||||
class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
|
|
||||||
style="border-color: var(--border); color: var(--text-primary);"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
data-action="save-dashboard-settings"
|
|
||||||
class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
|
|
||||||
style="background-color: var(--accent);"
|
|
||||||
>
|
|
||||||
Save Changes
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+307
-181
@@ -34,7 +34,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Dashboard - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"dashboard\" x-init=\"initDashboard()\" class=\"theme-{ user.Theme }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Dashboard - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"dashboard\" x-init=\"initDashboard()\" class=\"theme-{ user.Theme }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!-- Collections Container --><main id=\"collections-container\" class=\"w-full px-4 py-8\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main id=\"collections-container\" class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Your Library</h1><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">Pick up where you left off, or explore something new.</p></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</main><!-- Dashboard Settings Modal -->")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</main>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -97,14 +97,14 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
templ_7745c5c3_Var2 = templ.NopComponent
|
templ_7745c5c3_Var2 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"dashboard-collection mb-8\" data-collection-id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"dashboard-collection mb-10\" data-collection-id=\"")
|
||||||
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.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 37, Col: 33}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 40, Col: 33}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -117,33 +117,33 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 38, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 41, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 7, "\"><!-- Collection Header --><div class=\"flex items-center justify-between mb-4\"><div class=\"flex items-center gap-3\"><span class=\"text-2xl\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"><div class=\"flex items-end justify-between mb-4\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-9 w-9 rounded-lg text-lg\" style=\"background-color: var(--accent-muted);\">")
|
||||||
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(section.Icon)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 43, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 45, Col: 130}
|
||||||
}
|
}
|
||||||
_, 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, 8, "</span><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><div><h2 class=\"text-lg font-bold tracking-tight\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 45, Col: 85}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 47, Col: 100}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -154,14 +154,14 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if section.Description != "" {
|
if section.Description != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<p class=\"text-xs\" style=\"color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 47, Col: 83}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 49, Col: 83}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -184,31 +184,47 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 templ.SafeURL
|
var templ_7745c5c3_Var8 templ.SafeURL
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 53, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 55, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"text-sm font-medium hover:underline transition-colors\" style=\"color: var(--accent);\">View All →</a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"inline-flex items-center gap-1 text-sm font-medium hover:underline transition-colors\" style=\"color: var(--accent);\">View All")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("arrow-right", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div><!-- Carousel --><div class=\"carousel-container relative group\"><button class=\"carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10 w-12 h-full bg-gradient-to-r from-gray-900 to-transparent flex items-center justify-start opacity-0 group-hover:opacity-100 transition-opacity duration-200\" data-action=\"scroll-carousel\" data-collection-id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div><div class=\"carousel-container relative group\"><button class=\"carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10 h-full w-12 flex items-center justify-center opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 transition-opacity duration-200\" data-action=\"scroll-carousel\" data-collection-id=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 69, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 68, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" data-direction=\"-1\" aria-label=\"Scroll left\"><span class=\"text-3xl pl-2\" style=\"color: var(--text-primary);\">‹</span></button><div id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" data-direction=\"-1\" aria-label=\"Scroll left\" style=\"background: linear-gradient(to right, var(--bg-primary), transparent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("chevron-left", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</button><div id=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -221,36 +237,73 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"carousel-track flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory px-12 pb-4\" style=\"scrollbar-width: none; -ms-overflow-style: none;\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"carousel-track flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory px-6 pb-2\" style=\"scrollbar-width: none; -ms-overflow-style: none;\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, item := range section.Items {
|
for _, item := range section.Items {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"flex-shrink-0 w-36 sm:w-40 snap-start\" data-media-item-id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var11 string
|
||||||
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MediaItemID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 81, Col: 92}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
templ_7745c5c3_Err = BookCard(item).Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = BookCard(item).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(section.Items) == 0 {
|
if len(section.Items) == 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"text-center py-8 w-full\" style=\"color: var(--text-secondary);\"><p>No items in this collection</p></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"flex flex-col items-center justify-center text-center py-12 w-full gap-2\" style=\"color: var(--text-secondary);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("book", "h-8 w-8 opacity-50").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<p class=\"text-sm\">No items in this collection</p></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><button class=\"carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10 w-12 h-full bg-gradient-to-l from-gray-900 to-transparent flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity duration-200\" data-action=\"scroll-carousel\" data-collection-id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><button class=\"carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10 h-full w-12 flex items-center justify-center opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 transition-opacity duration-200\" data-action=\"scroll-carousel\" data-collection-id=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 97, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 95, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" data-direction=\"1\" aria-label=\"Scroll right\"><span class=\"text-3xl pr-2\" style=\"color: var(--text-primary);\">›</span></button></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" data-direction=\"1\" aria-label=\"Scroll right\" style=\"background: linear-gradient(to left, var(--bg-primary), transparent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("chevron-right", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</button></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -274,167 +327,224 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var12 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var13 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var12 == nil {
|
if templ_7745c5c3_Var13 == nil {
|
||||||
templ_7745c5c3_Var12 = templ.NopComponent
|
templ_7745c5c3_Var13 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"book-card relative w-full h-full rounded-xl overflow-hidden cursor-pointer\"><a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 templ.SafeURL
|
var templ_7745c5c3_Var14 templ.SafeURL
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 39}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 40}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" title=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"block h-full\"><div class=\"book-card-cover aspect-[2/3] overflow-hidden\" style=\"background-color: color-mix(in srgb, var(--text-primary) 8%, transparent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var14 string
|
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 60}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"><div class=\"book-card flex-shrink-0 w-36 rounded-lg overflow-hidden snap-start cursor-pointer transition-transform duration-200 hover:scale-105\" tabindex=\"0\" role=\"button\" aria-label=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var15 string
|
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("View " + item.Title)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 114, Col: 36}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\"><div class=\"aspect-[2/3] overflow-hidden shadow-lg bg-gradient-to-br from-gray-700 to-gray-900\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if item.CoverImagePath != "" {
|
if item.CoverImagePath != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<img src=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<img src=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 115, Col: 31}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" alt=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 122, Col: 31}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 116, Col: 22}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" alt=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" class=\"w-full h-full object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<img src=\"/static/placeholder-book.svg\" alt=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 123, Col: 22}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 124, Col: 22}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"w-full h-full object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" class=\"w-full h-full object-cover\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<img src=\"/static/placeholder-book.svg\" alt=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var18 string
|
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 22}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"w-full h-full object-cover\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div><div class=\"book-card-text px-2 py-1 bg-[color-mix(in_srgb,var(--wood-border)_40%,transparent)]\"><h3 class=\"font-semibold text-base line-clamp-2\" style=\"color: var(--text-primary)\" title=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div><div class=\"book-card-meta\"><h3 class=\"font-semibold text-sm leading-snug line-clamp-2\" style=\"color: var(--text-primary)\" title=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var18 string
|
||||||
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 130, Col: 117}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 137, Col: 106}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 17}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</h3>")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var20 string
|
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 138, Col: 17}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</h3>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if item.Author != "" {
|
if item.Author != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<p class=\"text-sm line-clamp-1\" style=\"color: var(--text-secondary)\" title=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<p class=\"text-xs mt-0.5 line-clamp-1\" style=\"color: var(--text-secondary)\" title=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var20 string
|
||||||
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 134, Col: 100}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 141, Col: 93}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 135, Col: 19}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var22 string
|
}
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div></a><div class=\"book-card-action\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if item.HasConflict {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 142, Col: 19}
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var22 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 143, Col: 40}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" class=\"book-card-action-btn\" aria-label=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var23 string
|
||||||
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("Resolve progress conflict for " + item.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 145, Col: 63}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" title=\"Resolve progress conflict\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("book-open", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</a>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var24 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 152, Col: 42}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" class=\"book-card-action-btn\" aria-label=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var25 string
|
||||||
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("Read " + item.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 154, Col: 38}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" title=\"Read\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("book-open", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div></div></a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -458,143 +568,159 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var23 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var26 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var23 == nil {
|
if templ_7745c5c3_Var26 == nil {
|
||||||
templ_7745c5c3_Var23 = templ.NopComponent
|
templ_7745c5c3_Var26 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div id=\"dashboard-settings-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: rgba(0, 0, 0, 0.7);\"><div class=\"rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl\" style=\"background-color: var(--bg-secondary);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Customize Dashboard</h2><button data-action=\"close-dashboard-settings\" class=\"p-2 hover:bg-gray-700 rounded transition-colors\">✕</button></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary);\">Drag to reorder collections, toggle visibility with the switch.</p><!-- Draggable Collection List --><div id=\"collection-list\" class=\"space-y-2 mb-6\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div id=\"dashboard-settings-modal\" class=\"hidden fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-2xl shadow-2xl\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Customize Dashboard</h2><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">Drag to reorder, toggle to show or hide.</p></div><button data-action=\"close-dashboard-settings\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button></div><div id=\"collection-list\" class=\"space-y-2 mb-6\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, section := range sections {
|
for _, section := range sections {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"collection-item flex items-center justify-between p-3 rounded border cursor-move select-none\" data-collection-id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<div class=\"collection-item flex items-center justify-between p-3 rounded-xl cursor-move select-none card\" data-collection-id=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var24 string
|
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 178, Col: 37}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" data-is-system=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var25 string
|
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 179, Col: 58}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" draggable=\"true\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><div class=\"flex items-center gap-3\"><span class=\"text-xl\" style=\"color: var(--text-secondary);\">☰</span> <span class=\"text-xl\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var26 string
|
|
||||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 185, Col: 43}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</span><div><span class=\"font-medium\" style=\"color: var(--text-primary);\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var27 string
|
var templ_7745c5c3_Var27 string
|
||||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 187, Col: 85}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 184, Col: 37}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" data-is-system=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var28 string
|
||||||
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 185, Col: 58}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" draggable=\"true\"><div class=\"flex items-center gap-3\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("grip", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span class=\"text-xl\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var29 string
|
||||||
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 190, Col: 43}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span><div class=\"flex items-center gap-2\"><span class=\"font-medium\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var30 string
|
||||||
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 192, Col: 84}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if section.IsSystem {
|
if section.IsSystem {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<span class=\"text-xs ml-2 px-2 py-1 rounded\" style=\"background-color: var(--accent);\">System</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">System</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div></div><div class=\"flex items-center gap-3\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</div></div><div class=\"flex items-center gap-3\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if section.IsSystem {
|
if section.IsSystem {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<button data-action=\"restore-system-collection\" data-collection-name=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<button data-action=\"restore-system-collection\" data-collection-name=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var28 string
|
var templ_7745c5c3_Var31 string
|
||||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 197, Col: 42}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 202, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" class=\"text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity\" style=\"border-color: var(--border); color: var(--text-secondary);\" title=\"Restore { section.Title } to defaults\">Restore</button> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" class=\"btn btn-secondary py-1 px-2.5 text-xs\" title=\"Restore { section.Title } to defaults\">Restore</button> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<label class=\"relative inline-flex items-center cursor-pointer\"><input type=\"checkbox\" class=\"sr-only peer\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<label class=\"relative inline-flex items-center cursor-pointer\"><input type=\"checkbox\" class=\"sr-only peer\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if !ContainsString(hiddenCollections, section.ID) {
|
if !ContainsString(hiddenCollections, section.ID) {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " checked")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " checked")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "><div class=\"w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600\"></div></label></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "><div class=\"w-11 h-6 rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all\" style=\"background-color: var(--border-strong);\"></div></label></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div><!-- Items Per Section Slider --><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Items per Collection: <span id=\"items-count-display\" class=\"font-bold\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</div><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Items per Collection: <span id=\"items-count-display\" class=\"font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var29 string
|
var templ_7745c5c3_Var32 string
|
||||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
|
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 228, Col: 93}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 228, Col: 128}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span></label> <input type=\"range\" min=\"10\" max=\"50\" step=\"5\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</span></label> <input type=\"range\" min=\"10\" max=\"50\" step=\"5\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var30 string
|
var templ_7745c5c3_Var33 string
|
||||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
|
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 235, Col: 28}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 235, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" class=\"w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer\" data-input-action=\"update-items-count\" target=\"items-count-display\"></div><div class=\"flex justify-end gap-3\"><button data-action=\"close-dashboard-settings\" class=\"px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors\" style=\"border-color: var(--border); color: var(--text-primary);\">Cancel</button> <button data-action=\"save-dashboard-settings\" class=\"px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity\" style=\"background-color: var(--accent);\">Save Changes</button></div></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\" class=\"w-full h-2 rounded-lg appearance-none cursor-pointer\" style=\"background-color: var(--border-strong);\" data-input-action=\"update-items-count\" target=\"items-count-display\"></div><div class=\"flex justify-end gap-3\"><button data-action=\"close-dashboard-settings\" class=\"btn btn-secondary\">Cancel</button> <button data-action=\"save-dashboard-settings\" class=\"btn btn-primary\">Save Changes</button></div></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+178
-156
@@ -11,59 +11,62 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
<title>Device Management - Bookhoard</title>
|
<title>Device Management - Bookhoard</title>
|
||||||
<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"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body class="theme-{ user.Theme }" x-data="devices" x-init="setupEventDelegation()">
|
<body class="theme-{ user.Theme }" x-data="devices" x-init="setupEventDelegation()">
|
||||||
@Header(user, "/devices")
|
@Header(user, "/devices")
|
||||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<!-- Header Section -->
|
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Device Management</h1>
|
<div class="flex items-center gap-3 mb-1">
|
||||||
<p style="color: var(--text-secondary)">Manage your reading devices and sync settings</p>
|
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
@Icon("device", "h-5 w-5")
|
||||||
|
</span>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Device Management</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Manage your reading devices and sync settings</p>
|
||||||
</div>
|
</div>
|
||||||
<button @click="showAddDeviceModal()" class="btn-primary px-4 py-2 rounded-lg">
|
<button @click="showAddDeviceModal()" class="btn btn-primary">
|
||||||
➕ Add New Device
|
@Icon("plus", "h-4 w-4")
|
||||||
|
Add New Device
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Devices Grid -->
|
|
||||||
<div id="devices-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div id="devices-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<!-- Empty state -->
|
|
||||||
if len(devices) == 0 {
|
if len(devices) == 0 {
|
||||||
<div id="empty-state" class="text-center py-16 col-span-full" style="color: var(--text-secondary)">
|
<div id="empty-state" class="card text-center py-16 col-span-full">
|
||||||
<div class="text-6xl mb-4">📱</div>
|
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
@Icon("device", "h-7 w-7")
|
||||||
|
</span>
|
||||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Devices Yet</h3>
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Devices Yet</h3>
|
||||||
<p class="mb-4">Add your reading devices to enable cross-device sync</p>
|
<p class="mb-4 text-sm" style="color: var(--text-secondary)">Add your reading devices to enable cross-device sync</p>
|
||||||
<button @click="showAddDeviceModal()" class="btn-primary px-4 py-2 rounded-lg">
|
<button @click="showAddDeviceModal()" class="btn btn-primary">Add Your First Device</button>
|
||||||
Add Your First Device
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Devices Grid -->
|
|
||||||
if len(devices) > 0 {
|
if len(devices) > 0 {
|
||||||
<div id="devices-grid" class="grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div id="devices-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 col-span-full">
|
||||||
for _, device := range devices {
|
for _, device := range devices {
|
||||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="card p-6">
|
||||||
<div class="flex items-start justify-between mb-4">
|
<div class="flex items-start justify-between mb-4">
|
||||||
<div class="text-4xl">
|
<span class="grid place-items-center h-11 w-11 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
if device.DeviceType == "koreader" {
|
if device.DeviceType == "koreader" {
|
||||||
📖
|
@Icon("book-open", "h-5 w-5")
|
||||||
} else if device.DeviceType == "kobo" {
|
} else if device.DeviceType == "kobo" {
|
||||||
📚
|
@Icon("library", "h-5 w-5")
|
||||||
} else if device.DeviceType == "web" {
|
} else if device.DeviceType == "web" {
|
||||||
🌐
|
@Icon("globe", "h-5 w-5")
|
||||||
} else {
|
} else {
|
||||||
📱
|
@Icon("device", "h-5 w-5")
|
||||||
}
|
}
|
||||||
</div>
|
</span>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-1">
|
||||||
<button @click={ "showShelfMappings('" + device.ID.String() + "')" } class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
|
<button @click={ "showShelfMappings('" + device.ID.String() + "')" } class="icon-btn" title="Shelf mappings" aria-label="Shelf mappings">
|
||||||
📚
|
@Icon("layers", "h-5 w-5")
|
||||||
</button>
|
|
||||||
<button @click={ "showDeviceSettings('" + device.ID.String() + "')" } class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
|
|
||||||
⚙️
|
|
||||||
</button>
|
</button>
|
||||||
|
<button @click={ "showDeviceSettings('" + device.ID.String() + "')" } class="icon-btn" title="Device settings" aria-label="Device settings">
|
||||||
|
@Icon("gear", "h-5 w-5")
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-semibold mb-1" style="color: var(--text-primary)">{ device.DeviceName }</h3>
|
<h3 class="text-lg font-semibold mb-1" style="color: var(--text-primary)">{ device.DeviceName }</h3>
|
||||||
@@ -72,9 +75,9 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span style="color: var(--text-secondary)">Sync Status</span>
|
<span style="color: var(--text-secondary)">Sync Status</span>
|
||||||
if device.SyncEnabled {
|
if device.SyncEnabled {
|
||||||
<span style="color: var(--accent)">✓ Enabled</span>
|
<span class="badge status-completed">Enabled</span>
|
||||||
} else {
|
} else {
|
||||||
<span style="color: var(--text-secondary)">✗ Disabled</span>
|
<span class="badge" style="background-color: var(--surface-hover); color: var(--text-secondary);">Disabled</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
@@ -82,7 +85,7 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
if device.LastSync != nil {
|
if device.LastSync != nil {
|
||||||
<span style="color: var(--text-primary)">{ FormatInTimezone(*device.LastSync, user.Timezone) }</span>
|
<span style="color: var(--text-primary)">{ FormatInTimezone(*device.LastSync, user.Timezone) }</span>
|
||||||
} else {
|
} else {
|
||||||
<span style="color: var(--text-primary)">Never</span>
|
<span style="color: var(--text-secondary)">Never</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
@@ -90,15 +93,13 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
if device.LastSeen != nil {
|
if device.LastSeen != nil {
|
||||||
<span style="color: var(--text-primary)">{ FormatInTimezone(*device.LastSeen, user.Timezone) }</span>
|
<span style="color: var(--text-primary)">{ FormatInTimezone(*device.LastSeen, user.Timezone) }</span>
|
||||||
} else {
|
} else {
|
||||||
<span style="color: var(--text-primary)">Never</span>
|
<span style="color: var(--text-secondary)">Never</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- NEW: Sync URL & Token Management -->
|
|
||||||
<div class="border-t pt-4" style="border-color: var(--border);">
|
<div class="border-t pt-4" style="border-color: var(--border);">
|
||||||
<p class="text-xs font-semibold mb-2" style="color: var(--text-secondary)">DEVICE SYNC CONFIGURATION</p>
|
<p class="text-xs font-semibold uppercase tracking-wide mb-3" style="color: var(--text-secondary)">Device Sync Configuration</p>
|
||||||
if device.DeviceType == "kobo" {
|
if device.DeviceType == "kobo" {
|
||||||
<!-- Kobo: Copy Full Sync URL -->
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="block text-xs mb-1" style="color: var(--text-secondary)">Kobo Sync URL</label>
|
<label class="block text-xs mb-1" style="color: var(--text-secondary)">Kobo Sync URL</label>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
@@ -107,22 +108,20 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
id="sync-url-{ device.ID }"
|
id="sync-url-{ device.ID }"
|
||||||
readonly
|
readonly
|
||||||
value={ baseURL + "/api/sync/kobo/" + device.AuthToken }
|
value={ baseURL + "/api/sync/kobo/" + device.AuthToken }
|
||||||
class="flex-1 px-3 py-2 text-xs rounded border"
|
class="input text-xs font-mono"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@click="copyToClipboard(document.getElementById('sync-url-{ device.ID }').value, 'Kobo sync URL')"
|
@click="copyToClipboard(document.getElementById('sync-url-{ device.ID }').value, 'Kobo sync URL')"
|
||||||
class="px-3 py-2 text-xs rounded hover:opacity-80"
|
class="btn btn-primary text-xs px-3"
|
||||||
style="background-color: var(--accent); color: white;"
|
|
||||||
>
|
>
|
||||||
📋 Copy
|
@Icon("copy", "h-4 w-4")
|
||||||
|
Copy
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs mt-1" style="color: var(--text-secondary);">Paste this URL into Kobo's <code class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary);">api_endpoint</code> setting</p>
|
<p class="text-xs mt-1" style="color: var(--text-secondary);">Paste this URL into Kobo's <code class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary);">api_endpoint</code> setting</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if device.DeviceType == "koreader" {
|
if device.DeviceType == "koreader" {
|
||||||
<!-- KOReader: Copy Auth Token -->
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="block text-xs mb-1" style="color: var(--text-secondary)">Auth Token (for plugin)</label>
|
<label class="block text-xs mb-1" style="color: var(--text-secondary)">Auth Token (for plugin)</label>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
@@ -131,43 +130,40 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
id="auth-token-{ device.ID }"
|
id="auth-token-{ device.ID }"
|
||||||
readonly
|
readonly
|
||||||
value={ device.AuthToken }
|
value={ device.AuthToken }
|
||||||
class="flex-1 px-3 py-2 text-xs rounded border"
|
class="input text-xs font-mono"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border); font-family: monospace;"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@click="copyToClipboard(document.getElementById('auth-token-{ device.ID }').value, 'Auth token')"
|
@click="copyToClipboard(document.getElementById('auth-token-{ device.ID }').value, 'Auth token')"
|
||||||
class="px-3 py-2 text-xs rounded hover:opacity-80"
|
class="btn btn-primary text-xs px-3"
|
||||||
style="background-color: var(--accent); color: white;"
|
|
||||||
>
|
>
|
||||||
📋 Copy
|
@Icon("copy", "h-4 w-4")
|
||||||
|
Copy
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs mt-1" style="color: var(--text-secondary);">Enter this token in the KOReader plugin settings</p>
|
<p class="text-xs mt-1" style="color: var(--text-secondary);">Enter this token in the KOReader plugin settings</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Regenerate Token Button -->
|
|
||||||
<button
|
<button
|
||||||
hx-put={ "/api/devices/" + device.ID.String() + "/regenerate-token" }
|
hx-put={ "/api/devices/" + device.ID.String() + "/regenerate-token" }
|
||||||
hx-confirm="⚠️ Old token will immediately stop working. Regenerate anyway?"
|
hx-confirm="Old token will immediately stop working. Regenerate anyway?"
|
||||||
class="w-full px-3 py-2 text-xs rounded border hover:opacity-80"
|
class="btn btn-secondary w-full text-xs"
|
||||||
style="border-color: var(--border); color: var(--text-secondary); background-color: var(--bg-primary);"
|
|
||||||
>
|
>
|
||||||
🔄 Regenerate Token
|
@Icon("refresh", "h-4 w-4")
|
||||||
|
Regenerate Token
|
||||||
</button>
|
</button>
|
||||||
<p class="text-xs mt-1" style="color: var(--text-secondary);">⚠️ Old token will immediately stop working</p>
|
<p class="text-xs mt-1" style="color: var(--status-warning);">Old token will immediately stop working</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- Pending Registrations -->
|
|
||||||
if len(pendingRegistrations) > 0 {
|
if len(pendingRegistrations) > 0 {
|
||||||
<div id="pending-section" class="mt-12">
|
<div id="pending-section" class="mt-12">
|
||||||
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Pending Device Registrations</h2>
|
<h2 class="text-lg font-bold tracking-tight mb-4" style="color: var(--text-primary)">Pending Device Registrations</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
for _, reg := range pendingRegistrations {
|
for _, reg := range pendingRegistrations {
|
||||||
<div class="card p-4 rounded-lg border flex items-center justify-between" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="card p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold" style="color: var(--text-primary)">{ reg.DeviceName }</h3>
|
<h3 class="font-semibold" style="color: var(--text-primary)">{ reg.DeviceName }</h3>
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">
|
<p class="text-sm" style="color: var(--text-secondary)">
|
||||||
@@ -175,10 +171,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<button @click={ "approveDevice('" + reg.RegistrationID + "')" } class="btn-primary px-4 py-2 rounded-lg text-sm">
|
<button @click={ "approveDevice('" + reg.RegistrationID + "')" } class="btn btn-primary text-sm">
|
||||||
Approve
|
Approve
|
||||||
</button>
|
</button>
|
||||||
<button @click={ "rejectDevice('" + reg.RegistrationID + "')" } class="btn-danger px-4 py-2 rounded-lg text-sm">
|
<button @click={ "rejectDevice('" + reg.RegistrationID + "')" } class="btn btn-danger text-sm">
|
||||||
Reject
|
Reject
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,92 +183,134 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Sync Queue Section -->
|
|
||||||
<div id="sync-queue-section" class="mt-12 hidden">
|
<div id="sync-queue-section" class="mt-12 hidden">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Sync Queue</h2>
|
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">Sync Queue</h2>
|
||||||
<button @click="clearSyncQueue()" class="btn-secondary px-4 py-2 rounded-lg">
|
<button @click="clearSyncQueue()" class="btn btn-secondary">Clear Queue</button>
|
||||||
Clear Queue
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="sync-queue" class="space-y-3"></div>
|
<div id="sync-queue" class="space-y-3"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Add Device Modal -->
|
<div id="add-device-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" x-data="{ selectedType: '' }" style="background-color: var(--surface-overlay);">
|
||||||
<div id="add-device-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
||||||
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add New Device</h2>
|
||||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add New Device</h2>
|
<button @click="hideAddDeviceModal()" class="icon-btn" aria-label="Close">
|
||||||
<button @click="hideAddDeviceModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
@Icon("close", "h-5 w-5")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Type</label>
|
||||||
|
<select
|
||||||
|
id="device-type"
|
||||||
|
x-model="selectedType"
|
||||||
|
required
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
<option value="">Select device type...</option>
|
||||||
|
<option value="koreader">KOReader (Kindle, Kobo, PocketBook)</option>
|
||||||
|
<option value="kobo">Kobo E-Reader</option>
|
||||||
|
<!-- <option value="web">Web Browser</option> -->
|
||||||
|
<!-- <option value="mobile">Mobile App</option> -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div x-show="selectedType === 'koreader'" x-cloak class="space-y-4">
|
||||||
|
<div class="rounded-lg p-4 space-y-4" style="background-color: var(--surface-secondary);">
|
||||||
|
<h3 class="text-sm font-semibold flex items-center gap-2" style="color: var(--text-primary)">
|
||||||
|
@Icon("book-open", "h-4 w-4")
|
||||||
|
KOReader Plugin Setup
|
||||||
|
</h3>
|
||||||
|
<ol class="space-y-3 text-sm" style="color: var(--text-secondary)">
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold" style="background-color: var(--accent-muted); color: var(--accent);">1</span>
|
||||||
|
<span>Clone the <a href="https://git.linuxhg.com/Bookhoard/bookhoard.koplugin" class="underline" style="color: var(--accent);">Bookhoard plugin</a> to your KOReader <code class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary);">plugins/</code> directory</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold" style="background-color: var(--accent-muted); color: var(--accent);">2</span>
|
||||||
|
<span>Open KOReader, tap the <strong>wrench icon</strong> at the top</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold" style="background-color: var(--accent-muted); color: var(--accent);">3</span>
|
||||||
|
<span>Find and tap <strong>Bookhoard sync</strong></span>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold" style="background-color: var(--accent-muted); color: var(--accent);">4</span>
|
||||||
|
<span>Tap <strong>Server URL</strong>, enter your server address, then tap <strong>OK</strong>:</span>
|
||||||
|
</li>
|
||||||
|
<li class="ml-8">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="text-xs px-2 py-1 rounded font-mono flex-1" style="background-color: var(--bg-primary); color: var(--text-primary);">{ baseURL }</code>
|
||||||
|
<button
|
||||||
|
@click={ "copyToClipboard('" + baseURL + "', 'Server URL')" }
|
||||||
|
class="btn btn-secondary text-xs px-3"
|
||||||
|
>
|
||||||
|
@Icon("copy", "h-4 w-4")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold" style="background-color: var(--accent-muted); color: var(--accent);">5</span>
|
||||||
|
<span>Return to this page and refresh — you'll see a <strong>pending registration</strong>. Click <strong>Approve</strong> to connect the device.</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<div class="rounded-md p-3 text-xs flex items-start gap-2" style="background-color: var(--accent-muted); color: var(--text-secondary);">
|
||||||
|
@Icon("info", "h-4 w-4 flex-shrink-0 mt-0.5")
|
||||||
|
<span>Once approved, reading progress sync and OPDS catalog access are set up automatically.</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="button" @click="hideAddDeviceModal()" class="btn btn-primary">Got it</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-show="selectedType === 'kobo'" x-cloak>
|
||||||
<form id="add-device-form" @submit="handleAddDevice($event)">
|
<form id="add-device-form" @submit="handleAddDevice($event)">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Name</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="device-name"
|
id="device-name"
|
||||||
required
|
required
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
placeholder="My Kobo"
|
||||||
placeholder="My Kindle Paperwhite"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Type</label>
|
|
||||||
<select
|
|
||||||
id="device-type"
|
|
||||||
required
|
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
>
|
|
||||||
<option value="">Select device type...</option>
|
|
||||||
<option value="koreader">KOReader (Kindle, Kobo, PocketBook)</option>
|
|
||||||
<option value="kobo">Kobo E-Reader</option>
|
|
||||||
<option value="web">Web Browser</option>
|
|
||||||
<option value="mobile">Mobile App</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Identifier</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Identifier</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="device-identifier"
|
id="device-identifier"
|
||||||
required
|
required
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
placeholder="Hardware ID or Serial Number"
|
placeholder="Hardware ID or Serial Number"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Enter your device's unique identifier</p>
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Enter your device's unique identifier</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" @click="hideAddDeviceModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
<button type="button" @click="hideAddDeviceModal()" class="btn btn-secondary">Cancel</button>
|
||||||
Cancel
|
<button type="submit" class="btn btn-primary">Register Device</button>
|
||||||
</button>
|
|
||||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
|
|
||||||
Register Device
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Device Settings Modal -->
|
</div>
|
||||||
<div id="device-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
<div id="device-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||||
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Device Settings</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Device Settings</h2>
|
||||||
<button @click="hideDeviceSettingsModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
<button @click="hideDeviceSettingsModal()" class="icon-btn" aria-label="Close">
|
||||||
|
@Icon("close", "h-5 w-5")
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="device-settings-form" @submit="handleSaveDeviceSettings($event)">
|
<form id="device-settings-form" @submit="handleSaveDeviceSettings($event)">
|
||||||
<input type="hidden" id="settings-device-id"/>
|
<input type="hidden" id="settings-device-id"/>
|
||||||
<input type="hidden" id="settings-device-type"/>
|
<input type="hidden" id="settings-device-type"/>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Name</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="settings-device-name"
|
id="settings-device-name"
|
||||||
required
|
required
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@@ -300,11 +338,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
<p class="text-xs mt-1 ml-8" style="color: var(--text-secondary)">Automatically sync changes</p>
|
<p class="text-xs mt-1 ml-8" style="color: var(--text-secondary)">Automatically sync changes</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Sync Frequency</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Sync Frequency</label>
|
||||||
<select
|
<select
|
||||||
id="settings-sync-frequency"
|
id="settings-sync-frequency"
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
>
|
>
|
||||||
<option value="1">Every 1 minute</option>
|
<option value="1">Every 1 minute</option>
|
||||||
<option value="5" selected>Every 5 minutes</option>
|
<option value="5" selected>Every 5 minutes</option>
|
||||||
@@ -314,14 +351,13 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t pt-6 mb-6" style="border-color: var(--border);">
|
<div class="border-t pt-6 mb-6" style="border-color: var(--border);">
|
||||||
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Collection View Settings</h3>
|
<h3 class="text-base font-semibold mb-1" style="color: var(--text-primary)">Collection View Settings</h3>
|
||||||
<p class="text-sm mb-4" style="color: var(--text-secondary)">Configure how collections are displayed on this device</p>
|
<p class="text-sm mb-4" style="color: var(--text-secondary)">Configure how collections are displayed on this device</p>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Default View Mode</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Default View Mode</label>
|
||||||
<select
|
<select
|
||||||
id="settings-view-mode"
|
id="settings-view-mode"
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
>
|
>
|
||||||
<option value="grid">Grid View</option>
|
<option value="grid">Grid View</option>
|
||||||
<option value="list">List View</option>
|
<option value="list">List View</option>
|
||||||
@@ -329,11 +365,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Sort Collections By</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Sort Collections By</label>
|
||||||
<select
|
<select
|
||||||
id="settings-sort-order"
|
id="settings-sort-order"
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
>
|
>
|
||||||
<option value="name">Collection Name</option>
|
<option value="name">Collection Name</option>
|
||||||
<option value="created">Date Created</option>
|
<option value="created">Date Created</option>
|
||||||
@@ -342,11 +377,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Items Per Page</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Items Per Page</label>
|
||||||
<select
|
<select
|
||||||
id="settings-items-per-page"
|
id="settings-items-per-page"
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
>
|
>
|
||||||
<option value="12">12 items</option>
|
<option value="12">12 items</option>
|
||||||
<option value="24" selected>24 items</option>
|
<option value="24" selected>24 items</option>
|
||||||
@@ -378,81 +412,73 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
<p class="text-xs mt-1 ml-8" style="color: var(--text-secondary)">Display progress indicators for books</p>
|
<p class="text-xs mt-1 ml-8" style="color: var(--text-secondary)">Display progress indicators for books</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3 flex-wrap">
|
||||||
<button type="button" @click="hideDeviceSettingsModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
<button type="button" @click="hideDeviceSettingsModal()" class="btn btn-secondary">Cancel</button>
|
||||||
Cancel
|
<button type="button" @click="handleRevokeDevice()" class="btn btn-danger">Revoke Device</button>
|
||||||
</button>
|
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||||
<button type="button" @click="handleRevokeDevice()" class="btn-danger px-4 py-2 rounded-lg">
|
|
||||||
Revoke Device
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
|
|
||||||
Save Settings
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Device Shelf Mappings Modal -->
|
<div id="shelf-mappings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||||
<div id="shelf-mappings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
<div class="card p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto" style="box-shadow: var(--shadow-pop);">
|
||||||
<div class="card rounded-lg p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Collection to Shelf Mappings</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Collection to Shelf Mappings</h2>
|
||||||
<button @click="hideShelfMappingsModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
<button @click="hideShelfMappingsModal()" class="icon-btn" aria-label="Close">
|
||||||
|
@Icon("close", "h-5 w-5")
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="mappings-device-id"/>
|
<input type="hidden" id="mappings-device-id"/>
|
||||||
<div id="shelf-mappings-container" class="space-y-4 mb-6">
|
<div id="shelf-mappings-container" class="space-y-4 mb-6">
|
||||||
<p style="color: var(--text-secondary)">Loading mappings...</p>
|
<p style="color: var(--text-secondary)">Loading mappings...</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" @click="hideShelfMappingsModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
<button type="button" @click="hideShelfMappingsModal()" class="btn btn-secondary">Close</button>
|
||||||
Close
|
<button type="button" @click="showAddMappingModal()" class="btn btn-primary">
|
||||||
</button>
|
@Icon("plus", "h-4 w-4")
|
||||||
<button type="button" @click="showAddMappingModal()" class="btn-primary px-4 py-2 rounded-lg">
|
Add Mapping
|
||||||
➕ Add Mapping
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Add/Edit Shelf Mapping Modal -->
|
<div id="add-mapping-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||||
<div id="add-mapping-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
||||||
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Collection Mapping</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Collection Mapping</h2>
|
||||||
<button @click="hideAddMappingModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
<button @click="hideAddMappingModal()" class="icon-btn" aria-label="Close">
|
||||||
|
@Icon("close", "h-5 w-5")
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="mapping-form" @submit="handleSaveMapping($event)">
|
<form id="mapping-form" @submit="handleSaveMapping($event)">
|
||||||
<input type="hidden" id="mapping-device-id"/>
|
<input type="hidden" id="mapping-device-id"/>
|
||||||
<input type="hidden" id="mapping-id"/>
|
<input type="hidden" id="mapping-id"/>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Collection</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Collection</label>
|
||||||
<select
|
<select
|
||||||
id="mapping-collection"
|
id="mapping-collection"
|
||||||
required
|
required
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
>
|
>
|
||||||
<option value="">Select collection...</option>
|
<option value="">Select collection...</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Shelf Name</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Shelf Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="mapping-shelf-name"
|
id="mapping-shelf-name"
|
||||||
required
|
required
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
placeholder="e.g., Sci-Fi"
|
placeholder="e.g., Sci-Fi"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Name of the shelf on this device</p>
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Name of the shelf on this device</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Sync Direction</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Sync Direction</label>
|
||||||
<select
|
<select
|
||||||
id="mapping-sync-direction"
|
id="mapping-sync-direction"
|
||||||
required
|
required
|
||||||
class="w-full px-4 py-2 border rounded-lg"
|
class="input"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
||||||
>
|
>
|
||||||
<option value="bidirectional">Bidirectional (sync both ways)</option>
|
<option value="bidirectional">Bidirectional (sync both ways)</option>
|
||||||
<option value="book_to_device">Bookhoard → Device</option>
|
<option value="book_to_device">Bookhoard → Device</option>
|
||||||
@@ -461,12 +487,8 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" @click="hideAddMappingModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
<button type="button" @click="hideAddMappingModal()" class="btn btn-secondary">Cancel</button>
|
||||||
Cancel
|
<button type="submit" class="btn btn-primary">Save Mapping</button>
|
||||||
</button>
|
|
||||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
|
|
||||||
Save Mapping
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+203
-53
File diff suppressed because one or more lines are too long
+85
-83
@@ -10,103 +10,105 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>{ doc.Title } - Bookhoard Documentation</title>
|
<title>{ doc.Title } - Bookhoard Documentation</title>
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
<link rel="stylesheet" href="/static/highlight-dark.min.css"/>
|
<link rel="stylesheet" href="/static/highlight-dark.min.css"/>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="docs" x-init="initializeSearch(); highlightCurrentPage(); initializeCodeCopyButtons()" class={ "theme-" + user.Theme + " page-docs bg-background-primary text-text-primary font-sans antialiased" }>
|
<body x-data="docs" x-init="initializeSearch(); highlightCurrentPage(); initializeCodeCopyButtons()" class={ "theme-" + user.Theme + " page-docs font-sans antialiased" }>
|
||||||
@Header(user, currentPath)
|
@Header(user, currentPath)
|
||||||
<!-- Mobile Menu Button -->
|
|
||||||
<button
|
<button
|
||||||
class="lg:hidden fixed top-4 left-4 z-50 bg-background-secondary border border-border rounded p-2 text-text-primary hover:bg-background-secondary/80"
|
class="lg:hidden icon-btn fixed top-[4.75rem] left-4 z-50 bg-surface-raised"
|
||||||
onclick="toggleSidebar()"
|
onclick="toggleSidebar()"
|
||||||
aria-label="Toggle menu"
|
aria-label="Toggle documentation menu"
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
@Icon("menu", "h-5 w-5")
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<!-- Search Results Overlay -->
|
<div id="docs-overlay" class="hidden fixed inset-0 z-40 lg:hidden" style="background: var(--surface-overlay);" onclick="toggleSidebar()"></div>
|
||||||
<div id="search-results" class="hidden fixed inset-0 bg-black/95 z-50 overflow-y-auto p-8 transition-opacity duration-200 ease-in-out"></div>
|
<div id="search-results" class="hidden fixed inset-0 z-50 overflow-y-auto p-8" style="background: var(--surface-overlay);"></div>
|
||||||
<!-- Sidebar -->
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
<div class="sidebar fixed left-0 top-0 bottom-0 w-72 bg-background-secondary border-r border-border overflow-y-auto mt-16 lg:translate-x-0 transition-transform duration-300 ease-in-out">
|
<div class="flex gap-8 py-8">
|
||||||
<!-- Search -->
|
<aside id="docs-sidebar" class="sidebar fixed lg:sticky top-16 lg:top-20 bottom-0 lg:bottom-auto left-0 lg:left-auto z-40 lg:z-10 w-64 shrink-0 lg:h-[calc(100vh-6rem)] overflow-y-auto -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out bg-surface-raised border-r border-line lg:border-r-0">
|
||||||
<div class="p-4 border-b border-border">
|
<div class="p-4 border-b border-line">
|
||||||
<input
|
<div class="relative">
|
||||||
type="text"
|
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
|
||||||
class="search-input w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent text-sm"
|
@Icon("search", "h-4 w-4")
|
||||||
placeholder="Search documentation..."
|
</span>
|
||||||
id="docs-search"
|
<input
|
||||||
/>
|
type="text"
|
||||||
</div>
|
class="search-input input pl-9"
|
||||||
<!-- Navigation Sections -->
|
placeholder="Search documentation…"
|
||||||
for _, section := range nav.Sections {
|
id="docs-search"
|
||||||
<div class="nav-section mb-6">
|
/>
|
||||||
<div
|
</div>
|
||||||
class="nav-section-title font-semibold text-xs uppercase tracking-wider text-text-secondary px-4 py-3 cursor-pointer select-none flex items-center justify-between"
|
|
||||||
@click="toggleSection($el)"
|
|
||||||
>
|
|
||||||
<span>{ section.Title }</span>
|
|
||||||
<svg class="w-4 h-4 transform transition-transform section-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
if section.Collapsed {
|
for _, section := range nav.Sections {
|
||||||
<div class="nav-items hidden" data-collapsed="true">
|
<div class="nav-section border-b border-line">
|
||||||
for _, item := range section.Items {
|
<div
|
||||||
<a href={ item.URL } class="nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline">
|
class="nav-section-title flex items-center justify-between px-4 py-3 cursor-pointer select-none text-xs uppercase tracking-wider font-semibold transition-colors hover:bg-surface-hover"
|
||||||
<span class="mr-2">{ item.Icon }</span>{ item.Title }
|
style="color: var(--text-secondary);"
|
||||||
</a>
|
@click="toggleSection($el)"
|
||||||
}
|
>
|
||||||
</div>
|
<span>{ section.Title }</span>
|
||||||
} else {
|
@Icon("chevron-down", "h-4 w-4 section-arrow transition-transform")
|
||||||
<div class="nav-items" data-collapsed="false">
|
</div>
|
||||||
for _, item := range section.Items {
|
if section.Collapsed {
|
||||||
<a href={ item.URL } class="nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline">
|
<div class="nav-items hidden" data-collapsed="true">
|
||||||
<span class="mr-2">{ item.Icon }</span>{ item.Title }
|
for _, item := range section.Items {
|
||||||
</a>
|
<a href={ item.URL } class="nav-item flex items-center gap-2 py-2 pl-8 pr-4 text-sm no-underline transition-colors hover:bg-surface-hover" style="color: var(--text-secondary);">
|
||||||
|
<span class="text-xs" style="color: var(--accent);">{ item.Icon }</span>
|
||||||
|
<span>{ item.Title }</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="nav-items" data-collapsed="false">
|
||||||
|
for _, item := range section.Items {
|
||||||
|
<a href={ item.URL } class="nav-item flex items-center gap-2 py-2 pl-8 pr-4 text-sm no-underline transition-colors hover:bg-surface-hover" style="color: var(--text-secondary);">
|
||||||
|
<span class="text-xs" style="color: var(--accent);">{ item.Icon }</span>
|
||||||
|
<span>{ item.Title }</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</aside>
|
||||||
}
|
<main class="main-content flex-1 min-w-0 max-w-4xl">
|
||||||
</div>
|
if len(doc.Breadcrumb) > 0 {
|
||||||
<!-- Main Content -->
|
<nav class="breadcrumb flex flex-wrap items-center gap-1.5 text-sm mb-8" aria-label="Breadcrumb" style="color: var(--text-secondary);">
|
||||||
<main class="main-content lg:ml-72 p-8 max-w-4xl mx-auto">
|
for i, crumb := range doc.Breadcrumb {
|
||||||
<!-- Breadcrumb -->
|
if i > 0 {
|
||||||
if len(doc.Breadcrumb) > 0 {
|
@Icon("chevron-right", "h-4 w-4")
|
||||||
<nav class="breadcrumb flex gap-2 text-sm text-text-secondary mb-8" aria-label="Breadcrumb">
|
}
|
||||||
for i, crumb := range doc.Breadcrumb {
|
<a href={ crumb.URL } class="no-underline transition-colors hover:underline" style="color: var(--accent);">{ crumb.Title }</a>
|
||||||
if i > 0 {
|
}
|
||||||
<span class="text-text-secondary/50">›</span>
|
</nav>
|
||||||
}
|
|
||||||
<a href={ crumb.URL } class="text-accent hover:underline no-underline">{ crumb.Title }</a>
|
|
||||||
}
|
}
|
||||||
</nav>
|
<h1 class="text-4xl font-bold mb-8 tracking-tight" style="color: var(--text-primary);">{ doc.Title }</h1>
|
||||||
}
|
if len(doc.TOC) > 0 {
|
||||||
<!-- Title -->
|
<details class="toc card p-4 mb-8">
|
||||||
<h1 class="text-4xl font-bold mb-8 text-text-primary">{ doc.Title }</h1>
|
<summary class="cursor-pointer list-none [&::-webkit-details-marker]:hidden flex items-center gap-2 font-semibold transition-colors hover:bg-surface-hover -m-4 p-4 rounded-2xl" style="color: var(--text-primary);">
|
||||||
<!-- Table of Contents -->
|
<span>On this page</span>
|
||||||
if len(doc.TOC) > 0 {
|
@Icon("chevron-down", "h-4 w-4 ml-auto")
|
||||||
<details class="toc bg-background-secondary p-4 rounded-lg mb-8 border border-border">
|
</summary>
|
||||||
<summary class="cursor-pointer hover:opacity-80 transition-opacity">
|
<div class="mt-3">
|
||||||
<strong class="text-text-primary font-semibold">On this page</strong>
|
for _, item := range doc.TOC {
|
||||||
<span class="ml-2 text-text-secondary text-xs">▼</span>
|
<a
|
||||||
</summary>
|
href={ "#" + item.Anchor }
|
||||||
for _, item := range doc.TOC {
|
class="toc-item block py-1 text-sm no-underline transition-colors hover:text-brand"
|
||||||
<a
|
style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);" }
|
||||||
href={ "#" + item.Anchor }
|
>
|
||||||
class="toc-item block py-1 text-text-secondary hover:text-accent text-sm no-underline"
|
{ item.Title }
|
||||||
style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) }
|
</a>
|
||||||
>
|
}
|
||||||
{ item.Title }
|
</div>
|
||||||
</a>
|
</details>
|
||||||
}
|
}
|
||||||
</details>
|
<div class="prose prose-invert max-w-none">
|
||||||
}
|
@UnsafeHTML(doc.Content).ToComponent()
|
||||||
<!-- Content -->
|
</div>
|
||||||
<div class="prose prose-invert max-w-none">
|
</main>
|
||||||
@UnsafeHTML(doc.Content).ToComponent()
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-49
@@ -44,11 +44,11 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard Documentation</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"stylesheet\" href=\"/static/highlight-dark.min.css\"></head>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard Documentation</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"><link rel=\"stylesheet\" href=\"/static/highlight-dark.min.css\"></head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme + " page-docs bg-background-primary text-text-primary font-sans antialiased"}
|
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme + " page-docs font-sans antialiased"}
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
@@ -74,267 +74,299 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<!-- Mobile Menu Button --><button class=\"lg:hidden fixed top-4 left-4 z-50 bg-background-secondary border border-border rounded p-2 text-text-primary hover:bg-background-secondary/80\" onclick=\"toggleSidebar()\" aria-label=\"Toggle menu\"><svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 6h16M4 12h16M4 18h16\"></path></svg></button><!-- Search Results Overlay --><div id=\"search-results\" class=\"hidden fixed inset-0 bg-black/95 z-50 overflow-y-auto p-8 transition-opacity duration-200 ease-in-out\"></div><!-- Sidebar --><div class=\"sidebar fixed left-0 top-0 bottom-0 w-72 bg-background-secondary border-r border-border overflow-y-auto mt-16 lg:translate-x-0 transition-transform duration-300 ease-in-out\"><!-- Search --><div class=\"p-4 border-b border-border\"><input type=\"text\" class=\"search-input w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent text-sm\" placeholder=\"Search documentation...\" id=\"docs-search\"></div><!-- Navigation Sections -->")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<button class=\"lg:hidden icon-btn fixed top-[4.75rem] left-4 z-50 bg-surface-raised\" onclick=\"toggleSidebar()\" aria-label=\"Toggle documentation menu\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("menu", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</button><div id=\"docs-overlay\" class=\"hidden fixed inset-0 z-40 lg:hidden\" style=\"background: var(--surface-overlay);\" onclick=\"toggleSidebar()\"></div><div id=\"search-results\" class=\"hidden fixed inset-0 z-50 overflow-y-auto p-8\" style=\"background: var(--surface-overlay);\"></div><div class=\"mx-auto max-w-7xl px-4 sm:px-6 lg:px-8\"><div class=\"flex gap-8 py-8\"><aside id=\"docs-sidebar\" class=\"sidebar fixed lg:sticky top-16 lg:top-20 bottom-0 lg:bottom-auto left-0 lg:left-auto z-40 lg:z-10 w-64 shrink-0 lg:h-[calc(100vh-6rem)] overflow-y-auto -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out bg-surface-raised border-r border-line lg:border-r-0\"><div class=\"p-4 border-b border-line\"><div class=\"relative\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span> <input type=\"text\" class=\"search-input input pl-9\" placeholder=\"Search documentation…\" id=\"docs-search\"></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, section := range nav.Sections {
|
for _, section := range nav.Sections {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"nav-section mb-6\"><div class=\"nav-section-title font-semibold text-xs uppercase tracking-wider text-text-secondary px-4 py-3 cursor-pointer select-none flex items-center justify-between\" @click=\"toggleSection($el)\"><span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"nav-section border-b border-line\"><div class=\"nav-section-title flex items-center justify-between px-4 py-3 cursor-pointer select-none text-xs uppercase tracking-wider font-semibold transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" @click=\"toggleSection($el)\"><span>")
|
||||||
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(section.Title)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 47, Col: 28}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 50, Col: 30}
|
||||||
}
|
}
|
||||||
_, 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, "</span> <svg class=\"w-4 h-4 transform transition-transform section-arrow\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"></path></svg></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("chevron-down", "h-4 w-4 section-arrow transition-transform").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if section.Collapsed {
|
if section.Collapsed {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"nav-items hidden\" data-collapsed=\"true\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"nav-items hidden\" data-collapsed=\"true\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, item := range section.Items {
|
for _, item := range section.Items {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 templ.SafeURL
|
var templ_7745c5c3_Var6 templ.SafeURL
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 55, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 29}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline\"><span class=\"mr-2\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"nav-item flex items-center gap-2 py-2 pl-8 pr-4 text-sm no-underline transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\"><span class=\"text-xs\" style=\"color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 57, Col: 75}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span> <span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 61}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 58, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span></a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"nav-items\" data-collapsed=\"false\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"nav-items\" data-collapsed=\"false\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, item := range section.Items {
|
for _, item := range section.Items {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 templ.SafeURL
|
var templ_7745c5c3_Var9 templ.SafeURL
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 63, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 65, Col: 29}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline\"><span class=\"mr-2\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"nav-item flex items-center gap-2 py-2 pl-8 pr-4 text-sm no-underline transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\"><span class=\"text-xs\" style=\"color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 64, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 66, Col: 75}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span> <span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 64, Col: 61}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 67, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span></a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</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, 20, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</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, 21, "</div><!-- Main Content --><main class=\"main-content lg:ml-72 p-8 max-w-4xl mx-auto\"><!-- Breadcrumb -->")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</aside><main class=\"main-content flex-1 min-w-0 max-w-4xl\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(doc.Breadcrumb) > 0 {
|
if len(doc.Breadcrumb) > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<nav class=\"breadcrumb flex gap-2 text-sm text-text-secondary mb-8\" aria-label=\"Breadcrumb\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<nav class=\"breadcrumb flex flex-wrap items-center gap-1.5 text-sm mb-8\" aria-label=\"Breadcrumb\" style=\"color: var(--text-secondary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for i, crumb := range doc.Breadcrumb {
|
for i, crumb := range doc.Breadcrumb {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"text-text-secondary/50\">›</span>")
|
templ_7745c5c3_Err = Icon("chevron-right", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " <a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " <a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 templ.SafeURL
|
var templ_7745c5c3_Var12 templ.SafeURL
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 81, Col: 26}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" class=\"text-accent hover:underline no-underline\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"no-underline transition-colors hover:underline\" style=\"color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 81, Col: 91}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 129}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</nav>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</nav>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<!-- Title --><h1 class=\"text-4xl font-bold mb-8 text-text-primary\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<h1 class=\"text-4xl font-bold mb-8 tracking-tight\" style=\"color: var(--text-primary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 86, Col: 69}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 86, Col: 104}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</h1><!-- Table of Contents -->")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</h1>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(doc.TOC) > 0 {
|
if len(doc.TOC) > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<details class=\"toc bg-background-secondary p-4 rounded-lg mb-8 border border-border\"><summary class=\"cursor-pointer hover:opacity-80 transition-opacity\"><strong class=\"text-text-primary font-semibold\">On this page</strong> <span class=\"ml-2 text-text-secondary text-xs\">▼</span></summary> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<details class=\"toc card p-4 mb-8\"><summary class=\"cursor-pointer list-none [&::-webkit-details-marker]:hidden flex items-center gap-2 font-semibold transition-colors hover:bg-surface-hover -m-4 p-4 rounded-2xl\" style=\"color: var(--text-primary);\"><span>On this page</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("chevron-down", "h-4 w-4 ml-auto").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</summary><div class=\"mt-3\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, item := range doc.TOC {
|
for _, item := range doc.TOC {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var15 templ.SafeURL
|
var templ_7745c5c3_Var15 templ.SafeURL
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 96, Col: 32}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 96, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" class=\"toc-item block py-1 text-text-secondary hover:text-accent text-sm no-underline\" style=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" class=\"toc-item block py-1 text-sm no-underline transition-colors hover:text-brand\" style=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level))
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 98, Col: 66}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 98, Col: 105}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 100, Col: 20}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 100, Col: 23}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</details>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div></details>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<!-- Content --><div class=\"prose prose-invert max-w-none\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"prose prose-invert max-w-none\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -342,7 +374,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div></main></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div></main></div></div></body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ templ ErrorPage(message string, errorType string) {
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Error - Bookhoard</title>
|
<title>Error - Bookhoard</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -29,14 +29,14 @@ func ErrorPage(message string, errorType string) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Error - Bookhoard</title><style>\n\t\t\t\t* {\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tbox-sizing: border-box;\n\t\t\t\t}\n\t\t\t\tbody {\n\t\t\t\t\tfont-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n\t\t\t\t\tbackground: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);\n\t\t\t\t\tmin-height: 100vh;\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\talign-items: center;\n\t\t\t\t\tjustify-content: center;\n\t\t\t\t\tcolor: #eee;\n\t\t\t\t}\n\t\t\t\t.error-container {\n\t\t\t\t\ttext-align: center;\n\t\t\t\t\tpadding: 2rem;\n\t\t\t\t\tmax-width: 500px;\n\t\t\t\t}\n\t\t\t\t.error-icon {\n\t\t\t\t\tfont-size: 4rem;\n\t\t\t\t\tmargin-bottom: 1rem;\n\t\t\t\t}\n\t\t\t\t.error-title {\n\t\t\t\t\tfont-size: 1.5rem;\n\t\t\t\t\tfont-weight: 600;\n\t\t\t\t\tmargin-bottom: 1rem;\n\t\t\t\t\tcolor: #fff;\n\t\t\t\t}\n\t\t\t\t.error-message {\n\t\t\t\t\tfont-size: 1rem;\n\t\t\t\t\tcolor: #ccc;\n\t\t\t\t\tmargin-bottom: 2rem;\n\t\t\t\t\tline-height: 1.6;\n\t\t\t\t}\n\t\t\t\t.error-actions {\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\tgap: 1rem;\n\t\t\t\t\tjustify-content: center;\n\t\t\t\t\tflex-wrap: wrap;\n\t\t\t\t}\n\t\t\t\t.btn {\n\t\t\t\t\tpadding: 0.75rem 1.5rem;\n\t\t\t\t\tborder-radius: 0.5rem;\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\tfont-weight: 500;\n\t\t\t\t\ttransition: all 0.2s;\n\t\t\t\t\tborder: none;\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t\tfont-size: 0.95rem;\n\t\t\t\t}\n\t\t\t\t.btn-primary {\n\t\t\t\t\tbackground: #7aa2f7;\n\t\t\t\t\tcolor: #1a1b26;\n\t\t\t\t}\n\t\t\t\t.btn-primary:hover {\n\t\t\t\t\tbackground: #89b4fa;\n\t\t\t\t}\n\t\t\t\t.btn-secondary {\n\t\t\t\t\tbackground: transparent;\n\t\t\t\t\tcolor: #7aa2f7;\n\t\t\t\t\tborder: 1px solid #7aa2f7;\n\t\t\t\t}\n\t\t\t\t.btn-secondary:hover {\n\t\t\t\t\tbackground: rgba(122, 162, 247, 0.1);\n\t\t\t\t}\n\t\t\t</style></head><body><div class=\"error-container\"><div class=\"error-icon\">⚠️</div><h1 class=\"error-title\">Something went wrong</h1><p class=\"error-message\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Error - Bookhoard</title><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"><style>\n\t\t\t\t* {\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tbox-sizing: border-box;\n\t\t\t\t}\n\t\t\t\tbody {\n\t\t\t\t\tfont-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n\t\t\t\t\tbackground: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);\n\t\t\t\t\tmin-height: 100vh;\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\talign-items: center;\n\t\t\t\t\tjustify-content: center;\n\t\t\t\t\tcolor: #eee;\n\t\t\t\t}\n\t\t\t\t.error-container {\n\t\t\t\t\ttext-align: center;\n\t\t\t\t\tpadding: 2rem;\n\t\t\t\t\tmax-width: 500px;\n\t\t\t\t}\n\t\t\t\t.error-icon {\n\t\t\t\t\tfont-size: 4rem;\n\t\t\t\t\tmargin-bottom: 1rem;\n\t\t\t\t}\n\t\t\t\t.error-title {\n\t\t\t\t\tfont-size: 1.5rem;\n\t\t\t\t\tfont-weight: 600;\n\t\t\t\t\tmargin-bottom: 1rem;\n\t\t\t\t\tcolor: #fff;\n\t\t\t\t}\n\t\t\t\t.error-message {\n\t\t\t\t\tfont-size: 1rem;\n\t\t\t\t\tcolor: #ccc;\n\t\t\t\t\tmargin-bottom: 2rem;\n\t\t\t\t\tline-height: 1.6;\n\t\t\t\t}\n\t\t\t\t.error-actions {\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\tgap: 1rem;\n\t\t\t\t\tjustify-content: center;\n\t\t\t\t\tflex-wrap: wrap;\n\t\t\t\t}\n\t\t\t\t.btn {\n\t\t\t\t\tpadding: 0.75rem 1.5rem;\n\t\t\t\t\tborder-radius: 0.5rem;\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\tfont-weight: 500;\n\t\t\t\t\ttransition: all 0.2s;\n\t\t\t\t\tborder: none;\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t\tfont-size: 0.95rem;\n\t\t\t\t}\n\t\t\t\t.btn-primary {\n\t\t\t\t\tbackground: #7aa2f7;\n\t\t\t\t\tcolor: #1a1b26;\n\t\t\t\t}\n\t\t\t\t.btn-primary:hover {\n\t\t\t\t\tbackground: #89b4fa;\n\t\t\t\t}\n\t\t\t\t.btn-secondary {\n\t\t\t\t\tbackground: transparent;\n\t\t\t\t\tcolor: #7aa2f7;\n\t\t\t\t\tborder: 1px solid #7aa2f7;\n\t\t\t\t}\n\t\t\t\t.btn-secondary:hover {\n\t\t\t\t\tbackground: rgba(122, 162, 247, 0.1);\n\t\t\t\t}\n\t\t\t</style></head><body><div class=\"error-container\"><div class=\"error-icon\">⚠️</div><h1 class=\"error-title\">Something went wrong</h1><p class=\"error-message\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(message)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(message)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 83, Col: 37}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 84, Col: 37}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -49,7 +49,7 @@ func ErrorPage(message string, errorType string) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(errorType)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(errorType)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 89, Col: 75}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 90, Col: 75}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
// FilterItem renders a single saved filter item for the list
|
|
||||||
templ FilterItem(id string, name string) {
|
templ FilterItem(id string, name string) {
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between p-2 rounded hover:opacity-80"
|
class="flex items-center justify-between gap-2 p-2 rounded-lg hover:bg-surface-hover"
|
||||||
style="background-color: var(--bg-primary);"
|
|
||||||
data-filter-id={ id }
|
data-filter-id={ id }
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
data-action="load-filter"
|
data-action="load-filter"
|
||||||
@click="loadFilter($event)"
|
@click="loadFilter($event)"
|
||||||
class="flex-1 text-left px-2 py-1 rounded"
|
class="flex-1 text-left px-2 py-1 text-sm rounded-lg hover:bg-surface-hover"
|
||||||
style="color: var(--text-primary);"
|
style="color: var(--text-primary);"
|
||||||
>
|
>
|
||||||
{ name }
|
{ name }
|
||||||
@@ -18,11 +16,12 @@ templ FilterItem(id string, name string) {
|
|||||||
<button
|
<button
|
||||||
data-action="delete-filter"
|
data-action="delete-filter"
|
||||||
@click="deleteFilter($event)"
|
@click="deleteFilter($event)"
|
||||||
class="p-1 hover:opacity-70 rounded"
|
class="icon-btn shrink-0"
|
||||||
style="color: var(--text-secondary);"
|
style="color: var(--text-secondary);"
|
||||||
title="Delete filter"
|
title="Delete filter"
|
||||||
|
aria-label="Delete filter"
|
||||||
>
|
>
|
||||||
🗑
|
@Icon("trash", "h-4 w-4")
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user