Merge remote-tracking branch 'origin/main'
# Conflicts: # templates/book_detail.templ # templates/collection_rules.templ # templates/conflicts.templ # templates/header.templ # templates/progress.templ
This commit is contained in:
@@ -10,6 +10,21 @@ JWT_SECRET=your-secure-jwt-secret-key-here
|
||||
# Generate with: openssl rand -hex 16
|
||||
DBPASS=your-secure-database-password-here
|
||||
|
||||
# Networking: change a port if it conflicts on your host
|
||||
# Postgres port, host + container (e.g. another local DB already uses 5432)
|
||||
# DB_PORT=15432
|
||||
# App web port, host + container
|
||||
# SERVER_PORT=8765
|
||||
|
||||
# Deployment
|
||||
# External URL for device sync (must include protocol; defaults to http://localhost:8765)
|
||||
# Examples: https://bookhoard.example.com | http://192.168.1.10:8765
|
||||
# BASE_URL=https://bookhoard.example.com
|
||||
# Mark session cookies Secure — set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
|
||||
# COOKIE_SECURE=true
|
||||
# Pin or rollback a specific published image version (defaults to "latest")
|
||||
# IMAGE_TAG=1.0.0
|
||||
|
||||
# Optional: Override Defaults (defaults are set in docker-compose.yml)
|
||||
# Test Mode: WARNING - Only set to true for integration testing
|
||||
# TEST_MODE=true
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
name: Release
|
||||
|
||||
# Overrides the default run name (the tagged commit's message) so the Actions
|
||||
# runs list shows "Release v0.3.0" instead.
|
||||
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:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to release (e.g. v0.3.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
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:
|
||||
- name: Checkout
|
||||
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
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.linuxhg.com
|
||||
username: ${{ gitea.actor }}
|
||||
# PAT stored as a repo Actions secret (auto GITHUB_TOKEN lacks package scope in Gitea)
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
# Publishes both the exact version (e.g. v0.2.0) and the movable "latest" tag.
|
||||
# Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml;
|
||||
# pin or roll back by setting IMAGE_TAG in .env.
|
||||
tags: |
|
||||
git.linuxhg.com/bookhoard/bookhoard:${{ env.TAG }}
|
||||
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)
|
||||
# Ignore if .env doesn't exist yet
|
||||
@@ -11,6 +11,10 @@ endif
|
||||
# Override with: CONTAINER_RUNTIME=podman make rebuild-app
|
||||
CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null)
|
||||
|
||||
# Dev compose stack: base prod file merged with the dev override (local build + tests).
|
||||
# Prod deploy does NOT use this — it runs plain `docker compose` against the base file only.
|
||||
COMPOSE := $(CONTAINER_RUNTIME) compose -f docker-compose.yml -f docker-compose.dev.yml
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@@ -40,6 +44,9 @@ help:
|
||||
@echo "Verification:"
|
||||
@echo " make verify-guidelines - Run comprehensive 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)
|
||||
test:
|
||||
@@ -48,9 +55,9 @@ test:
|
||||
# Run integration tests in containers (matches production environment)
|
||||
test-integration:
|
||||
@echo "Building test containers..."
|
||||
$(CONTAINER_RUNTIME) compose --profile tests build
|
||||
$(COMPOSE) --profile tests build
|
||||
@echo "Starting application containers..."
|
||||
$(CONTAINER_RUNTIME) compose up -d db app
|
||||
$(COMPOSE) up -d db app
|
||||
@echo "Waiting for services to be healthy..."
|
||||
@until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \
|
||||
echo " Database not ready yet..."; \
|
||||
@@ -64,7 +71,7 @@ test-integration:
|
||||
echo " ✓ Application is ready"
|
||||
@echo ""
|
||||
@echo "Running integration tests in container..."
|
||||
$(CONTAINER_RUNTIME) compose --profile tests run --rm tests
|
||||
$(COMPOSE) --profile tests run --rm tests
|
||||
@echo ""
|
||||
@echo "✅ Integration tests completed!"
|
||||
@echo "📝 Containers are still running. Use 'make logs' to view logs or 'make clean' to stop."
|
||||
@@ -75,67 +82,67 @@ test-all: test test-integration
|
||||
# Rebuild app container only (preserve DB, with cache)
|
||||
rebuild-app:
|
||||
@echo "Rebuilding app container (database stays running)..."
|
||||
$(CONTAINER_RUNTIME) compose up --build --force-recreate -d app
|
||||
$(COMPOSE) up --build --force-recreate -d app
|
||||
@echo "✓ App container rebuilt and restarted"
|
||||
|
||||
# Rebuild app container only (preserve DB, no cache)
|
||||
rebuild-app-force:
|
||||
@echo "Force rebuilding app container (database stays running, no cache)..."
|
||||
$(CONTAINER_RUNTIME) compose build --no-cache app
|
||||
$(CONTAINER_RUNTIME) compose up --force-recreate -d app
|
||||
$(COMPOSE) build --no-cache app
|
||||
$(COMPOSE) up --force-recreate -d app
|
||||
@echo "✓ App container rebuilt and restarted"
|
||||
|
||||
# Rebuild all containers (preserve DB, with cache)
|
||||
rebuild:
|
||||
@echo "Rebuilding all containers (database preserved)..."
|
||||
$(CONTAINER_RUNTIME) compose up --build --force-recreate -d
|
||||
$(COMPOSE) up --build --force-recreate -d
|
||||
@echo "✓ All containers rebuilt and restarted"
|
||||
|
||||
# Rebuild all containers (preserve DB, no cache)
|
||||
rebuild-force:
|
||||
@echo "Force rebuilding all containers (database preserved, no cache)..."
|
||||
$(CONTAINER_RUNTIME) compose build --no-cache
|
||||
$(CONTAINER_RUNTIME) compose up --force-recreate -d
|
||||
$(COMPOSE) build --no-cache
|
||||
$(COMPOSE) up --force-recreate -d
|
||||
@echo "✓ All containers rebuilt and restarted"
|
||||
|
||||
# Rebuild all containers (remove DB, no cache)
|
||||
rebuild-force-db:
|
||||
@echo "Force rebuilding all containers (database will be DELETED, no cache)..."
|
||||
$(CONTAINER_RUNTIME) compose down -v
|
||||
$(CONTAINER_RUNTIME) compose build --no-cache
|
||||
$(CONTAINER_RUNTIME) compose up --force-recreate -d
|
||||
$(COMPOSE) down -v
|
||||
$(COMPOSE) build --no-cache
|
||||
$(COMPOSE) up --force-recreate -d
|
||||
@echo "✓ All containers rebuilt and restarted"
|
||||
|
||||
# Stop and remove containers
|
||||
clean:
|
||||
$(CONTAINER_RUNTIME) compose down -v
|
||||
$(COMPOSE) down -v
|
||||
|
||||
# Quick start (if already built)
|
||||
up:
|
||||
$(CONTAINER_RUNTIME) compose up -d
|
||||
$(COMPOSE) up -d
|
||||
|
||||
# Stop all containers (alias for clean)
|
||||
down:
|
||||
$(CONTAINER_RUNTIME) compose down
|
||||
$(COMPOSE) down
|
||||
|
||||
# Restart app container (preserves database)
|
||||
restart:
|
||||
@echo "Restarting app container (database stays running)..."
|
||||
$(CONTAINER_RUNTIME) compose restart app
|
||||
$(COMPOSE) restart app
|
||||
@echo "✓ App container restarted"
|
||||
|
||||
# Show container status
|
||||
ps:
|
||||
$(CONTAINER_RUNTIME) compose ps
|
||||
$(COMPOSE) ps
|
||||
|
||||
# Show container logs
|
||||
logs:
|
||||
$(CONTAINER_RUNTIME) compose logs -f
|
||||
$(COMPOSE) logs -f
|
||||
|
||||
# Start containers with test mode enabled for manual testing
|
||||
test-env-up:
|
||||
@echo "Starting containers with test mode enabled..."
|
||||
TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 $(CONTAINER_RUNTIME) compose up --build --force-recreate -d
|
||||
TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 $(COMPOSE) up --build --force-recreate -d
|
||||
@echo "Waiting for services to be ready..."
|
||||
@until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do sleep 1; done
|
||||
@until $(CONTAINER_RUNTIME) exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do sleep 1; done
|
||||
@@ -144,7 +151,7 @@ test-env-up:
|
||||
|
||||
# Stop test environment
|
||||
test-env-down:
|
||||
$(CONTAINER_RUNTIME) compose down -v
|
||||
$(COMPOSE) down -v
|
||||
|
||||
# Verify project guidelines compliance
|
||||
verify-guidelines:
|
||||
@@ -154,3 +161,27 @@ verify-guidelines:
|
||||
verify-quick:
|
||||
@echo "Running quick project guidelines verification..."
|
||||
@./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."
|
||||
|
||||
@@ -25,7 +25,7 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/yourusername/bookhoard.git
|
||||
git clone https://git.linuxhg.com/Bookhoard/bookhoard.git
|
||||
cd bookhoard
|
||||
|
||||
# 2. Set up environment
|
||||
@@ -35,8 +35,10 @@ cp .env.example .env
|
||||
# DBPASS: openssl rand -hex 16
|
||||
# Edit .env with your generated values
|
||||
|
||||
# 3. Start the server
|
||||
podman-compose up --build -d # or: docker-compose up --build -d
|
||||
# 3. Pull images and start the server
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
# Optionally pin a specific version: set IMAGE_TAG in .env (defaults to "latest")
|
||||
|
||||
# 4. Open your browser
|
||||
open http://localhost:8765
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
#!/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
|
||||
|
||||
**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
|
||||
|
||||
**Response:** Updated rating object
|
||||
|
||||
@@ -83,9 +83,10 @@ docs:
|
||||
- **Update Highlight**: PUT /api/highlights/:id - Update highlight
|
||||
- **Delete Highlight**: DELETE /api/highlights/:id - Remove highlight
|
||||
Ratings (All Users)
|
||||
- **Get Rating**: GET /api/ratings/:media_id - User's rating (returns 0 if unrated)
|
||||
- **Create/Update Rating**: POST /api/ratings - Rate media item (1-5 stars, half-star precision)
|
||||
- **Delete Rating**: DELETE /api/ratings/:media_id - Remove rating
|
||||
- **Get Rating**: GET /api/media-items/:id/rating - User's rating (returns null if unrated)
|
||||
- **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.
|
||||
- **Update Rating**: PUT /api/media-items/:id/rating - Update rating (upsert)
|
||||
- **Delete Rating**: DELETE /api/media-items/:id/rating - Remove rating
|
||||
Collections (All Users)
|
||||
- **List Collections**: GET /api/collections - Get user's collections
|
||||
- **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"
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
_ "time/tzdata"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/labstack/echo/v5"
|
||||
@@ -48,6 +50,35 @@ func main() {
|
||||
}
|
||||
log.Println("✅ Database schema initialized and verified, starting server...")
|
||||
|
||||
// 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: 5 failed attempts = 15 minute lockout
|
||||
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
|
||||
|
||||
@@ -63,9 +94,13 @@ func main() {
|
||||
connManager := sync.NewConnectionManager()
|
||||
|
||||
progressService := sync.NewProgressService(queries, connManager)
|
||||
annotationService := sync.NewAnnotationService(queries, connManager)
|
||||
tombstonePurgerCancel := annotationService.StartTombstonePurger()
|
||||
defer tombstonePurgerCancel()
|
||||
|
||||
queueProcessor := sync.NewSyncQueueProcessor(queries)
|
||||
queueProcessor.SetProgressService(progressService)
|
||||
queueProcessor.SetAnnotationService(annotationService)
|
||||
|
||||
// Create library service
|
||||
libraryService := services.NewLibraryService(queries)
|
||||
@@ -79,6 +114,7 @@ func main() {
|
||||
|
||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||
koreaderHandler.SetProgressService(progressService)
|
||||
koreaderHandler.SetAnnotationService(annotationService)
|
||||
koreaderHandler.SetLibraryService(libraryService)
|
||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||
@@ -95,6 +131,7 @@ func main() {
|
||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||
mediaHandler.SetProgressService(progressService)
|
||||
mediaHandler.SetAnnotationService(annotationService)
|
||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||
|
||||
@@ -162,6 +199,7 @@ func main() {
|
||||
ConnManager: connManager,
|
||||
QueueProcessor: queueProcessor,
|
||||
ProgressService: progressService,
|
||||
AnnotationService: annotationService,
|
||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||
JobsHandler: jobsHandler,
|
||||
LoginTracker: loginAttemptTracker,
|
||||
|
||||
@@ -90,7 +90,7 @@ func TestCalibreLibraryScan(t *testing.T) {
|
||||
// Create scanner and configure it
|
||||
scanner := services.NewMediaScanner(setup.DB)
|
||||
scanner.SetAdminID(adminID)
|
||||
err = scanner.SetFolders([]string{tmpDir})
|
||||
err = scanner.SetFolders([]string{tmpDir}, false)
|
||||
require.NoError(t, err, "Failed to set scanner folders")
|
||||
|
||||
// Scan library
|
||||
@@ -167,7 +167,7 @@ func TestCalibreLibraryScanWithoutSidecar(t *testing.T) {
|
||||
// Create scanner and configure it
|
||||
scanner := services.NewMediaScanner(setup.DB)
|
||||
scanner.SetAdminID(adminID)
|
||||
err = scanner.SetFolders([]string{tmpDir})
|
||||
err = scanner.SetFolders([]string{tmpDir}, false)
|
||||
require.NoError(t, err, "Failed to set scanner folders")
|
||||
|
||||
// Scan library
|
||||
|
||||
@@ -84,6 +84,7 @@ type TestServerSetup struct {
|
||||
ConnManager *wsync.ConnectionManager
|
||||
QueueProcessor *wsync.SyncQueueProcessor
|
||||
ProgressService *wsync.ProgressService
|
||||
AnnotationService *wsync.AnnotationService
|
||||
CleanupCancel context.CancelFunc
|
||||
QueueCtx context.Context
|
||||
QueueCancel context.CancelFunc
|
||||
@@ -455,6 +456,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
cleanupCancel := connManager.StartCleanupTask()
|
||||
|
||||
progressService := wsync.NewProgressService(queries, connManager)
|
||||
annotationService := wsync.NewAnnotationService(queries, connManager)
|
||||
|
||||
queueProcessor := wsync.NewSyncQueueProcessor(queries)
|
||||
queueProcessor.SetProgressService(progressService)
|
||||
@@ -463,6 +465,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
|
||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||
koreaderHandler.SetProgressService(progressService)
|
||||
koreaderHandler.SetAnnotationService(annotationService)
|
||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||
@@ -482,6 +485,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||
mediaHandler.SetProgressService(progressService)
|
||||
mediaHandler.SetAnnotationService(annotationService)
|
||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||
|
||||
// Create conversion service for OPDS
|
||||
@@ -537,6 +541,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
ConnManager: connManager,
|
||||
QueueProcessor: queueProcessor,
|
||||
ProgressService: progressService,
|
||||
AnnotationService: annotationService,
|
||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||
LoginTracker: loginAttemptTracker,
|
||||
}
|
||||
|
||||
@@ -49,8 +49,7 @@ CREATE TABLE IF NOT EXISTS system_settings (
|
||||
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
|
||||
('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'),
|
||||
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'),
|
||||
('default_timezone', 'UTC', 'System default timezone'),
|
||||
('setup_complete', 'false', 'Whether the initial setup wizard has been completed')
|
||||
('default_timezone', 'UTC', 'System default timezone')
|
||||
ON CONFLICT (setting_key) DO NOTHING;
|
||||
|
||||
-- Create refresh_tokens table
|
||||
@@ -1150,12 +1149,14 @@ CREATE TABLE IF NOT EXISTS system_config (
|
||||
updated_by UUID REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- Pre-seeded values
|
||||
INSERT INTO system_config (key, value) VALUES
|
||||
('base_url', 'https://bookhoard.example.com'),
|
||||
('opds_base_url', 'https://bookhoard.example.com/opds'),
|
||||
('api_base_url', 'https://bookhoard.example.com/api')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
-- One-time cleanup: clear the old placeholder seed so the startup logic
|
||||
-- can re-seed from the BASE_URL env var (or the setup wizard can set it).
|
||||
UPDATE system_config SET value = ''
|
||||
WHERE key = 'base_url' AND value = 'https://bookhoard.example.com';
|
||||
UPDATE system_config SET value = ''
|
||||
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 TABLE IF NOT EXISTS opds_tokens (
|
||||
@@ -1313,3 +1314,48 @@ CREATE TABLE IF NOT EXISTS media_bookmarks (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id);
|
||||
|
||||
-- ============================================
|
||||
-- ANNOTATION SYNC MIGRATIONS
|
||||
-- Adds dedup_key, LWW timestamps, soft-delete,
|
||||
-- and device_sync_data to annotation tables.
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
||||
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
||||
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
||||
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS note_text TEXT;
|
||||
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
||||
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
||||
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
||||
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS device_sync_data JSONB;
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS percentage_location FLOAT;
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS epubcfi_location TEXT;
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS chapter_reference INTEGER;
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup
|
||||
ON media_highlights (user_id, media_item_id, dedup_key)
|
||||
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_notes_dedup
|
||||
ON media_notes (user_id, media_item_id, dedup_key)
|
||||
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_bookmarks_dedup
|
||||
ON media_bookmarks (user_id, media_item_id, dedup_key)
|
||||
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_media_highlights_deleted_at ON media_highlights(deleted_at) WHERE deleted = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_media_notes_deleted_at ON media_notes(deleted_at) WHERE deleted = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_deleted_at ON media_bookmarks(deleted_at) WHERE deleted = TRUE;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Development override — merged on top of docker-compose.yml (the base/prod file).
|
||||
# Activated by all `make` targets via:
|
||||
# COMPOSE = <runtime> compose -f docker-compose.yml -f docker-compose.dev.yml
|
||||
#
|
||||
# What this adds over prod:
|
||||
# - Local image BUILDING (prod pulls a prebuilt image from the registry)
|
||||
# - The integration-tests service (dev only, gated behind the "tests" profile)
|
||||
# Everything else (env vars, volumes, ports, healthchecks) is inherited from the base file.
|
||||
services:
|
||||
# Build the app image locally instead of pulling from the registry
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
|
||||
# Integration Tests - runs against containerized app and db (dev only)
|
||||
tests:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
target: test-runner
|
||||
container_name: bookhoard_tests
|
||||
environment:
|
||||
# Database Configuration
|
||||
DATABASE_HOST: db
|
||||
DATABASE_PORT: ${DB_PORT:-5432}
|
||||
DATABASE_USER: postgres
|
||||
DATABASE_PASSWORD: ${DBPASS}
|
||||
DATABASE_NAME: bookhoard
|
||||
COOKIE_SECURE: false
|
||||
|
||||
# Application Configuration
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SERVER_PORT: ${SERVER_PORT:-8765}
|
||||
|
||||
# Test Configuration
|
||||
TEST_MODE: "true"
|
||||
RATE_LIMIT_ENABLED: "false"
|
||||
REQUESTS_PER_MINUTE: 1000
|
||||
|
||||
# Conversion Service Configuration
|
||||
BOOKHOARD_CONVERSION_CACHE_DIR: /app/cache/kepub
|
||||
BOOKHOARD_CONVERSION_TOOL: /usr/bin/kepubify
|
||||
BOOKHOARD_CONVERSION_CACHE_TTL: 24h
|
||||
|
||||
# Test upload path (inside container)
|
||||
TEST_UPLOAD_PATH: /app/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
app:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- bookhoard_conversion_cache:/app/cache/kepub
|
||||
profiles:
|
||||
- tests
|
||||
+14
-55
@@ -1,5 +1,3 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
db:
|
||||
@@ -9,14 +7,15 @@ services:
|
||||
POSTGRES_DB: bookhoard
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ${DBPASS}
|
||||
COOKIE_SECURE: false # make true in production with HTTPS
|
||||
# PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT)
|
||||
PGPORT: ${DB_PORT:-5432}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./database/schema:/docker-entrypoint-initdb.d
|
||||
# Make other volumes as needed
|
||||
- ./uploads:/app/uploads
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 30s
|
||||
@@ -27,27 +26,30 @@ services:
|
||||
- .env
|
||||
|
||||
# Bookhoard Application
|
||||
# In production this image is pulled from the Gitea container registry.
|
||||
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
|
||||
container_name: bookhoard
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Database Configuration
|
||||
DATABASE_HOST: db
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_PORT: ${DB_PORT:-5432}
|
||||
DATABASE_USER: postgres
|
||||
DATABASE_PASSWORD: ${DBPASS}
|
||||
DATABASE_NAME: bookhoard
|
||||
|
||||
# Application Configuration
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SERVER_PORT: 8765
|
||||
SERVER_PORT: ${SERVER_PORT:-8765}
|
||||
# IMPORTANT: Device sync requires full URL with protocol
|
||||
# Local: http://localhost:8765
|
||||
# Local network: http://192.168.1.X:8765
|
||||
# Domain: https://bookhoard.example.com
|
||||
BASE_URL: http://localhost:${SERVER_PORT}
|
||||
BASE_URL: ${BASE_URL:-http://localhost:8765}
|
||||
# Mark session cookies Secure; set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||
|
||||
# Rate Limiting Configuration
|
||||
TEST_MODE: ${TEST_MODE:-false}
|
||||
@@ -62,7 +64,7 @@ services:
|
||||
# System timezone (fallback for server-side time operations)
|
||||
TZ: ${TZ:-UTC}
|
||||
ports:
|
||||
- "8765:8765"
|
||||
- "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -70,55 +72,12 @@ services:
|
||||
- ./uploads:/app/uploads
|
||||
- bookhoard_conversion_cache:/app/cache/kepub
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8765/health || exit 1"]
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Integration Tests - runs against containerized app and db
|
||||
tests:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
target: test-runner
|
||||
container_name: bookhoard_tests
|
||||
environment:
|
||||
# Database Configuration
|
||||
DATABASE_HOST: db
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_USER: postgres
|
||||
DATABASE_PASSWORD: ${DBPASS}
|
||||
DATABASE_NAME: bookhoard
|
||||
COOKIE_SECURE: false
|
||||
|
||||
# Application Configuration
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SERVER_PORT: 8765
|
||||
|
||||
# Test Configuration
|
||||
TEST_MODE: "true"
|
||||
RATE_LIMIT_ENABLED: "false"
|
||||
REQUESTS_PER_MINUTE: 1000
|
||||
|
||||
# Conversion Service Configuration
|
||||
BOOKHOARD_CONVERSION_CACHE_DIR: /app/cache/kepub
|
||||
BOOKHOARD_CONVERSION_TOOL: /usr/bin/kepubify
|
||||
BOOKHOARD_CONVERSION_CACHE_TTL: 24h
|
||||
|
||||
# Test upload path (inside container)
|
||||
TEST_UPLOAD_PATH: /app/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
app:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- bookhoard_conversion_cache:/app/cache/kepub
|
||||
profiles:
|
||||
- tests
|
||||
|
||||
# Named Volumes
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -986,25 +986,39 @@ GET /opds/devices/{deviceId}/catalog?page={page}&per_page={per_page}
|
||||
- `page` (optional): Page number (default: 1)
|
||||
- `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):
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom"
|
||||
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>
|
||||
<title>Bookhoard Library</title>
|
||||
<updated>2026-02-01T12:00:00Z</updated>
|
||||
|
||||
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog"/>
|
||||
<link rel="search" href="http://localhost:8765/opds/devices/kobo-id/search"/>
|
||||
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/nav"/>
|
||||
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=2&per_page=50"/>
|
||||
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
||||
<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>
|
||||
<id>urn:uuid:bookhoard-uuid-123</id>
|
||||
<dc:title>The Hobbit</dc:title>
|
||||
<dc:creator>J.R.R. Tolkien</dc:creator>
|
||||
<title>The Hobbit</title>
|
||||
<author><name>J.R.R. Tolkien</name></author>
|
||||
<updated>2026-02-01T10:00:00Z</updated>
|
||||
|
||||
<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
|
||||
|
||||
```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
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ require (
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/text v0.36.0
|
||||
)
|
||||
|
||||
@@ -57,7 +58,6 @@ require (
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/xyproto/randomstring v1.2.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/sys v0.43.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)
|
||||
}
|
||||
|
||||
// GetBaseURL returns the base URL from system configuration database with fallback to config/env var
|
||||
func GetBaseURL(ctx context.Context, db interface{}) string {
|
||||
// Try to get from database first
|
||||
type SystemConfigQuerier interface {
|
||||
GetSystemConfig(ctx context.Context, key string) (SystemConfigRow, error)
|
||||
}
|
||||
// SystemConfigGetter returns the value for a system config key, or an error.
|
||||
type SystemConfigGetter func(ctx context.Context, key string) (string, error)
|
||||
|
||||
if querier, ok := db.(SystemConfigQuerier); ok {
|
||||
config, err := querier.GetSystemConfig(ctx, "base_url")
|
||||
if err == nil && config.Value != "" {
|
||||
return config.Value
|
||||
// GetBaseURL returns the base URL from system configuration database, or empty
|
||||
// string if not set. The getter abstraction avoids importing the database package.
|
||||
func GetBaseURL(ctx context.Context, getter SystemConfigGetter) string {
|
||||
val, err := getter(ctx, "base_url")
|
||||
if err == nil && val != "" {
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return empty string - caller should use their own fallback
|
||||
return ""
|
||||
}
|
||||
|
||||
// SystemConfigRow represents a system configuration row
|
||||
type SystemConfigRow struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
|
||||
package database
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
|
||||
package database
|
||||
|
||||
@@ -165,6 +165,15 @@ type MediaBookmarks struct {
|
||||
Position pgtype.Text `db:"position" json:"position"`
|
||||
Notes pgtype.Text `db:"notes" json:"notes"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
||||
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
||||
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||
PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"`
|
||||
EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"`
|
||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
|
||||
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||
}
|
||||
|
||||
type MediaHighlights struct {
|
||||
@@ -189,6 +198,12 @@ type MediaHighlights struct {
|
||||
ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"`
|
||||
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
|
||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
||||
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
||||
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
||||
NoteText pgtype.Text `db:"note_text" json:"note_text"`
|
||||
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
|
||||
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||
}
|
||||
|
||||
type MediaItemFormats struct {
|
||||
@@ -304,6 +319,11 @@ type MediaNotes struct {
|
||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||
ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"`
|
||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
||||
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
||||
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
||||
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
|
||||
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||
}
|
||||
|
||||
type MediaRatings struct {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
|
||||
package database
|
||||
|
||||
@@ -30,9 +30,11 @@ type Querier interface {
|
||||
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
||||
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
||||
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
||||
CountAdmins(ctx context.Context) (int64, error)
|
||||
// Count unlinked books for a device
|
||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
|
||||
// COLLECTIONS QUERIES
|
||||
// Create collection
|
||||
CreateCollection(ctx context.Context, arg CreateCollectionParams) (Collections, error)
|
||||
@@ -54,8 +56,10 @@ type Querier interface {
|
||||
// Libraries queries
|
||||
CreateLibrary(ctx context.Context, arg CreateLibraryParams) (Libraries, error)
|
||||
CreateMediaBookmark(ctx context.Context, arg CreateMediaBookmarkParams) (MediaBookmarks, error)
|
||||
CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBookmarkFullParams) (MediaBookmarks, error)
|
||||
// Media Highlights queries
|
||||
CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error)
|
||||
CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error)
|
||||
// Media Items queries
|
||||
CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error)
|
||||
// MEDIA ITEM FORMATS QUERIES
|
||||
@@ -63,6 +67,7 @@ type Querier interface {
|
||||
CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error)
|
||||
// Media Notes queries
|
||||
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
|
||||
CreateMediaNoteFull(ctx context.Context, arg CreateMediaNoteFullParams) (MediaNotes, error)
|
||||
CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error)
|
||||
// OPDS TOKENS QUERIES
|
||||
// Create OPDS token
|
||||
@@ -125,6 +130,10 @@ type Querier interface {
|
||||
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
||||
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
||||
// ============================================
|
||||
// ANNOTATION SERVE QUERIES
|
||||
// ============================================
|
||||
GetActiveAnnotationsForBook(ctx context.Context, arg GetActiveAnnotationsForBookParams) ([]GetActiveAnnotationsForBookRow, error)
|
||||
// Get all system config
|
||||
GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error)
|
||||
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
||||
@@ -196,8 +205,17 @@ type Querier interface {
|
||||
// LIBRARY WITH TYPE INFO QUERIES
|
||||
// ============================================================================
|
||||
GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLibraryWithTypeRow, error)
|
||||
GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBookmarks, error)
|
||||
// ============================================
|
||||
// ANNOTATION SYNC QUERIES (bookmarks)
|
||||
// ============================================
|
||||
GetMediaBookmarkByDedupKey(ctx context.Context, arg GetMediaBookmarkByDedupKeyParams) (MediaBookmarks, error)
|
||||
GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksParams) ([]MediaBookmarks, error)
|
||||
GetMediaHighlight(ctx context.Context, id pgtype.UUID) (MediaHighlights, error)
|
||||
// ============================================
|
||||
// ANNOTATION SYNC QUERIES (highlights)
|
||||
// ============================================
|
||||
GetMediaHighlightByDedupKey(ctx context.Context, arg GetMediaHighlightByDedupKeyParams) (MediaHighlights, error)
|
||||
GetMediaHighlights(ctx context.Context, arg GetMediaHighlightsParams) ([]MediaHighlights, error)
|
||||
GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error)
|
||||
GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemByFilePathParams) (MediaItems, error)
|
||||
@@ -220,6 +238,10 @@ type Querier interface {
|
||||
// Get media item formats
|
||||
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
|
||||
GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error)
|
||||
// ============================================
|
||||
// ANNOTATION SYNC QUERIES (notes)
|
||||
// ============================================
|
||||
GetMediaNoteByDedupKey(ctx context.Context, arg GetMediaNoteByDedupKeyParams) (MediaNotes, error)
|
||||
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
|
||||
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
|
||||
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
|
||||
@@ -257,6 +279,7 @@ type Querier interface {
|
||||
// System Settings queries
|
||||
GetSystemSetting(ctx context.Context, settingKey string) (string, error)
|
||||
GetSystemTimezone(ctx context.Context) (string, error)
|
||||
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
|
||||
// Get universal progress for a book
|
||||
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
|
||||
// Get unlinked book by ContentId
|
||||
@@ -280,6 +303,7 @@ type Querier interface {
|
||||
// Get user reading history for analytics
|
||||
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, 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)
|
||||
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
|
||||
// Check if book is in collection
|
||||
@@ -303,6 +327,9 @@ type Querier interface {
|
||||
// List unresolved unlinked books with pagination
|
||||
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||
PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||
PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||
PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||
// Query media items by multiple identifiers with confidence scoring
|
||||
QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error)
|
||||
ReassignLibraries(ctx context.Context, arg ReassignLibrariesParams) error
|
||||
@@ -333,6 +360,12 @@ type Querier interface {
|
||||
// Set system config
|
||||
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
|
||||
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
|
||||
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
|
||||
TombstoneMediaBookmarkByID(ctx context.Context, id pgtype.UUID) error
|
||||
TombstoneMediaHighlightByDedupKey(ctx context.Context, arg TombstoneMediaHighlightByDedupKeyParams) error
|
||||
TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error
|
||||
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
|
||||
TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error
|
||||
// Update collection
|
||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
||||
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||
@@ -356,7 +389,9 @@ type Querier interface {
|
||||
UpdateKoboShelfCollection(ctx context.Context, arg UpdateKoboShelfCollectionParams) (KoboShelves, error)
|
||||
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
|
||||
UpdateMediaBookmark(ctx context.Context, arg UpdateMediaBookmarkParams) (MediaBookmarks, error)
|
||||
UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMediaBookmarkForSyncParams) (MediaBookmarks, error)
|
||||
UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighlightParams) (MediaHighlights, error)
|
||||
UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error)
|
||||
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error)
|
||||
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
|
||||
// Update media item format
|
||||
@@ -374,6 +409,7 @@ type Querier interface {
|
||||
UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMediaItemIdentifiersParams) (MediaItems, error)
|
||||
UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMediaItemKoboMetadataParams) (MediaItems, error)
|
||||
UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams) (MediaNotes, error)
|
||||
UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error)
|
||||
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
|
||||
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
||||
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
||||
|
||||
+1106
-15
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
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
|
||||
-- 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)
|
||||
@@ -346,6 +354,9 @@ WHERE role = 'admin'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: CountAdmins :one
|
||||
SELECT COUNT(*) FROM users WHERE role = 'admin';
|
||||
|
||||
-- name: ReassignLibraries :exec
|
||||
UPDATE libraries SET created_by_admin_id = $2, updated_at = NOW() WHERE created_by_admin_id = $1;
|
||||
|
||||
@@ -693,7 +704,7 @@ RETURNING *;
|
||||
SELECT * FROM media_notes WHERE id = $1;
|
||||
|
||||
-- name: GetMediaNotes :many
|
||||
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
|
||||
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
|
||||
|
||||
-- name: UpdateMediaNote :one
|
||||
UPDATE media_notes SET
|
||||
@@ -716,7 +727,7 @@ RETURNING *;
|
||||
SELECT * FROM media_highlights WHERE id = $1;
|
||||
|
||||
-- name: GetMediaHighlights :many
|
||||
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
|
||||
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
|
||||
|
||||
-- name: UpdateMediaHighlight :one
|
||||
UPDATE media_highlights SET
|
||||
@@ -732,6 +743,251 @@ RETURNING *;
|
||||
-- name: DeleteMediaHighlight :exec
|
||||
DELETE FROM media_highlights WHERE id = $1;
|
||||
|
||||
-- ============================================
|
||||
-- ANNOTATION SYNC QUERIES (highlights)
|
||||
-- ============================================
|
||||
|
||||
-- name: GetMediaHighlightByDedupKey :one
|
||||
SELECT * FROM media_highlights
|
||||
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
||||
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
||||
LIMIT 1;
|
||||
|
||||
-- name: CreateMediaHighlightFull :one
|
||||
INSERT INTO media_highlights (
|
||||
media_item_id, user_id, selection_text,
|
||||
start_position, end_position, color, note_text,
|
||||
percentage_start, percentage_end,
|
||||
epubcfi_start, epubcfi_end,
|
||||
chapter_reference,
|
||||
dedup_key, last_modified_at, last_modified_source,
|
||||
device_sync_data
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
|
||||
) RETURNING *;
|
||||
|
||||
-- name: UpdateMediaHighlightForSync :one
|
||||
UPDATE media_highlights SET
|
||||
selection_text = $2,
|
||||
start_position = $3,
|
||||
end_position = $4,
|
||||
color = $5,
|
||||
note_text = $6,
|
||||
percentage_start = $7,
|
||||
percentage_end = $8,
|
||||
epubcfi_start = $9,
|
||||
epubcfi_end = $10,
|
||||
chapter_reference = $11,
|
||||
last_modified_at = $12,
|
||||
last_modified_source = $13,
|
||||
device_sync_data = $14,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: TombstoneMediaHighlightByDedupKey :exec
|
||||
UPDATE media_highlights SET
|
||||
deleted = TRUE,
|
||||
deleted_at = NOW(),
|
||||
last_modified_at = NOW()
|
||||
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
|
||||
|
||||
-- name: TombstoneMediaHighlightByID :exec
|
||||
UPDATE media_highlights SET
|
||||
deleted = TRUE,
|
||||
deleted_at = NOW(),
|
||||
last_modified_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: PurgeExpiredHighlightTombstones :exec
|
||||
DELETE FROM media_highlights WHERE deleted = TRUE AND deleted_at < $1;
|
||||
|
||||
-- ============================================
|
||||
-- ANNOTATION SYNC QUERIES (notes)
|
||||
-- ============================================
|
||||
|
||||
-- name: GetMediaNoteByDedupKey :one
|
||||
SELECT * FROM media_notes
|
||||
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
||||
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
||||
LIMIT 1;
|
||||
|
||||
-- name: CreateMediaNoteFull :one
|
||||
INSERT INTO media_notes (
|
||||
media_item_id, user_id, content, position,
|
||||
percentage_location, character_start, character_end,
|
||||
epubcfi_location, chapter_reference, paragraph_reference,
|
||||
dedup_key, last_modified_at, last_modified_source,
|
||||
device_sync_data
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
|
||||
) RETURNING *;
|
||||
|
||||
-- name: UpdateMediaNoteForSync :one
|
||||
UPDATE media_notes SET
|
||||
content = $2,
|
||||
position = $3,
|
||||
percentage_location = $4,
|
||||
character_start = $5,
|
||||
character_end = $6,
|
||||
epubcfi_location = $7,
|
||||
chapter_reference = $8,
|
||||
paragraph_reference = $9,
|
||||
last_modified_at = $10,
|
||||
last_modified_source = $11,
|
||||
device_sync_data = $12,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: TombstoneMediaNoteByDedupKey :exec
|
||||
UPDATE media_notes SET
|
||||
deleted = TRUE,
|
||||
deleted_at = NOW(),
|
||||
last_modified_at = NOW()
|
||||
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
|
||||
|
||||
-- name: TombstoneMediaNoteByID :exec
|
||||
UPDATE media_notes SET
|
||||
deleted = TRUE,
|
||||
deleted_at = NOW(),
|
||||
last_modified_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: PurgeExpiredNoteTombstones :exec
|
||||
DELETE FROM media_notes WHERE deleted = TRUE AND deleted_at < $1;
|
||||
|
||||
-- ============================================
|
||||
-- ANNOTATION SYNC QUERIES (bookmarks)
|
||||
-- ============================================
|
||||
|
||||
-- name: GetMediaBookmarkByDedupKey :one
|
||||
SELECT * FROM media_bookmarks
|
||||
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
||||
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
||||
LIMIT 1;
|
||||
|
||||
-- name: CreateMediaBookmarkFull :one
|
||||
INSERT INTO media_bookmarks (
|
||||
media_item_id, user_id, page_number, chapter_number,
|
||||
cfi_position, title, position, notes,
|
||||
percentage_location, epubcfi_location, chapter_reference,
|
||||
dedup_key, last_modified_at, last_modified_source,
|
||||
device_sync_data
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
|
||||
) RETURNING *;
|
||||
|
||||
-- name: UpdateMediaBookmarkForSync :one
|
||||
UPDATE media_bookmarks SET
|
||||
page_number = $2,
|
||||
chapter_number = $3,
|
||||
cfi_position = $4,
|
||||
title = $5,
|
||||
position = $6,
|
||||
notes = $7,
|
||||
percentage_location = $8,
|
||||
epubcfi_location = $9,
|
||||
chapter_reference = $10,
|
||||
last_modified_at = $11,
|
||||
last_modified_source = $12,
|
||||
device_sync_data = $13,
|
||||
created_at = created_at
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: TombstoneMediaBookmarkByDedupKey :exec
|
||||
UPDATE media_bookmarks SET
|
||||
deleted = TRUE,
|
||||
deleted_at = NOW(),
|
||||
last_modified_at = NOW()
|
||||
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
|
||||
|
||||
-- name: TombstoneMediaBookmarkByID :exec
|
||||
UPDATE media_bookmarks SET
|
||||
deleted = TRUE,
|
||||
deleted_at = NOW(),
|
||||
last_modified_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: PurgeExpiredBookmarkTombstones :exec
|
||||
DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1;
|
||||
|
||||
-- ============================================
|
||||
-- ANNOTATION SERVE QUERIES
|
||||
-- ============================================
|
||||
|
||||
-- name: GetActiveAnnotationsForBook :many
|
||||
SELECT
|
||||
mh.id,
|
||||
mh.selection_text,
|
||||
mh.start_position,
|
||||
mh.end_position,
|
||||
mh.color,
|
||||
mh.created_at,
|
||||
mh.updated_at,
|
||||
'highlight' as annotation_type,
|
||||
mh.percentage_start,
|
||||
mh.percentage_end,
|
||||
mh.epubcfi_start,
|
||||
mh.epubcfi_end,
|
||||
mh.note_text,
|
||||
mh.dedup_key,
|
||||
mh.last_modified_at,
|
||||
mh.last_modified_source
|
||||
FROM media_highlights mh
|
||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = FALSE
|
||||
UNION ALL
|
||||
SELECT
|
||||
mn.id,
|
||||
mn.content,
|
||||
mn.position,
|
||||
NULL as end_position,
|
||||
NULL as color,
|
||||
mn.created_at,
|
||||
mn.updated_at,
|
||||
'note' as annotation_type,
|
||||
mn.percentage_location as percentage_start,
|
||||
NULL as percentage_end,
|
||||
mn.epubcfi_location as epubcfi_start,
|
||||
NULL as epubcfi_end,
|
||||
NULL as note_text,
|
||||
mn.dedup_key,
|
||||
mn.last_modified_at,
|
||||
mn.last_modified_source
|
||||
FROM media_notes mn
|
||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = FALSE
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: GetTombstonedAnnotationsForBook :many
|
||||
SELECT
|
||||
mh.id,
|
||||
mh.dedup_key,
|
||||
'highlight' as annotation_type,
|
||||
mh.device_sync_data,
|
||||
mh.deleted_at
|
||||
FROM media_highlights mh
|
||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3
|
||||
UNION ALL
|
||||
SELECT
|
||||
mn.id,
|
||||
mn.dedup_key,
|
||||
'note' as annotation_type,
|
||||
mn.device_sync_data,
|
||||
mn.deleted_at
|
||||
FROM media_notes mn
|
||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3
|
||||
UNION ALL
|
||||
SELECT
|
||||
mb.id,
|
||||
mb.dedup_key,
|
||||
'bookmark' as annotation_type,
|
||||
mb.device_sync_data,
|
||||
mb.deleted_at
|
||||
FROM media_bookmarks mb
|
||||
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3
|
||||
ORDER BY deleted_at DESC;
|
||||
|
||||
-- Refresh Tokens queries
|
||||
-- name: CreateRefreshToken :one
|
||||
INSERT INTO refresh_tokens (user_id, token, expires_at)
|
||||
@@ -1123,6 +1379,11 @@ INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *;
|
||||
|
||||
-- name: CreateAutoResolvedSyncConflict :one
|
||||
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_at)
|
||||
VALUES ($1, $2, $3, $4, 'auto_resolved', $5, NOW())
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetSyncConflict :one
|
||||
SELECT * FROM sync_conflicts WHERE id = $1;
|
||||
|
||||
@@ -1222,7 +1483,7 @@ SELECT
|
||||
mh.epubcfi_start,
|
||||
mh.epubcfi_end
|
||||
FROM media_highlights mh
|
||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2
|
||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND COALESCE(mh.deleted, FALSE) = FALSE
|
||||
UNION ALL
|
||||
SELECT
|
||||
mn.id,
|
||||
@@ -1238,7 +1499,7 @@ SELECT
|
||||
mn.epubcfi_location as epubcfi_start,
|
||||
NULL as epubcfi_end
|
||||
FROM media_notes mn
|
||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2
|
||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND COALESCE(mn.deleted, FALSE) = FALSE
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: UpdateDeviceSyncTimestamp :one
|
||||
@@ -2109,9 +2370,12 @@ RETURNING *;
|
||||
|
||||
-- name: GetMediaBookmarks :many
|
||||
SELECT * FROM media_bookmarks
|
||||
WHERE media_item_id = $1 AND user_id = $2
|
||||
WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: GetMediaBookmark :one
|
||||
SELECT * FROM media_bookmarks WHERE id = $1;
|
||||
|
||||
-- name: CreateMediaBookmark :one
|
||||
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
|
||||
+28
-17
@@ -6,6 +6,7 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/middleware"
|
||||
"bookhoard/internal/setupstatus"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -82,22 +83,22 @@ type UserProfile struct {
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
|
||||
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" validate:"omitempty"`
|
||||
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
|
||||
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
|
||||
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type AdminUpdateUserRequest struct {
|
||||
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
|
||||
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" validate:"omitempty"`
|
||||
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
|
||||
Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"`
|
||||
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
|
||||
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
|
||||
}
|
||||
|
||||
// Register handles POST /api/auth/register
|
||||
@@ -190,7 +191,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
||||
}
|
||||
|
||||
var userRole string
|
||||
if len(users) == 0 {
|
||||
if !adminExists {
|
||||
userRole = "admin"
|
||||
} else {
|
||||
userRole = req.Role
|
||||
@@ -232,6 +233,10 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// A new user may have changed the admin count (e.g. first user becomes
|
||||
// admin), so refresh the setup-status cache.
|
||||
setupstatus.Invalidate()
|
||||
|
||||
if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to create default collections</div>`)
|
||||
@@ -548,6 +553,9 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Role changes can affect the admin count, so refresh the setup-status cache.
|
||||
setupstatus.Invalidate()
|
||||
}
|
||||
|
||||
// Update username (if provided)
|
||||
@@ -785,9 +793,9 @@ func (h *AuthHandler) UpdatePassword(c *echo.Context) error {
|
||||
}
|
||||
|
||||
type PasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password,omitempty"`
|
||||
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
|
||||
ConfirmPassword string `json:"confirm_password" validate:"required"`
|
||||
CurrentPassword string `json:"current_password,omitempty" form:"current_password"`
|
||||
NewPassword string `json:"new_password" form:"new_password" validate:"required,passwordcomplex"`
|
||||
ConfirmPassword string `json:"confirm_password" form:"confirm_password" validate:"required"`
|
||||
}
|
||||
|
||||
var req PasswordRequest
|
||||
@@ -934,6 +942,9 @@ func (h *AuthHandler) DeleteUser(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Deletion may have changed the admin count, so refresh the setup-status cache.
|
||||
setupstatus.Invalidate()
|
||||
|
||||
// Create success message based on context
|
||||
var message string
|
||||
if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes {
|
||||
|
||||
@@ -73,6 +73,7 @@ type BookInfo struct {
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
HasConflict bool `json:"has_conflict"`
|
||||
}
|
||||
|
||||
type SectionData struct {
|
||||
|
||||
@@ -225,7 +225,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "access denied")
|
||||
}
|
||||
|
||||
if conflict.ResolutionStatus.String != "unresolved" {
|
||||
if conflict.ResolutionStatus.String == "user_resolved" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "conflict already resolved")
|
||||
}
|
||||
|
||||
@@ -258,6 +258,12 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" {
|
||||
if err := h.applyAnnotationResolution(conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil {
|
||||
appliedTo["annotations"] = true
|
||||
}
|
||||
}
|
||||
|
||||
resolutionData := map[string]interface{}{
|
||||
"winner": req.Winner,
|
||||
"applied_to": appliedTo,
|
||||
@@ -356,6 +362,143 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
dedupKey, _ := winnerData["dedup_key"].(string)
|
||||
if dedupKey == "" {
|
||||
return errors.New("missing dedup_key in winner data")
|
||||
}
|
||||
|
||||
switch conflictType {
|
||||
case "annotation_highlight":
|
||||
return h.applyHighlightResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
|
||||
case "annotation_bookmark":
|
||||
return h.applyBookmarkResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
|
||||
case "annotation_note":
|
||||
return h.applyNoteResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
|
||||
default:
|
||||
return errors.New("unknown annotation conflict type")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyHighlightResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
|
||||
existing, err := h.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
|
||||
MediaItemID: mediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := database.UpdateMediaHighlightForSyncParams{
|
||||
ID: existing.ID,
|
||||
SelectionText: existing.SelectionText,
|
||||
StartPosition: existing.StartPosition,
|
||||
EndPosition: existing.EndPosition,
|
||||
Color: existing.Color,
|
||||
NoteText: existing.NoteText,
|
||||
PercentageStart: existing.PercentageStart,
|
||||
PercentageEnd: existing.PercentageEnd,
|
||||
EpubcfiStart: existing.EpubcfiStart,
|
||||
EpubcfiEnd: existing.EpubcfiEnd,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
}
|
||||
|
||||
if v, ok := data["selection_text"].(string); ok {
|
||||
params.SelectionText = v
|
||||
}
|
||||
if v, ok := data["color"].(string); ok {
|
||||
params.Color = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
if v, ok := data["note_text"].(string); ok {
|
||||
params.NoteText = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
if v, ok := data["start_position"].(string); ok {
|
||||
params.StartPosition = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
if v, ok := data["end_position"].(string); ok {
|
||||
params.EndPosition = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
|
||||
_, err = h.db.UpdateMediaHighlightForSync(ctx, params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyBookmarkResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
|
||||
existing, err := h.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{
|
||||
MediaItemID: mediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := database.UpdateMediaBookmarkForSyncParams{
|
||||
ID: existing.ID,
|
||||
PageNumber: existing.PageNumber,
|
||||
ChapterNumber: existing.ChapterNumber,
|
||||
CfiPosition: existing.CfiPosition,
|
||||
Title: existing.Title,
|
||||
Position: existing.Position,
|
||||
Notes: existing.Notes,
|
||||
PercentageLocation: existing.PercentageLocation,
|
||||
EpubcfiLocation: existing.EpubcfiLocation,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
}
|
||||
|
||||
if v, ok := data["title"].(string); ok {
|
||||
params.Title = v
|
||||
}
|
||||
if v, ok := data["notes"].(string); ok {
|
||||
params.Notes = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
|
||||
_, err = h.db.UpdateMediaBookmarkForSync(ctx, params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyNoteResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
|
||||
existing, err := h.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{
|
||||
MediaItemID: mediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := database.UpdateMediaNoteForSyncParams{
|
||||
ID: existing.ID,
|
||||
Content: existing.Content,
|
||||
Position: existing.Position,
|
||||
PercentageLocation: existing.PercentageLocation,
|
||||
CharacterStart: existing.CharacterStart,
|
||||
CharacterEnd: existing.CharacterEnd,
|
||||
EpubcfiLocation: existing.EpubcfiLocation,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
ParagraphReference: existing.ParagraphReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
}
|
||||
|
||||
if v, ok := data["content"].(string); ok {
|
||||
params.Content = v
|
||||
}
|
||||
if v, ok := data["position"].(string); ok {
|
||||
params.Position = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
|
||||
_, err = h.db.UpdateMediaNoteForSync(ctx, params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) notifyDevicesOfResolution(mediaItemID pgtype.UUID, data map[string]interface{}) []string {
|
||||
devices, err := h.db.ListDevicesByType(context.Background(), "koreader")
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -64,6 +65,7 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error {
|
||||
}
|
||||
|
||||
sectionData := BuildSections(sections, libraryID)
|
||||
sectionData = MarkActiveConflictsSections(c.Request().Context(), h.db, user.ID, sectionData)
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
|
||||
}
|
||||
@@ -188,6 +190,59 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID string
|
||||
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 {
|
||||
if collectionID != "" {
|
||||
if libraryID != "" {
|
||||
|
||||
+170
-1
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
wsync "bookhoard/internal/sync"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -19,6 +20,7 @@ type KoboHandler struct {
|
||||
db *database.Queries
|
||||
connManager *wsync.ConnectionManager
|
||||
progressSvc *wsync.ProgressService
|
||||
annotationSvc *wsync.AnnotationService
|
||||
libraryService LibraryPathResolver
|
||||
}
|
||||
|
||||
@@ -30,6 +32,10 @@ func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) {
|
||||
h.progressSvc = svc
|
||||
}
|
||||
|
||||
func (h *KoboHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||
h.annotationSvc = svc
|
||||
}
|
||||
|
||||
func (h *KoboHandler) SetLibraryService(svc LibraryPathResolver) {
|
||||
h.libraryService = svc
|
||||
}
|
||||
@@ -248,6 +254,13 @@ type KoboSyncStatus struct {
|
||||
Status string `json:"Status"`
|
||||
MarkupsSynced int `json:"MarkupsSynced"`
|
||||
BookmarksSynced int `json:"BookmarksSynced"`
|
||||
DeletedAnnotations []KoboDeletedAnnotation `json:"DeletedAnnotations,omitempty"`
|
||||
}
|
||||
|
||||
type KoboDeletedAnnotation struct {
|
||||
ContentId string `json:"ContentId"`
|
||||
BookmarkId string `json:"BookmarkId"`
|
||||
Type string `json:"Type"`
|
||||
}
|
||||
|
||||
type KoboServerSyncData struct {
|
||||
@@ -311,7 +324,7 @@ func (h *KoboHandler) Initialization(c *echo.Context) error {
|
||||
}
|
||||
|
||||
bookmarkCount := 0
|
||||
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
||||
annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
@@ -403,6 +416,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
markupsSynced := 0
|
||||
bookmarksSynced := 0
|
||||
unlinkedBooks := 0
|
||||
processedBooks := make(map[pgtype.UUID]string)
|
||||
|
||||
for _, readingSync := range req.ReadingSync {
|
||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
|
||||
@@ -412,6 +426,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
}
|
||||
|
||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||
processedBooks[pgMediaUUID] = readingSync.ContentId
|
||||
percentage := readingSync.PercentRead / 100.0
|
||||
|
||||
// Kobo only sends a percentage. For fixed-layout & comic formats the page
|
||||
@@ -465,10 +480,32 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
}
|
||||
|
||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||
processedBooks[pgMediaUUID] = bookmarkSync.ContentId
|
||||
|
||||
switch bookmarkSync.BookmarkType {
|
||||
case "annotation":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: bookmarkSync.BookmarkId,
|
||||
EndPosition: bookmarkSync.BookmarkId,
|
||||
Color: "#ffff00",
|
||||
NoteText: bookmarkSync.BookmarkTitle,
|
||||
Source: "kobo",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSynced++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
@@ -479,8 +516,28 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
})
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
case "bookmark":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Title: bookmarkSync.BookmarkText,
|
||||
Position: bookmarkSync.BookmarkId,
|
||||
ChapterNumber: int32(bookmarkSync.Chapter),
|
||||
Source: "kobo",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSynced++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
@@ -489,6 +546,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
})
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
case "last-read-place":
|
||||
if bookmarkSync.BookmarkId != "" {
|
||||
var epubcfi string
|
||||
@@ -564,6 +622,32 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
BookmarksSynced: bookmarksSynced,
|
||||
}
|
||||
|
||||
if h.annotationSvc != nil && len(processedBooks) > 0 {
|
||||
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true}
|
||||
for mediaItemID, contentId := range processedBooks {
|
||||
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
DeletedAt: cutoff,
|
||||
})
|
||||
for _, ts := range tombstones {
|
||||
var dd map[string]interface{}
|
||||
if len(ts.DeviceSyncData) > 0 {
|
||||
json.Unmarshal(ts.DeviceSyncData, &dd)
|
||||
}
|
||||
bookmarkID, _ := dd["bookmark_id"].(string)
|
||||
if bookmarkID == "" {
|
||||
continue
|
||||
}
|
||||
response.DeletedAnnotations = append(response.DeletedAnnotations, KoboDeletedAnnotation{
|
||||
ContentId: contentId,
|
||||
BookmarkId: bookmarkID,
|
||||
Type: ts.AnnotationType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Include unlinked books count if any
|
||||
if unlinkedBooks > 0 {
|
||||
// For now, just log it. In production, this should trigger an alert
|
||||
@@ -606,6 +690,27 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error {
|
||||
switch bookmarkSync.BookmarkType {
|
||||
case "annotation":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: bookmarkSync.BookmarkId,
|
||||
EndPosition: bookmarkSync.BookmarkId,
|
||||
Color: "#ffff00",
|
||||
NoteText: bookmarkSync.BookmarkTitle,
|
||||
Source: "kobo",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSynced++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
@@ -616,8 +721,28 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error {
|
||||
})
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
case "bookmark":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Title: bookmarkSync.BookmarkText,
|
||||
Position: bookmarkSync.BookmarkId,
|
||||
ChapterNumber: int32(bookmarkSync.Chapter),
|
||||
Source: "kobo",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSynced++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
@@ -628,6 +753,7 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
||||
if err != nil {
|
||||
@@ -756,6 +882,18 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
||||
|
||||
for _, bookmark := range syncData.Bookmarks {
|
||||
if bookmark.BookmarkType == "bookmark" {
|
||||
if h.annotationSvc != nil {
|
||||
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Title: bookmark.BookmarkText,
|
||||
Position: bookmark.BookmarkId,
|
||||
Source: "kobo",
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSent++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
@@ -763,7 +901,22 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
||||
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSent++
|
||||
}
|
||||
} else if bookmark.BookmarkType == "annotation" {
|
||||
if h.annotationSvc != nil {
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmark.BookmarkText,
|
||||
StartPosition: bookmark.BookmarkId,
|
||||
EndPosition: bookmark.BookmarkId,
|
||||
Color: "#ffff00",
|
||||
Source: "kobo",
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
highlightsSent++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
@@ -775,8 +928,23 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
||||
highlightsSent++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, highlight := range syncData.Highlights {
|
||||
if h.annotationSvc != nil {
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: highlight.BookmarkText,
|
||||
StartPosition: highlight.BookmarkId,
|
||||
EndPosition: highlight.BookmarkId,
|
||||
Color: "#ffff00",
|
||||
Source: "kobo",
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
highlightsSent++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
@@ -788,6 +956,7 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
||||
highlightsSent++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bookhoard/internal/database"
|
||||
wsync "bookhoard/internal/sync"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -19,6 +20,7 @@ type KOReaderHandler struct {
|
||||
connManager *wsync.ConnectionManager
|
||||
queue *wsync.SyncQueueProcessor
|
||||
progressSvc *wsync.ProgressService
|
||||
annotationSvc *wsync.AnnotationService
|
||||
libraryService LibraryPathResolver
|
||||
}
|
||||
|
||||
@@ -34,6 +36,27 @@ func (h *KOReaderHandler) SetProgressService(svc *wsync.ProgressService) {
|
||||
h.progressSvc = svc
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||
h.annotationSvc = svc
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1 string) (string, string) {
|
||||
if pos0 == "" || h.libraryService == nil {
|
||||
return "", ""
|
||||
}
|
||||
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
|
||||
if err != nil || epubPath == "" {
|
||||
return "", ""
|
||||
}
|
||||
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, "", mediaItem.FormatGroup, epubPath, "")
|
||||
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, 0, "", mediaItem.FormatGroup, epubPath, "")
|
||||
return startLoc.CFI, endLoc.CFI
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) SetLibraryService(svc LibraryPathResolver) {
|
||||
h.libraryService = svc
|
||||
}
|
||||
@@ -157,6 +180,8 @@ type KOReaderAnnotations struct {
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||
DeletedHighlights []map[string]interface{} `json:"deleted_highlights,omitempty"`
|
||||
DeletedBookmarks []map[string]interface{} `json:"deleted_bookmarks,omitempty"`
|
||||
}
|
||||
|
||||
type KOReaderLibraryResponse struct {
|
||||
@@ -396,6 +421,7 @@ func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.
|
||||
if synced {
|
||||
booksEnqueued++
|
||||
}
|
||||
h.processBookAnnotations(c.Request().Context(), device.ID, userID, mediaItemID, book)
|
||||
bookResults = append(bookResults, KOReaderBookSyncResult{
|
||||
SHA256: book.SHA256,
|
||||
BookUUID: uuid.UUID(mediaItemID.Bytes).String(),
|
||||
@@ -441,6 +467,102 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp
|
||||
return h.queue.EnqueueProgress(update)
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, userID, mediaItemID pgtype.UUID, book KOReaderBookProgress) {
|
||||
if h.annotationSvc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, hl := range book.Highlights {
|
||||
startPos := hl.Pos0
|
||||
endPos := hl.Pos1
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos)
|
||||
|
||||
pctStart := 0.0
|
||||
if hl.Percentage != nil {
|
||||
pctStart = *hl.Percentage
|
||||
}
|
||||
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": hl.Datetime,
|
||||
"pos0": hl.Pos0,
|
||||
"pos1": hl.Pos1,
|
||||
"page": hl.Page,
|
||||
})
|
||||
|
||||
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
SelectionText: hl.Text,
|
||||
StartPosition: startPos,
|
||||
EndPosition: endPos,
|
||||
Color: hl.Color,
|
||||
NoteText: hl.Notes,
|
||||
PercentageStart: pctStart,
|
||||
EpubcfiStart: epubcfiStart,
|
||||
EpubcfiEnd: epubcfiEnd,
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
}
|
||||
|
||||
for _, note := range book.Notes {
|
||||
startPos := note.Pos0
|
||||
endPos := note.Pos1
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos)
|
||||
|
||||
pctStart := 0.0
|
||||
if note.Percentage != nil {
|
||||
pctStart = *note.Percentage
|
||||
}
|
||||
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": note.Datetime,
|
||||
"pos0": note.Pos0,
|
||||
"pos1": note.Pos1,
|
||||
"page": note.Page,
|
||||
})
|
||||
|
||||
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
SelectionText: note.Text,
|
||||
StartPosition: startPos,
|
||||
EndPosition: endPos,
|
||||
NoteText: note.Notes,
|
||||
PercentageStart: pctStart,
|
||||
EpubcfiStart: epubcfiStart,
|
||||
EpubcfiEnd: epubcfiEnd,
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
}
|
||||
|
||||
for _, bookmark := range book.Bookmarks {
|
||||
position := ""
|
||||
if bookmark.Pos0 != "" {
|
||||
position = bookmark.Pos0
|
||||
} else if bookmark.Page > 0 {
|
||||
position = fmt.Sprintf("page:%d", bookmark.Page)
|
||||
}
|
||||
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": bookmark.Datetime,
|
||||
"pos0": bookmark.Pos0,
|
||||
"page": bookmark.Page,
|
||||
})
|
||||
|
||||
h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
Title: bookmark.Text,
|
||||
Position: position,
|
||||
ChapterNumber: int32(bookmark.Chapter),
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
@@ -522,8 +644,12 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
||||
}
|
||||
|
||||
_, err := h.progressSvc.SaveProgress(ctx, saveReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
|
||||
MediaItemID: mediaItemID,
|
||||
@@ -558,6 +684,8 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
||||
},
|
||||
)
|
||||
|
||||
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -657,7 +785,7 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
progressData.TotalPages = &progress
|
||||
}
|
||||
|
||||
annotations, err := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
||||
annotations, err := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
|
||||
MediaItemID: pgBookUUID,
|
||||
UserID: pgUserID,
|
||||
})
|
||||
@@ -670,13 +798,29 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
|
||||
for _, ann := range annotations {
|
||||
if ann.AnnotationType == "highlight" {
|
||||
annotationsResponse.Highlights = append(annotationsResponse.Highlights, KOReaderHighlight{
|
||||
pos0 := ann.StartPosition.String
|
||||
pos1 := ann.EndPosition.String
|
||||
if ann.EpubcfiStart.Valid && ann.EpubcfiStart.String != "" {
|
||||
if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiStart.String); converted != "" {
|
||||
pos0 = converted
|
||||
}
|
||||
}
|
||||
if ann.EpubcfiEnd.Valid && ann.EpubcfiEnd.String != "" {
|
||||
if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiEnd.String); converted != "" {
|
||||
pos1 = converted
|
||||
}
|
||||
}
|
||||
highlight := KOReaderHighlight{
|
||||
Text: ann.SelectionText,
|
||||
Pos0: ann.StartPosition.String,
|
||||
Pos1: ann.EndPosition.String,
|
||||
Pos0: pos0,
|
||||
Pos1: pos1,
|
||||
Color: ann.Color.String,
|
||||
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
if ann.NoteText.Valid && ann.NoteText.String != "" {
|
||||
highlight.Notes = ann.NoteText.String
|
||||
}
|
||||
annotationsResponse.Highlights = append(annotationsResponse.Highlights, highlight)
|
||||
} else if ann.AnnotationType == "note" {
|
||||
annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{
|
||||
Text: ann.SelectionText,
|
||||
@@ -686,6 +830,52 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
bookmarks, _ := h.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
|
||||
MediaItemID: pgBookUUID,
|
||||
UserID: pgUserID,
|
||||
})
|
||||
for _, bm := range bookmarks {
|
||||
pos0 := bm.Position.String
|
||||
if pos0 == "" && bm.CfiPosition.Valid {
|
||||
pos0 = bm.CfiPosition.String
|
||||
}
|
||||
koreaderBookmark := KOReaderBookmark{
|
||||
Text: bm.Title,
|
||||
Pos0: pos0,
|
||||
Pos1: pos0,
|
||||
Datetime: bm.CreatedAt.Time.Format(time.RFC3339),
|
||||
}
|
||||
if bm.Notes.Valid && bm.Notes.String != "" {
|
||||
koreaderBookmark.Notes = bm.Notes.String
|
||||
}
|
||||
if bm.ChapterNumber.Valid {
|
||||
koreaderBookmark.Chapter = int(bm.ChapterNumber.Int32)
|
||||
}
|
||||
annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark)
|
||||
}
|
||||
|
||||
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true}
|
||||
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
|
||||
MediaItemID: pgBookUUID,
|
||||
UserID: pgUserID,
|
||||
DeletedAt: cutoff,
|
||||
})
|
||||
for _, ts := range tombstones {
|
||||
var dd map[string]interface{}
|
||||
if len(ts.DeviceSyncData) > 0 {
|
||||
json.Unmarshal(ts.DeviceSyncData, &dd)
|
||||
}
|
||||
if dd == nil {
|
||||
dd = map[string]interface{}{}
|
||||
}
|
||||
dd["dedup_key"] = ts.DedupKey.String
|
||||
if ts.AnnotationType == "highlight" {
|
||||
annotationsResponse.DeletedHighlights = append(annotationsResponse.DeletedHighlights, dd)
|
||||
} else if ts.AnnotationType == "bookmark" {
|
||||
annotationsResponse.DeletedBookmarks = append(annotationsResponse.DeletedBookmarks, dd)
|
||||
}
|
||||
}
|
||||
|
||||
lastSync := "never"
|
||||
if progress.LastSyncTimestamp.Valid {
|
||||
lastSync = progress.LastSyncTimestamp.Time.Format(time.RFC3339)
|
||||
@@ -737,6 +927,21 @@ func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem databa
|
||||
}
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string) string {
|
||||
if h.libraryService == nil || epubcfi == "" {
|
||||
return ""
|
||||
}
|
||||
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
||||
if err != nil || epubPath == "" {
|
||||
return ""
|
||||
}
|
||||
loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, 0, "", mediaItem.FormatGroup, epubPath, "")
|
||||
if loc.Position != "" && loc.Position != epubcfi {
|
||||
return loc.Position
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
|
||||
device := c.Get("device").(database.Devices)
|
||||
userID := device.UserID.Bytes
|
||||
@@ -776,7 +981,7 @@ func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
||||
annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
|
||||
MediaItemID: pgItemUUID,
|
||||
UserID: pgUserID,
|
||||
})
|
||||
@@ -876,6 +1081,26 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
position = fmt.Sprintf("page:%d", bookmark.Page)
|
||||
}
|
||||
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": bookmark.Datetime,
|
||||
"pos0": bookmark.Pos0,
|
||||
"page": bookmark.Page,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
Title: bookmark.Text,
|
||||
Position: position,
|
||||
ChapterNumber: int32(bookmark.Chapter),
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSynced++
|
||||
}
|
||||
} else {
|
||||
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
@@ -887,6 +1112,7 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, note := range req.Notes {
|
||||
mediaItemID := pgBookUUID
|
||||
@@ -906,6 +1132,25 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
position = fmt.Sprintf("page:%d", note.Page)
|
||||
}
|
||||
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": note.Datetime,
|
||||
"pos0": note.Pos0,
|
||||
"page": note.Page,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveNote(ctx, wsync.SaveNoteRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
Content: note.Notes,
|
||||
Position: position,
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
notesSynced++
|
||||
}
|
||||
} else {
|
||||
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
@@ -917,6 +1162,7 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
notesSynced++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, highlight := range req.Highlights {
|
||||
mediaItemID := pgBookUUID
|
||||
@@ -941,6 +1187,39 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
color = highlight.Color
|
||||
}
|
||||
|
||||
if h.annotationSvc != nil {
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1)
|
||||
|
||||
pctStart := 0.0
|
||||
if highlight.Percentage != nil {
|
||||
pctStart = *highlight.Percentage
|
||||
}
|
||||
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": highlight.Datetime,
|
||||
"pos0": highlight.Pos0,
|
||||
"pos1": highlight.Pos1,
|
||||
"page": highlight.Page,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: highlight.Text,
|
||||
StartPosition: startPos,
|
||||
EndPosition: endPos,
|
||||
Color: color,
|
||||
NoteText: highlight.Notes,
|
||||
PercentageStart: pctStart,
|
||||
EpubcfiStart: epubcfiStart,
|
||||
EpubcfiEnd: epubcfiEnd,
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
highlightsSynced++
|
||||
}
|
||||
} else {
|
||||
_, err := h.db.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
@@ -954,6 +1233,7 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
highlightsSynced++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"sync_status": "completed",
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -136,6 +137,7 @@ type MediaHandler struct {
|
||||
libraryService *services.LibraryService
|
||||
searchService *services.SearchService
|
||||
progressSvc *wsync.ProgressService
|
||||
annotationSvc *wsync.AnnotationService
|
||||
}
|
||||
|
||||
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler {
|
||||
@@ -154,6 +156,10 @@ func (mh *MediaHandler) SetProgressService(svc *wsync.ProgressService) {
|
||||
mh.progressSvc = svc
|
||||
}
|
||||
|
||||
func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||
mh.annotationSvc = svc
|
||||
}
|
||||
|
||||
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
|
||||
bookUUID, err := uuid.Parse(c.Param("uuid"))
|
||||
if err != nil {
|
||||
@@ -1388,7 +1394,23 @@ func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
note, err := mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
var note database.MediaNotes
|
||||
if mh.annotationSvc != nil {
|
||||
result, err := mh.annotationSvc.SaveNote(c.Request().Context(), wsync.SaveNoteRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Content: req.Content,
|
||||
Position: req.Position,
|
||||
Source: "web",
|
||||
ModifiedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
note = result.Note
|
||||
} else {
|
||||
var err error
|
||||
note, err = mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Content: req.Content,
|
||||
@@ -1397,6 +1419,7 @@ func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error {
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, note)
|
||||
}
|
||||
@@ -1456,7 +1479,11 @@ func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
||||
}
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||
} else {
|
||||
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||
}
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
@@ -1525,9 +1552,29 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error {
|
||||
color = req.Color
|
||||
}
|
||||
|
||||
pgMediaID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
|
||||
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: req.StartPosition,
|
||||
EndPosition: req.EndPosition,
|
||||
Color: color,
|
||||
Source: "web",
|
||||
ModifiedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusCreated, result.Highlight)
|
||||
}
|
||||
|
||||
highlight, err := mh.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
MediaItemID: pgMediaID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
|
||||
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
|
||||
@@ -1613,7 +1660,16 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
|
||||
}
|
||||
|
||||
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
|
||||
pgHighlightID := pgtype.UUID{Bytes: highlightUUID, Valid: true}
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgHighlightID, "web"); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgHighlightID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
@@ -1735,6 +1791,10 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
"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)
|
||||
}
|
||||
|
||||
|
||||
+82
-22
@@ -38,15 +38,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) {
|
||||
baseURL, err := h.db.GetSystemConfig(c.Request().Context(), "base_url")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get base_url from config: %w", err)
|
||||
var dbBaseURL string
|
||||
if config, err := h.db.GetSystemConfig(c.Request().Context(), "base_url"); err == nil {
|
||||
dbBaseURL = config.Value
|
||||
}
|
||||
|
||||
opdsBaseURL := baseURL.Value + "/opds"
|
||||
return baseURL.Value, opdsBaseURL, nil
|
||||
baseURL := deriveBaseURL(c, dbBaseURL)
|
||||
opdsBaseURL := baseURL + "/opds"
|
||||
return baseURL, opdsBaseURL, nil
|
||||
}
|
||||
|
||||
func (h *OPDSHandler) getAuthToken(c *echo.Context) string {
|
||||
@@ -67,6 +67,42 @@ func appendToken(url, token string) string {
|
||||
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
|
||||
// mime_type, then format_mimetype, and finally falling back to EPUB.
|
||||
func resolveMimeType(mime, formatMime pgtype.Text) string {
|
||||
@@ -206,14 +242,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
"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)
|
||||
catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
|
||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||
catalogBase := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||
addCatalogPaginationLinks(feed, catalogBase, pageNum, perPageNum, totalItems, token)
|
||||
|
||||
// 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)
|
||||
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "search")
|
||||
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
|
||||
|
||||
// Add entries
|
||||
for _, item := range allItems {
|
||||
@@ -286,16 +325,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
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 {
|
||||
deviceID := c.Param("deviceId")
|
||||
|
||||
query := c.QueryParam("q")
|
||||
|
||||
if query == "" {
|
||||
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Missing search query"))
|
||||
}
|
||||
|
||||
// Get base URLs
|
||||
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
|
||||
if err != nil {
|
||||
@@ -316,12 +356,30 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
|
||||
// Get user's visible libraries
|
||||
userID := device.UserID.Bytes
|
||||
|
||||
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||
if err != nil {
|
||||
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
|
||||
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
@@ -340,12 +398,14 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
)
|
||||
|
||||
// Add feed links
|
||||
token := h.getAuthToken(c)
|
||||
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)
|
||||
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)
|
||||
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")
|
||||
}
|
||||
@@ -374,6 +374,11 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
|
||||
deviceName = progress.LastSyncDevice.String
|
||||
}
|
||||
|
||||
lastUpdated := ""
|
||||
if progress.LastReadAt.Valid {
|
||||
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
|
||||
}
|
||||
|
||||
progressList = append(progressList, ProgressWithMedia{
|
||||
MediaItemID: progress.MediaItemID.Bytes,
|
||||
Title: mediaItem.Title,
|
||||
@@ -386,6 +391,11 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
|
||||
Epubcfi: epubcfi,
|
||||
LastSyncDevice: deviceName,
|
||||
ProgressPercentage: progress.Percentage.Float64 * 100,
|
||||
EpubCFI: epubcfi,
|
||||
LastUpdated: lastUpdated,
|
||||
DeviceIcon: getDeviceIcon(deviceName),
|
||||
DeviceName: deviceName,
|
||||
DeviceType: deviceName,
|
||||
FormatGroup: mediaItem.FormatGroup,
|
||||
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||
})
|
||||
|
||||
@@ -128,8 +128,8 @@ func (h *Handler) StartScanner(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
|
||||
// Set the folder paths
|
||||
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
|
||||
// Set the folder paths (watch=true: this long-lived scanner reads events)
|
||||
if err := h.scanner.SetFolders(req.FolderPaths, true); err != nil {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/config"
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/setupstatus"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -81,15 +82,13 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
||||
userID := device.UserID.Bytes
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
|
||||
// Get base URL and compute paths
|
||||
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||
if baseURL.Value == "" {
|
||||
baseURL.Value = h.cfg.BaseURL
|
||||
}
|
||||
// Get base URL and compute paths (with request-derived fallback)
|
||||
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||
baseURL := deriveBaseURL(c, dbBaseURL.Value)
|
||||
|
||||
// Generate URLs
|
||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
|
||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
|
||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
|
||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
|
||||
|
||||
// Get user's visible libraries with media items
|
||||
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
||||
@@ -176,8 +175,8 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
||||
Bookhoard: SidecarBookhoardConfig{
|
||||
OPDSCatalog: opdsCatalogURL,
|
||||
SyncAPI: syncAPIURL,
|
||||
OPDSBaseURL: baseURL.Value + "/opds",
|
||||
APIBaseURL: baseURL.Value + "/api",
|
||||
OPDSBaseURL: baseURL + "/opds",
|
||||
APIBaseURL: baseURL + "/api",
|
||||
DeviceID: deviceID.String(),
|
||||
DeviceToken: device.AuthToken,
|
||||
},
|
||||
@@ -219,15 +218,13 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
|
||||
userID := device.UserID.Bytes
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
|
||||
// Get base URL and compute paths
|
||||
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||
if baseURL.Value == "" {
|
||||
baseURL.Value = h.cfg.BaseURL
|
||||
}
|
||||
// Get base URL and compute paths (with request-derived fallback)
|
||||
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||
baseURL := deriveBaseURL(c, dbBaseURL.Value)
|
||||
|
||||
// Generate URLs
|
||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
|
||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
|
||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
|
||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
|
||||
|
||||
// Get user's visible libraries with media items
|
||||
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
||||
@@ -309,8 +306,8 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
|
||||
Bookhoard: SidecarBookhoardConfig{
|
||||
OPDSCatalog: opdsCatalogURL,
|
||||
SyncAPI: syncAPIURL,
|
||||
OPDSBaseURL: baseURL.Value + "/opds",
|
||||
APIBaseURL: baseURL.Value + "/api",
|
||||
OPDSBaseURL: baseURL + "/opds",
|
||||
APIBaseURL: baseURL + "/api",
|
||||
DeviceID: deviceID.String(),
|
||||
DeviceToken: device.AuthToken,
|
||||
},
|
||||
@@ -434,6 +431,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
|
||||
|
||||
@@ -95,32 +95,6 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SystemSettingsHandler) SetSetupComplete(c *echo.Context) error {
|
||||
err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{
|
||||
SettingKey: "setup_complete",
|
||||
SettingValue: "true",
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "setup complete"})
|
||||
}
|
||||
|
||||
func (h *SystemSettingsHandler) GetSetupStatus(c *echo.Context) error {
|
||||
val, err := h.db.GetSystemSetting(c.Request().Context(), "setup_complete")
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusOK, map[string]bool{"setup_complete": false})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
complete, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
complete = false
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]bool{"setup_complete": complete})
|
||||
}
|
||||
|
||||
func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error {
|
||||
scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds")
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -13,10 +13,14 @@ type Feed struct {
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
OpdsNS string `xml:"xmlns:opds,attr"`
|
||||
DcNS string `xml:"xmlns:dc,attr"`
|
||||
OpenSearchNS string `xml:"xmlns:opensearch,attr,omitempty"`
|
||||
ID string `xml:"id"`
|
||||
Title string `xml:"title"`
|
||||
Updated string `xml:"updated"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -67,6 +71,7 @@ func NewFeed(feedID, title string) *Feed {
|
||||
Xmlns: "http://www.w3.org/2005/Atom",
|
||||
OpdsNS: "http://opds-spec.org/2010/",
|
||||
DcNS: "http://purl.org/dc/elements/1.1/",
|
||||
OpenSearchNS: "http://a9.com/-/spec/opensearch/1.1/",
|
||||
ID: feedID,
|
||||
Title: title,
|
||||
Updated: now,
|
||||
@@ -75,6 +80,17 @@ func NewFeed(feedID, title string) *Feed {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
func (f *Feed) AddLink(href, linkType, rel string) {
|
||||
f.Links = append(f.Links, Link{
|
||||
@@ -178,6 +194,62 @@ func (f *Feed) GenerateXMLString() (string, error) {
|
||||
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
|
||||
func NewErrorFeed(message string) *Feed {
|
||||
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)
|
||||
}
|
||||
|
||||
if entry.Creator != "Test Author" {
|
||||
t.Errorf("expected Creator to be 'Test Author', got '%s'", entry.Creator)
|
||||
if entry.Author == nil || entry.Author.Name != "Test Author" {
|
||||
t.Errorf("expected Author.Name to be 'Test Author', got %v", entry.Author)
|
||||
}
|
||||
|
||||
if entry.Updated != "2023-01-01T00:00:00Z" {
|
||||
@@ -201,8 +201,8 @@ func TestFeedGenerateXML(t *testing.T) {
|
||||
`<title>Test Feed</title>`,
|
||||
`<entry>`,
|
||||
`<id>urn:uuid:book-id</id>`,
|
||||
`<dc:title>Test Book</dc:title>`,
|
||||
`<dc:creator>Test Author</dc:creator>`,
|
||||
`<title>Test Book</title>`,
|
||||
`<name>Test Author</name>`,
|
||||
`<link href="http://example.com/book.epub"`,
|
||||
`rel="http://opds-spec.org/acquisition/open-access"`,
|
||||
`<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 {
|
||||
return len(s) >= len(substr) && indexOf(s, substr) >= 0
|
||||
}
|
||||
|
||||
+13
-14
@@ -9,7 +9,6 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/config"
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/services"
|
||||
@@ -215,6 +214,9 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
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
|
||||
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 {
|
||||
@@ -267,6 +269,9 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
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
|
||||
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 {
|
||||
@@ -354,6 +359,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -408,7 +414,8 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
// Get only visible sections for the dashboard display
|
||||
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)
|
||||
|
||||
var buf bytes.Buffer
|
||||
@@ -623,6 +630,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
Description: collection.Description.String,
|
||||
Color: collection.Color.String,
|
||||
Icon: collection.Icon.String,
|
||||
IsSystem: collection.IsSystemCollection.Bool,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
@@ -725,10 +733,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
// Get base URL from database config with fallback to config/env var
|
||||
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
||||
if baseURL == "" {
|
||||
baseURL = cfg.Cfg.BaseURL
|
||||
}
|
||||
baseURL := cfg.getBaseURL(c.Request().Context())
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
||||
@@ -986,10 +991,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
// Fetch current system configuration - just base_url
|
||||
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
||||
if baseURL == "" {
|
||||
baseURL = cfg.Cfg.BaseURL
|
||||
}
|
||||
baseURL := cfg.getBaseURL(c.Request().Context())
|
||||
|
||||
systemConfig := map[string]string{
|
||||
"base_url": baseURL,
|
||||
@@ -1077,10 +1079,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
// Get base URL from database config with fallback to config/env var
|
||||
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
||||
if baseURL == "" {
|
||||
baseURL = cfg.Cfg.BaseURL
|
||||
}
|
||||
baseURL := cfg.getBaseURL(c.Request().Context())
|
||||
|
||||
var buf bytes.Buffer
|
||||
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{}
|
||||
}
|
||||
|
||||
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))
|
||||
for i, lib := range libraries {
|
||||
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,
|
||||
Description: getText(lib.Description),
|
||||
TypeName: lib.TypeName,
|
||||
MediaCount: countMap[libUUID.String()],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ type Config struct {
|
||||
ConnManager *sync.ConnectionManager
|
||||
QueueProcessor *sync.SyncQueueProcessor
|
||||
ProgressService *sync.ProgressService
|
||||
AnnotationService *sync.AnnotationService
|
||||
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
|
||||
LoginTracker *ratelimit.LoginAttemptTracker
|
||||
ScannerHandler *handlers.Handler
|
||||
@@ -71,6 +72,24 @@ type Config struct {
|
||||
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
|
||||
func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
return echojwt.WithConfig(echojwt.Config{
|
||||
|
||||
@@ -112,9 +112,15 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
|
||||
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
|
||||
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 {
|
||||
log.Printf("Template render error: %v", err)
|
||||
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Render error</div>`)
|
||||
|
||||
+35
-63
@@ -3,65 +3,51 @@ package router
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/setupstatus"
|
||||
"bookhoard/templates"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
setupCacheMu sync.RWMutex
|
||||
setupCacheComplete bool = true
|
||||
setupCacheExpiry time.Time
|
||||
setupCacheTTL = 10 * time.Second
|
||||
)
|
||||
|
||||
func isSetupComplete(cfg *Config) bool {
|
||||
setupCacheMu.RLock()
|
||||
if time.Now().Before(setupCacheExpiry) {
|
||||
complete := setupCacheComplete
|
||||
setupCacheMu.RUnlock()
|
||||
return complete
|
||||
}
|
||||
setupCacheMu.RUnlock()
|
||||
|
||||
val, err := cfg.Queries.GetSystemSetting(context.Background(), "setup_complete")
|
||||
getter := func(ctx context.Context) (string, error) {
|
||||
row, err := cfg.Queries.GetSystemConfig(ctx, "base_url")
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
setupCacheMu.Lock()
|
||||
setupCacheComplete = false
|
||||
setupCacheExpiry = time.Now().Add(setupCacheTTL)
|
||||
setupCacheMu.Unlock()
|
||||
return false
|
||||
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
|
||||
}
|
||||
|
||||
complete, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
complete = false
|
||||
if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" {
|
||||
return true
|
||||
}
|
||||
|
||||
setupCacheMu.Lock()
|
||||
setupCacheComplete = complete
|
||||
setupCacheExpiry = time.Now().Add(setupCacheTTL)
|
||||
setupCacheMu.Unlock()
|
||||
return complete
|
||||
for _, route := range setupAllowedAPIRoutes {
|
||||
if path == route || strings.HasPrefix(path, route+"/") {
|
||||
return true
|
||||
}
|
||||
|
||||
func invalidateSetupCache() {
|
||||
setupCacheMu.Lock()
|
||||
setupCacheComplete = true
|
||||
setupCacheExpiry = time.Time{}
|
||||
setupCacheMu.Unlock()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
@@ -69,19 +55,16 @@ func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
return func(c *echo.Context) error {
|
||||
path := c.Request().URL.Path
|
||||
|
||||
if path == "/setup" || path == "/setup/" {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" {
|
||||
if isAllowedDuringSetup(path) {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -104,15 +87,4 @@ func registerSetupRoutes(cfg *Config) {
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
jwtMiddleware := createJWTMiddleware(cfg)
|
||||
protected := e.Group("/api/setup", jwtMiddleware)
|
||||
protected.PUT("/complete", func(c *echo.Context) error {
|
||||
err := cfg.SystemSettingsHandler.SetSetupComplete(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
invalidateSetupCache()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ func registerSyncRoutes(cfg *Config) {
|
||||
// API clients can use Authorization header: Authorization: Bearer {token}
|
||||
koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager)
|
||||
koboHandler.SetProgressService(cfg.ProgressService)
|
||||
koboHandler.SetAnnotationService(cfg.AnnotationService)
|
||||
koboHandler.SetLibraryService(cfg.LibraryService)
|
||||
koboSync := e.Group("/api/sync/kobo/:token")
|
||||
koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup))
|
||||
|
||||
@@ -146,16 +146,18 @@ type CalibreOPFMetadata struct {
|
||||
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 {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to create file watcher: %v", err))
|
||||
}
|
||||
|
||||
return &MediaScanner{
|
||||
db: db,
|
||||
watcher: watcher,
|
||||
watcher: nil,
|
||||
settingsCache: NewSettingsCache(30 * time.Second),
|
||||
dirtyDirs: make(map[string]time.Time),
|
||||
fileStability: make(map[string]*atomic.Bool),
|
||||
@@ -238,24 +240,34 @@ func (s *MediaScanner) GetStats() (int, int, int) {
|
||||
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
|
||||
|
||||
// Remove old watch if exists
|
||||
if s.watcher != nil {
|
||||
// Always close any previously-owned watcher so reconfiguration doesn't leak.
|
||||
if s.watcher != nil {
|
||||
if err := s.watcher.Close(); err != nil {
|
||||
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.
|
||||
if watch {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create watcher: %v", err)
|
||||
// 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
|
||||
}
|
||||
|
||||
// Build cache of allowed extensions per folder
|
||||
// Uses Go AllowedExtensions map as source of truth (not DB)
|
||||
@@ -286,7 +298,9 @@ 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).
|
||||
// Only when watching; scan jobs (watch=false) skip this entirely.
|
||||
if s.watcher != nil {
|
||||
watchCount := 0
|
||||
for _, folder := range folders {
|
||||
if err := s.watcher.Add(folder); err != nil {
|
||||
@@ -311,6 +325,9 @@ func (s *MediaScanner) SetFolders(folders []string) error {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -417,9 +434,11 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
if s.watcher != nil {
|
||||
if err := s.watcher.Add(path); err != nil {
|
||||
fmt.Printf("Warning: failed to watch subdirectory %s: %v\n", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2601,6 +2620,13 @@ func (s *MediaScanner) WatchChanges(ctx context.Context) error {
|
||||
go s.startBackupScan(ctx)
|
||||
|
||||
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))
|
||||
for {
|
||||
select {
|
||||
|
||||
@@ -205,7 +205,23 @@ func (w *Worker) worker() {
|
||||
return
|
||||
}
|
||||
|
||||
// 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():
|
||||
return
|
||||
@@ -348,6 +364,7 @@ func (w *Worker) processScanJob(job *Job) (interface{}, error) {
|
||||
}
|
||||
|
||||
scanner := NewMediaScanner(db)
|
||||
defer scanner.Close()
|
||||
scanner.job = job
|
||||
|
||||
job.ProgressCallback = func(progress float64, filesScanned, newItems, errors int) {
|
||||
@@ -377,7 +394,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
|
||||
}
|
||||
|
||||
@@ -521,7 +538,8 @@ func (w *Worker) processSetFoldersJob(job *Job) (interface{}, error) {
|
||||
|
||||
// Create scanner and configure folders
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -900,6 +918,7 @@ func (w *Worker) processDirectoryScanJob(job *Job) (interface{}, error) {
|
||||
|
||||
// Create temporary scanner instance for this job
|
||||
scanner := NewMediaScanner(db)
|
||||
defer scanner.Close()
|
||||
scanner.job = job
|
||||
// Find which library owns this directory (prefix match for subdirectories)
|
||||
ctx := context.Background()
|
||||
@@ -918,7 +937,7 @@ func (w *Worker) processDirectoryScanJob(job *Job) (interface{}, error) {
|
||||
folderPaths = append(folderPaths, f.FolderPath)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
// Now scan the directory
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Package setupstatus reports whether the application's initial setup has been
|
||||
// completed. Setup is considered complete when at least one admin user exists
|
||||
// AND a non-empty base_url has been configured, regardless of how those were
|
||||
// created (setup wizard, API, or a future CLI). This keeps the setup gate a
|
||||
// derived property of real data rather than a manually-flipped flag that can
|
||||
// drift out of sync.
|
||||
package setupstatus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AdminCounter is satisfied by *database.Queries. It is defined as an interface
|
||||
// here so this package does not import the database package, keeping the
|
||||
// dependency graph flat and avoiding import cycles.
|
||||
type AdminCounter interface {
|
||||
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 (
|
||||
cacheMu sync.RWMutex
|
||||
cacheComplete bool = true
|
||||
cacheExpiry time.Time
|
||||
cacheTTL = 10 * time.Second
|
||||
)
|
||||
|
||||
// IsSetupComplete reports whether setup is complete. Setup is complete when at
|
||||
// least one admin user exists AND base_url is configured. A short in-memory
|
||||
// cache avoids hammering the database on every request. On a database error the
|
||||
// function fails open (returns true) so a transient outage does not lock users
|
||||
// out of the app.
|
||||
func IsSetupComplete(ctx context.Context, q AdminCounter, baseURLGetter BaseURLGetter) bool {
|
||||
cacheMu.RLock()
|
||||
if time.Now().Before(cacheExpiry) {
|
||||
complete := cacheComplete
|
||||
cacheMu.RUnlock()
|
||||
return complete
|
||||
}
|
||||
cacheMu.RUnlock()
|
||||
|
||||
complete := true
|
||||
|
||||
count, err := q.CountAdmins(ctx)
|
||||
if err == nil {
|
||||
complete = count > 0
|
||||
}
|
||||
|
||||
if complete && baseURLGetter != nil {
|
||||
baseURL, err := baseURLGetter(ctx)
|
||||
if err == nil {
|
||||
complete = baseURL != ""
|
||||
}
|
||||
}
|
||||
|
||||
cacheMu.Lock()
|
||||
cacheComplete = complete
|
||||
cacheExpiry = time.Now().Add(cacheTTL)
|
||||
cacheMu.Unlock()
|
||||
return complete
|
||||
}
|
||||
|
||||
// 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
|
||||
// admin user count (user creation, role promotion/demotion, user deletion) or
|
||||
// the base_url configuration.
|
||||
func Invalidate() {
|
||||
cacheMu.Lock()
|
||||
cacheComplete = true
|
||||
cacheExpiry = time.Time{}
|
||||
cacheMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const TombstoneTTL = 30 * 24 * time.Hour
|
||||
|
||||
type SaveOutcome string
|
||||
|
||||
const (
|
||||
SaveOutcomeCreated SaveOutcome = "created"
|
||||
SaveOutcomeUpdated SaveOutcome = "updated"
|
||||
SaveOutcomeSkipped SaveOutcome = "skipped"
|
||||
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 {
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
SelectionText string
|
||||
StartPosition string
|
||||
EndPosition string
|
||||
Color string
|
||||
NoteText string
|
||||
PercentageStart float64
|
||||
PercentageEnd float64
|
||||
EpubcfiStart string
|
||||
EpubcfiEnd string
|
||||
ChapterReference int32
|
||||
Source string
|
||||
ModifiedAt time.Time
|
||||
DeviceSyncData json.RawMessage
|
||||
}
|
||||
|
||||
type SaveHighlightResult struct {
|
||||
Highlight database.MediaHighlights
|
||||
Outcome SaveOutcome
|
||||
Conflict bool
|
||||
}
|
||||
|
||||
func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) {
|
||||
dedupKey := ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition)
|
||||
|
||||
existing, err := s.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
|
||||
UserID: req.UserID,
|
||||
MediaItemID: req.MediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("query existing highlight: %w", err)
|
||||
}
|
||||
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return s.createHighlight(ctx, req, dedupKey)
|
||||
}
|
||||
|
||||
if existing.Deleted.Bool {
|
||||
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < TombstoneTTL {
|
||||
return &SaveHighlightResult{Highlight: existing, Outcome: SaveOutcomeDeleted}, nil
|
||||
}
|
||||
return s.createHighlight(ctx, req, dedupKey)
|
||||
}
|
||||
|
||||
return s.applyLWW(ctx, req, existing, dedupKey)
|
||||
}
|
||||
|
||||
func (s *AnnotationService) createHighlight(
|
||||
ctx context.Context,
|
||||
req SaveHighlightRequest,
|
||||
dedupKey string,
|
||||
) (*SaveHighlightResult, error) {
|
||||
modifiedAt := req.ModifiedAt
|
||||
if modifiedAt.IsZero() {
|
||||
modifiedAt = time.Now()
|
||||
}
|
||||
|
||||
deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)
|
||||
|
||||
highlight, err := s.db.CreateMediaHighlightFull(ctx, database.CreateMediaHighlightFullParams{
|
||||
MediaItemID: req.MediaItemID,
|
||||
UserID: req.UserID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: pgText(req.StartPosition),
|
||||
EndPosition: pgText(req.EndPosition),
|
||||
Color: pgText(req.Color),
|
||||
NoteText: pgText(req.NoteText),
|
||||
PercentageStart: pgFloat8(req.PercentageStart),
|
||||
PercentageEnd: pgFloat8(req.PercentageEnd),
|
||||
EpubcfiStart: pgText(req.EpubcfiStart),
|
||||
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create highlight: %w", err)
|
||||
}
|
||||
|
||||
s.broadcast(highlight.ID, req.UserID, req.MediaItemID, "highlight", req.Source)
|
||||
return &SaveHighlightResult{Highlight: highlight, Outcome: SaveOutcomeCreated}, nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) applyLWW(
|
||||
ctx context.Context,
|
||||
req SaveHighlightRequest,
|
||||
existing database.MediaHighlights,
|
||||
dedupKey string,
|
||||
) (*SaveHighlightResult, error) {
|
||||
incomingNewer, contentChanged := s.compareIncoming(req, existing)
|
||||
|
||||
if !incomingNewer && !contentChanged {
|
||||
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
|
||||
if conflict {
|
||||
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_highlight",
|
||||
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
|
||||
req, existing, "existing")
|
||||
}
|
||||
return &SaveHighlightResult{
|
||||
Highlight: existing,
|
||||
Outcome: SaveOutcomeSkipped,
|
||||
Conflict: conflict,
|
||||
}, nil
|
||||
}
|
||||
|
||||
modifiedAt := req.ModifiedAt
|
||||
if modifiedAt.IsZero() {
|
||||
modifiedAt = time.Now()
|
||||
}
|
||||
|
||||
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
||||
|
||||
highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{
|
||||
ID: existing.ID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: pgText(req.StartPosition),
|
||||
EndPosition: pgText(req.EndPosition),
|
||||
Color: pgText(req.Color),
|
||||
NoteText: pgText(req.NoteText),
|
||||
PercentageStart: pgFloat8(req.PercentageStart),
|
||||
PercentageEnd: pgFloat8(req.PercentageEnd),
|
||||
EpubcfiStart: pgText(req.EpubcfiStart),
|
||||
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update highlight: %w", err)
|
||||
}
|
||||
|
||||
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
|
||||
if conflict {
|
||||
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_highlight",
|
||||
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
|
||||
req, existing, "incoming")
|
||||
}
|
||||
s.broadcast(highlight.ID, req.UserID, req.MediaItemID, "highlight", req.Source)
|
||||
return &SaveHighlightResult{Highlight: highlight, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing database.MediaHighlights) (incomingNewer bool, contentChanged bool) {
|
||||
if req.ModifiedAt.IsZero() {
|
||||
contentSame := strings.EqualFold(req.SelectionText, existing.SelectionText) &&
|
||||
textEq(req.Color, existing.Color) &&
|
||||
textEq(req.NoteText, existing.NoteText) &&
|
||||
floatEq(req.PercentageStart, existing.PercentageStart) &&
|
||||
floatEq(req.PercentageEnd, existing.PercentageEnd)
|
||||
return !contentSame, !contentSame
|
||||
}
|
||||
|
||||
existingMod := existing.LastModifiedAt
|
||||
if !existingMod.Valid {
|
||||
existingMod = existing.UpdatedAt
|
||||
}
|
||||
return req.ModifiedAt.After(existingMod.Time), true
|
||||
}
|
||||
|
||||
func (s *AnnotationService) TombstoneHighlight(
|
||||
ctx context.Context,
|
||||
userID, mediaItemID pgtype.UUID,
|
||||
dedupKey string,
|
||||
source string,
|
||||
) error {
|
||||
err := s.db.TombstoneMediaHighlightByDedupKey(ctx, database.TombstoneMediaHighlightByDedupKeyParams{
|
||||
UserID: userID,
|
||||
MediaItemID: mediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("tombstone highlight: %w", err)
|
||||
}
|
||||
s.broadcast(pgtype.UUID{}, userID, mediaItemID, "highlight_delete", source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) TombstoneHighlightByID(
|
||||
ctx context.Context,
|
||||
highlightID pgtype.UUID,
|
||||
source string,
|
||||
) error {
|
||||
h, err := s.db.GetMediaHighlight(ctx, highlightID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get highlight for tombstone: %w", err)
|
||||
}
|
||||
err = s.db.TombstoneMediaHighlightByID(ctx, highlightID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tombstone highlight by ID: %w", err)
|
||||
}
|
||||
s.broadcast(pgtype.UUID{}, h.UserID, h.MediaItemID, "highlight_delete", source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error {
|
||||
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-TombstoneTTL), Valid: true}
|
||||
if err := s.db.PurgeExpiredHighlightTombstones(ctx, cutoff); err != nil {
|
||||
return fmt.Errorf("purge highlight tombstones: %w", err)
|
||||
}
|
||||
if err := s.db.PurgeExpiredNoteTombstones(ctx, cutoff); err != nil {
|
||||
return fmt.Errorf("purge note tombstones: %w", err)
|
||||
}
|
||||
if err := s.db.PurgeExpiredBookmarkTombstones(ctx, cutoff); err != nil {
|
||||
return fmt.Errorf("purge bookmark tombstones: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) StartTombstonePurger() context.CancelFunc {
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ticker.Stop()
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := s.PurgeExpiredTombstones(ctx); err != nil {
|
||||
log.Printf("AnnotationService: tombstone purge failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return cancel
|
||||
}
|
||||
|
||||
type SaveNoteRequest struct {
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Content string
|
||||
Position string
|
||||
PercentageLocation float64
|
||||
CharacterStart int32
|
||||
CharacterEnd int32
|
||||
EpubcfiLocation string
|
||||
ChapterReference int32
|
||||
ParagraphReference int32
|
||||
Source string
|
||||
ModifiedAt time.Time
|
||||
DeviceSyncData []byte
|
||||
}
|
||||
|
||||
type SaveNoteResult struct {
|
||||
Note database.MediaNotes
|
||||
Outcome SaveOutcome
|
||||
Conflict bool
|
||||
}
|
||||
|
||||
func (s *AnnotationService) SaveNote(ctx context.Context, req SaveNoteRequest) (*SaveNoteResult, error) {
|
||||
if !req.UserID.Valid || !req.MediaItemID.Valid {
|
||||
return nil, errors.New("invalid user_id or media_item_id")
|
||||
}
|
||||
|
||||
dedupKey := ComputeDedupKey(req.Content, req.EpubcfiLocation, req.Position)
|
||||
|
||||
existing, err := s.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{
|
||||
UserID: req.UserID,
|
||||
MediaItemID: req.MediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("get note by dedup key: %w", err)
|
||||
}
|
||||
return s.createNote(ctx, req, dedupKey)
|
||||
}
|
||||
|
||||
if existing.Deleted.Valid && existing.Deleted.Bool {
|
||||
return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeDeleted}, nil
|
||||
}
|
||||
|
||||
return s.applyNoteLWW(ctx, req, existing, dedupKey)
|
||||
}
|
||||
|
||||
func (s *AnnotationService) createNote(ctx context.Context, req SaveNoteRequest, dedupKey string) (*SaveNoteResult, error) {
|
||||
modifiedAt := req.ModifiedAt
|
||||
if modifiedAt.IsZero() {
|
||||
modifiedAt = time.Now()
|
||||
}
|
||||
|
||||
note, err := s.db.CreateMediaNoteFull(ctx, database.CreateMediaNoteFullParams{
|
||||
MediaItemID: req.MediaItemID,
|
||||
UserID: req.UserID,
|
||||
Content: req.Content,
|
||||
Position: pgText(req.Position),
|
||||
PercentageLocation: pgFloat8(req.PercentageLocation),
|
||||
CharacterStart: pgInt4(req.CharacterStart),
|
||||
CharacterEnd: pgInt4(req.CharacterEnd),
|
||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
ParagraphReference: pgInt4(req.ParagraphReference),
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: req.DeviceSyncData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create note: %w", err)
|
||||
}
|
||||
s.broadcast(note.ID, req.UserID, req.MediaItemID, "note", req.Source)
|
||||
return &SaveNoteResult{Note: note, Outcome: SaveOutcomeCreated}, nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) applyNoteLWW(ctx context.Context, req SaveNoteRequest, existing database.MediaNotes, dedupKey string) (*SaveNoteResult, error) {
|
||||
incomingNewer, contentChanged := s.compareIncomingNote(req, existing)
|
||||
|
||||
if !incomingNewer && !contentChanged {
|
||||
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
|
||||
if conflict {
|
||||
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_note",
|
||||
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
|
||||
req, existing, "existing")
|
||||
}
|
||||
return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeSkipped, Conflict: conflict}, nil
|
||||
}
|
||||
|
||||
modifiedAt := req.ModifiedAt
|
||||
if modifiedAt.IsZero() {
|
||||
modifiedAt = time.Now()
|
||||
}
|
||||
|
||||
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
||||
|
||||
note, err := s.db.UpdateMediaNoteForSync(ctx, database.UpdateMediaNoteForSyncParams{
|
||||
ID: existing.ID,
|
||||
Content: req.Content,
|
||||
Position: pgText(req.Position),
|
||||
PercentageLocation: pgFloat8(req.PercentageLocation),
|
||||
CharacterStart: pgInt4(req.CharacterStart),
|
||||
CharacterEnd: pgInt4(req.CharacterEnd),
|
||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
ParagraphReference: pgInt4(req.ParagraphReference),
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update note: %w", err)
|
||||
}
|
||||
|
||||
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
|
||||
if conflict {
|
||||
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_note",
|
||||
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
|
||||
req, existing, "incoming")
|
||||
}
|
||||
s.broadcast(note.ID, req.UserID, req.MediaItemID, "note", req.Source)
|
||||
return &SaveNoteResult{Note: note, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) compareIncomingNote(req SaveNoteRequest, existing database.MediaNotes) (incomingNewer bool, contentChanged bool) {
|
||||
if req.ModifiedAt.IsZero() {
|
||||
contentSame := strings.EqualFold(req.Content, existing.Content) &&
|
||||
textEq(req.Position, existing.Position)
|
||||
return !contentSame, !contentSame
|
||||
}
|
||||
existingMod := existing.LastModifiedAt
|
||||
if !existingMod.Valid {
|
||||
existingMod = existing.UpdatedAt
|
||||
}
|
||||
if !existingMod.Valid {
|
||||
return true, true
|
||||
}
|
||||
return req.ModifiedAt.After(existingMod.Time), true
|
||||
}
|
||||
|
||||
func (s *AnnotationService) TombstoneNoteByID(ctx context.Context, id pgtype.UUID) error {
|
||||
return s.db.TombstoneMediaNoteByID(ctx, id)
|
||||
}
|
||||
|
||||
type SaveBookmarkRequest struct {
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Title string
|
||||
Position string
|
||||
Notes string
|
||||
PageNumber int32
|
||||
ChapterNumber int32
|
||||
CFIPosition string
|
||||
PercentageLoc float64
|
||||
EpubcfiLocation string
|
||||
ChapterReference int32
|
||||
Source string
|
||||
ModifiedAt time.Time
|
||||
DeviceSyncData json.RawMessage
|
||||
}
|
||||
|
||||
type SaveBookmarkResult struct {
|
||||
Bookmark database.MediaBookmarks
|
||||
Outcome SaveOutcome
|
||||
Conflict bool
|
||||
}
|
||||
|
||||
func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRequest) (*SaveBookmarkResult, error) {
|
||||
dedupKey := ComputeDedupKey(req.Title, req.EpubcfiLocation, req.Position)
|
||||
|
||||
existing, err := s.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{
|
||||
UserID: req.UserID,
|
||||
MediaItemID: req.MediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("query existing bookmark: %w", err)
|
||||
}
|
||||
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return s.createBookmark(ctx, req, dedupKey)
|
||||
}
|
||||
|
||||
if existing.Deleted.Bool {
|
||||
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < TombstoneTTL {
|
||||
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
|
||||
}
|
||||
return s.createBookmark(ctx, req, dedupKey)
|
||||
}
|
||||
|
||||
return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
|
||||
}
|
||||
|
||||
func (s *AnnotationService) createBookmark(ctx context.Context, req SaveBookmarkRequest, dedupKey string) (*SaveBookmarkResult, error) {
|
||||
modifiedAt := req.ModifiedAt
|
||||
if modifiedAt.IsZero() {
|
||||
modifiedAt = time.Now()
|
||||
}
|
||||
deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)
|
||||
|
||||
bm, err := s.db.CreateMediaBookmarkFull(ctx, database.CreateMediaBookmarkFullParams{
|
||||
MediaItemID: req.MediaItemID,
|
||||
UserID: req.UserID,
|
||||
PageNumber: pgInt4(req.PageNumber),
|
||||
ChapterNumber: pgInt4(req.ChapterNumber),
|
||||
CfiPosition: pgText(req.CFIPosition),
|
||||
Title: req.Title,
|
||||
Position: pgText(req.Position),
|
||||
Notes: pgText(req.Notes),
|
||||
PercentageLocation: pgFloat8(req.PercentageLoc),
|
||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create bookmark: %w", err)
|
||||
}
|
||||
s.broadcast(bm.ID, req.UserID, req.MediaItemID, "bookmark", req.Source)
|
||||
return &SaveBookmarkResult{Bookmark: bm, Outcome: SaveOutcomeCreated}, nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookmarkRequest, existing database.MediaBookmarks, dedupKey string) (*SaveBookmarkResult, error) {
|
||||
incomingNewer, contentChanged := s.compareIncomingBookmark(req, existing)
|
||||
|
||||
if !incomingNewer && !contentChanged {
|
||||
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
|
||||
if conflict {
|
||||
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_bookmark",
|
||||
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
|
||||
req, existing, "existing")
|
||||
}
|
||||
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeSkipped, Conflict: conflict}, nil
|
||||
}
|
||||
|
||||
modifiedAt := req.ModifiedAt
|
||||
if modifiedAt.IsZero() {
|
||||
modifiedAt = time.Now()
|
||||
}
|
||||
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
||||
|
||||
bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{
|
||||
ID: existing.ID,
|
||||
PageNumber: pgInt4(req.PageNumber),
|
||||
ChapterNumber: pgInt4(req.ChapterNumber),
|
||||
CfiPosition: pgText(req.CFIPosition),
|
||||
Title: req.Title,
|
||||
Position: pgText(req.Position),
|
||||
Notes: pgText(req.Notes),
|
||||
PercentageLocation: pgFloat8(req.PercentageLoc),
|
||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update bookmark: %w", err)
|
||||
}
|
||||
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
|
||||
if conflict {
|
||||
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_bookmark",
|
||||
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
|
||||
req, existing, "incoming")
|
||||
}
|
||||
s.broadcast(bm.ID, req.UserID, req.MediaItemID, "bookmark", req.Source)
|
||||
return &SaveBookmarkResult{Bookmark: bm, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) compareIncomingBookmark(req SaveBookmarkRequest, existing database.MediaBookmarks) (incomingNewer bool, contentChanged bool) {
|
||||
if req.ModifiedAt.IsZero() {
|
||||
contentSame := strings.EqualFold(req.Title, existing.Title) &&
|
||||
textEq(req.Notes, existing.Notes)
|
||||
return !contentSame, !contentSame
|
||||
}
|
||||
existingMod := existing.LastModifiedAt
|
||||
if !existingMod.Valid {
|
||||
existingMod = existing.CreatedAt
|
||||
}
|
||||
return req.ModifiedAt.After(existingMod.Time), true
|
||||
}
|
||||
|
||||
func (s *AnnotationService) TombstoneBookmarkByID(ctx context.Context, bookmarkID pgtype.UUID, source string) error {
|
||||
bm, err := s.db.GetMediaBookmark(ctx, bookmarkID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get bookmark for tombstone: %w", err)
|
||||
}
|
||||
err = s.db.TombstoneMediaBookmarkByID(ctx, bookmarkID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tombstone bookmark by ID: %w", err)
|
||||
}
|
||||
s.broadcast(pgtype.UUID{}, bm.UserID, bm.MediaItemID, "bookmark_delete", source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) recordConflict(
|
||||
ctx context.Context,
|
||||
userID, mediaItemID pgtype.UUID,
|
||||
conflictType, dedupKey string,
|
||||
incomingSource, existingSource string,
|
||||
incoming any,
|
||||
existing any,
|
||||
winner string,
|
||||
) {
|
||||
if s.connMgr == nil || !userID.Valid || !mediaItemID.Valid {
|
||||
return
|
||||
}
|
||||
|
||||
incomingJSON, _ := json.Marshal(incoming)
|
||||
existingJSON, _ := json.Marshal(existing)
|
||||
|
||||
var incomingMap, existingMap map[string]interface{}
|
||||
json.Unmarshal(incomingJSON, &incomingMap)
|
||||
json.Unmarshal(existingJSON, &existingMap)
|
||||
if incomingMap == nil {
|
||||
incomingMap = map[string]interface{}{}
|
||||
}
|
||||
if existingMap == nil {
|
||||
existingMap = map[string]interface{}{}
|
||||
}
|
||||
incomingMap["dedup_key"] = dedupKey
|
||||
existingMap["dedup_key"] = dedupKey
|
||||
|
||||
conflictData, _ := json.Marshal(map[string]interface{}{
|
||||
"incoming": map[string]interface{}{
|
||||
"source": incomingSource,
|
||||
"data": incomingMap,
|
||||
},
|
||||
"existing": map[string]interface{}{
|
||||
"source": existingSource,
|
||||
"data": existingMap,
|
||||
},
|
||||
})
|
||||
|
||||
resolutionData, _ := json.Marshal(map[string]interface{}{
|
||||
"winner": winner,
|
||||
"reason": "last_modified_at_wins",
|
||||
})
|
||||
|
||||
conflict, err := s.db.CreateAutoResolvedSyncConflict(ctx, database.CreateAutoResolvedSyncConflictParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
ConflictType: conflictType,
|
||||
ConflictData: conflictData,
|
||||
ResolutionData: resolutionData,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("AnnotationService: failed to record conflict: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var conflictIDStr string
|
||||
if conflict.ID.Valid {
|
||||
conflictIDStr = uuid.UUID(conflict.ID.Bytes).String()
|
||||
}
|
||||
s.connMgr.BroadcastConflictNotification(
|
||||
uuid.UUID(mediaItemID.Bytes),
|
||||
"annotation_conflict",
|
||||
conflictIDStr,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *AnnotationService) broadcast(
|
||||
highlightID, userID, mediaItemID pgtype.UUID,
|
||||
annotationType string,
|
||||
source string,
|
||||
) {
|
||||
if s.connMgr == nil || !userID.Valid || !mediaItemID.Valid {
|
||||
return
|
||||
}
|
||||
src := SourceDevice{Type: source}
|
||||
s.connMgr.BroadcastAnnotationUpdate(
|
||||
uuid.UUID(mediaItemID.Bytes),
|
||||
annotationType,
|
||||
map[string]interface{}{
|
||||
"highlight_id": uuid.UUID(highlightID.Bytes),
|
||||
},
|
||||
src,
|
||||
)
|
||||
}
|
||||
|
||||
func ComputeDedupKey(selectionText, epubcfiStart, startPosition string) string {
|
||||
normalized := normalizeText(selectionText)
|
||||
posBucket := bucketPosition(epubcfiStart)
|
||||
if posBucket == "" {
|
||||
posBucket = bucketPosition(startPosition)
|
||||
}
|
||||
|
||||
h := sha1.New()
|
||||
h.Write([]byte(normalized))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(posBucket))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func normalizeText(s string) string {
|
||||
fields := strings.Fields(strings.ToLower(s))
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
|
||||
func bucketPosition(pos string) string {
|
||||
if pos == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(pos, "epubcfi(") {
|
||||
if idx := strings.LastIndex(pos, ":"); idx > 0 {
|
||||
return pos[:idx]
|
||||
}
|
||||
}
|
||||
if len(pos) > 50 {
|
||||
return pos[:50]
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
func mergeDeviceSyncData(existing []byte, source string, data json.RawMessage) []byte {
|
||||
if source == "" && len(data) == 0 {
|
||||
return existing
|
||||
}
|
||||
m := make(map[string]interface{})
|
||||
if len(existing) > 0 {
|
||||
_ = json.Unmarshal(existing, &m)
|
||||
}
|
||||
if source != "" {
|
||||
if len(data) > 0 {
|
||||
var val interface{}
|
||||
_ = json.Unmarshal(data, &val)
|
||||
m[source] = val
|
||||
} else {
|
||||
m[source] = map[string]interface{}{"synced_at": time.Now().UTC().Format(time.RFC3339)}
|
||||
}
|
||||
}
|
||||
result, _ := json.Marshal(m)
|
||||
return result
|
||||
}
|
||||
|
||||
func isCrossSource(incoming string, existing pgtype.Text) bool {
|
||||
if incoming == "" || !existing.Valid {
|
||||
return false
|
||||
}
|
||||
return incoming != existing.String
|
||||
}
|
||||
|
||||
func pgText(s string) pgtype.Text {
|
||||
if s == "" {
|
||||
return pgtype.Text{Valid: false}
|
||||
}
|
||||
return pgtype.Text{String: s, Valid: true}
|
||||
}
|
||||
|
||||
func pgFloat8(f float64) pgtype.Float8 {
|
||||
if f == 0 {
|
||||
return pgtype.Float8{Valid: false}
|
||||
}
|
||||
return pgtype.Float8{Float64: f, Valid: true}
|
||||
}
|
||||
|
||||
func pgInt4(i int32) pgtype.Int4 {
|
||||
if i == 0 {
|
||||
return pgtype.Int4{Valid: false}
|
||||
}
|
||||
return pgtype.Int4{Int32: i, Valid: true}
|
||||
}
|
||||
|
||||
func textEq(a string, b pgtype.Text) bool {
|
||||
if !b.Valid {
|
||||
return a == ""
|
||||
}
|
||||
return a == b.String
|
||||
}
|
||||
|
||||
func floatEq(a float64, b pgtype.Float8) bool {
|
||||
if !b.Valid {
|
||||
return a == 0
|
||||
}
|
||||
return math.Abs(a-b.Float64) < 0.001
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func TestComputeDedupKey_Deterministic(t *testing.T) {
|
||||
k1 := ComputeDedupKey("Hello World", "epubcfi(/6/4!/4/10/3:100)", "")
|
||||
k2 := ComputeDedupKey("Hello World", "epubcfi(/6/4!/4/10/3:100)", "")
|
||||
if k1 != k2 {
|
||||
t.Errorf("same input should produce same key: %q vs %q", k1, k2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDedupKey_Normalization(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"Hello World", " Hello World "},
|
||||
{"HELLO WORLD", "hello world"},
|
||||
{"Hello World", "Hello World"},
|
||||
{"Hello\t\nWorld", "Hello World"},
|
||||
}
|
||||
cfi := "epubcfi(/6/4!/4/10/3:100)"
|
||||
for _, c := range cases {
|
||||
k1 := ComputeDedupKey(c[0], cfi, "")
|
||||
k2 := ComputeDedupKey(c[1], cfi, "")
|
||||
if k1 != k2 {
|
||||
t.Errorf("normalized texts should match: %q vs %q → %q vs %q", c[0], c[1], k1, k2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDedupKey_PositionSensitivity(t *testing.T) {
|
||||
text := "same text"
|
||||
k1 := ComputeDedupKey(text, "epubcfi(/6/4!/4/10/3:100)", "")
|
||||
k2 := ComputeDedupKey(text, "epubcfi(/6/4!/4/20/3:100)", "")
|
||||
if k1 == k2 {
|
||||
t.Error("different element paths should produce different keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDedupKey_OffsetInsensitive(t *testing.T) {
|
||||
text := "same text"
|
||||
base := "epubcfi(/6/4!/4/10/3:100)"
|
||||
offsetShift := "epubcfi(/6/4!/4/10/3:200)"
|
||||
k1 := ComputeDedupKey(text, base, "")
|
||||
k2 := ComputeDedupKey(text, offsetShift, "")
|
||||
if k1 != k2 {
|
||||
t.Error("same element path with different char offsets should produce same key (bucket)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDedupKey_FallbackToRawPosition(t *testing.T) {
|
||||
text := "same text"
|
||||
k1 := ComputeDedupKey(text, "", "page:42")
|
||||
k2 := ComputeDedupKey(text, "", "page:42")
|
||||
if k1 != k2 {
|
||||
t.Error("same raw position should produce same key")
|
||||
}
|
||||
k3 := ComputeDedupKey(text, "", "page:99")
|
||||
if k1 == k3 {
|
||||
t.Error("different raw positions should produce different keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDedupKey_DifferentTextSamePosition(t *testing.T) {
|
||||
cfi := "epubcfi(/6/4!/4/10/3:100)"
|
||||
k1 := ComputeDedupKey("first highlight", cfi, "")
|
||||
k2 := ComputeDedupKey("second highlight", cfi, "")
|
||||
if k1 == k2 {
|
||||
t.Error("different selection text should produce different keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeText(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"Hello World", "hello world"},
|
||||
{" Hello World ", "hello world"},
|
||||
{"Hello\t\nWorld", "hello world"},
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := normalizeText(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("normalizeText(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBucketPosition(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"epubcfi(/6/4!/4/10/3:100)", "epubcfi(/6/4!/4/10/3"},
|
||||
{"epubcfi(/6/4!/4/10/3:0)", "epubcfi(/6/4!/4/10/3"},
|
||||
{"page:42", "page:42"},
|
||||
{"short", "short"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := bucketPosition(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("bucketPosition(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBucketPosition_LongString(t *testing.T) {
|
||||
long := "this_is_a_very_long_position_string_that_exceeds_fifty_characters_total"
|
||||
got := bucketPosition(long)
|
||||
if len(got) > 50 {
|
||||
t.Errorf("bucketPosition should truncate to <=50 chars, got %d", len(got))
|
||||
}
|
||||
if got != long[:50] {
|
||||
t.Errorf("bucketPosition truncated wrong: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDeviceSyncData_NewEntry(t *testing.T) {
|
||||
result := mergeDeviceSyncData(nil, "koreader", json.RawMessage(`{"datetime":"2024-01-01"}`))
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(result, &m); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
entry, ok := m["koreader"]
|
||||
if !ok {
|
||||
t.Fatal("expected koreader entry")
|
||||
}
|
||||
entryMap := entry.(map[string]interface{})
|
||||
if entryMap["datetime"] != "2024-01-01" {
|
||||
t.Errorf("unexpected datetime: %v", entryMap["datetime"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDeviceSyncData_PreservesExisting(t *testing.T) {
|
||||
existing := []byte(`{"koreader":{"datetime":"2024-01-01"}}`)
|
||||
result := mergeDeviceSyncData(existing, "kobo", json.RawMessage(`{"bookmark_id":"abc"}`))
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(result, &m); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
if _, ok := m["koreader"]; !ok {
|
||||
t.Error("koreader entry should be preserved")
|
||||
}
|
||||
if _, ok := m["kobo"]; !ok {
|
||||
t.Error("kobo entry should be added")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDeviceSyncData_OverwritesSameSource(t *testing.T) {
|
||||
existing := []byte(`{"koreader":{"datetime":"old"}}`)
|
||||
result := mergeDeviceSyncData(existing, "koreader", json.RawMessage(`{"datetime":"new"}`))
|
||||
var m map[string]interface{}
|
||||
json.Unmarshal(result, &m)
|
||||
entry := m["koreader"].(map[string]interface{})
|
||||
if entry["datetime"] != "new" {
|
||||
t.Errorf("expected overwritten datetime 'new', got %v", entry["datetime"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCrossSource(t *testing.T) {
|
||||
if isCrossSource("koreader", pgtype.Text{String: "kobo", Valid: true}) != true {
|
||||
t.Error("different sources should be cross-source")
|
||||
}
|
||||
if isCrossSource("koreader", pgtype.Text{String: "koreader", Valid: true}) != false {
|
||||
t.Error("same sources should not be cross-source")
|
||||
}
|
||||
if isCrossSource("", pgtype.Text{String: "koreader", Valid: true}) != false {
|
||||
t.Error("empty incoming source should not be cross-source")
|
||||
}
|
||||
if isCrossSource("koreader", pgtype.Text{Valid: false}) != false {
|
||||
t.Error("invalid existing source should not be cross-source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareIncoming_FieldDiff_Identical(t *testing.T) {
|
||||
svc := &AnnotationService{}
|
||||
req := SaveHighlightRequest{
|
||||
SelectionText: "hello",
|
||||
Color: "#ffff00",
|
||||
NoteText: "a note",
|
||||
PercentageStart: 10.5,
|
||||
PercentageEnd: 11.0,
|
||||
}
|
||||
existing := pgHighlights("hello", "#ffff00", "a note", 10.5, 11.0)
|
||||
newer, changed := svc.compareIncoming(req, existing)
|
||||
if newer {
|
||||
t.Error("identical content should not be newer")
|
||||
}
|
||||
if changed {
|
||||
t.Error("identical content should not be changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareIncoming_FieldDiff_DifferentText(t *testing.T) {
|
||||
svc := &AnnotationService{}
|
||||
req := SaveHighlightRequest{
|
||||
SelectionText: "edited text",
|
||||
}
|
||||
existing := pgHighlights("original text", "#ffff00", "", 0, 0)
|
||||
newer, changed := svc.compareIncoming(req, existing)
|
||||
if !newer {
|
||||
t.Error("different content should be newer")
|
||||
}
|
||||
if !changed {
|
||||
t.Error("different content should be changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareIncoming_FieldDiff_DifferentColor(t *testing.T) {
|
||||
svc := &AnnotationService{}
|
||||
req := SaveHighlightRequest{
|
||||
SelectionText: "same",
|
||||
Color: "#ff0000",
|
||||
}
|
||||
existing := pgHighlights("same", "#ffff00", "", 0, 0)
|
||||
_, changed := svc.compareIncoming(req, existing)
|
||||
if !changed {
|
||||
t.Error("different color should be detected as changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareIncoming_LWW_NewerWins(t *testing.T) {
|
||||
svc := &AnnotationService{}
|
||||
now := time.Now()
|
||||
req := SaveHighlightRequest{
|
||||
SelectionText: "same",
|
||||
ModifiedAt: now.Add(1 * time.Hour),
|
||||
}
|
||||
existing := pgHighlights("same", "", "", 0, 0)
|
||||
existing.LastModifiedAt = pgtype.Timestamptz{Time: now, Valid: true}
|
||||
newer, changed := svc.compareIncoming(req, existing)
|
||||
if !newer {
|
||||
t.Error("future timestamp should be newer")
|
||||
}
|
||||
if !changed {
|
||||
t.Error("LWW mode should always report changed=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareIncoming_LWW_OlderSkipped(t *testing.T) {
|
||||
svc := &AnnotationService{}
|
||||
now := time.Now()
|
||||
req := SaveHighlightRequest{
|
||||
SelectionText: "same",
|
||||
ModifiedAt: now.Add(-1 * time.Hour),
|
||||
}
|
||||
existing := pgHighlights("same", "", "", 0, 0)
|
||||
existing.LastModifiedAt = pgtype.Timestamptz{Time: now, Valid: true}
|
||||
newer, _ := svc.compareIncoming(req, existing)
|
||||
if newer {
|
||||
t.Error("past timestamp should not be newer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareIncoming_LWW_FallsBackToUpdatedAt(t *testing.T) {
|
||||
svc := &AnnotationService{}
|
||||
now := time.Now()
|
||||
req := SaveHighlightRequest{
|
||||
SelectionText: "same",
|
||||
ModifiedAt: now.Add(1 * time.Hour),
|
||||
}
|
||||
existing := pgHighlights("same", "", "", 0, 0)
|
||||
existing.LastModifiedAt = pgtype.Timestamptz{Valid: false}
|
||||
existing.UpdatedAt = pgtype.Timestamptz{Time: now, Valid: true}
|
||||
newer, _ := svc.compareIncoming(req, existing)
|
||||
if !newer {
|
||||
t.Error("should fall back to updated_at when last_modified_at is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPgText(t *testing.T) {
|
||||
if pgText("").Valid {
|
||||
t.Error("empty string should produce invalid pgtype.Text")
|
||||
}
|
||||
v := pgText("hello")
|
||||
if !v.Valid || v.String != "hello" {
|
||||
t.Errorf("expected valid 'hello', got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPgFloat8(t *testing.T) {
|
||||
if pgFloat8(0).Valid {
|
||||
t.Error("zero should produce invalid pgtype.Float8")
|
||||
}
|
||||
v := pgFloat8(1.5)
|
||||
if !v.Valid || v.Float64 != 1.5 {
|
||||
t.Errorf("expected valid 1.5, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPgInt4(t *testing.T) {
|
||||
if pgInt4(0).Valid {
|
||||
t.Error("zero should produce invalid pgtype.Int4")
|
||||
}
|
||||
v := pgInt4(3)
|
||||
if !v.Valid || v.Int32 != 3 {
|
||||
t.Errorf("expected valid 3, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFloatEq(t *testing.T) {
|
||||
if !floatEq(0, pgtype.Float8{Valid: false}) {
|
||||
t.Error("0 vs invalid should be equal")
|
||||
}
|
||||
if !floatEq(10.5, pgtype.Float8{Float64: 10.5, Valid: true}) {
|
||||
t.Error("10.5 vs 10.5 should be equal")
|
||||
}
|
||||
if floatEq(10.6, pgtype.Float8{Float64: 10.5, Valid: true}) {
|
||||
t.Error("10.6 vs 10.5 should not be equal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextEq(t *testing.T) {
|
||||
if !textEq("", pgtype.Text{Valid: false}) {
|
||||
t.Error("empty vs invalid should be equal")
|
||||
}
|
||||
if !textEq("hi", pgtype.Text{String: "hi", Valid: true}) {
|
||||
t.Error("same strings should be equal")
|
||||
}
|
||||
if textEq("hi", pgtype.Text{String: "bye", Valid: true}) {
|
||||
t.Error("different strings should not be equal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTombstoneTTL(t *testing.T) {
|
||||
if TombstoneTTL != 30*24*time.Hour {
|
||||
t.Errorf("expected 30 days, got %v", TombstoneTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func pgHighlights(text, color, note string, pctStart, pctEnd float64) database.MediaHighlights {
|
||||
return database.MediaHighlights{
|
||||
SelectionText: text,
|
||||
Color: pgtype.Text{String: color, Valid: color != ""},
|
||||
NoteText: pgtype.Text{String: note, Valid: note != ""},
|
||||
PercentageStart: pgtype.Float8{Float64: pctStart, Valid: pctStart != 0},
|
||||
PercentageEnd: pgtype.Float8{Float64: pctEnd, Valid: pctEnd != 0},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package sync
|
||||
|
||||
import "log"
|
||||
|
||||
type LocatorSource string
|
||||
|
||||
const (
|
||||
LocatorSourceKOReader LocatorSource = "koreader"
|
||||
LocatorSourceKobo LocatorSource = "kobo"
|
||||
LocatorSourceWeb LocatorSource = "web"
|
||||
)
|
||||
|
||||
type CanonicalLocator struct {
|
||||
CFI string
|
||||
Precision string
|
||||
Percentage float64
|
||||
}
|
||||
|
||||
type DeviceLocator struct {
|
||||
Position string
|
||||
Precision string
|
||||
Percentage float64
|
||||
}
|
||||
|
||||
func isConvertible(formatGroup string) bool {
|
||||
return formatGroup == string(FormatGroupReflowable)
|
||||
}
|
||||
|
||||
func ConvertToCanonical(
|
||||
source LocatorSource,
|
||||
devicePos string,
|
||||
percentage float64,
|
||||
contextText string,
|
||||
formatGroup string,
|
||||
epubPath string,
|
||||
kepubPath string,
|
||||
) CanonicalLocator {
|
||||
if !isConvertible(formatGroup) || epubPath == "" {
|
||||
return CanonicalLocator{
|
||||
CFI: devicePos,
|
||||
Precision: "passthrough",
|
||||
Percentage: percentage,
|
||||
}
|
||||
}
|
||||
|
||||
switch source {
|
||||
case LocatorSourceKOReader:
|
||||
if !IsCREXPointer(devicePos) {
|
||||
return CanonicalLocator{CFI: devicePos, Precision: "already-standard", Percentage: percentage}
|
||||
}
|
||||
converter := NewCFIConverter(epubPath)
|
||||
result, err := converter.ConvertCREToStandard(devicePos, percentage, contextText)
|
||||
if err != nil || result == nil {
|
||||
log.Printf("Bookhoard: locator CRE→CFI conversion failed: %v", err)
|
||||
return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage}
|
||||
}
|
||||
if result.EPUBCFI != "" {
|
||||
return CanonicalLocator{CFI: result.EPUBCFI, Precision: result.Precision, Percentage: result.Percentage}
|
||||
}
|
||||
if result.Href != "" {
|
||||
return CanonicalLocator{CFI: result.Href, Precision: result.Precision, Percentage: result.Percentage}
|
||||
}
|
||||
return CanonicalLocator{CFI: devicePos, Precision: result.Precision, Percentage: percentage}
|
||||
|
||||
case LocatorSourceKobo:
|
||||
if kepubPath == "" {
|
||||
return CanonicalLocator{CFI: devicePos, Precision: "no-kepub", Percentage: percentage}
|
||||
}
|
||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
||||
result, err := converter.ConvertKEPUBCFIToStandard(devicePos, percentage, contextText)
|
||||
if err != nil || result == nil {
|
||||
log.Printf("Bookhoard: locator KEPUB→CFI conversion failed: %v", err)
|
||||
return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage}
|
||||
}
|
||||
if result.CFI != "" {
|
||||
return CanonicalLocator{CFI: result.CFI, Precision: result.Precision, Percentage: result.Percentage}
|
||||
}
|
||||
return CanonicalLocator{CFI: devicePos, Precision: result.Precision, Percentage: percentage}
|
||||
|
||||
default:
|
||||
return CanonicalLocator{CFI: devicePos, Precision: "passthrough", Percentage: percentage}
|
||||
}
|
||||
}
|
||||
|
||||
func ConvertFromCanonical(
|
||||
source LocatorSource,
|
||||
canonicalCFI string,
|
||||
percentage float64,
|
||||
contextText string,
|
||||
formatGroup string,
|
||||
epubPath string,
|
||||
kepubPath string,
|
||||
) DeviceLocator {
|
||||
if !isConvertible(formatGroup) || epubPath == "" || canonicalCFI == "" {
|
||||
return DeviceLocator{
|
||||
Position: canonicalCFI,
|
||||
Precision: "passthrough",
|
||||
Percentage: percentage,
|
||||
}
|
||||
}
|
||||
|
||||
switch source {
|
||||
case LocatorSourceKOReader:
|
||||
converter := NewCFIConverter(epubPath)
|
||||
result, err := converter.ConvertStandardToCRE(canonicalCFI, percentage, contextText)
|
||||
if err != nil || result == nil {
|
||||
log.Printf("Bookhoard: locator CFI→CRE conversion failed: %v", err)
|
||||
return DeviceLocator{Position: canonicalCFI, Precision: "fallback", Percentage: percentage}
|
||||
}
|
||||
if result.XPointer != "" {
|
||||
return DeviceLocator{Position: result.XPointer, Precision: result.Precision, Percentage: result.Percentage}
|
||||
}
|
||||
return DeviceLocator{Position: canonicalCFI, Precision: result.Precision, Percentage: percentage}
|
||||
|
||||
case LocatorSourceKobo:
|
||||
if kepubPath == "" {
|
||||
return DeviceLocator{Position: canonicalCFI, Precision: "no-kepub", Percentage: percentage}
|
||||
}
|
||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
||||
result, err := converter.ConvertStandardCFIToKEPUB(canonicalCFI, percentage, contextText)
|
||||
if err != nil || result == nil {
|
||||
log.Printf("Bookhoard: locator CFI→KEPUB conversion failed: %v", err)
|
||||
return DeviceLocator{Position: canonicalCFI, Precision: "fallback", Percentage: percentage}
|
||||
}
|
||||
if result.CFI != "" {
|
||||
return DeviceLocator{Position: result.CFI, Precision: result.Precision, Percentage: result.Percentage}
|
||||
}
|
||||
return DeviceLocator{Position: canonicalCFI, Precision: result.Precision, Percentage: percentage}
|
||||
|
||||
default:
|
||||
return DeviceLocator{Position: canonicalCFI, Precision: "passthrough", Percentage: percentage}
|
||||
}
|
||||
}
|
||||
+230
-2
@@ -37,6 +37,7 @@ const (
|
||||
type SyncQueueProcessor struct {
|
||||
db *database.Queries
|
||||
progressSvc *ProgressService
|
||||
annotationSvc *AnnotationService
|
||||
progressChan chan *ProgressUpdate
|
||||
interval time.Duration
|
||||
batchSize int
|
||||
@@ -85,6 +86,10 @@ func (p *SyncQueueProcessor) SetProgressService(svc *ProgressService) {
|
||||
p.progressSvc = svc
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) SetAnnotationService(svc *AnnotationService) {
|
||||
p.annotationSvc = svc
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) Start(ctx context.Context) {
|
||||
log.Printf("Starting sync queue processor (interval: %v, batch: %d)", p.interval, p.batchSize)
|
||||
|
||||
@@ -113,6 +118,100 @@ func (p *SyncQueueProcessor) EnqueueProgress(update *ProgressUpdate) error {
|
||||
}
|
||||
}
|
||||
|
||||
type HighlightUpdate struct {
|
||||
DeviceID pgtype.UUID
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
SelectionText string
|
||||
StartPosition string
|
||||
EndPosition string
|
||||
Color string
|
||||
NoteText string
|
||||
EpubcfiStart string
|
||||
EpubcfiEnd string
|
||||
PercentageStart float64
|
||||
PercentageEnd float64
|
||||
Source string
|
||||
DeviceSyncData map[string]interface{}
|
||||
}
|
||||
|
||||
type NoteUpdate struct {
|
||||
DeviceID pgtype.UUID
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Content string
|
||||
Position string
|
||||
Source string
|
||||
DeviceSyncData map[string]interface{}
|
||||
}
|
||||
|
||||
type BookmarkUpdate struct {
|
||||
DeviceID pgtype.UUID
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Title string
|
||||
Position string
|
||||
Notes string
|
||||
Source string
|
||||
DeviceSyncData map[string]interface{}
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) EnqueueHighlight(ctx context.Context, update *HighlightUpdate) error {
|
||||
syncData := map[string]interface{}{
|
||||
"selection_text": update.SelectionText,
|
||||
"start_position": update.StartPosition,
|
||||
"end_position": update.EndPosition,
|
||||
"color": update.Color,
|
||||
"note_text": update.NoteText,
|
||||
"source": update.Source,
|
||||
"epubcfi_start": update.EpubcfiStart,
|
||||
"epubcfi_end": update.EpubcfiEnd,
|
||||
"percentage_start": update.PercentageStart,
|
||||
"percentage_end": update.PercentageEnd,
|
||||
"device_sync_data": update.DeviceSyncData,
|
||||
}
|
||||
return p.enqueueAnnotation(ctx, update.DeviceID, update.MediaItemID, SyncTypeHighlight, syncData)
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) EnqueueNote(ctx context.Context, update *NoteUpdate) error {
|
||||
syncData := map[string]interface{}{
|
||||
"content": update.Content,
|
||||
"position": update.Position,
|
||||
"source": update.Source,
|
||||
"device_sync_data": update.DeviceSyncData,
|
||||
}
|
||||
return p.enqueueAnnotation(ctx, update.DeviceID, update.MediaItemID, SyncTypeNote, syncData)
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) EnqueueBookmark(ctx context.Context, update *BookmarkUpdate) error {
|
||||
syncData := map[string]interface{}{
|
||||
"title": update.Title,
|
||||
"position": update.Position,
|
||||
"notes": update.Notes,
|
||||
"source": update.Source,
|
||||
"device_sync_data": update.DeviceSyncData,
|
||||
}
|
||||
return p.enqueueAnnotation(ctx, update.DeviceID, update.MediaItemID, SyncTypeBookmark, syncData)
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) enqueueAnnotation(ctx context.Context, deviceID, mediaItemID pgtype.UUID, syncType string, syncData map[string]interface{}) error {
|
||||
syncDataJSON, err := json.Marshal(syncData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal sync data: %w", err)
|
||||
}
|
||||
|
||||
_, err = p.db.CreateSyncQueueItem(ctx, database.CreateSyncQueueItemParams{
|
||||
DeviceID: deviceID,
|
||||
MediaItemID: mediaItemID,
|
||||
SyncType: syncType,
|
||||
SyncData: syncDataJSON,
|
||||
Priority: pgtype.Int4{Int32: int32(PriorityCriticalNote), Valid: true},
|
||||
MaxAttempts: pgtype.Int4{Int32: 3, Valid: true},
|
||||
Status: pgtype.Text{String: SyncStatusPending, Valid: true},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) enqueueProgressUpdate(ctx context.Context, update *ProgressUpdate) {
|
||||
syncData := map[string]interface{}{
|
||||
"percentage": update.Percentage,
|
||||
@@ -308,6 +407,8 @@ func (p *SyncQueueProcessor) executeSync(ctx context.Context, item SyncQueueItem
|
||||
return p.syncNote(ctx, device.UserID, item.MediaItemID, syncData)
|
||||
case SyncTypeHighlight:
|
||||
return p.syncHighlight(ctx, device.UserID, item.MediaItemID, syncData)
|
||||
case SyncTypeBookmark:
|
||||
return p.syncBookmark(ctx, device.UserID, item.MediaItemID, syncData)
|
||||
default:
|
||||
return fmt.Errorf("unsupported sync type: %s", item.SyncType)
|
||||
}
|
||||
@@ -407,11 +508,138 @@ func (p *SyncQueueProcessor) syncProgress(ctx context.Context, userID pgtype.UUI
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) syncNote(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error {
|
||||
return fmt.Errorf("note sync not yet implemented")
|
||||
if p.annotationSvc == nil {
|
||||
return fmt.Errorf("annotation service not available")
|
||||
}
|
||||
|
||||
req := SaveNoteRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if v, ok := syncData["content"].(string); ok {
|
||||
req.Content = v
|
||||
}
|
||||
if v, ok := syncData["position"].(string); ok {
|
||||
req.Position = v
|
||||
}
|
||||
if v, ok := syncData["source"].(string); ok {
|
||||
req.Source = v
|
||||
}
|
||||
if v, ok := syncData["epubcfi_location"].(string); ok {
|
||||
req.EpubcfiLocation = v
|
||||
}
|
||||
if v, ok := syncData["percentage_location"].(float64); ok {
|
||||
req.PercentageLocation = v
|
||||
}
|
||||
if v, ok := syncData["chapter_reference"].(float64); ok {
|
||||
req.ChapterReference = int32(v)
|
||||
}
|
||||
if v, ok := syncData["device_sync_data"].(map[string]interface{}); ok {
|
||||
req.DeviceSyncData, _ = json.Marshal(v)
|
||||
}
|
||||
|
||||
_, err := p.annotationSvc.SaveNote(ctx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) syncHighlight(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error {
|
||||
return fmt.Errorf("highlight sync not yet implemented")
|
||||
if p.annotationSvc == nil {
|
||||
return fmt.Errorf("annotation service not available")
|
||||
}
|
||||
|
||||
req := SaveHighlightRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if v, ok := syncData["selection_text"].(string); ok {
|
||||
req.SelectionText = v
|
||||
}
|
||||
if v, ok := syncData["start_position"].(string); ok {
|
||||
req.StartPosition = v
|
||||
}
|
||||
if v, ok := syncData["end_position"].(string); ok {
|
||||
req.EndPosition = v
|
||||
}
|
||||
if v, ok := syncData["color"].(string); ok {
|
||||
req.Color = v
|
||||
}
|
||||
if v, ok := syncData["note_text"].(string); ok {
|
||||
req.NoteText = v
|
||||
}
|
||||
if v, ok := syncData["source"].(string); ok {
|
||||
req.Source = v
|
||||
}
|
||||
if v, ok := syncData["epubcfi_start"].(string); ok {
|
||||
req.EpubcfiStart = v
|
||||
}
|
||||
if v, ok := syncData["epubcfi_end"].(string); ok {
|
||||
req.EpubcfiEnd = v
|
||||
}
|
||||
if v, ok := syncData["percentage_start"].(float64); ok {
|
||||
req.PercentageStart = v
|
||||
}
|
||||
if v, ok := syncData["percentage_end"].(float64); ok {
|
||||
req.PercentageEnd = v
|
||||
}
|
||||
if v, ok := syncData["chapter_reference"].(float64); ok {
|
||||
req.ChapterReference = int32(v)
|
||||
}
|
||||
if v, ok := syncData["device_sync_data"].(map[string]interface{}); ok {
|
||||
req.DeviceSyncData, _ = json.Marshal(v)
|
||||
}
|
||||
|
||||
_, err := p.annotationSvc.SaveHighlight(ctx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) syncBookmark(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error {
|
||||
if p.annotationSvc == nil {
|
||||
return fmt.Errorf("annotation service not available")
|
||||
}
|
||||
|
||||
req := SaveBookmarkRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if v, ok := syncData["title"].(string); ok {
|
||||
req.Title = v
|
||||
}
|
||||
if v, ok := syncData["position"].(string); ok {
|
||||
req.Position = v
|
||||
}
|
||||
if v, ok := syncData["notes"].(string); ok {
|
||||
req.Notes = v
|
||||
}
|
||||
if v, ok := syncData["source"].(string); ok {
|
||||
req.Source = v
|
||||
}
|
||||
if v, ok := syncData["cfi_position"].(string); ok {
|
||||
req.CFIPosition = v
|
||||
}
|
||||
if v, ok := syncData["epubcfi_location"].(string); ok {
|
||||
req.EpubcfiLocation = v
|
||||
}
|
||||
if v, ok := syncData["percentage_loc"].(float64); ok {
|
||||
req.PercentageLoc = v
|
||||
}
|
||||
if v, ok := syncData["page_number"].(float64); ok {
|
||||
req.PageNumber = int32(v)
|
||||
}
|
||||
if v, ok := syncData["chapter_number"].(float64); ok {
|
||||
req.ChapterNumber = int32(v)
|
||||
}
|
||||
if v, ok := syncData["chapter_reference"].(float64); ok {
|
||||
req.ChapterReference = int32(v)
|
||||
}
|
||||
if v, ok := syncData["device_sync_data"].(map[string]interface{}); ok {
|
||||
req.DeviceSyncData, _ = json.Marshal(v)
|
||||
}
|
||||
|
||||
_, err := p.annotationSvc.SaveBookmark(ctx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) markItemFailed(ctx context.Context, item SyncQueueItem, errMsg string) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env sh
|
||||
# Project-attached wrapper around `make release` so you can run:
|
||||
# ./release v0.3.0 (or) ./release 0.3.0
|
||||
# instead of:
|
||||
# make release VERSION=v0.3.0
|
||||
# Lives in the repo (no machine-specific alias needed).
|
||||
set -eu
|
||||
|
||||
[ "$#" -ge 1 ] || { echo "Usage: ./release v0.3.0" >&2; exit 1; }
|
||||
|
||||
# Accept "0.3.0" or "v0.3.0"; ensure the tag starts with 'v' (the workflow
|
||||
# only triggers on v* tags).
|
||||
VERSION="v${1#v}"
|
||||
|
||||
exec make release "VERSION=${VERSION}"
|
||||
+36
-18
@@ -44,25 +44,43 @@ const config: Config = {
|
||||
],
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: "#7aa2f7",
|
||||
50: "#f0f9ff",
|
||||
100: "#e0f2fe",
|
||||
200: "#bae6fd",
|
||||
300: "#7dd3fc",
|
||||
400: "#38bdf8",
|
||||
500: "#0ea5e9",
|
||||
600: "#0284c7",
|
||||
700: "#0369a1",
|
||||
800: "#075985",
|
||||
900: "#0c4a6e",
|
||||
// Semantic surface tokens (page bg, cards, raised layers)
|
||||
surface: {
|
||||
DEFAULT: "var(--bg-primary)",
|
||||
raised: "var(--bg-secondary)",
|
||||
hover: "var(--surface-hover)",
|
||||
overlay: "var(--surface-overlay)",
|
||||
},
|
||||
"bg-primary": "var(--bg-primary)",
|
||||
"bg-secondary": "var(--bg-secondary)",
|
||||
"text-primary": "var(--text-primary)",
|
||||
"text-secondary": "var(--text-secondary)",
|
||||
accent: "var(--accent)",
|
||||
border: "var(--border)",
|
||||
// Text tokens
|
||||
content: {
|
||||
DEFAULT: "var(--text-primary)",
|
||||
muted: "var(--text-secondary)",
|
||||
},
|
||||
// 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: {
|
||||
"wood-light": "url('/static/textures/wood-light.png')",
|
||||
|
||||
+54
-32
@@ -11,60 +11,79 @@ templ Admin(user User) {
|
||||
</head>
|
||||
<body x-data="admin" x-init="loadWatchStatus(); initializeScanWebSocket()" class="theme-tokyo-night">
|
||||
@Header(user, "/admin")
|
||||
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
|
||||
<div class="flex">
|
||||
@AdminSidebar(user, "/admin")
|
||||
<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 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("grid", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Dashboard</h1>
|
||||
</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>
|
||||
<p class="text-sm" 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-6">
|
||||
<div class="stat-card">
|
||||
<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("library", "h-5 w-5")
|
||||
</span>
|
||||
<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>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">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>
|
||||
<a href="/" class="btn btn-secondary mt-4 text-sm">
|
||||
@Icon("arrow-right", "h-4 w-4")
|
||||
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 class="stat-card">
|
||||
<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>
|
||||
<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>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">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>
|
||||
<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>
|
||||
<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="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-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 @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")
|
||||
Rescan Library
|
||||
</span>
|
||||
<span class="text-xs font-normal opacity-80">Re-scan existing files and fix metadata</span>
|
||||
</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 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 scan directories</span>
|
||||
</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 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" style="color: var(--text-primary)">
|
||||
📚 Scanning Libraries
|
||||
<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="p-2 hover:bg-gray-700 rounded">
|
||||
✕
|
||||
<button @click="hideScanProgress()" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<!-- Overall Progress -->
|
||||
@@ -73,7 +92,7 @@ templ Admin(user User) {
|
||||
<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 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"
|
||||
@@ -89,21 +108,24 @@ templ Admin(user User) {
|
||||
<!-- 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" 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)">
|
||||
<!-- 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"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Refresh to View Books
|
||||
</button>
|
||||
<button
|
||||
@click="hideScanProgress()"
|
||||
class="btn-secondary px-4 py-2 rounded-lg"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
|
||||
@@ -10,49 +10,69 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
||||
</head>
|
||||
<body x-data="library" x-init="initializeLibraryAdmin" class="theme-{ user.Theme }">
|
||||
@Header(user, "/admin/library")
|
||||
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
|
||||
<div class="flex">
|
||||
@AdminSidebar(user, "/admin/library")
|
||||
<main class="flex-1 p-8">
|
||||
<div class="w-full">
|
||||
<div class="max-w-5xl">
|
||||
<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
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap mb-4">
|
||||
<a href="/admin" class="btn btn-secondary">
|
||||
@Icon("arrow-left", "h-4 w-4")
|
||||
Back to Dashboard
|
||||
</a>
|
||||
<button data-action="show-create-modal" class="btn-primary px-4 py-2 rounded-lg font-medium">
|
||||
+ Create Library
|
||||
<button data-action="show-create-modal" class="btn btn-primary">
|
||||
@Icon("plus", "h-4 w-4")
|
||||
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 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("library", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Library Management</h1>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Manage libraries and configure media scanning</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- 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">
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
@Icon("library", "h-5 w-5 shrink-0")
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Libraries</h3>
|
||||
</div>
|
||||
<p class="text-sm mb-4" style="color: var(--text-secondary)">Manage media libraries and their folders</p>
|
||||
<div id="libraries-list" class="space-y-3 mb-2">
|
||||
if len(libraries) == 0 {
|
||||
<p style="color: var(--text-secondary)" class="text-center py-8">
|
||||
<p class="text-center py-8 text-sm" style="color: var(--text-secondary)">
|
||||
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>
|
||||
<div class="p-4 rounded-xl border transition-colors hover:bg-surface-hover" style="background-color: var(--bg-primary); border-color: var(--border)">
|
||||
<div class="flex justify-between items-start gap-3 mb-2">
|
||||
<div class="min-w-0">
|
||||
<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)">
|
||||
<span class="chip mt-1">
|
||||
@Icon("tag", "h-3 w-3")
|
||||
{ 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 class="flex flex-wrap gap-1 shrink-0">
|
||||
<button data-library-id={ library.ID } data-action="show-folders" class="btn btn-secondary text-xs px-2.5 py-1">
|
||||
@Icon("folder", "h-4 w-4")
|
||||
Folders
|
||||
</button>
|
||||
<button data-library-id={ library.ID } data-action="edit" class="btn btn-primary text-xs px-2.5 py-1">
|
||||
@Icon("edit", "h-4 w-4")
|
||||
Edit
|
||||
</button>
|
||||
<button data-library-id={ library.ID } data-action="delete" class="btn btn-danger text-xs px-2.5 py-1">
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id={ "library-folders-" + library.ID } class="hidden mt-3 space-y-2"></div>
|
||||
@@ -62,20 +82,26 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
||||
</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 class="card p-6">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
@Icon("check-circle", "h-5 w-5 shrink-0")
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Library Visibility</h3>
|
||||
</div>
|
||||
<p class="text-sm mb-4" style="color: var(--text-secondary)">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="mt-6 card p-6">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
@Icon("users", "h-5 w-5 shrink-0")
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">User Library Access</h3>
|
||||
</div>
|
||||
<p class="text-sm mb-4" style="color: var(--text-secondary)">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)">
|
||||
<select id="user-select" onchange="loadUserVisibility()" class="input w-auto">
|
||||
<option value="">Select a user...</option>
|
||||
for _, user := range users {
|
||||
<option value={ user.ID }>{ user.Username } ({ user.Email })</option>
|
||||
@@ -90,43 +116,50 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
||||
</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 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 mx-4" 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" data-action="hide-create-modal" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||
<button type="button" data-action="hide-create-modal" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</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/>
|
||||
<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-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>
|
||||
<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-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>
|
||||
<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" 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>
|
||||
<button type="button" data-action="hide-create-modal" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
@Icon("plus", "h-4 w-4")
|
||||
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 id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-md mx-4" 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" data-action="browse-cancel" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||
<button type="button" data-action="browse-cancel" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<div id="folder-browser-content">
|
||||
<!-- Directory listings will be rendered here -->
|
||||
@@ -134,18 +167,23 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
||||
</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 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" data-action="hide-delete-modal" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||
<button type="button" data-action="hide-delete-modal" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</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>
|
||||
<button type="button" data-action="hide-delete-modal" class="btn btn-secondary">Cancel</button>
|
||||
<button type="button" data-action="confirm-delete" class="btn btn-danger">
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, "<div class=\"flex\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -45,161 +45,290 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, "<main class=\"flex-1 p-8\"><div class=\"max-w-5xl\"><div class=\"mb-8\"><div class=\"flex items-center justify-between gap-4 flex-wrap mb-4\"><a href=\"/admin\" class=\"btn btn-secondary\">")
|
||||
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, 4, "Back to Dashboard</a> <button data-action=\"show-create-modal\" 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, "Create Library</button></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("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, 6, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Library Management</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Manage libraries and configure media scanning</p></div><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6\"><!-- Libraries Section --><div class=\"card p-6\"><div class=\"flex items-center gap-2 mb-1\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("library", "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, 7, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Libraries</h3></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Manage media libraries and their folders</p><div id=\"libraries-list\" class=\"space-y-3 mb-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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, 8, "<p class=\"text-center py-8 text-sm\" style=\"color: var(--text-secondary)\">No libraries yet. Create your first library to get started.</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
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, 9, "<div class=\"p-4 rounded-xl border transition-colors hover:bg-surface-hover\" style=\"background-color: var(--bg-primary); border-color: var(--border)\"><div class=\"flex justify-between items-start gap-3 mb-2\"><div class=\"min-w-0\"><h4 class=\"font-semibold\" 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(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 44, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 54, Col: 89}
|
||||
}
|
||||
_, 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, 6, "</h4>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</h4>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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, 11, "<p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
|
||||
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: 56, 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, 8, "</p>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, 13, "<span class=\"chip mt-1\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("tag", "h-3 w-3").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 60, 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=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span></div><div class=\"flex flex-wrap gap-1 shrink-0\"><button data-library-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
||||
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: 64, Col: 50}
|
||||
}
|
||||
_, 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, 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, 15, "\" data-action=\"show-folders\" class=\"btn btn-secondary text-xs px-2.5 py-1\">")
|
||||
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, 16, "Folders</button> <button data-library-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 54, Col: 50}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 68, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" data-action=\"edit\" class=\"btn btn-primary 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, 18, "Edit</button> <button data-library-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
||||
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: 72, Col: 50}
|
||||
}
|
||||
_, 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, 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, 19, "\" data-action=\"delete\" 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, 20, "Delete</button></div></div><div id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-folders-" + library.ID)
|
||||
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: 78, Col: 53}
|
||||
}
|
||||
_, 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, 14, "\" class=\"hidden mt-3 space-y-2\"></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"hidden mt-3 space-y-2\"></div></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, 22, "</div></div><!-- User Library Visibility Section --><div class=\"card p-6\"><div class=\"flex items-center gap-2 mb-1\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("check-circle", "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, 23, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Library Visibility</h3></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">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-6 card p-6\"><div class=\"flex items-center gap-2 mb-1\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("users", "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, 24, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">User Library Access</h3></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Manage individual user access to specific libraries</p><div class=\"mb-4\"><select id=\"user-select\" onchange=\"loadUserVisibility()\" class=\"input w-auto\"><option value=\"\">Select a user...</option> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, user := range users {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<option value=\"{ user.ID }\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 81, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 107, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
_, 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, 17, " (")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 81, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 107, Col: 51}
|
||||
}
|
||||
_, 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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, 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: 107, Col: 67}
|
||||
}
|
||||
_, 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, 28, ")</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, 29, "</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 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-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Create Library</h2><button type=\"button\" data-action=\"hide-create-modal\" 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, 30, "</button></div><form id=\"create-library-form\"><input type=\"hidden\" id=\"library-id\" name=\"id\"><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\" data-action=\"hide-create-modal\" 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, 31, "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 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)\">Browse Folders</h2><button type=\"button\" data-action=\"browse-cancel\" 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, 32, "</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 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\" data-action=\"hide-delete-modal\" 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, 33, "</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 btn-secondary\">Cancel</button> <button type=\"button\" data-action=\"confirm-delete\" 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, 34, "Delete</button></div></div></div><script src=\"/static/htmx.min.js\"></script></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
package templates
|
||||
|
||||
templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssueData, stats IssueStats) {
|
||||
@@ -12,14 +11,21 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
||||
<body x-data="processingIssues" x-init="initializeProcessingIssues('{ libraryID }')" class="theme-{ user.Theme }">
|
||||
@Header(user, "/admin/libraries/"+libraryID)
|
||||
<main class="flex-1 p-8">
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap 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 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("alert", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Processing Issues</h1>
|
||||
</div>
|
||||
<a href="/admin/libraries/{ libraryID }" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
← Back to Library
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Items that couldn't be processed in this library</p>
|
||||
</div>
|
||||
<a href="/admin/libraries/{ libraryID }" class="btn btn-secondary">
|
||||
@Icon("arrow-left", "h-4 w-4")
|
||||
Back to Library
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,60 +33,74 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
if stats.ErrorCount > 0 {
|
||||
<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">{ stats.ErrorCount }</p>
|
||||
<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);">
|
||||
@Icon("x-circle", "h-5 w-5")
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide">Errors</h3>
|
||||
</div>
|
||||
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.ErrorCount }</p>
|
||||
</div>
|
||||
}
|
||||
if stats.WarningCount > 0 {
|
||||
<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">{ stats.WarningCount }</p>
|
||||
<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);">
|
||||
@Icon("alert", "h-5 w-5")
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide">Warnings</h3>
|
||||
</div>
|
||||
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.WarningCount }</p>
|
||||
</div>
|
||||
}
|
||||
if stats.InfoCount > 0 {
|
||||
<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">{ stats.InfoCount }</p>
|
||||
<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);">
|
||||
@Icon("info", "h-5 w-5")
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide">Info</h3>
|
||||
</div>
|
||||
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.InfoCount }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
<div class="card p-8 rounded-lg text-center">
|
||||
<p class="text-gray-600">No processing issues found for this library.</p>
|
||||
<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);">
|
||||
@Icon("check-circle", "h-6 w-6")
|
||||
</span>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">No processing issues found for this library.</p>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Issues List -->
|
||||
<div class="space-y-4">
|
||||
for _, issue := range issues {
|
||||
<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">{ issue.Title }</h4>
|
||||
<p class="text-gray-700 mb-3">{ issue.IssueDescription }</p>
|
||||
<div class="text-sm text-gray-500 space-y-1">
|
||||
<p><strong>Type:</strong> { issue.IssueType }</p>
|
||||
<p><strong>Format:</strong> { issue.FormatGroup }</p>
|
||||
<p><strong>File:</strong> { issue.FilePath }</p>
|
||||
<p><strong>Library:</strong> { issue.LibraryTypeName }</p>
|
||||
<div class="card p-6">
|
||||
<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-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 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 class="flex gap-3 mt-4">
|
||||
if issue.Severity == "warning" || issue.Severity == "info" {
|
||||
<button
|
||||
@click="dismissIssue('{ issue.ID }', '{ issue.MediaItemID }')"
|
||||
class="btn-secondary px-4 py-2 rounded text-sm"
|
||||
class="btn btn-secondary text-sm"
|
||||
>
|
||||
@Icon("close", "h-4 w-4")
|
||||
Dismiss
|
||||
</button>
|
||||
}
|
||||
@@ -89,6 +109,7 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// 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.
|
||||
@@ -38,200 +37,302 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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=\"flex-1 p-8\"><div class=\"mx-auto 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 {
|
||||
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><a href=\"/admin/libraries/{ libraryID }\" class=\"btn btn-secondary\">")
|
||||
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, 4, "Back to Library</a></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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, 5, "<!-- Stats Cards --> <div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 mb-8\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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, 6, "<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, 7, "<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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
|
||||
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: 41, 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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</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\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<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, 10, "<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: 38, Col: 58}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 50, 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, 7, "</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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, 12, "<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, 13, "<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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
|
||||
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: 59, Col: 91}
|
||||
}
|
||||
_, 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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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, 16, "<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, 17, "</span><p class=\"text-sm\" style=\"color: var(--text-secondary)\">No processing issues found for this library.</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} 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, 18, "<!-- Issues List --> <div class=\"space-y-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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, 19, "<div class=\"card p-6\"><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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
|
||||
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: 78, Col: 98}
|
||||
}
|
||||
_, 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, 14, "</h4><p class=\"text-gray-700 mb-3\">")
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
|
||||
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: 79, Col: 96}
|
||||
}
|
||||
_, 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, 15, "</p><div class=\"text-sm text-gray-500 space-y-1\"><p><strong>Type:</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
|
||||
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: 81, Col: 140}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p><p><strong>Format:</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
|
||||
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: 82, Col: 144}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</p><p><strong>File:</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
|
||||
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: 83, Col: 139}
|
||||
}
|
||||
_, 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, "</p><p><strong>Library:</strong> ")
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
|
||||
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: 84, Col: 149}
|
||||
}
|
||||
_, 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, 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, 25, "</p></div></div><div class=\"ml-2 shrink-0\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if issue.Severity == "error" {
|
||||
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_Var11 string
|
||||
templ_7745c5c3_Var11, 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: 74, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 89, Col: 62}
|
||||
}
|
||||
_, 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, 20, "</span></div></div><div class=\"flex gap-3 mt-4\">")
|
||||
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_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: 91, Col: 63}
|
||||
}
|
||||
_, 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, 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_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: 93, Col: 66}
|
||||
}
|
||||
_, 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, 31, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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, 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, 33, "<button @click=\"dismissIssue('{ issue.ID }', '{ issue.MediaItemID }')\" 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, 34, "Dismiss</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</main></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div></main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -11,55 +11,67 @@ templ AdminSettings(user User, systemConfig map[string]string, errorMessage stri
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }" x-data="adminSettings">
|
||||
@Header(user, "/admin/settings")
|
||||
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
|
||||
<div class="flex">
|
||||
@AdminSidebar(user, "/admin/settings")
|
||||
<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
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap mb-4">
|
||||
<a href="/admin" class="btn btn-secondary">
|
||||
@Icon("arrow-left", "h-4 w-4")
|
||||
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 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("settings", "h-5 w-5")
|
||||
</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 errorMessage != "" {
|
||||
<div class="mb-6 p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--error); color: var(--error);">
|
||||
{ errorMessage }
|
||||
<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);">
|
||||
@Icon("alert", "h-5 w-5 shrink-0 mt-0.5")
|
||||
<span>{ errorMessage }</span>
|
||||
</div>
|
||||
}
|
||||
<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 class="card p-6">
|
||||
<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>
|
||||
<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
|
||||
type="url"
|
||||
name="base_url"
|
||||
value={ systemConfig["base_url"] }
|
||||
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);"
|
||||
class="input"
|
||||
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 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
|
||||
</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 class="mt-6 card p-6">
|
||||
<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>
|
||||
<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
|
||||
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);"
|
||||
class="input"
|
||||
>
|
||||
<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>
|
||||
@@ -86,16 +98,19 @@ 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="Pacific/Auckland" selected?={ systemConfig["default_timezone"] == "Pacific/Auckland" }>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>
|
||||
<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-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="mt-6 card p-6">
|
||||
<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);">
|
||||
<p><strong>OPDS:</strong> { systemConfig["base_url"] }/opds</p>
|
||||
<p><strong>API:</strong> { 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)">OPDS:</span> { systemConfig["base_url"] }/opds</p>
|
||||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">API:</span> { systemConfig["base_url"] }/api</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>
|
||||
|
||||
@@ -37,7 +37,7 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, "<div class=\"flex\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -45,322 +45,378 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, "<main class=\"flex-1 p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center justify-between gap-4 flex-wrap mb-4\"><a href=\"/admin\" class=\"btn btn-secondary\">")
|
||||
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, 4, "Back to Dashboard</a></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("settings", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">System Settings</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Configure your Bookhoard instance</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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, 6, "<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, 7, "<span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
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: 36, Col: 28}
|
||||
}
|
||||
_, 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, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, 9, "<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, 10, "<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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
|
||||
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: 50, Col: 42}
|
||||
}
|
||||
_, 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, 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, 11, "\" 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, 12, "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, 13, "<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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "America/Los_Angeles" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "America/Denver" {
|
||||
if systemConfig["default_timezone"] == "Pacific/Honolulu" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "America/Phoenix" {
|
||||
if systemConfig["default_timezone"] == "America/Anchorage" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "America/Chicago" {
|
||||
if systemConfig["default_timezone"] == "America/Los_Angeles" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "America/New_York" {
|
||||
if systemConfig["default_timezone"] == "America/Denver" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "America/Sao_Paulo" {
|
||||
if systemConfig["default_timezone"] == "America/Phoenix" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Europe/London" {
|
||||
if systemConfig["default_timezone"] == "America/Chicago" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Europe/Paris" {
|
||||
if systemConfig["default_timezone"] == "America/New_York" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Europe/Helsinki" {
|
||||
if systemConfig["default_timezone"] == "America/Sao_Paulo" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Europe/Moscow" {
|
||||
if systemConfig["default_timezone"] == "Europe/London" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Asia/Tehran" {
|
||||
if systemConfig["default_timezone"] == "Europe/Paris" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Asia/Dubai" {
|
||||
if systemConfig["default_timezone"] == "Europe/Helsinki" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Asia/Karachi" {
|
||||
if systemConfig["default_timezone"] == "Europe/Moscow" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Asia/Kolkata" {
|
||||
if systemConfig["default_timezone"] == "Asia/Tehran" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Asia/Dhaka" {
|
||||
if systemConfig["default_timezone"] == "Asia/Dubai" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Asia/Bangkok" {
|
||||
if systemConfig["default_timezone"] == "Asia/Karachi" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Asia/Shanghai" {
|
||||
if systemConfig["default_timezone"] == "Asia/Kolkata" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Asia/Tokyo" {
|
||||
if systemConfig["default_timezone"] == "Asia/Dhaka" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Australia/Darwin" {
|
||||
if systemConfig["default_timezone"] == "Asia/Bangkok" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Australia/Sydney" {
|
||||
if systemConfig["default_timezone"] == "Asia/Shanghai" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Pacific/Auckland" {
|
||||
if systemConfig["default_timezone"] == "Asia/Tokyo" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Australia/Darwin" {
|
||||
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 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, 58, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">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, 60, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">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, 62, "<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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
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: 111, Col: 145}
|
||||
}
|
||||
_, 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, 56, "/opds</p><p><strong>API:</strong> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "/opds</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">API:</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
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: 112, Col: 144}
|
||||
}
|
||||
_, 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, 57, "/api</p><p><strong>Device Sync:</strong> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "/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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
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: 113, Col: 152}
|
||||
}
|
||||
_, 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, 58, "/api/sync</p></div></div></div></main></div></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "/api/sync</p></div></div></div></main></div></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
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
|
||||
<aside class="w-64 shrink-0 self-start sticky top-16 h-[calc(100vh-4rem)] overflow-y-auto border-r" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center gap-2 mb-6 px-2">
|
||||
<span class="grid place-items-center h-8 w-8 rounded-lg shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("shield", "h-4 w-4")
|
||||
</span>
|
||||
<h2 class="text-xs font-bold uppercase tracking-wide" style="color: var(--text-primary)">Admin Panel</h2>
|
||||
</div>
|
||||
<nav class="space-y-0.5">
|
||||
<a href="/admin" class={ activeClass(currentPath, "/admin") }>
|
||||
@Icon("grid", "h-5 w-5 shrink-0")
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
<a href="/admin/users" class={activeClass(currentPath, "/admin/users")} style="color: var(--text-primary)">
|
||||
👤 User Administration
|
||||
<a href="/admin/users" class={ activeClass(currentPath, "/admin/users") }>
|
||||
@Icon("users", "h-5 w-5 shrink-0")
|
||||
<span>Users</span>
|
||||
</a>
|
||||
<a href="/admin/library" class={activeClass(currentPath, "/admin/library")} style="color: var(--text-primary)">
|
||||
📚 Library Management
|
||||
<a href="/admin/library" class={ activeClass(currentPath, "/admin/library") }>
|
||||
@Icon("library", "h-5 w-5 shrink-0")
|
||||
<span>Library</span>
|
||||
</a>
|
||||
<a href="/admin/settings" class={activeClass(currentPath, "/admin/settings")} style="color: var(--text-primary)">
|
||||
⚙️ System Settings
|
||||
<a href="/admin/settings" class={ activeClass(currentPath, "/admin/settings") }>
|
||||
@Icon("settings", "h-5 w-5 shrink-0")
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,15 @@ func AdminSidebar(user User, currentPath string) templ.Component {
|
||||
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\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<aside class=\"w-64 shrink-0 self-start sticky top-16 h-[calc(100vh-4rem)] overflow-y-auto border-r\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"p-5\"><div class=\"flex items-center gap-2 mb-6 px-2\"><span class=\"grid place-items-center h-8 w-8 rounded-lg shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("shield", "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, 2, "</span><h2 class=\"text-xs font-bold uppercase tracking-wide\" style=\"color: var(--text-primary)\">Admin Panel</h2></div><nav class=\"space-y-0.5\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -38,7 +46,7 @@ func AdminSidebar(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<a href=\"/admin\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<a href=\"/admin\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -51,7 +59,15 @@ func AdminSidebar(user User, currentPath string) templ.Component {
|
||||
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> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("grid", "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, 5, "<span>Dashboard</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -60,7 +76,7 @@ func AdminSidebar(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<a href=\"/admin/users\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<a href=\"/admin/users\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -73,7 +89,15 @@ func AdminSidebar(user User, currentPath string) templ.Component {
|
||||
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> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("users", "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, "<span>Users</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -82,7 +106,7 @@ func AdminSidebar(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<a href=\"/admin/library\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<a href=\"/admin/library\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -95,7 +119,15 @@ func AdminSidebar(user User, currentPath string) templ.Component {
|
||||
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> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("library", "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, "<span>Library</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -104,7 +136,7 @@ func AdminSidebar(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"/admin/settings\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"/admin/settings\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -117,7 +149,15 @@ func AdminSidebar(user User, currentPath string) templ.Component {
|
||||
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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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, 14, "<span>Settings</span></a></nav></div></aside>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func Admin(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, "<div class=\"flex\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -45,7 +45,79 @@ func Admin(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, "<main class=\"flex-1 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("grid", "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, "</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 library and settings</p></div><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6 mb-6\"><div class=\"stat-card\"><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("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, 5, "</span><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Library</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Manage your ebook collection</p></div></div><a href=\"/\" class=\"btn btn-secondary mt-4 text-sm\">")
|
||||
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, 6, "View Library</a></div><div class=\"stat-card\"><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, 7, "</span><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Scan Watch Status</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">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 mr-2\" style=\"background-color: var(--status-success);\"></span> Watching <span id=\"watch-count\">0</span> libraries</div></div></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\">")
|
||||
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, 8, "Rescan Library</span> <span class=\"text-xs font-normal opacity-80\">Re-scan existing files and fix metadata</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, 9, "Manage Libraries</span> <span class=\"text-xs font-normal opacity-80\">Add or remove libraries and scan directories</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, 10, "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, 11, "</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 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><!-- 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-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, 12, "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 btn-primary\">Refresh to View Books</button> <button @click=\"hideScanProgress()\" class=\"btn btn-secondary\">Dismiss</button></div></div></div></div></main></div></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+33
-23
@@ -13,35 +13,44 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
||||
@Header(currentUser, "/admin/users")
|
||||
<!-- Modal Container (populated by HTMX) -->
|
||||
<div id="modal-container"></div>
|
||||
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
|
||||
<div class="flex">
|
||||
@AdminSidebar(currentUser, "/admin/users")
|
||||
<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 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);">
|
||||
@Icon("users", "h-5 w-5")
|
||||
</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 rounded-lg border overflow-hidden" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<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-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>
|
||||
<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)">
|
||||
for _, user := range users {
|
||||
<tr id={ "user-" + user.ID } class="hover:bg-opacity-50" style="transition: background-color 0.2s;">
|
||||
<tr id={ "user-" + user.ID } class="transition-colors hover:bg-surface-hover">
|
||||
<!-- Username -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<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="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: white;">You</span>
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">You</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,8 +66,7 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
||||
<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);"
|
||||
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>
|
||||
@@ -72,8 +80,7 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
||||
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);"
|
||||
class="input w-auto py-1 pr-7 text-xs"
|
||||
onchange="this.dispatchEvent(new Event('htmx:trigger'))"
|
||||
hx-trigger="change"
|
||||
hx-vals='{"role": this.value}'
|
||||
@@ -90,22 +97,23 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
||||
</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="text-sm px-3 py-1 rounded mr-2"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
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="text-sm px-3 py-1 rounded cursor-not-allowed opacity-50"
|
||||
style="background-color: #dc2626; color: white;"
|
||||
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 {
|
||||
@@ -115,18 +123,20 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
||||
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;"
|
||||
class="btn btn-danger text-xs px-2.5 py-1"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -37,7 +37,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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><div class=\"flex\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -45,207 +45,247 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, "<main class=\"flex-1 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("users", "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, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, user := range users {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<tr id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<tr id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
|
||||
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: 43, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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, 6, "\" 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, 7, "</span><div><div class=\"text-sm font-medium\" 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(user.Username)
|
||||
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: 51, Col: 97}
|
||||
}
|
||||
_, 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, 6, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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, 9, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">You</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, 10, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
||||
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: 60, Col: 80}
|
||||
}
|
||||
_, 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, "</div></td><!-- Role Toggle (with last-admin protection) --><td class=\"px-6 py-4 whitespace-nowrap\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></td><!-- Role Toggle (with last-admin protection) --><td class=\"px-6 py-4 whitespace-nowrap\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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, 12, "<!-- 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<select hx-put=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<select hx-put=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||
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: 78, Col: 53}
|
||||
}
|
||||
_, 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, 12, "\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
|
||||
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: 80, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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, 15, "\" hx-swap=\"innerHTML\" name=\"role\" class=\"input w-auto py-1 pr-7 text-xs\" onchange=\"this.dispatchEvent(new Event('htmx:trigger'))\" hx-trigger=\"change\" hx-vals='{\"role\": this.value}'><option value=\"user\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "user" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, ">User</option> <option value=\"admin\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, 17, ">User</option> <option value=\"admin\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ">Admin</option></select><div id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
|
||||
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: 91, Col: 47}
|
||||
}
|
||||
_, 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, 18, "\" class=\"text-xs mt-1\"></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" class=\"text-xs mt-1\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, 21, "</td><!-- Created --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
|
||||
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: 96, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></td><!-- Actions --><td class=\"px-6 py-4 whitespace-nowrap text-right\"><button hx-get=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
|
||||
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: 102, Col: 67}
|
||||
}
|
||||
_, 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, 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, 23, "\" 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, 24, "Edit</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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, 25, "<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, 26, "Delete</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<button hx-delete=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<button hx-delete=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||
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: 121, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, 28, "\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
|
||||
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: 123, Col: 45}
|
||||
}
|
||||
_, 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, 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, 29, "\" 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, 30, "Delete</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td></tr>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div></td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, 32, "</tbody></table></div></div></main></div></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+35
-38
@@ -12,78 +12,75 @@ templ Analytics(user User) {
|
||||
</head>
|
||||
<body x-data="analytics" x-init="loadAnalytics" class="theme-{ user.Theme }">
|
||||
@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">
|
||||
<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 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("chart", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Reading Analytics</h1>
|
||||
</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">
|
||||
<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-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
|
||||
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);"
|
||||
class="input"
|
||||
/>
|
||||
</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>
|
||||
<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="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button @click="loadAnalytics()" class="btn-primary px-6 py-2 rounded-lg">Update</button>
|
||||
<div>
|
||||
<button @click="loadAnalytics()" class="btn btn-primary">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 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="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 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="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 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="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 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>
|
||||
<!-- 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="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>
|
||||
<!-- 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="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>
|
||||
<!-- 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 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>
|
||||
|
||||
@@ -37,7 +37,15 @@ func Analytics(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -2,64 +2,97 @@ package templates
|
||||
|
||||
templ APIExplorer(explorer APIExplorerData) {
|
||||
if !explorer.IsLoggedIn {
|
||||
// Show mode toggle and mock data
|
||||
<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 x-data="devices" class="card p-6 mt-8 space-y-5">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
if explorer.Endpoint.Method == "GET" {
|
||||
<span class="badge status-completed">{ explorer.Endpoint.Method }</span>
|
||||
} 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 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" 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 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>
|
||||
<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>
|
||||
<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);">{ explorer.Endpoint.RequestBody }</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">{ explorer.Endpoint.Response }</code></pre>
|
||||
<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);">{ explorer.Endpoint.Response }</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 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>
|
||||
} else {
|
||||
// Show interactive explorer with real execution
|
||||
<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 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 explorer.Endpoint.Method == "GET" {
|
||||
<span class="badge status-completed">{ explorer.Endpoint.Method }</span>
|
||||
} 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 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">
|
||||
<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" 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</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">{ explorer.Endpoint.RequestBody }</textarea>
|
||||
<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="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>
|
||||
<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>
|
||||
<pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code id="response-body" class="text-text-primary text-sm"></code></pre>
|
||||
<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 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 class="flex gap-2 flex-wrap">
|
||||
<button @click="copyDocRequest()" class="btn btn-secondary text-sm">
|
||||
@Icon("copy", "h-4 w-4")
|
||||
Copy Request
|
||||
</button>
|
||||
<button @click="copyDocResponse()" class="btn btn-secondary text-sm">
|
||||
@Icon("copy", "h-4 w-4")
|
||||
Copy Response
|
||||
</button>
|
||||
<button @click="generateDocCURL()" class="btn btn-secondary text-sm">
|
||||
@Icon("external", "h-4 w-4")
|
||||
Generate cURL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+276
-88
@@ -30,131 +30,319 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "GET" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, ">GET</option> <option value=\"POST\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "POST" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, ">POST</option> <option value=\"PUT\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "PUT" {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "DELETE" {
|
||||
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, ">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 = 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.RequestBody)
|
||||
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: 21, Col: 141}
|
||||
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, 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\">")
|
||||
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.Response)
|
||||
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: 25, Col: 138}
|
||||
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, 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 = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} 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\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "GET" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ">GET</option> <option value=\"POST\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "POST" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, ">POST</option> <option value=\"PUT\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "PUT" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ">PUT</option> <option value=\"DELETE\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "DELETE" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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\">")
|
||||
} 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.RequestBody)
|
||||
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: 49, Col: 274}
|
||||
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, 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, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<code class=\"text-sm\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, 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: 16, Col: 88}
|
||||
}
|
||||
_, 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, 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);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, 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: 24, Col: 131}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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\">")
|
||||
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, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "GET" {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "POST" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, ">POST</option> <option value=\"PUT\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "PUT" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ">PUT</option> <option value=\"DELETE\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "DELETE" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, 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: 70, Col: 136}
|
||||
}
|
||||
_, 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, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+191
-299
@@ -19,175 +19,179 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</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) }")
|
||||
<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 id="back-link" href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
← Back
|
||||
<!-- Hero with blurred cover backdrop -->
|
||||
<div class="relative overflow-hidden">
|
||||
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Top Section: Cover + Basic Info + Actions -->
|
||||
<div class="flex flex-col md:flex-row gap-8 mb-8">
|
||||
<!-- Left: Cover Image (256x384px) -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex flex-col md:flex-row gap-8">
|
||||
<!-- Cover -->
|
||||
<div class="flex-shrink-0 mx-auto md:mx-0">
|
||||
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
||||
<img
|
||||
src={ book.CoverImagePath.String }
|
||||
alt={ book.Title }
|
||||
class="w-64 h-96 object-cover rounded-lg shadow-xl"
|
||||
class="w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl"
|
||||
style="box-shadow: var(--shadow-pop);"
|
||||
onerror="this.src='/static/placeholder-book.svg'"
|
||||
/>
|
||||
} else {
|
||||
<img
|
||||
src="/static/placeholder-book.svg"
|
||||
alt={ book.Title }
|
||||
class="w-64 h-96 object-cover rounded-lg shadow-xl"
|
||||
class="w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl"
|
||||
style="box-shadow: var(--shadow-pop);"
|
||||
/>
|
||||
}
|
||||
</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>
|
||||
<!-- Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-3xl md:text-4xl font-bold tracking-tight 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)">
|
||||
<h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)">
|
||||
by { book.Author.String }
|
||||
</h2>
|
||||
}
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap gap-3 mb-6">
|
||||
<!-- Read Now (placeholder) -->
|
||||
<!-- Action buttons -->
|
||||
<div class="flex flex-wrap gap-2.5 mb-6">
|
||||
<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;"
|
||||
class="btn btn-primary px-5 py-2.5"
|
||||
>
|
||||
📖 Read Now
|
||||
@Icon("book-open", "h-4 w-4")
|
||||
<span>Read Now</span>
|
||||
</button>
|
||||
<!-- Sync Progress -->
|
||||
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>
|
||||
}
|
||||
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 @click="showProgressSyncModal()" class="btn btn-secondary px-5 py-2.5">
|
||||
@Icon("sync", "h-4 w-4")
|
||||
<span>Sync Progress</span>
|
||||
</button>
|
||||
}
|
||||
<!-- View Notes/Highlights -->
|
||||
<button
|
||||
@click="showNotesModal()"
|
||||
class="px-6 py-3 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||
>
|
||||
📝 Notes & Highlights
|
||||
<button @click="showNotesModal()" class="btn btn-secondary px-5 py-2.5">
|
||||
@Icon("edit", "h-4 w-4")
|
||||
<span>Notes</span>
|
||||
if book.NotesCount + book.HighlightsCount > 0 {
|
||||
<span
|
||||
class="ml-2 px-2 py-0.5 rounded text-xs font-semibold"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
>
|
||||
{ book.NotesCount + book.HighlightsCount }
|
||||
</span>
|
||||
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
|
||||
}
|
||||
</button>
|
||||
<!-- Edit Metadata (placeholder) -->
|
||||
<button
|
||||
@click="showMetadataEditor()"
|
||||
class="px-6 py-3 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||
>
|
||||
✏️ Edit Metadata
|
||||
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
|
||||
@Icon("edit", "h-4 w-4")
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Rating Display -->
|
||||
<div class="mb-6">
|
||||
<!-- Rating -->
|
||||
<div class="mb-5" @mouseleave="ratingHover = 0">
|
||||
<span class="text-2xl">
|
||||
@templ.Raw(renderStars(getBookRating(book.Rating)))
|
||||
<template x-for="i in 5" :key="i">
|
||||
<span style="position: relative; display: inline-block;">
|
||||
<span :style="starFill(i)">★</span>
|
||||
<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>
|
||||
<span class="ml-2 text-sm" style="color: var(--text-secondary);">
|
||||
({ fmt.Sprintf("%.1f", float64(getBookRating(book.Rating))/2.0) } / 5)
|
||||
</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 Display (from metadata) -->
|
||||
<!-- Community rating -->
|
||||
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
|
||||
<div class="mb-2">
|
||||
<span class="text-lg" style="color: var(--text-secondary);">
|
||||
Community Rating:
|
||||
<span class="font-bold" style="color: var(--text-primary);">
|
||||
<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)))
|
||||
</span>
|
||||
<span class="ml-2 text-sm" style="color: var(--text-secondary);">
|
||||
{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }
|
||||
</span>
|
||||
</span>
|
||||
<span class="ml-2">{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Series Badge -->
|
||||
<!-- Badges: series, direction, tags -->
|
||||
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||
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;"
|
||||
class="chip hover:opacity-90"
|
||||
style="background-color: var(--accent); color: var(--bg-primary); 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>
|
||||
<span class="chip" style="background-color: var(--accent); color: var(--bg-primary);">{ strings.ToUpper(book.ReadingDirection.String) }</span>
|
||||
}
|
||||
<!-- 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>
|
||||
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">{ book.AgeRating.String }</span>
|
||||
}
|
||||
<!-- Black and White Badge -->
|
||||
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
|
||||
<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);"
|
||||
>
|
||||
B&W
|
||||
</span>
|
||||
<span class="badge" style="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>
|
||||
<span class="badge" style="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
|
||||
href={ "/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID) }
|
||||
class="px-2 py-1 rounded-full text-xs font-semibold hover:opacity-80 transition-opacity"
|
||||
class="badge hover:opacity-80 transition-opacity"
|
||||
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary); text-decoration: none;"
|
||||
>
|
||||
{ tag }
|
||||
@@ -195,334 +199,223 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Description/Synopsis -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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="mb-6">
|
||||
<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" style="color: var(--text-secondary)">
|
||||
@UnsafeHTML(
|
||||
bluemonday.UGCPolicy().Sanitize(book.Description.String),
|
||||
).ToComponent()
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Summary (from ComicInfo.xml, if different from description) -->
|
||||
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>
|
||||
<div class="max-h-[20rem] overflow-y-auto pr-2" style="color: var(--text-secondary)">
|
||||
@UnsafeHTML(
|
||||
bluemonday.UGCPolicy().Sanitize(book.Summary.String),
|
||||
).ToComponent()
|
||||
<div class="max-h-[20rem] overflow-y-auto pr-2 text-sm leading-relaxed" style="color: var(--text-secondary)">
|
||||
@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Summary.String)).ToComponent()
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Metadata Notes (technical notes from metadata files) -->
|
||||
if book.MetadataNotes.Valid && book.MetadataNotes.String != "" {
|
||||
<div
|
||||
class="card p-6 rounded-lg border mb-6"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
>
|
||||
<div class="card p-6 mb-6">
|
||||
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Metadata Notes</h3>
|
||||
<div class="text-sm" style="color: var(--text-secondary);">
|
||||
{ book.MetadataNotes.String }
|
||||
</div>
|
||||
<div class="text-sm" style="color: var(--text-secondary);">{ book.MetadataNotes.String }</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Progress Section -->
|
||||
<!-- Progress -->
|
||||
if book.ReadingProgress != nil {
|
||||
<div
|
||||
class="card p-6 rounded-lg border mb-6"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
>
|
||||
<div class="card p-6 mb-6">
|
||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Reading Progress</h3>
|
||||
if book.ActiveConflict != nil {
|
||||
<div
|
||||
class="mb-4 p-3 rounded-lg border"
|
||||
style="background-color: #f59e0b20; border-color: #f59e0b;"
|
||||
>
|
||||
<p style="color: var(--text-primary);">
|
||||
⚠️ Progress conflict detected - Click "Sync Progress" to review and resolve
|
||||
</p>
|
||||
<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);">
|
||||
@Icon("alert", "h-5 w-5 shrink-0")
|
||||
<p class="text-sm" style="color: var(--text-primary);">Progress conflict detected — click "Sync Progress" to review and resolve.</p>
|
||||
</div>
|
||||
}
|
||||
<!-- Progress Bar -->
|
||||
<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
|
||||
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) }
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Progress Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Progress</p>
|
||||
<p class="text-2xl font-bold" style="color: var(--accent);">
|
||||
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64 * 100) }%
|
||||
</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);">{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100) }%</p>
|
||||
</div>
|
||||
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Page</p>
|
||||
<p>{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Page</p>
|
||||
<p style="color: var(--text-primary)">{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p>
|
||||
</div>
|
||||
}
|
||||
if book.ReadingProgress.LastReadAt.Valid {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Last Read</p>
|
||||
<p>{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Last Read</p>
|
||||
<p class="text-sm" style="color: var(--text-primary)">{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p>
|
||||
</div>
|
||||
}
|
||||
if book.ReadingProgress.LastSyncSource.Valid {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Source</p>
|
||||
<p class="capitalize">{ book.ReadingProgress.LastSyncSource.String }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Source</p>
|
||||
<p class="capitalize" style="color: var(--text-primary)">{ book.ReadingProgress.LastSyncSource.String }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Metadata Grid -->
|
||||
<div
|
||||
class="card p-6 rounded-lg border mb-6"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
>
|
||||
<!-- Metadata grid -->
|
||||
<div class="card p-6 mb-6">
|
||||
<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">
|
||||
<!-- Publication Info -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-4 text-sm">
|
||||
if book.Publisher.Valid && book.Publisher.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Publisher</p>
|
||||
<p>{ book.Publisher.String }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Publisher</p>
|
||||
<p style="color: var(--text-primary)">{ book.Publisher.String }</p>
|
||||
</div>
|
||||
}
|
||||
if book.DatePublished.Valid {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Published</p>
|
||||
<p>{ book.DatePublished.Time.Format("01-02-2006") }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Published</p>
|
||||
<p style="color: var(--text-primary)">{ book.DatePublished.Time.Format("01-02-2006") }</p>
|
||||
</div>
|
||||
}
|
||||
if book.Isbn.Valid && book.Isbn.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Isbn</p>
|
||||
<p>{ book.Isbn.String }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">ISBN</p>
|
||||
<p style="color: var(--text-primary)">{ book.Isbn.String }</p>
|
||||
</div>
|
||||
}
|
||||
if book.Language.Valid && book.Language.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Language</p>
|
||||
<p class="capitalize">{ book.Language.String }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Language</p>
|
||||
<p class="capitalize" style="color: var(--text-primary)">{ book.Language.String }</p>
|
||||
</div>
|
||||
}
|
||||
if book.Edition.Valid && book.Edition.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Edition</p>
|
||||
<p>{ book.Edition.String }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Edition</p>
|
||||
<p style="color: var(--text-primary)">{ book.Edition.String }</p>
|
||||
</div>
|
||||
}
|
||||
if book.PageCount.Valid && book.PageCount.Int32 > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Pages</p>
|
||||
<p>{ book.PageCount.Int32 }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Pages</p>
|
||||
<p style="color: var(--text-primary)">{ book.PageCount.Int32 }</p>
|
||||
</div>
|
||||
}
|
||||
if book.Genre.Valid && book.Genre.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Genre</p>
|
||||
<p>{ book.Genre.String }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Genre</p>
|
||||
<p style="color: var(--text-primary)">{ book.Genre.String }</p>
|
||||
</div>
|
||||
}
|
||||
<!-- Series Information (if not shown in badge) -->
|
||||
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Series Count</p>
|
||||
<p>{ book.SeriesCount.Int32 } items</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Series Count</p>
|
||||
<p style="color: var(--text-primary)">{ book.SeriesCount.Int32 } items</p>
|
||||
</div>
|
||||
}
|
||||
if book.Volume.Valid && book.Volume.Int32 > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Volume</p>
|
||||
<p>Vol. { book.Volume.Int32 }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Volume</p>
|
||||
<p style="color: var(--text-primary)">Vol. { book.Volume.Int32 }</p>
|
||||
</div>
|
||||
}
|
||||
if book.Imprint.Valid && book.Imprint.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Imprint</p>
|
||||
<p>{ book.Imprint.String }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Imprint</p>
|
||||
<p style="color: var(--text-primary)">{ book.Imprint.String }</p>
|
||||
</div>
|
||||
}
|
||||
if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Copyright Year</p>
|
||||
<p>{ book.CopyrightYear.Int32 }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Copyright</p>
|
||||
<p style="color: var(--text-primary)">{ book.CopyrightYear.Int32 }</p>
|
||||
</div>
|
||||
}
|
||||
<!-- Comic-Specific Fields -->
|
||||
if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Manga Type</p>
|
||||
<p class="capitalize">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Manga Type</p>
|
||||
<p class="capitalize" style="color: var(--text-primary)">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</p>
|
||||
</div>
|
||||
}
|
||||
if book.ScanInformation.Valid && book.ScanInformation.String != "" {
|
||||
<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>
|
||||
</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 != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Alternate Series</p>
|
||||
<p>{ altSeries }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Alternate Series</p>
|
||||
<p style="color: var(--text-primary)">{ altSeries }</p>
|
||||
</div>
|
||||
}
|
||||
if len(book.Contributors) > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Contributors</p>
|
||||
<p>{ strings.Join(book.Contributors, ", ") }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Contributors</p>
|
||||
<p style="color: var(--text-primary)">{ strings.Join(book.Contributors, ", ") }</p>
|
||||
</div>
|
||||
}
|
||||
<!-- Technical Info -->
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Format</p>
|
||||
<p>{ book.MimeType.String }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Format</p>
|
||||
<p style="color: var(--text-primary)">{ book.MimeType.String }</p>
|
||||
</div>
|
||||
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">File Size</p>
|
||||
<p>{ formatFileSize(book.FileSize.Int64) }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">File Size</p>
|
||||
<p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- External IDs Section -->
|
||||
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);">
|
||||
<h4 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">External Links</h4>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<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);">
|
||||
if book.GoodreadsID.Valid && book.GoodreadsID.String != "" {
|
||||
<a
|
||||
href={ getExternalURL("goodreads", book.GoodreadsID.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);"
|
||||
>
|
||||
📚 Goodreads
|
||||
</a>
|
||||
<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>
|
||||
} else {
|
||||
<a
|
||||
href={ getExternalURL("goodreads", "", 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);"
|
||||
>
|
||||
📚 Goodreads
|
||||
</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>
|
||||
}
|
||||
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>
|
||||
<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>
|
||||
} 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>
|
||||
<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>
|
||||
}
|
||||
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>
|
||||
<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 {
|
||||
<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>
|
||||
<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>
|
||||
}
|
||||
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>
|
||||
<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>
|
||||
} 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>
|
||||
<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>
|
||||
}
|
||||
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>
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Collections Section -->
|
||||
<!-- Collections -->
|
||||
if len(book.Collections) > 0 {
|
||||
<div
|
||||
class="card p-6 rounded-lg border mb-6"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
>
|
||||
<div class="card p-6 mb-6">
|
||||
<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 {
|
||||
<a
|
||||
href={ "/collections/" + uuidToString(col.ID) }
|
||||
class="px-3 py-2 rounded-lg border flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
style="border-color: { col.Color.String }; background-color: var(--bg-primary); text-decoration: none;"
|
||||
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;" }
|
||||
>
|
||||
<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>
|
||||
</a>
|
||||
}
|
||||
@@ -530,7 +423,6 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Modals -->
|
||||
@ProgressSyncModal(user, book)
|
||||
@NotesHighlightsModal(book)
|
||||
@MetadataEditorModal(book)
|
||||
|
||||
+176
-224
@@ -8,50 +8,52 @@ import (
|
||||
templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
||||
<div
|
||||
id="progress-sync-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);"
|
||||
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);"
|
||||
class="card w-full max-w-4xl my-8 p-6"
|
||||
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);">
|
||||
@Icon("sync", "h-5 w-5")
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Sync Progress</h2>
|
||||
<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>
|
||||
<button
|
||||
@click="hideProgressSyncModal()"
|
||||
class="p-2 hover:opacity-80 rounded-lg"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
class="icon-btn"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
if book.ActiveConflict != nil {
|
||||
<div
|
||||
class="mb-6 p-4 rounded-lg border"
|
||||
style="background-color: #f59e0b20; border-color: #f59e0b;"
|
||||
class="mb-6 p-4 rounded-xl flex items-start gap-3"
|
||||
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);">
|
||||
⚠️ Conflict Detected
|
||||
<span class="shrink-0 mt-0.5" style="color: var(--status-warning);">@Icon("alert", "h-5 w-5")</span>
|
||||
<div>
|
||||
<p class="font-semibold mb-1" style="color: var(--text-primary);">
|
||||
Conflict Detected
|
||||
</p>
|
||||
<p class="text-sm" style="color: var(--text-secondary);">
|
||||
Progress differs between devices. Choose which version to keep.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
for source, data := range book.ActiveConflict.ConflictData {
|
||||
<div
|
||||
class="card p-4 rounded-lg border"
|
||||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||
>
|
||||
<div class="card p-4">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<span
|
||||
class="inline-block px-2 py-1 rounded text-xs font-semibold capitalize mb-2"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
>
|
||||
<span class="badge mb-2" style="background-color: var(--accent); color: #fff;">
|
||||
{ data.Source }
|
||||
</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">
|
||||
@@ -83,8 +85,7 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
||||
<div class="mt-3">
|
||||
<button
|
||||
@click={"resolveConflict('" + book.ActiveConflict.ID + "', '" + source + "')"}
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-semibold"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Keep This
|
||||
</button>
|
||||
@@ -96,10 +97,7 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
||||
<div class="mb-4" style="color: var(--text-secondary);">
|
||||
<p>No conflicts detected. Current progress from <strong>{ book.ReadingProgress.LastSyncSource.String }</strong>:</p>
|
||||
</div>
|
||||
<div
|
||||
class="card p-6 rounded-lg border text-center"
|
||||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||
>
|
||||
<div class="card p-6 text-center">
|
||||
<div class="text-5xl font-bold mb-4" style="color: var(--accent);">
|
||||
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64 * 100) }%
|
||||
</div>
|
||||
@@ -121,8 +119,7 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
@click="hideProgressSyncModal()"
|
||||
class="px-4 py-2 rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
@@ -134,14 +131,16 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
||||
templ NotesHighlightsModal(book handlers.MediaDetail) {
|
||||
<div
|
||||
id="notes-modal"
|
||||
class="hidden fixed inset-0 z-50 flex items-center justify-center"
|
||||
style="background-color: rgba(0, 0, 0, 0.7);"
|
||||
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-8 w-full max-w-2xl mx-4 text-center"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
class="card w-full max-w-2xl p-8 text-center"
|
||||
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>
|
||||
<p class="mb-2" style="color: var(--text-secondary);">
|
||||
This book has <strong>{ book.NotesCount }</strong> notes and <strong>{ book.HighlightsCount }</strong> highlights.
|
||||
@@ -150,8 +149,7 @@ templ NotesHighlightsModal(book handlers.MediaDetail) {
|
||||
<div>
|
||||
<button
|
||||
@click="hideNotesModal()"
|
||||
class="px-6 py-2 rounded-lg font-semibold"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
@@ -163,27 +161,31 @@ templ NotesHighlightsModal(book handlers.MediaDetail) {
|
||||
templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
<div
|
||||
id="metadata-editor-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);"
|
||||
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="rounded-lg w-full max-w-5xl mx-4 my-8 flex flex-col max-h-[90vh]"
|
||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border);"
|
||||
class="card w-full max-w-5xl my-8 flex flex-col max-h-[90vh]"
|
||||
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);">
|
||||
<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
|
||||
@click="hideMetadataEditor()"
|
||||
class="p-2 hover:opacity-80 rounded-lg"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
class="icon-btn"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<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="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);"
|
||||
@click="document.getElementById('cover-upload-input').click()"
|
||||
>
|
||||
@@ -209,27 +211,26 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
<div class="w-64 space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-sm rounded-lg font-semibold"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
class="btn btn-primary w-full"
|
||||
@click="generateCover()"
|
||||
x-show="coverGenerating"
|
||||
disabled
|
||||
>
|
||||
@Icon("refresh", "h-4 w-4")
|
||||
Generating...
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-sm rounded-lg font-semibold"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
class="btn btn-primary w-full"
|
||||
@click="generateCover()"
|
||||
x-show="!coverGenerating"
|
||||
>
|
||||
@Icon("refresh", "h-4 w-4")
|
||||
Generate Cover
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-sm rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border);"
|
||||
class="btn btn-ghost w-full"
|
||||
@click="removeCover()"
|
||||
x-show="hasExistingCover || newCoverPreview"
|
||||
>
|
||||
@@ -237,53 +238,43 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Accordion Fields (Right) -->
|
||||
<div class="flex-1 min-w-0 space-y-2">
|
||||
<!-- Basic Info -->
|
||||
<div class="border rounded-lg" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
@click="toggleSection('basic')">
|
||||
<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>
|
||||
<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>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Title</label>
|
||||
<input type="text" name="title" value={ book.Title }
|
||||
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);" />
|
||||
<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 } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Author</label>
|
||||
<input type="text" name="author" value={ textToString(book.Author) }
|
||||
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);" />
|
||||
<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) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Description</label>
|
||||
<textarea name="description" rows="3"
|
||||
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);"
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Description</label>
|
||||
<textarea name="description" rows="3" class="input"
|
||||
>{ textToString(book.Description) }</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Summary</label>
|
||||
<textarea name="summary" rows="2"
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);"
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Summary</label>
|
||||
<textarea name="summary" rows="2" class="input"
|
||||
>{ textToString(book.Summary) }</textarea>
|
||||
</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">
|
||||
<template x-for="(tag, idx) in editorTags" :key="idx">
|
||||
<span
|
||||
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 class="chip">
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
@@ -297,20 +288,19 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
@keydown="onTagKeydown($event)"
|
||||
@blur="hideEditorTagDropdown()"
|
||||
placeholder="Add tag..."
|
||||
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);" />
|
||||
class="input" />
|
||||
<div
|
||||
x-show="showTagDropdown"
|
||||
x-transition
|
||||
class="mt-1 w-full rounded-lg shadow-lg border max-h-48 overflow-y-auto"
|
||||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||
x-cloak
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center"
|
||||
:class="highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''"
|
||||
:style="highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'"
|
||||
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) ? 'bg-surface-hover' : ''"
|
||||
@click="selectEditorTagSuggestion(sug.value)"
|
||||
>
|
||||
<span x-text="sug.value"></span>
|
||||
@@ -321,164 +311,142 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</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"
|
||||
value={ fmt.Sprintf("%.1f", book.CommunityRating.Float64) }
|
||||
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);" />
|
||||
class="input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Publication -->
|
||||
<div class="border rounded-lg" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
@click="toggleSection('publication')">
|
||||
<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>
|
||||
<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>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Publisher</label>
|
||||
<input type="text" name="publisher" value={ textToString(book.Publisher) }
|
||||
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);" />
|
||||
<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) } class="input" />
|
||||
</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"
|
||||
value={ formatDateForInput(book.DatePublished) }
|
||||
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);" />
|
||||
class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Edition</label>
|
||||
<input type="text" name="edition" value={ textToString(book.Edition) }
|
||||
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);" />
|
||||
<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) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Language</label>
|
||||
<input type="text" name="language" value={ textToString(book.Language) }
|
||||
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);" />
|
||||
<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) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Genre</label>
|
||||
<input type="text" name="genre" value={ textToString(book.Genre) }
|
||||
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);" />
|
||||
<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) } class="input" />
|
||||
</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"
|
||||
value={ fmt.Sprintf("%d", book.CopyrightYear.Int32) }
|
||||
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);" />
|
||||
class="input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Series -->
|
||||
<div class="border rounded-lg" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
@click="toggleSection('series')">
|
||||
<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>
|
||||
<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>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Series</label>
|
||||
<input type="text" name="series" value={ textToString(book.Series) }
|
||||
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);" />
|
||||
<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) } class="input" />
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<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"
|
||||
value={ fmt.Sprintf("%d", book.SeriesNumber.Int32) }
|
||||
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);" />
|
||||
class="input" />
|
||||
</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"
|
||||
value={ fmt.Sprintf("%d", book.SeriesCount.Int32) }
|
||||
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);" />
|
||||
class="input" />
|
||||
</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"
|
||||
value={ fmt.Sprintf("%d", book.Volume.Int32) }
|
||||
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);" />
|
||||
class="input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Identifiers -->
|
||||
<div class="border rounded-lg" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
@click="toggleSection('identifiers')">
|
||||
<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>
|
||||
<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>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">ISBN</label>
|
||||
<input type="text" name="isbn" value={ textToString(book.Isbn) }
|
||||
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);" />
|
||||
<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) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">ASIN</label>
|
||||
<input type="text" name="asin" value={ textToString(book.Asin) }
|
||||
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);" />
|
||||
<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) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Goodreads ID</label>
|
||||
<input type="text" name="goodreads_id" value={ textToString(book.GoodreadsID) }
|
||||
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);" />
|
||||
<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) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">OpenLibrary ID</label>
|
||||
<input type="text" name="openlibrary_id" value={ textToString(book.OpenlibraryID) }
|
||||
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);" />
|
||||
<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) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Google Books ID</label>
|
||||
<input type="text" name="google_books_id" value={ textToString(book.GoogleBooksID) }
|
||||
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);" />
|
||||
<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) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Web URL</label>
|
||||
<input type="url" name="web_url" value={ textToString(book.WebUrl) }
|
||||
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);" />
|
||||
<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) } class="input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Comic/Manga -->
|
||||
<div class="border rounded-lg" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
@click="toggleSection('comic')">
|
||||
<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>
|
||||
<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>
|
||||
<label for="manga_type" class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Manga Type</label>
|
||||
<select id="manga_type" name="manga_type"
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);">
|
||||
<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" class="input">
|
||||
<option value="unknown" selected={ textToString(book.MangaType) == "unknown" }>Unknown</option>
|
||||
<option value="no" selected={ textToString(book.MangaType) == "no" }>No</option>
|
||||
<option value="yes" selected={ textToString(book.MangaType) == "yes" }>Yes</option>
|
||||
@@ -486,10 +454,8 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="reading_direction" class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Reading Direction</label>
|
||||
<select id="reading_direction" name="reading_direction"
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);">
|
||||
<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" class="input">
|
||||
<option value="auto" selected={ textToString(book.ReadingDirection) == "auto" }>Auto</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>
|
||||
@@ -497,34 +463,24 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Age Rating</label>
|
||||
<input type="text" name="age_rating" value={ textToString(book.AgeRating) }
|
||||
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);" />
|
||||
<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) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Story Arc</label>
|
||||
<input type="text" name="story_arc" value={ textToString(book.StoryArc) }
|
||||
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);" />
|
||||
<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) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Imprint</label>
|
||||
<input type="text" name="imprint" value={ textToString(book.Imprint) }
|
||||
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);" />
|
||||
<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) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Scan Information</label>
|
||||
<input type="text" name="scan_information" value={ textToString(book.ScanInformation) }
|
||||
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);" />
|
||||
<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) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Metadata Notes</label>
|
||||
<textarea name="metadata_notes" rows="2"
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);"
|
||||
<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" class="input"
|
||||
>{ textToString(book.MetadataNotes) }</textarea>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -535,40 +491,37 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Technical -->
|
||||
<div class="border rounded-lg" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
@click="toggleSection('technical')">
|
||||
<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>
|
||||
<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>
|
||||
<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"
|
||||
value={ fmt.Sprintf("%d", book.PageCount.Int32) }
|
||||
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);" />
|
||||
class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Contributors (comma-separated)</label>
|
||||
<input type="text" name="contributors" value={ stringSliceToString(book.Contributors) }
|
||||
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);" />
|
||||
<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) } class="input" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Format</label>
|
||||
<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
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm opacity-60"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
class="input opacity-60" />
|
||||
</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
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm opacity-60"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
class="input opacity-60" />
|
||||
</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);">
|
||||
<button
|
||||
@click="hideMetadataEditor()"
|
||||
class="px-4 py-2 rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="saveMetadata()"
|
||||
class="px-6 py-2 rounded-lg font-semibold"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
@Icon("save", "h-4 w-4")
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+632
-532
File diff suppressed because it is too large
Load Diff
+162
-260
@@ -3,6 +3,7 @@ package templates
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
templ BookShelf(
|
||||
@@ -11,10 +12,10 @@ templ BookShelf(
|
||||
currentLibraryID string,
|
||||
errorMessage string,
|
||||
savedFilters []database.SavedFilters,
|
||||
books []handlers.BookInfo, // NEW
|
||||
limit int, // NEW
|
||||
offset int, // NEW
|
||||
count int, // NEW
|
||||
books []handlers.BookInfo,
|
||||
limit int,
|
||||
offset int,
|
||||
count int,
|
||||
) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -31,81 +32,150 @@ templ BookShelf(
|
||||
x-init="initBookshelf()"
|
||||
>
|
||||
@Header(user, "/bookshelf")
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Filter Bar -->
|
||||
<div
|
||||
class="mb-6 card p-4 rounded-lg border"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
>
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-6">
|
||||
<form id="filter-form" hx-get="/api/media-items/search" hx-target="#books-grid">
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<!-- Library Selector -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Library
|
||||
</label>
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-wrap items-center gap-3 mb-6">
|
||||
<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);">
|
||||
@Icon("search", "h-4 w-4")
|
||||
</span>
|
||||
<input
|
||||
name="q"
|
||||
type="text"
|
||||
class="input pl-10"
|
||||
placeholder="Search all fields…"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
id="library-select"
|
||||
name="library_id"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
name="sort"
|
||||
class="input w-auto cursor-pointer"
|
||||
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>
|
||||
<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 {
|
||||
if currentLibraryID == "" {
|
||||
<option value="" selected>All Books</option>
|
||||
<option value="" selected>All Books ({ TotalMediaCount(libraries) })</option>
|
||||
} else {
|
||||
<option value="">All Books</option>
|
||||
<option value="">All Books ({ TotalMediaCount(libraries) })</option>
|
||||
}
|
||||
for _, lib := range libraries {
|
||||
if lib.ID == currentLibraryID {
|
||||
<option value={ lib.ID } selected>{ lib.Name }</option>
|
||||
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
|
||||
} else {
|
||||
<option value={ lib.ID }>{ lib.Name }</option>
|
||||
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
|
||||
}
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Search Input -->
|
||||
<div class="flex-1 min-w-[150px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
name="q"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
placeholder="Search all fields..."
|
||||
/>
|
||||
</div>
|
||||
<!-- Author Filter with Autocomplete -->
|
||||
<div class="flex-1 min-w-[150px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Author
|
||||
</label>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Author</label>
|
||||
<input
|
||||
type="text"
|
||||
name="author_filter"
|
||||
placeholder="Filter by 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);"
|
||||
class="input"
|
||||
list="author-datalist"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
|
||||
/>
|
||||
<datalist id="author-datalist"></datalist>
|
||||
</div>
|
||||
<!-- Tags Filter with Autocomplete -->
|
||||
<div class="flex-1 min-w-[150px] relative">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Tags</label>
|
||||
<div class="relative">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Tags</label>
|
||||
<input
|
||||
type="text"
|
||||
name="tags_filter"
|
||||
placeholder="Filter by tags"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
|
||||
@keydown="onTagFilterKeydown($event)"
|
||||
@blur="hideTagDropdown()"
|
||||
@@ -113,8 +183,9 @@ templ BookShelf(
|
||||
<div
|
||||
x-show="showTagDropdown"
|
||||
x-transition
|
||||
class="absolute z-50 mt-1 w-full rounded-lg shadow-lg border max-h-48 overflow-y-auto"
|
||||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||
x-cloak
|
||||
class="absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||
style="background-color: var(--bg-primary); border: 1px solid var(--border);"
|
||||
>
|
||||
<template x-for="sug in tagSuggestions" :key="sug.value">
|
||||
<button
|
||||
@@ -130,81 +201,39 @@ templ BookShelf(
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
// <!-- Genre Filter with Autocomplete -->
|
||||
// <div class="flex-1 min-w-[150px]">
|
||||
// <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>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide 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);"
|
||||
class="input"
|
||||
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>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide 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);"
|
||||
class="input"
|
||||
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>
|
||||
<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="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);"
|
||||
/>
|
||||
<input type="number" name="year_min" placeholder="From" class="input"/>
|
||||
<input type="number" name="year_max" placeholder="To" class="input"/>
|
||||
</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>
|
||||
<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"
|
||||
@@ -214,10 +243,9 @@ templ BookShelf(
|
||||
'state-true': hasCoverState === true,
|
||||
'state-false': hasCoverState === false
|
||||
}"
|
||||
:value="hasCoverState === null ? '○ Cover: Any' : hasCoverState === true ? '✓ Has Cover' : '✗ No Cover'"
|
||||
:value="hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'"
|
||||
@click="cycleHasCover()"
|
||||
/>
|
||||
<!-- Hidden input for HTMX form submission - conditionally rendered -->
|
||||
<template x-if="hasCoverState !== null">
|
||||
<input
|
||||
type="hidden"
|
||||
@@ -226,168 +254,65 @@ templ BookShelf(
|
||||
/>
|
||||
</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 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);">
|
||||
<button type="submit" @click="filtersOpen = false" class="btn btn-primary w-full">Apply Filters</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>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Books Grid -->
|
||||
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
|
||||
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
@BooksGrid(books, limit, offset, count, currentLibraryID)
|
||||
<!-- Error Message -->
|
||||
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>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
<div id="pagination" class="mt-6 flex justify-center gap-2">
|
||||
<div id="pagination" class="mt-6 flex justify-center items-center gap-2">
|
||||
if count > 0 {
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-lg border disabled:opacity-50"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }"
|
||||
class="btn btn-secondary disabled:opacity-40"
|
||||
hx-get={ fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit) }
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
disabled?={ offset <= 0 }
|
||||
>
|
||||
← Previous
|
||||
@Icon("chevron-left", "h-4 w-4")
|
||||
<span>Prev</span>
|
||||
</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 }
|
||||
</span>
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg border disabled:opacity-50"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset + limit }"
|
||||
type="button"
|
||||
class="btn btn-secondary disabled:opacity-40"
|
||||
hx-get={ fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit) }
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
disabled?={ offset+limit >= count }
|
||||
>
|
||||
Next →
|
||||
<span>Next</span>
|
||||
@Icon("chevron-right", "h-4 w-4")
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Save Filter Modal -->
|
||||
<div
|
||||
x-show="showSaveModal"
|
||||
@click.self="showSaveModal = false"
|
||||
x-transition
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style="background-color: rgba(0, 0, 0, 0.7); display: none;"
|
||||
>
|
||||
<div
|
||||
@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);"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-[70] flex items-center justify-center p-4"
|
||||
style="background-color: var(--surface-overlay);"
|
||||
>
|
||||
<div @click.stop class="card rounded-2xl p-6 w-full max-w-md">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Save Filter</h2>
|
||||
<button
|
||||
@click="showSaveModal = false"
|
||||
class="p-2 hover:opacity-80 rounded"
|
||||
style="color: var(--text-primary)"
|
||||
>✕</button>
|
||||
<button @click="showSaveModal = false" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
hx-post="/api/saved-filters"
|
||||
@@ -397,38 +322,15 @@ templ BookShelf(
|
||||
@htmx:afterRequest="if(event.detail.xhr.status < 400) { afterFilterSave() }"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Filter Name
|
||||
</label>
|
||||
<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>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Filter Name</label>
|
||||
<input type="text" name="filter_name" placeholder="My Custom Filter" class="input" required/>
|
||||
<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="hidden" name="resource_type" value="media-items"/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="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>
|
||||
<button type="button" @click="showSaveModal = false" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
<!-- Hidden pagination state -->
|
||||
<input type="hidden" name="limit" value="50"/>
|
||||
<input type="hidden" name="offset" value="0"/>
|
||||
</form>
|
||||
|
||||
+272
-117
File diff suppressed because one or more lines are too long
@@ -16,20 +16,21 @@ templ BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
@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="w-full px-4 py-3 flex items-center gap-4">
|
||||
<a href={ backUrl } class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
← { backLabel }
|
||||
<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={ backUrl } class="btn btn-ghost">
|
||||
@Icon("arrow-left", "h-4 w-4")
|
||||
<span>{ backLabel }</span>
|
||||
</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;">
|
||||
<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);">
|
||||
{ badgeIcon } { badgeLabel }
|
||||
</span>
|
||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ title }</h1>
|
||||
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d", len(books)) } books</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">{ title }</h1>
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ fmt.Sprintf("%d", len(books)) } books</span>
|
||||
</div>
|
||||
if len(books) > 0 {
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
|
||||
@@ -38,10 +39,10 @@ templ BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">{ emptyIcon }</div>
|
||||
<div class="card text-center py-16 px-6">
|
||||
<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>
|
||||
<p>{ emptyMessage }</p>
|
||||
<p style="color: var(--text-secondary)">{ emptyMessage }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -68,77 +68,85 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel)
|
||||
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: 23, Col: 23}
|
||||
}
|
||||
_, 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, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon)
|
||||
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: 30, Col: 17}
|
||||
}
|
||||
_, 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, 6, " ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel)
|
||||
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: 30, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
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: 32, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books)))
|
||||
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: 33, Col: 125}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " books</span></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " books</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -148,43 +156,43 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon)
|
||||
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: 43, Col: 44}
|
||||
}
|
||||
_, 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, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage)
|
||||
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: 45, Col: 60}
|
||||
}
|
||||
_, 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, 14, "</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -192,7 +200,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
package templates
|
||||
|
||||
templ CollectionModal(collection CollectionData) {
|
||||
<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 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);">
|
||||
@Icon("folder", "h-5 w-5")
|
||||
</span>
|
||||
if collection.ID != "" {
|
||||
<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>
|
||||
}
|
||||
<button type="button" @click="closeCollectionModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||
</div>
|
||||
<button type="button" @click="closeCollectionModal()" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
if collection.ID != "" {
|
||||
<form
|
||||
@@ -18,65 +25,57 @@ templ CollectionModal(collection CollectionData) {
|
||||
>
|
||||
<input type="hidden" name="id" value={ collection.ID }/>
|
||||
<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
|
||||
type="text"
|
||||
name="name"
|
||||
value={ collection.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);"
|
||||
class="input"
|
||||
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>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide 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);"
|
||||
class="input"
|
||||
placeholder="Optional description"
|
||||
rows="3"
|
||||
>{ collection.Description }</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 -->
|
||||
<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="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..."
|
||||
class="input mb-2"
|
||||
placeholder="Search or type emoji..."
|
||||
maxlength="4"
|
||||
oninput="filterIcons(this.value)"
|
||||
onfocus="showAllIcons()"
|
||||
@input="filterIcons($event.target.value)"
|
||||
@focus="showAllIcons()"
|
||||
/>
|
||||
<!-- Hidden input for form submission -->
|
||||
<input type="hidden" name="icon" id="collection-icon" value={ collection.Icon }/>
|
||||
<!-- Emoji Grid -->
|
||||
<div
|
||||
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);"
|
||||
>
|
||||
<!-- Populated dynamically by JavaScript -->
|
||||
</div>
|
||||
></div>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
<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={ collection.Color }/>
|
||||
</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>
|
||||
<button type="button" @click="closeCollectionModal()" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Update Collection</button>
|
||||
</div>
|
||||
</form>
|
||||
} else {
|
||||
@@ -85,64 +84,56 @@ templ CollectionModal(collection CollectionData) {
|
||||
hx-redirect="/collections"
|
||||
>
|
||||
<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
|
||||
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);"
|
||||
class="input"
|
||||
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>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide 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);"
|
||||
class="input"
|
||||
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 -->
|
||||
<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="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..."
|
||||
class="input mb-2"
|
||||
placeholder="Search or type emoji..."
|
||||
maxlength="4"
|
||||
oninput="filterIcons(this.value)"
|
||||
onfocus="showAllIcons()"
|
||||
@input="filterIcons($event.target.value)"
|
||||
@focus="showAllIcons()"
|
||||
/>
|
||||
<!-- Hidden input for form submission -->
|
||||
<input type="hidden" name="icon" id="collection-icon" value={ collection.Icon }/>
|
||||
<!-- Emoji Grid -->
|
||||
<div
|
||||
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);"
|
||||
>
|
||||
<!-- Populated dynamically by JavaScript -->
|
||||
</div>
|
||||
></div>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
<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-secondary px-4 py-2 rounded-lg">Cancel</button>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Create Collection</button>
|
||||
<button type="button" @click="closeCollectionModal()" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create Collection</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
@@ -29,128 +29,144 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} 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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -14,11 +14,12 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
@Header(user, "/collections")
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-6">
|
||||
<button @click="backToCollection()" class="btn-secondary px-4 py-2 rounded-lg mb-4">
|
||||
← Back to Collection
|
||||
<button @click="backToCollection()" class="btn btn-secondary mb-4">
|
||||
@Icon("arrow-left", "h-4 w-4")
|
||||
Back to Collection
|
||||
</button>
|
||||
<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>
|
||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||||
<p style="color: var(--text-secondary)">Auto-Assign Rules</p>
|
||||
@@ -28,8 +29,10 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
<div class="mb-8">
|
||||
<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="no-rules" class="card p-6 rounded-lg border text-center" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div class="text-4xl mb-2">📋</div>
|
||||
<div id="no-rules" class="card p-8 text-center">
|
||||
<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>
|
||||
<p style="color: var(--text-secondary)">Create auto-assign rules to automatically add books to this collection</p>
|
||||
</div>
|
||||
@@ -37,16 +40,15 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<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 }/>
|
||||
<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>
|
||||
<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
|
||||
id="rule-field"
|
||||
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);"
|
||||
class="input"
|
||||
>
|
||||
<option value="">Select field...</option>
|
||||
<option value="genre">Genre</option>
|
||||
@@ -59,12 +61,11 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
</select>
|
||||
</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
|
||||
id="rule-operator"
|
||||
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);"
|
||||
class="input"
|
||||
>
|
||||
<option value="">Select operator...</option>
|
||||
<option value="equals">equals</option>
|
||||
@@ -78,18 +79,17 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Value</label>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Value</label>
|
||||
<input
|
||||
type="text"
|
||||
id="rule-value"
|
||||
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);"
|
||||
class="input"
|
||||
placeholder="e.g., Science Fiction"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<div>
|
||||
<label class="flex items-center space-x-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -97,16 +97,16 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
checked
|
||||
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>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Uncheck to disable without deleting</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Priority</label>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Priority</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<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 class="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
@@ -116,7 +116,7 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
checked
|
||||
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 class="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
@@ -125,36 +125,38 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
value="3"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="testRule()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
🧪 Test Rule
|
||||
<button type="button" @click="testRule()" class="btn btn-secondary">
|
||||
@Icon("search", "h-4 w-4")
|
||||
Test Rule
|
||||
</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
|
||||
</button>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
|
||||
➕ Add Rule
|
||||
<button type="submit" class="btn btn-primary">
|
||||
@Icon("plus", "h-4 w-4")
|
||||
Add Rule
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
<div class="space-y-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>
|
||||
<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
|
||||
<br/>
|
||||
Operator: equals
|
||||
@@ -164,10 +166,10 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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
|
||||
<br/>
|
||||
Operator: starts with
|
||||
@@ -177,10 +179,10 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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
|
||||
<br/>
|
||||
Operator: greater than
|
||||
|
||||
File diff suppressed because one or more lines are too long
+164
-102
@@ -14,82 +14,89 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
</head>
|
||||
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
|
||||
@Header(user, "/collections")
|
||||
<!-- 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 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);">
|
||||
@Icon("folder", "h-5 w-5")
|
||||
</span>
|
||||
<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>
|
||||
<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 class="flex gap-3">
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
hx-get="/collections/restore-modal"
|
||||
hx-target="#modal-container"
|
||||
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
|
||||
hx-get="/collections/create-modal"
|
||||
hx-target="#modal-container"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
<div id="collections-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
if len(collections) == 0 {
|
||||
<div class="text-center py-16 col-span-full" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">📚</div>
|
||||
<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);">
|
||||
@Icon("folder", "h-7 w-7")
|
||||
</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>
|
||||
<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-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>
|
||||
</div>
|
||||
}
|
||||
for _, col := range collections {
|
||||
<div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } 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);"
|
||||
class="card p-6 rounded-2xl border-l-4 cursor-pointer"
|
||||
data-color={ col.Color }
|
||||
>
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="text-3xl">{ col.Icon }</div>
|
||||
<div class="flex space-x-2">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
hx-get={ "/collections/" + col.ID + "/edit-modal" }
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
class="p-2 hover:opacity-80 rounded"
|
||||
style="color: var(--text-secondary); background-color: var(--bg-primary);"
|
||||
class="icon-btn"
|
||||
aria-label="Edit collection"
|
||||
>
|
||||
✏️
|
||||
@Icon("edit", "h-4 w-4")
|
||||
</button>
|
||||
<button
|
||||
hx-delete={ "/api/collections/" + col.ID + "" }
|
||||
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);"
|
||||
class="icon-btn"
|
||||
aria-label="Delete collection"
|
||||
>
|
||||
🗑️
|
||||
@Icon("trash", "h-4 w-4")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
}
|
||||
@@ -113,123 +120,128 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
|
||||
@Header(user, "/collections")
|
||||
@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">
|
||||
<a href="/collections" class="btn-secondary px-4 py-2 rounded-lg mb-4 inline-block">
|
||||
← Back to Collections
|
||||
<a href="/collections" class="btn btn-secondary mb-4">
|
||||
@Icon("arrow-left", "h-4 w-4")
|
||||
<span>Back to Collections</span>
|
||||
</a>
|
||||
<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>
|
||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||||
<p style="color: var(--text-secondary)">{ collection.Description }</p>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">{ collection.Description }</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 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 gap-3">
|
||||
<div class="flex-1 max-w-md">
|
||||
<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..."
|
||||
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);"
|
||||
@input="filterCollectionBooks()"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
if !collection.IsSystem {
|
||||
<button
|
||||
id="bulk-remove-btn"
|
||||
disabled
|
||||
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="requestBulkRemove()"
|
||||
:disabled="selectedBooks.length === 0"
|
||||
:class="selectedBooks.length === 0 ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
class="btn btn-danger"
|
||||
>
|
||||
🗑️ Remove Selected
|
||||
@Icon("trash", "h-4 w-4")
|
||||
<span>Remove Selected</span>
|
||||
</button>
|
||||
<button
|
||||
@click="$store.bookPicker.open()"
|
||||
class="btn-primary px-4 py-2 rounded-lg"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
➕ Add Books
|
||||
@Icon("plus", "h-4 w-4")
|
||||
<span>Add Books</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
if len(books) == 0 {
|
||||
<div id="empty-state" class="col-span-full 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 {
|
||||
<a href={ "/media/" + book.MediaItemID }>
|
||||
<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="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
|
||||
<div class="flex gap-4">
|
||||
if !collection.IsSystem {
|
||||
<div class="flex-shrink-0 pt-1">
|
||||
<label class="flex items-center cursor-pointer p-2 -m-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
onchange="toggleBookForRemoval('{ book.MediaItemID }')"
|
||||
class="w-5 h-5"
|
||||
:checked="selectedBooks.includes('{ book.MediaItemID }')"
|
||||
@change="toggleSelection('{ book.MediaItemID }')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3
|
||||
class="font-semibold text-lg mb-1 line-clamp-2"
|
||||
style="color: var(--text-primary)"
|
||||
>
|
||||
<a href={ "/media/" + book.MediaItemID }>
|
||||
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">
|
||||
{ book.Title }
|
||||
</h3>
|
||||
</a>
|
||||
if book.Author != "" {
|
||||
<p
|
||||
class="text-sm line-clamp-1"
|
||||
style="color: var(--text-secondary)"
|
||||
>
|
||||
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">
|
||||
by { book.Author }
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-shrink-0 w-16 sm:w-20">
|
||||
<a href={ "/media/" + book.MediaItemID }>
|
||||
if book.CoverImagePath != "" {
|
||||
<img
|
||||
src={ book.CoverImagePath }
|
||||
alt="Cover"
|
||||
class="w-full aspect-[3/4] object-cover rounded shadow-md"
|
||||
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
|
||||
onerror="this.src='/static/placeholder-book.svg'"
|
||||
/>
|
||||
} else {
|
||||
<img
|
||||
src="/static/placeholder-book.svg"
|
||||
alt="Cover"
|
||||
class="w-full aspect-[3/4] object-cover rounded shadow-md"
|
||||
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
|
||||
/>
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
if !collection.IsSystem {
|
||||
<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);"
|
||||
@click="requestRemoveBook('{ book.MediaItemID }')"
|
||||
class="btn btn-secondary w-full"
|
||||
>
|
||||
🗑️ Remove from Collection
|
||||
@Icon("trash", "h-4 w-4")
|
||||
<span>Remove from Collection</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Book Picker Modal -->
|
||||
if !collection.IsSystem {
|
||||
<div
|
||||
x-data="bookPicker"
|
||||
x-show="$store.bookPicker.isOpen"
|
||||
@keyup.escape.window="$store.bookPicker.close()"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style="display: none;"
|
||||
@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
|
||||
@@ -240,8 +252,8 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
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;"
|
||||
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"
|
||||
@@ -252,23 +264,24 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
</h2>
|
||||
<button
|
||||
@click="$store.bookPicker.close()"
|
||||
class="p-2 hover:opacity-80 rounded"
|
||||
style="color: var(--text-primary)"
|
||||
>✕</button>
|
||||
class="icon-btn"
|
||||
aria-label="Close"
|
||||
>
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="p-4 border-b"
|
||||
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]">
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
name="q"
|
||||
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 }"
|
||||
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"
|
||||
@@ -279,9 +292,8 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
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 }"
|
||||
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"
|
||||
@@ -292,32 +304,30 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
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 }"
|
||||
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>
|
||||
<button
|
||||
@click="$store.bookPicker.clearFilters()"
|
||||
class="px-4 py-2 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
class="btn btn-ghost"
|
||||
>
|
||||
✕ Clear
|
||||
@Icon("close", "h-4 w-4")
|
||||
<span>Clear</span>
|
||||
</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>
|
||||
<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
|
||||
@@ -330,15 +340,13 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
<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);"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
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);"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Add Selected Books
|
||||
</button>
|
||||
@@ -346,12 +354,66 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
</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>
|
||||
<div
|
||||
id="collection-data"
|
||||
data-id={ collection.ID }
|
||||
data-library-id={ libraryID }
|
||||
data-is-system={ collection.IsSystem }
|
||||
style="display: none;"
|
||||
></div>
|
||||
</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>
|
||||
}
|
||||
}
|
||||
|
||||
+439
-85
@@ -39,114 +39,170 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
|
||||
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: 70, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
|
||||
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: 73, Col: 30}
|
||||
}
|
||||
_, 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, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
|
||||
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: 76, Col: 41}
|
||||
}
|
||||
_, 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, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
|
||||
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: 79, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
|
||||
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: 88, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
||||
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: 98, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
|
||||
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: 99, Col: 81}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -154,7 +210,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -183,20 +239,20 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
templ_7745c5c3_Var9 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
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: 116, Col: 27}
|
||||
}
|
||||
_, 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, 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\"></head><body x-data=\"collections\" x-init=\"initCollectionsPage()\" class=\"theme-{ user.Theme }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -208,165 +264,326 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||
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: 130, Col: 166}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div><div><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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
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: 132, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
||||
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: 133, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 templ.SafeURL
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||
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: 177, 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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
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: 177, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</h3>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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, 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.JoinStringErrs(book.Author)
|
||||
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: 192, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 177, Col: 147}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
_, 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, 25, "</p>")
|
||||
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, 26, "</div><div class=\"flex-shrink-0 w-16 sm:w-20\">")
|
||||
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
|
||||
}
|
||||
if book.CoverImagePath != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<img src=\"")
|
||||
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: 192, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 199, 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, 28, "\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded shadow-md\" 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, 29, "<img src=\"/static/placeholder-book.svg\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded shadow-md\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, 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.ResolveAttributeValue(collection.ID)
|
||||
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: 352, Col: 26}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 194, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
_, 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, 32, "\" data-library-id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</h3></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if book.Author != "" {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 353, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 199, Col: 27}
|
||||
}
|
||||
_, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" style=\"display: none;\"></div></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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: 204, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if book.CoverImagePath != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 207, Col: 37}
|
||||
}
|
||||
_, 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, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !collection.IsSystem {
|
||||
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 {
|
||||
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 = 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 {
|
||||
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, 57, "<span x-text=\"confirmLabel\"></span></button></div></div></div></body><div id=\"collection-data\" data-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: 383, 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: 384, 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: 385, 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 {
|
||||
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: 395, 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: 399, 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: 399, 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: 401, 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: 404, 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: 409, 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: 416, 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
|
||||
|
||||
+79
-66
@@ -14,55 +14,66 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
</head>
|
||||
<body x-data="conflicts" class="theme-{ user.Theme }">
|
||||
@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="flex justify-between items-center">
|
||||
<div class="flex justify-between items-center gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Sync Conflicts</h1>
|
||||
<p style="color: var(--text-secondary)">Resolve conflicts when reading progress differs across devices</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: 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 class="flex space-x-3">
|
||||
<select
|
||||
id="status-filter"
|
||||
onchange="filterConflicts()"
|
||||
class="px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="unresolved">Unresolved Only</option>
|
||||
<option value="all">All Conflicts</option>
|
||||
<option value="resolved">Resolved Only</option>
|
||||
</select>
|
||||
<button @click="dismissAllResolved()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Dismiss All Resolved
|
||||
</button>
|
||||
<button @click="dismissAllResolved()" class="btn btn-secondary">Dismiss All Resolved</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="stats-bar" class="mt-4 flex space-x-6 text-sm">
|
||||
<span style="color: var(--text-secondary)">Total: <strong style="color: var(--text-primary)">{ total }</strong></span>
|
||||
<span style="color: var(--text-secondary)">Unresolved: <strong style="color: #f59e0b;">{ unresolved }</strong></span>
|
||||
<span style="color: var(--text-secondary)">Resolved: <strong style="color: #10b981;">{ total - unresolved }</strong></span>
|
||||
<div id="stats-bar" class="mt-4 grid grid-cols-3 gap-4 max-w-lg">
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--text-primary);">{ total }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Total</div>
|
||||
</div>
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--status-warning);">{ unresolved }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Unresolved</div>
|
||||
</div>
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--status-success);">{ total - unresolved }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Resolved</div>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<span class="text-sm font-semibold" style="color: var(--text-primary);">
|
||||
<span id="selected-count">0</span> conflicts selected
|
||||
</span>
|
||||
<select
|
||||
id="bulk-strategy"
|
||||
class="px-3 py-2 border rounded-lg text-sm"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input w-auto"
|
||||
>
|
||||
<option value="most_recent">Most Recent</option>
|
||||
<option value="highest_progress">Highest Progress</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button @click="bulkResolve()" class="btn-primary px-4 py-2 rounded-lg text-sm">
|
||||
✅ Resolve Selected
|
||||
<button @click="bulkResolve()" class="btn btn-primary text-sm">
|
||||
@Icon("check", "h-4 w-4")
|
||||
Resolve Selected
|
||||
</button>
|
||||
<button @click="bulkDismiss()" class="btn-danger px-4 py-2 rounded-lg text-sm">
|
||||
❌ Dismiss Selected
|
||||
<button @click="bulkDismiss()" class="btn btn-danger text-sm">
|
||||
Dismiss Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,12 +83,14 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p>Loading conflicts...</p>
|
||||
</div>
|
||||
<div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">✅</div>
|
||||
<div id="empty-state" class="hidden card text-center py-16">
|
||||
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
|
||||
@Icon("check-circle", "h-7 w-7")
|
||||
</span>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
|
||||
<p>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 id="conflicts-list" class="space-y-6">
|
||||
<div id="conflicts-list" class="space-y-4">
|
||||
<div id="select-all-container" class="mb-4 hidden">
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" onchange="toggleAllConflicts()"/>
|
||||
@@ -85,15 +98,17 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
</label>
|
||||
</div>
|
||||
if len(conflicts) == 0 {
|
||||
<div class="text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">✅</div>
|
||||
<div class="card text-center py-16">
|
||||
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
|
||||
@Icon("check-circle", "h-7 w-7")
|
||||
</span>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
|
||||
<p>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>
|
||||
}
|
||||
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="flex justify-between items-start mb-4">
|
||||
<div class="conflict-item card p-6" data-conflict-id={ conflict.ID } style="border-left-width: 4px; border-left-color: var(--status-warning);">
|
||||
<div class="flex justify-between items-start mb-4 gap-4">
|
||||
<div class="flex items-start space-x-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -101,14 +116,17 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
data-conflict-id={ conflict.ID }
|
||||
onchange="updateBulkActions()"
|
||||
/>
|
||||
<div class="flex items-start gap-3">
|
||||
<span style="color: var(--status-warning);">
|
||||
@Icon("alert", "h-5 w-5")
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="showResolveModal('{ conflict.ID }')" class="btn-primary px-4 py-2 rounded-lg">
|
||||
Resolve
|
||||
</button>
|
||||
</div>
|
||||
<button @click="showResolveModal('{ conflict.ID }')" class="btn btn-primary">Resolve</button>
|
||||
</div>
|
||||
<div class="text-sm" style="color: var(--text-secondary)">
|
||||
Created: { FormatInTimezone(conflict.CreatedAt, user.Timezone) }
|
||||
@@ -119,94 +137,90 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
</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 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 id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-4xl my-8" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 class="text-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>
|
||||
</div>
|
||||
<button @click="hideResolveModal()" class="p-2 hover:opacity-80 rounded-lg" style="color: var(--text-primary); background-color: var(--bg-primary);">
|
||||
X
|
||||
<button @click="hideResolveModal()" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<form id="conflict-resolution-form" @submit="handleResolveSubmit($event)">
|
||||
<input type="hidden" id="conflict-id"/>
|
||||
<div id="conflict-details" class="mb-6"></div>
|
||||
<div class="card p-4 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>
|
||||
<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);">
|
||||
<input type="radio" name="winner" value="koreader" class="mr-3" required/>
|
||||
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);">
|
||||
<input type="radio" name="winner" value="koreader" class="w-4 h-4" required/>
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold" style="color: var(--text-primary);">Keep KOReader Progress</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your KOReader device</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
|
||||
<input type="radio" name="winner" value="kobo" class="mr-3"/>
|
||||
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);">
|
||||
<input type="radio" name="winner" value="kobo" class="w-4 h-4"/>
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold" style="color: var(--text-primary);">Keep Kobo Progress</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your Kobo device</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
|
||||
<input type="radio" name="winner" value="web" class="mr-3"/>
|
||||
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);">
|
||||
<input type="radio" name="winner" value="web" class="w-4 h-4"/>
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold" style="color: var(--text-primary);">Keep Web Progress</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">Use progress from the web interface</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
|
||||
<input type="radio" name="winner" value="manual" class="mr-3"/>
|
||||
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);">
|
||||
<input type="radio" name="winner" value="manual" class="w-4 h-4"/>
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold" style="color: var(--text-primary);">Manual Override</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">Specify a custom progress value</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div id="manual-override" class="hidden mb-4 p-4 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>
|
||||
<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);">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
|
||||
type="number"
|
||||
id="manual-percentage"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
/>
|
||||
</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
|
||||
type="number"
|
||||
id="manual-page"
|
||||
min="1"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
/>
|
||||
</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
|
||||
type="text"
|
||||
id="manual-epubcfi"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
/>
|
||||
</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
|
||||
type="number"
|
||||
id="manual-chapter"
|
||||
min="1"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,20 +232,19 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
</label>
|
||||
</div>
|
||||
<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
|
||||
type="text"
|
||||
id="resolution-reason"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
placeholder="e.g., Device was offline, using most recent progress"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="hideResolveModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
|
||||
<button type="button" @click="deleteConflict()" class="btn-danger px-4 py-2 rounded-lg">Dismiss</button>
|
||||
<button type="submit" class="btn-primary px-6 py-2 rounded-lg">Resolve Conflict</button>
|
||||
<div class="flex justify-end space-x-3 flex-wrap">
|
||||
<button type="button" @click="hideResolveModal()" class="btn btn-secondary">Cancel</button>
|
||||
<button type="button" @click="deleteConflict()" class="btn btn-danger">Dismiss</button>
|
||||
<button type="submit" class="btn btn-primary">Resolve Conflict</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
+104
-30
File diff suppressed because one or more lines are too long
@@ -12,75 +12,82 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
@Header(user, "/custom-section")
|
||||
<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>
|
||||
<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);">
|
||||
@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">
|
||||
<!-- 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="card p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2" style="color: var(--text-primary)">
|
||||
@Icon("info", "h-5 w-5")
|
||||
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>
|
||||
<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="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);"
|
||||
class="input"
|
||||
/>
|
||||
</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
|
||||
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);"
|
||||
class="input"
|
||||
placeholder="📚"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
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);"
|
||||
class="input"
|
||||
></textarea>
|
||||
</div>
|
||||
<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
|
||||
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);"
|
||||
class="input"
|
||||
>
|
||||
<option value="">Select a library...</option>
|
||||
<option value="">Select a library…</option>
|
||||
for _, lib := range libraries {
|
||||
<option value={ lib.ID }>{ lib.Name }</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filter Rules -->
|
||||
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
||||
<div class="card p-6">
|
||||
<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
|
||||
type="button"
|
||||
id="add-rule-btn"
|
||||
class="px-3 py-1 rounded-lg text-sm font-medium"
|
||||
style="background-color: var(--accent);"
|
||||
class="btn btn-primary text-sm"
|
||||
>
|
||||
+ Add Rule
|
||||
@Icon("plus", "h-4 w-4")
|
||||
Add Rule
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm mb-4" style="color: var(--text-secondary)">
|
||||
@@ -88,101 +95,101 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
|
||||
</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>
|
||||
<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="px-3 py-1 rounded border"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input w-auto"
|
||||
>
|
||||
<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>
|
||||
<div class="card p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2" style="color: var(--text-primary)">
|
||||
@Icon("book", "h-5 w-5")
|
||||
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>
|
||||
<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="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..."
|
||||
class="input flex-1"
|
||||
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);"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
@Icon("search", "h-4 w-4")
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
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);"
|
||||
></div>
|
||||
<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
|
||||
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);"
|
||||
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>
|
||||
<!-- Live Preview -->
|
||||
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
||||
<div class="card p-6">
|
||||
<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
|
||||
type="button"
|
||||
id="preview-btn"
|
||||
class="px-4 py-2 rounded-lg font-medium"
|
||||
style="background-color: var(--accent);"
|
||||
class="btn btn-secondary text-sm"
|
||||
>
|
||||
@Icon("refresh", "h-4 w-4")
|
||||
Refresh Preview
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="preview-container"
|
||||
class="p-4 rounded-lg"
|
||||
class="card p-4"
|
||||
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.
|
||||
</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);"
|
||||
class="btn btn-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);"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
@Icon("save", "h-4 w-4")
|
||||
Save Section
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -37,43 +37,115 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 68, Col: 31}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 73, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
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: 73, Col: 44}
|
||||
}
|
||||
_, 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, "</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -81,7 +153,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+75
-87
@@ -18,13 +18,15 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
|
||||
<body x-data="dashboard" x-init="initDashboard()" class="theme-{ user.Theme }">
|
||||
@Header(user, "/dashboard")
|
||||
@LibrarySwitcher(libData, currentLibraryID, DashboardActions())
|
||||
<!-- Collections Container -->
|
||||
<main id="collections-container" class="w-full px-4 py-8">
|
||||
<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>
|
||||
for _, section := range sections {
|
||||
@CollectionCarousel(section)
|
||||
}
|
||||
</main>
|
||||
<!-- Dashboard Settings Modal -->
|
||||
@DashboardSettingsModal(allSections, hiddenCollections, itemsPerSection)
|
||||
@ErrorToast(errorMessage)
|
||||
</body>
|
||||
@@ -33,89 +35,79 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
|
||||
|
||||
templ CollectionCarousel(section handlers.SectionData) {
|
||||
<div
|
||||
class="dashboard-collection mb-8"
|
||||
class="dashboard-collection mb-10"
|
||||
data-collection-id={ section.ID }
|
||||
data-is-system={ section.IsSystem }
|
||||
>
|
||||
<!-- Collection Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-end justify-between mb-4">
|
||||
<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>
|
||||
<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 != "" {
|
||||
<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>
|
||||
if section.ViewAllURL != "" {
|
||||
<a
|
||||
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);"
|
||||
>
|
||||
View All →
|
||||
View All
|
||||
@Icon("arrow-right", "h-4 w-4")
|
||||
</a>
|
||||
}
|
||||
</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"
|
||||
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={ section.ID }
|
||||
data-direction="-1"
|
||||
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>
|
||||
<div
|
||||
id={ "carousel-track-" + section.ID }
|
||||
class="carousel-track flex gap-4 overflow-x-auto
|
||||
scroll-smooth snap-x snap-mandatory
|
||||
px-12 pb-4"
|
||||
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;"
|
||||
>
|
||||
for _, item := range section.Items {
|
||||
<div class="flex-shrink-0 w-36 sm:w-40 snap-start">
|
||||
@BookCard(item)
|
||||
</div>
|
||||
}
|
||||
if len(section.Items) == 0 {
|
||||
<div class="text-center py-8 w-full" style="color: var(--text-secondary);">
|
||||
<p>No items in this collection</p>
|
||||
<div class="flex flex-col items-center justify-center text-center py-12 w-full gap-2" style="color: var(--text-secondary);">
|
||||
@Icon("book", "h-8 w-8 opacity-50")
|
||||
<p class="text-sm">No items in this collection</p>
|
||||
</div>
|
||||
}
|
||||
</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"
|
||||
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={ section.ID }
|
||||
data-direction="1"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
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">
|
||||
<a href={ "/media/" + item.MediaItemID } class="block h-full">
|
||||
<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={ "View " + item.Title }
|
||||
>
|
||||
<div
|
||||
class="aspect-[2/3] overflow-hidden shadow-lg
|
||||
bg-gradient-to-br from-gray-700 to-gray-900"
|
||||
class="book-card-cover aspect-[2/3] overflow-hidden"
|
||||
style="background-color: color-mix(in srgb, var(--text-primary) 8%, transparent);"
|
||||
>
|
||||
if item.CoverImagePath != "" {
|
||||
<img
|
||||
@@ -133,60 +125,72 @@ templ BookCard(item handlers.BookInfo) {
|
||||
/>
|
||||
}
|
||||
</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={ item.Title }>
|
||||
<div class="book-card-meta">
|
||||
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
|
||||
{ item.Title }
|
||||
</h3>
|
||||
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 }
|
||||
</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>
|
||||
}
|
||||
|
||||
templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) {
|
||||
<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);"
|
||||
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>
|
||||
<button
|
||||
data-action="close-dashboard-settings"
|
||||
class="p-2 hover:bg-gray-700 rounded transition-colors"
|
||||
>
|
||||
✕
|
||||
<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">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</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">
|
||||
for _, section := range sections {
|
||||
<div
|
||||
class="collection-item flex items-center justify-between p-3 rounded border
|
||||
cursor-move select-none"
|
||||
class="collection-item flex items-center justify-between p-3 rounded-xl cursor-move select-none card"
|
||||
data-collection-id={ section.ID }
|
||||
data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
|
||||
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>
|
||||
@Icon("grip", "h-5 w-5 shrink-0")
|
||||
<span class="text-xl">{ section.Icon }</span>
|
||||
<div>
|
||||
<span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium" style="color: var(--text-primary)">{ section.Title }</span>
|
||||
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>
|
||||
@@ -195,8 +199,7 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
|
||||
<button
|
||||
data-action="restore-system-collection"
|
||||
data-collection-name={ section.ID }
|
||||
class="text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity"
|
||||
style="border-color: var(--border); color: var(--text-secondary);"
|
||||
class="btn btn-secondary py-1 px-2.5 text-xs"
|
||||
title="Restore { section.Title } to defaults"
|
||||
>
|
||||
Restore
|
||||
@@ -211,21 +214,17 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
|
||||
}
|
||||
/>
|
||||
<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"
|
||||
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>
|
||||
}
|
||||
</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">{ itemsPerSection }</span>
|
||||
Items per Collection: <span id="items-count-display" class="font-bold" style="color: var(--text-primary)">{ itemsPerSection }</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
@@ -233,26 +232,15 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
|
||||
max="50"
|
||||
step="5"
|
||||
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"
|
||||
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>
|
||||
<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>
|
||||
|
||||
+260
-147
@@ -46,7 +46,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -97,14 +97,14 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
templ_7745c5c3_Var2 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
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: 39, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -117,33 +117,33 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem)
|
||||
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: 40, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"><!-- 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||
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: 44, Col: 130}
|
||||
}
|
||||
_, 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, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||
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: 46, Col: 100}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -154,14 +154,14 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
|
||||
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: 48, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -184,73 +184,113 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
var templ_7745c5c3_Var8 templ.SafeURL
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
|
||||
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: 54, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
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: 67, Col: 35}
|
||||
}
|
||||
_, 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, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 76, Col: 39}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 75, Col: 39}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = BookCard(item).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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, 22, "<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, 23, "<p class=\"text-sm\">No items in this collection</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, 24, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
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: 94, Col: 35}
|
||||
}
|
||||
_, 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, 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, 25, "\" 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, 26, "</button></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -279,162 +319,219 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
templ_7745c5c3_Var12 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"book-card relative w-full h-full rounded-xl overflow-hidden cursor-pointer\"><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 templ.SafeURL
|
||||
templ_7745c5c3_Var13, 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: 108, Col: 39}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 107, Col: 40}
|
||||
}
|
||||
_, 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, 22, "\" title=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" 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
|
||||
}
|
||||
if item.CoverImagePath != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 114, Col: 31}
|
||||
}
|
||||
_, 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=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" alt=\"")
|
||||
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)
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 114, Col: 36}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 115, Col: 22}
|
||||
}
|
||||
_, 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\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" 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
|
||||
}
|
||||
if item.CoverImagePath != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<img src=\"")
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<img src=\"/static/placeholder-book.svg\" alt=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
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: 123, Col: 22}
|
||||
}
|
||||
_, 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, 26, "\" alt=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" class=\"w-full h-full object-cover\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</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_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
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: 129, Col: 117}
|
||||
}
|
||||
_, 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, 27, "\" 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, 28, "<img src=\"/static/placeholder-book.svg\" alt=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 22}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 130, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
_, 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, 29, "\" class=\"w-full h-full object-cover\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</h3>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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=\"")
|
||||
if item.Author != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<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_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
|
||||
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: 133, Col: 100}
|
||||
}
|
||||
_, 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, 31, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 138, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 134, Col: 19}
|
||||
}
|
||||
_, 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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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, 40, "</div></a><div class=\"book-card-action\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 141, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if item.HasConflict {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
|
||||
var templ_7745c5c3_Var21 templ.SafeURL
|
||||
templ_7745c5c3_Var21, 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: 142, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" class=\"book-card-action-btn\" aria-label=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||
templ_7745c5c3_Var22, 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: 142, Col: 19}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 144, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
_, 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, 35, "</p>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" 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, 44, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 templ.SafeURL
|
||||
templ_7745c5c3_Var23, 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: 151, Col: 42}
|
||||
}
|
||||
_, 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, 46, "\" class=\"book-card-action-btn\" aria-label=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, 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: 153, Col: 38}
|
||||
}
|
||||
_, 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, 47, "\" 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, 48, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div></div></a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -458,143 +555,159 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var23 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var23 == nil {
|
||||
templ_7745c5c3_Var23 = templ.NopComponent
|
||||
templ_7745c5c3_Var25 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var25 == nil {
|
||||
templ_7745c5c3_Var25 = templ.NopComponent
|
||||
}
|
||||
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, 50, "<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, 51, "</button></div><div id=\"collection-list\" class=\"space-y-2 mb-6\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, section := range sections {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"collection-item flex items-center justify-between p-3 rounded border cursor-move select-none\" 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\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<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_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 185, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 183, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</span><div><span class=\"font-medium\" style=\"color: var(--text-primary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" data-is-system=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||
templ_7745c5c3_Var27, 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: 187, Col: 85}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 184, Col: 58}
|
||||
}
|
||||
_, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" draggable=\"true\"><div class=\"flex items-center gap-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if section.IsSystem {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<span class=\"text-xs ml-2 px-2 py-1 rounded\" style=\"background-color: var(--accent);\">System</span>")
|
||||
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, 44, "</div></div><div class=\"flex items-center gap-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if section.IsSystem {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<button data-action=\"restore-system-collection\" data-collection-name=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"text-xl\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||
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: 189, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !ContainsString(hiddenCollections, section.ID) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, 56, "</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_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||
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: 191, Col: 84}
|
||||
}
|
||||
_, 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, 51, "</span></label> <input type=\"range\" min=\"10\" max=\"50\" step=\"5\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if section.IsSystem {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">System</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div></div><div class=\"flex items-center gap-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if section.IsSystem {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<button data-action=\"restore-system-collection\" data-collection-name=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
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: 201, Col: 42}
|
||||
}
|
||||
_, 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, 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, 61, "\" class=\"btn btn-secondary py-1 px-2.5 text-xs\" title=\"Restore { section.Title } to defaults\">Restore</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<label class=\"relative inline-flex items-center cursor-pointer\"><input type=\"checkbox\" class=\"sr-only peer\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !ContainsString(hiddenCollections, section.ID) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "><div class=\"w-11 h-6 rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all\" style=\"background-color: var(--border-strong);\"></div></label></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</div><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Items per Collection: <span id=\"items-count-display\" class=\"font-bold\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 227, Col: 128}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</span></label> <input type=\"range\" min=\"10\" max=\"50\" step=\"5\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 234, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" class=\"w-full h-2 rounded-lg appearance-none cursor-pointer\" style=\"background-color: var(--border-strong);\" data-input-action=\"update-items-count\" target=\"items-count-display\"></div><div class=\"flex justify-end gap-3\"><button data-action=\"close-dashboard-settings\" class=\"btn btn-secondary\">Cancel</button> <button data-action=\"save-dashboard-settings\" class=\"btn btn-primary\">Save Changes</button></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+172
-151
@@ -14,55 +14,57 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }" x-data="devices" x-init="setupEventDelegation()">
|
||||
@Header(user, "/devices")
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header Section -->
|
||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex justify-between items-center gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Device Management</h1>
|
||||
<p style="color: var(--text-secondary)">Manage your reading devices and sync settings</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("device", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Device Management</h1>
|
||||
</div>
|
||||
<button @click="showAddDeviceModal()" class="btn-primary px-4 py-2 rounded-lg">
|
||||
➕ Add New Device
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Manage your reading devices and sync settings</p>
|
||||
</div>
|
||||
<button @click="showAddDeviceModal()" class="btn btn-primary">
|
||||
@Icon("plus", "h-4 w-4")
|
||||
Add New Device
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Devices Grid -->
|
||||
<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 {
|
||||
<div id="empty-state" class="text-center py-16 col-span-full" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">📱</div>
|
||||
<div id="empty-state" class="card text-center py-16 col-span-full">
|
||||
<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>
|
||||
<p class="mb-4">Add your reading devices to enable cross-device sync</p>
|
||||
<button @click="showAddDeviceModal()" class="btn-primary px-4 py-2 rounded-lg">
|
||||
Add Your First Device
|
||||
</button>
|
||||
<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 btn-primary">Add Your First Device</button>
|
||||
</div>
|
||||
}
|
||||
<!-- Devices Grid -->
|
||||
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 {
|
||||
<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="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" {
|
||||
📖
|
||||
@Icon("book-open", "h-5 w-5")
|
||||
} else if device.DeviceType == "kobo" {
|
||||
📚
|
||||
@Icon("library", "h-5 w-5")
|
||||
} else if device.DeviceType == "web" {
|
||||
🌐
|
||||
@Icon("globe", "h-5 w-5")
|
||||
} else {
|
||||
📱
|
||||
@Icon("device", "h-5 w-5")
|
||||
}
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button @click={ "showShelfMappings('" + device.ID.String() + "')" } class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
|
||||
📚
|
||||
</span>
|
||||
<div class="flex space-x-1">
|
||||
<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 @click={ "showDeviceSettings('" + device.ID.String() + "')" } class="icon-btn" title="Device settings" aria-label="Device settings">
|
||||
@Icon("gear", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,9 +74,9 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
<div class="flex justify-between">
|
||||
<span style="color: var(--text-secondary)">Sync Status</span>
|
||||
if device.SyncEnabled {
|
||||
<span style="color: var(--accent)">✓ Enabled</span>
|
||||
<span class="badge status-completed">Enabled</span>
|
||||
} 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 class="flex justify-between">
|
||||
@@ -82,7 +84,7 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
if device.LastSync != nil {
|
||||
<span style="color: var(--text-primary)">{ FormatInTimezone(*device.LastSync, user.Timezone) }</span>
|
||||
} else {
|
||||
<span style="color: var(--text-primary)">Never</span>
|
||||
<span style="color: var(--text-secondary)">Never</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
@@ -90,15 +92,13 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
if device.LastSeen != nil {
|
||||
<span style="color: var(--text-primary)">{ FormatInTimezone(*device.LastSeen, user.Timezone) }</span>
|
||||
} else {
|
||||
<span style="color: var(--text-primary)">Never</span>
|
||||
<span style="color: var(--text-secondary)">Never</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- NEW: Sync URL & Token Management -->
|
||||
<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" {
|
||||
<!-- Kobo: Copy Full Sync URL -->
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs mb-1" style="color: var(--text-secondary)">Kobo Sync URL</label>
|
||||
<div class="flex space-x-2">
|
||||
@@ -107,22 +107,20 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
id="sync-url-{ device.ID }"
|
||||
readonly
|
||||
value={ baseURL + "/api/sync/kobo/" + device.AuthToken }
|
||||
class="flex-1 px-3 py-2 text-xs rounded border"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input text-xs font-mono"
|
||||
/>
|
||||
<button
|
||||
@click="copyToClipboard(document.getElementById('sync-url-{ device.ID }').value, 'Kobo sync URL')"
|
||||
class="px-3 py-2 text-xs rounded hover:opacity-80"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
class="btn btn-primary text-xs px-3"
|
||||
>
|
||||
📋 Copy
|
||||
@Icon("copy", "h-4 w-4")
|
||||
Copy
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
}
|
||||
if device.DeviceType == "koreader" {
|
||||
<!-- KOReader: Copy Auth Token -->
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs mb-1" style="color: var(--text-secondary)">Auth Token (for plugin)</label>
|
||||
<div class="flex space-x-2">
|
||||
@@ -131,43 +129,40 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
id="auth-token-{ device.ID }"
|
||||
readonly
|
||||
value={ device.AuthToken }
|
||||
class="flex-1 px-3 py-2 text-xs rounded border"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border); font-family: monospace;"
|
||||
class="input text-xs font-mono"
|
||||
/>
|
||||
<button
|
||||
@click="copyToClipboard(document.getElementById('auth-token-{ device.ID }').value, 'Auth token')"
|
||||
class="px-3 py-2 text-xs rounded hover:opacity-80"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
class="btn btn-primary text-xs px-3"
|
||||
>
|
||||
📋 Copy
|
||||
@Icon("copy", "h-4 w-4")
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary);">Enter this token in the KOReader plugin settings</p>
|
||||
</div>
|
||||
}
|
||||
<!-- Regenerate Token Button -->
|
||||
<button
|
||||
hx-put={ "/api/devices/" + device.ID.String() + "/regenerate-token" }
|
||||
hx-confirm="⚠️ Old token will immediately stop working. Regenerate anyway?"
|
||||
class="w-full px-3 py-2 text-xs rounded border hover:opacity-80"
|
||||
style="border-color: var(--border); color: var(--text-secondary); background-color: var(--bg-primary);"
|
||||
hx-confirm="Old token will immediately stop working. Regenerate anyway?"
|
||||
class="btn btn-secondary w-full text-xs"
|
||||
>
|
||||
🔄 Regenerate Token
|
||||
@Icon("refresh", "h-4 w-4")
|
||||
Regenerate Token
|
||||
</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>
|
||||
<!-- Pending Registrations -->
|
||||
if len(pendingRegistrations) > 0 {
|
||||
<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">
|
||||
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>
|
||||
<h3 class="font-semibold" style="color: var(--text-primary)">{ reg.DeviceName }</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">
|
||||
@@ -175,10 +170,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
</p>
|
||||
</div>
|
||||
<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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
@@ -187,92 +182,134 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Sync Queue Section -->
|
||||
<div id="sync-queue-section" class="mt-12 hidden">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Sync Queue</h2>
|
||||
<button @click="clearSyncQueue()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Clear Queue
|
||||
</button>
|
||||
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">Sync Queue</h2>
|
||||
<button @click="clearSyncQueue()" class="btn btn-secondary">Clear Queue</button>
|
||||
</div>
|
||||
<div id="sync-queue" class="space-y-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add Device Modal -->
|
||||
<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 rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<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 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)">Add New Device</h2>
|
||||
<button @click="hideAddDeviceModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||
</div>
|
||||
<form id="add-device-form" @submit="handleAddDevice($event)">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="device-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 Kindle Paperwhite"
|
||||
/>
|
||||
<button @click="hideAddDeviceModal()" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Type</label>
|
||||
<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="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
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>
|
||||
<!-- <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 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)">
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="device-name"
|
||||
required
|
||||
class="input"
|
||||
placeholder="My Kobo"
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
type="text"
|
||||
id="device-identifier"
|
||||
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);"
|
||||
class="input"
|
||||
placeholder="Hardware ID or Serial Number"
|
||||
/>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Enter your device's unique identifier</p>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="hideAddDeviceModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
|
||||
Register Device
|
||||
</button>
|
||||
<button type="button" @click="hideAddDeviceModal()" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Register Device</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Device Settings Modal -->
|
||||
<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 class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
</div>
|
||||
<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 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)">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>
|
||||
<form id="device-settings-form" @submit="handleSaveDeviceSettings($event)">
|
||||
<input type="hidden" id="settings-device-id"/>
|
||||
<input type="hidden" id="settings-device-type"/>
|
||||
<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
|
||||
type="text"
|
||||
id="settings-device-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);"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
@@ -300,11 +337,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>
|
||||
</div>
|
||||
<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
|
||||
id="settings-sync-frequency"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="1">Every 1 minute</option>
|
||||
<option value="5" selected>Every 5 minutes</option>
|
||||
@@ -314,14 +350,13 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
</select>
|
||||
</div>
|
||||
<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>
|
||||
<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
|
||||
id="settings-view-mode"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="grid">Grid View</option>
|
||||
<option value="list">List View</option>
|
||||
@@ -329,11 +364,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
</select>
|
||||
</div>
|
||||
<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
|
||||
id="settings-sort-order"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="name">Collection Name</option>
|
||||
<option value="created">Date Created</option>
|
||||
@@ -342,11 +376,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
</select>
|
||||
</div>
|
||||
<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
|
||||
id="settings-items-per-page"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="12">12 items</option>
|
||||
<option value="24" selected>24 items</option>
|
||||
@@ -378,81 +411,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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="hideDeviceSettingsModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Cancel
|
||||
</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 class="flex justify-end space-x-3 flex-wrap">
|
||||
<button type="button" @click="hideDeviceSettingsModal()" class="btn btn-secondary">Cancel</button>
|
||||
<button type="button" @click="handleRevokeDevice()" class="btn btn-danger">Revoke Device</button>
|
||||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Device Shelf Mappings Modal -->
|
||||
<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 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 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 class="card p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto" 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)">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>
|
||||
<input type="hidden" id="mappings-device-id"/>
|
||||
<div id="shelf-mappings-container" class="space-y-4 mb-6">
|
||||
<p style="color: var(--text-secondary)">Loading mappings...</p>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="hideShelfMappingsModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Close
|
||||
</button>
|
||||
<button type="button" @click="showAddMappingModal()" class="btn-primary px-4 py-2 rounded-lg">
|
||||
➕ Add Mapping
|
||||
<button type="button" @click="hideShelfMappingsModal()" class="btn btn-secondary">Close</button>
|
||||
<button type="button" @click="showAddMappingModal()" class="btn btn-primary">
|
||||
@Icon("plus", "h-4 w-4")
|
||||
Add Mapping
|
||||
</button>
|
||||
</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" 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 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 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)">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>
|
||||
<form id="mapping-form" @submit="handleSaveMapping($event)">
|
||||
<input type="hidden" id="mapping-device-id"/>
|
||||
<input type="hidden" id="mapping-id"/>
|
||||
<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
|
||||
id="mapping-collection"
|
||||
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);"
|
||||
class="input"
|
||||
>
|
||||
<option value="">Select collection...</option>
|
||||
</select>
|
||||
</div>
|
||||
<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
|
||||
type="text"
|
||||
id="mapping-shelf-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);"
|
||||
class="input"
|
||||
placeholder="e.g., Sci-Fi"
|
||||
/>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Name of the shelf on this device</p>
|
||||
</div>
|
||||
<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
|
||||
id="mapping-sync-direction"
|
||||
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);"
|
||||
class="input"
|
||||
>
|
||||
<option value="bidirectional">Bidirectional (sync both ways)</option>
|
||||
<option value="book_to_device">Bookhoard → Device</option>
|
||||
@@ -461,12 +486,8 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="hideAddMappingModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
|
||||
Save Mapping
|
||||
</button>
|
||||
<button type="button" @click="hideAddMappingModal()" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Mapping</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
+199
-49
File diff suppressed because one or more lines are too long
+43
-42
@@ -12,101 +12,102 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="/static/highlight-dark.min.css"/>
|
||||
</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)
|
||||
<!-- 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"
|
||||
class="lg:hidden icon-btn fixed top-[4.75rem] left-4 z-50 bg-surface-raised"
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
@Icon("menu", "h-5 w-5")
|
||||
</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">
|
||||
<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);">
|
||||
@Icon("search", "h-4 w-4")
|
||||
</span>
|
||||
<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..."
|
||||
class="search-input input pl-9"
|
||||
placeholder="Search documentation…"
|
||||
id="docs-search"
|
||||
/>
|
||||
</div>
|
||||
<!-- Navigation Sections -->
|
||||
</div>
|
||||
for _, section := range nav.Sections {
|
||||
<div class="nav-section mb-6">
|
||||
<div class="nav-section border-b border-line">
|
||||
<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"
|
||||
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>{ 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>
|
||||
@Icon("chevron-down", "h-4 w-4 section-arrow transition-transform")
|
||||
</div>
|
||||
if section.Collapsed {
|
||||
<div class="nav-items hidden" data-collapsed="true">
|
||||
for _, item := range section.Items {
|
||||
<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">
|
||||
<span class="mr-2">{ item.Icon }</span>{ item.Title }
|
||||
<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 block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline">
|
||||
<span class="mr-2">{ item.Icon }</span>{ item.Title }
|
||||
<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>
|
||||
<!-- Main Content -->
|
||||
<main class="main-content lg:ml-72 p-8 max-w-4xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
</aside>
|
||||
<main class="main-content flex-1 min-w-0 max-w-4xl">
|
||||
if len(doc.Breadcrumb) > 0 {
|
||||
<nav class="breadcrumb flex gap-2 text-sm text-text-secondary mb-8" aria-label="Breadcrumb">
|
||||
<nav class="breadcrumb flex flex-wrap items-center gap-1.5 text-sm mb-8" aria-label="Breadcrumb" style="color: var(--text-secondary);">
|
||||
for i, crumb := range doc.Breadcrumb {
|
||||
if i > 0 {
|
||||
<span class="text-text-secondary/50">›</span>
|
||||
@Icon("chevron-right", "h-4 w-4")
|
||||
}
|
||||
<a href={ crumb.URL } class="text-accent hover:underline no-underline">{ crumb.Title }</a>
|
||||
<a href={ crumb.URL } class="no-underline transition-colors hover:underline" style="color: var(--accent);">{ crumb.Title }</a>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
<!-- Title -->
|
||||
<h1 class="text-4xl font-bold mb-8 text-text-primary">{ doc.Title }</h1>
|
||||
<!-- Table of Contents -->
|
||||
<h1 class="text-4xl font-bold mb-8 tracking-tight" style="color: var(--text-primary);">{ doc.Title }</h1>
|
||||
if len(doc.TOC) > 0 {
|
||||
<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>
|
||||
<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>
|
||||
@Icon("chevron-down", "h-4 w-4 ml-auto")
|
||||
</summary>
|
||||
<div class="mt-3">
|
||||
for _, item := range doc.TOC {
|
||||
<a
|
||||
href={ "#" + item.Anchor }
|
||||
class="toc-item block py-1 text-text-secondary hover:text-accent text-sm no-underline"
|
||||
style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) }
|
||||
class="toc-item block py-1 text-sm no-underline transition-colors hover:text-brand"
|
||||
style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);" }
|
||||
>
|
||||
{ item.Title }
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
<!-- Content -->
|
||||
<div class="prose prose-invert max-w-none">
|
||||
@UnsafeHTML(doc.Content).ToComponent()
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
+80
-48
@@ -48,7 +48,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
@@ -74,267 +74,299 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||
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: 49, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 templ.SafeURL
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||
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: 55, Col: 29}
|
||||
}
|
||||
_, 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, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
||||
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: 56, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span> <span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
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: 57, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span></a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 templ.SafeURL
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||
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: 64, Col: 29}
|
||||
}
|
||||
_, 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, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
||||
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: 65, Col: 75}
|
||||
}
|
||||
_, 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, 17, "</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span> <span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
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: 66, Col: 30}
|
||||
}
|
||||
_, 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, 18, "</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span></a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for i, crumb := range doc.Breadcrumb {
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 templ.SafeURL
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL)
|
||||
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: 81, Col: 28}
|
||||
}
|
||||
_, 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, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title)
|
||||
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: 81, Col: 129}
|
||||
}
|
||||
_, 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, "</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
|
||||
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: 85, Col: 104}
|
||||
}
|
||||
_, 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, 29, "</h1><!-- Table of Contents -->")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</h1>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 templ.SafeURL
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor)
|
||||
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: 95, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
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: 97, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
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: 99, Col: 23}
|
||||
}
|
||||
_, 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, 34, "</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -342,7 +374,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
package templates
|
||||
|
||||
// FilterItem renders a single saved filter item for the list
|
||||
templ FilterItem(id string, name string) {
|
||||
<div
|
||||
class="flex items-center justify-between p-2 rounded hover:opacity-80"
|
||||
style="background-color: var(--bg-primary);"
|
||||
class="flex items-center justify-between gap-2 p-2 rounded-lg hover:bg-surface-hover"
|
||||
data-filter-id={ id }
|
||||
>
|
||||
<button
|
||||
data-action="load-filter"
|
||||
@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);"
|
||||
>
|
||||
{ name }
|
||||
@@ -18,11 +16,12 @@ templ FilterItem(id string, name string) {
|
||||
<button
|
||||
data-action="delete-filter"
|
||||
@click="deleteFilter($event)"
|
||||
class="p-1 hover:opacity-70 rounded"
|
||||
class="icon-btn shrink-0"
|
||||
style="color: var(--text-secondary);"
|
||||
title="Delete filter"
|
||||
aria-label="Delete filter"
|
||||
>
|
||||
🗑
|
||||
@Icon("trash", "h-4 w-4")
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ package templates
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
// FilterItem renders a single saved filter item for the list
|
||||
func FilterItem(id string, name 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
|
||||
@@ -30,33 +29,41 @@ func FilterItem(id string, name string) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"flex items-center justify-between p-2 rounded hover:opacity-80\" style=\"background-color: var(--bg-primary);\" data-filter-id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"flex items-center justify-between gap-2 p-2 rounded-lg hover:bg-surface-hover\" data-filter-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(id)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 8, Col: 21}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 6, Col: 21}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"><button data-action=\"load-filter\" @click=\"loadFilter($event)\" class=\"flex-1 text-left px-2 py-1 rounded\" style=\"color: var(--text-primary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"><button data-action=\"load-filter\" @click=\"loadFilter($event)\" class=\"flex-1 text-left px-2 py-1 text-sm rounded-lg hover:bg-surface-hover\" 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(name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 16, Col: 9}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 14, Col: 9}
|
||||
}
|
||||
_, 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, 3, "</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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</button> <button data-action=\"delete-filter\" @click=\"deleteFilter($event)\" class=\"icon-btn shrink-0\" style=\"color: var(--text-secondary);\" title=\"Delete filter\" aria-label=\"Delete filter\">")
|
||||
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, 4, "</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+177
-480
@@ -1,19 +1,32 @@
|
||||
package templates
|
||||
|
||||
templ Header(user User, currentPath string) {
|
||||
<nav x-data="header" x-init="initializeSearch(); initializeTheme(); loadWoodPaneling(); updateWoodPanelingIndicators(); restoreLibrarySelection(); initializeScanListener()" class="border-b header-nav" style="border-color: var(--border); background-color: var(--bg-secondary);">
|
||||
<div x-data="{}" x-init="console.log('Alpine is working!', $el)"></div>
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- Left: App Title & Navigation -->
|
||||
<div class="flex items-center space-x-6">
|
||||
<a href="/dashboard" class="text-xl font-bold hover:opacity-80 transition-opacity flex items-center gap-2" style="color: var(--text-primary); text-decoration: none;">
|
||||
<span>📚 Bookhoard</span>
|
||||
<!-- Scan spinner -->
|
||||
<div
|
||||
x-data="header"
|
||||
x-init="initializeSearch(); initializeTheme(); loadWoodPaneling(); updateWoodPanelingIndicators(); restoreLibrarySelection(); initializeScanListener()"
|
||||
>
|
||||
<!-- Mobile backdrop -->
|
||||
<div
|
||||
class="app-sidebar-backdrop lg:hidden"
|
||||
:class="{ 'is-open': mobileMenuOpen }"
|
||||
@click="mobileMenuOpen = false"
|
||||
x-cloak
|
||||
></div>
|
||||
<!-- Sidebar -->
|
||||
<aside class="app-sidebar" :class="{ 'is-open': mobileMenuOpen }">
|
||||
<!-- Logo -->
|
||||
<a
|
||||
href="/dashboard"
|
||||
class="flex items-center gap-2.5 px-5 h-16 shrink-0 border-b"
|
||||
style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"
|
||||
>
|
||||
<span class="text-xl">📚</span>
|
||||
<span class="font-bold text-lg tracking-tight" style="color: var(--text-primary)">Bookhoard</span>
|
||||
<svg
|
||||
x-show="scanning"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="h-5 w-5 animate-spin"
|
||||
class="h-4 w-4 animate-spin ml-auto"
|
||||
style="color: var(--accent);"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -21,511 +34,195 @@ templ Header(user User, currentPath string) {
|
||||
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span x-show="scanning && scanProgress > 0" x-transition class="text-xs font-normal" style="color: var(--text-secondary);" x-text="scanProgress + '%'"></span>
|
||||
</a>
|
||||
<div class="hidden nav:flex items-center space-x-4">
|
||||
<a href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Library
|
||||
<span
|
||||
x-show="scanning && scanProgress > 0"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="block px-5 py-1.5 text-xs font-medium -mt-1"
|
||||
style="color: var(--text-secondary);"
|
||||
x-text="'Scanning… ' + scanProgress + '%'"
|
||||
></span>
|
||||
<!-- Primary navigation -->
|
||||
<nav class="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||
<a href="/dashboard" class={ activeClass(currentPath, "/dashboard") }>
|
||||
@Icon("library", "h-5 w-5 shrink-0")
|
||||
<span>Library</span>
|
||||
</a>
|
||||
<a
|
||||
href="/bookshelf"
|
||||
class="text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-secondary); text-decoration: none;"
|
||||
<a href="/bookshelf" class={ activeClass(currentPath, "/bookshelf") }>
|
||||
@Icon("book", "h-5 w-5 shrink-0")
|
||||
<span>All Books</span>
|
||||
</a>
|
||||
<a href="/series" class={ activeClass(currentPath, "/series") }>
|
||||
@Icon("layers", "h-5 w-5 shrink-0")
|
||||
<span>Series</span>
|
||||
</a>
|
||||
<a href="/collections" class={ activeClass(currentPath, "/collections") }>
|
||||
@Icon("folder", "h-5 w-5 shrink-0")
|
||||
<span>Collections</span>
|
||||
</a>
|
||||
<a href="/progress" class={ activeClass(currentPath, "/progress") }>
|
||||
@Icon("clock", "h-5 w-5 shrink-0")
|
||||
<span>Progress</span>
|
||||
</a>
|
||||
<a href="/devices" class={ activeClass(currentPath, "/devices") }>
|
||||
@Icon("device", "h-5 w-5 shrink-0")
|
||||
<span>Devices</span>
|
||||
</a>
|
||||
</nav>
|
||||
<!-- Bottom: theme picker + user -->
|
||||
<div
|
||||
class="shrink-0 px-3 py-3 space-y-2 border-t"
|
||||
style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"
|
||||
>
|
||||
All Books
|
||||
</a>
|
||||
<a href="/series" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Series
|
||||
</a>
|
||||
<a href="/collections" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Collections
|
||||
</a>
|
||||
<a href="/progress" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Progress
|
||||
</a>
|
||||
<a href="/devices" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Devices
|
||||
</a>
|
||||
if user.ID != "" {
|
||||
@SidebarUserMenu(user)
|
||||
} else {
|
||||
@SidebarSignIn(currentPath)
|
||||
}
|
||||
<!-- Theme picker -->
|
||||
<div x-data="{ themeOpen: false }" class="sidebar-panel">
|
||||
<button
|
||||
type="button"
|
||||
@click="themeOpen = !themeOpen"
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover"
|
||||
>
|
||||
@Icon("palette", "h-5 w-5 shrink-0")
|
||||
<span>Appearance</span>
|
||||
@Icon("chevron-down", "h-4 w-4 ml-auto transition-transform")
|
||||
</button>
|
||||
<div x-show="themeOpen" x-cloak x-transition class="mt-1 space-y-0.5 pl-1">
|
||||
for _, opt := range ThemeOptions {
|
||||
<button
|
||||
type="button"
|
||||
@click={ "changeTheme('" + opt.Name + "'); themeOpen = false" }
|
||||
class="w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover"
|
||||
style="color: var(--text-secondary);"
|
||||
>
|
||||
<span class="inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10" style={ "background-color: " + opt.Color }></span>
|
||||
<span class="flex-1 text-left">{ opt.Label }</span>
|
||||
if user.Theme == opt.Name {
|
||||
@Icon("check", "h-4 w-4 shrink-0")
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<div class="my-1.5 mx-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"></div>
|
||||
<p class="px-3 pb-1 text-xs font-medium uppercase tracking-wide" style="color: var(--text-secondary);">Bookshelf</p>
|
||||
for _, w := range WoodOptions {
|
||||
<button
|
||||
type="button"
|
||||
@click={ "changeWoodPaneling('" + w.Name + "')" }
|
||||
class="wood-paneling-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||||
data-wood={ w.Name }
|
||||
style="color: var(--text-secondary);"
|
||||
>
|
||||
<span class="inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10" style="background-color: color-mix(in srgb, var(--text-primary) 12%, transparent);"></span>
|
||||
<span class="flex-1 text-left">{ w.Label }</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Center: Search Box -->
|
||||
<div class="flex-1 max-w-2xl mx-8 hidden nav:block">
|
||||
<div class="relative">
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Topbar -->
|
||||
<header class="app-topbar">
|
||||
<div class="flex items-center gap-3 px-4 sm:px-6 h-full">
|
||||
<button
|
||||
type="button"
|
||||
class="app-mobile-only icon-btn"
|
||||
@click="mobileMenuOpen = true"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
@Icon("menu", "h-5 w-5")
|
||||
</button>
|
||||
<div class="relative flex-1 max-w-xl">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
|
||||
@Icon("search", "h-4 w-4")
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
id="header-search"
|
||||
placeholder="Search your library..."
|
||||
class="w-full px-4 py-2 pl-10 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
placeholder="Search your library…"
|
||||
class="input pl-10"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<svg class="absolute left-3 top-2.5 h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: Theme Switcher & User Menu -->
|
||||
<div x-data="{ themeDropdownOpen: false, userMenuOpen: false }" class="hidden nav:flex items-center space-x-4">
|
||||
<!-- Theme Switcher -->
|
||||
<div class="relative">
|
||||
</header>
|
||||
<script type="module" src="/static/main.js"></script>
|
||||
</div>
|
||||
}
|
||||
|
||||
// SidebarUserMenu is the bottom-of-sidebar account control for signed-in users.
|
||||
templ SidebarUserMenu(user User) {
|
||||
<div x-data="{ userOpen: false }" class="sidebar-panel">
|
||||
<button
|
||||
@click="themeDropdownOpen = !themeDropdownOpen"
|
||||
type="button"
|
||||
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
style="background-color: var(--bg-primary);"
|
||||
>
|
||||
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
x-show="themeDropdownOpen"
|
||||
@click.outside="themeDropdownOpen = 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 right-0 mt-2 w-64 rounded-lg shadow-lg z-50"
|
||||
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)">Select Theme</h3>
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
@click="changeTheme('tokyo-night'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #7aa2f7;"></span>
|
||||
Tokyo Night
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('dracula'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #bd93f9;"></span>
|
||||
Dracula
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('nord'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #88c0d0;"></span>
|
||||
Nord
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('solarized-dark'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #2aa198;"></span>
|
||||
Solarized Dark
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('monokai'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #a6e22e;"></span>
|
||||
Monokai
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('one-dark-pro'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #61dafb;"></span>
|
||||
One Dark Pro
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('material-dark'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #80cbc4;"></span>
|
||||
Material Dark
|
||||
</button>
|
||||
<div class="border-t pt-2 mt-2" style="border-color: var(--border);">
|
||||
<p class="text-xs mb-2" style="color: var(--text-secondary)">Bookshelf Background</p>
|
||||
<button
|
||||
@click="changeWoodPaneling('none'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
@click="userOpen = !userOpen"
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="none"
|
||||
>
|
||||
None
|
||||
<span class="grid place-items-center h-7 w-7 rounded-full shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("user", "h-4 w-4")
|
||||
</span>
|
||||
<span class="flex-1 text-left truncate">{ user.Username }</span>
|
||||
@Icon("chevron-down", "h-4 w-4 shrink-0 transition-transform")
|
||||
</button>
|
||||
<button
|
||||
@click="changeWoodPaneling('wood-light'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-light"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-4 h-4 rounded mr-2"
|
||||
style="background: url('/static/textures/wood-light.png'); background-size: cover;"
|
||||
></span>
|
||||
Wood Light
|
||||
</button>
|
||||
<button
|
||||
@click="changeWoodPaneling('wood-dark'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-dark"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-4 h-4 rounded mr-2"
|
||||
style="background: url('/static/textures/wood-dark.png'); background-size: cover;"
|
||||
></span>
|
||||
Wood Dark
|
||||
</button>
|
||||
<button
|
||||
@click="changeWoodPaneling('wood-mahogany'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-mahogany"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-4 h-4 rounded mr-2"
|
||||
style="background: url('/static/textures/wood-mahogany.png'); background-size: cover;"
|
||||
></span>
|
||||
Wood Mahogany
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- User Icon with Dropdown -->
|
||||
<div class="relative">
|
||||
if user.ID != "" {
|
||||
<!-- LOGGED IN: Show user menu with logout -->
|
||||
<button
|
||||
@click="userMenuOpen = !userMenuOpen"
|
||||
type="button"
|
||||
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
style="background-color: var(--bg-primary);"
|
||||
>
|
||||
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||||
</svg>
|
||||
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span>
|
||||
</button>
|
||||
<div
|
||||
x-show="userMenuOpen"
|
||||
@click.outside="userMenuOpen = false"
|
||||
x-transition
|
||||
class="absolute right-0 mt-2 w-48 rounded-lg shadow-lg z-50"
|
||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
|
||||
>
|
||||
<div class="py-1">
|
||||
<a href="/profile" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;">
|
||||
Profile
|
||||
<div x-show="userOpen" x-cloak x-transition class="mt-1 space-y-0.5 pl-1">
|
||||
<a href="/profile" class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover" style="color: var(--text-secondary);">
|
||||
@Icon("user", "h-4 w-4")
|
||||
<span>Profile</span>
|
||||
</a>
|
||||
if user.Role == "admin" {
|
||||
<a href="/admin" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;">
|
||||
Admin Panel
|
||||
<a href="/admin" class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover" style="color: var(--text-secondary);">
|
||||
@Icon("settings", "h-4 w-4")
|
||||
<span>Admin Panel</span>
|
||||
</a>
|
||||
}
|
||||
<div class="border-t my-1" style="border-color: var(--border);"></div>
|
||||
<button
|
||||
@click="logout(); userMenuOpen = false"
|
||||
type="button"
|
||||
class="block w-full text-left px-4 py-2 text-sm hover:opacity-80"
|
||||
style="color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||
@click="logout()"
|
||||
class="w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover"
|
||||
style="color: var(--text-secondary);"
|
||||
>
|
||||
Logout
|
||||
@Icon("logout", "h-4 w-4")
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<!-- LOGGED OUT: Show login form -->
|
||||
}
|
||||
|
||||
// SidebarSignIn is an inline sign-in for signed-out visitors, preserving the
|
||||
// original header's inline login (htmx POST) so users can auth from any page.
|
||||
templ SidebarSignIn(currentPath string) {
|
||||
<div x-data="{ signInOpen: false }" class="sidebar-panel">
|
||||
<button
|
||||
@click="userMenuOpen = !userMenuOpen"
|
||||
type="button"
|
||||
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
style="background-color: var(--bg-primary);"
|
||||
@click="signInOpen = !signInOpen"
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||||
</svg>
|
||||
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">Login</span>
|
||||
<span class="grid place-items-center h-7 w-7 rounded-full shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("user", "h-4 w-4")
|
||||
</span>
|
||||
<span class="flex-1 text-left">Sign in</span>
|
||||
</button>
|
||||
<div
|
||||
x-show="userMenuOpen"
|
||||
@click.outside="userMenuOpen = false"
|
||||
x-transition
|
||||
class="absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50 p-4"
|
||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
|
||||
<div x-show="signInOpen" x-cloak x-transition class="mt-1 px-1">
|
||||
<form
|
||||
hx-post="/api/auth/login"
|
||||
hx-target="#login-result"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-2"
|
||||
>
|
||||
<form hx-post="/api/auth/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-3">
|
||||
<input type="hidden" name="redirect" value={ currentPath }/>
|
||||
<div>
|
||||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label>
|
||||
<input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label>
|
||||
<input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
|
||||
</div>
|
||||
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
|
||||
Sign In
|
||||
</button>
|
||||
<input type="text" name="login" class="input py-1.5 text-sm" placeholder="Email or username" required/>
|
||||
<input type="password" name="password" class="input py-1.5 text-sm" placeholder="Password" required/>
|
||||
<button type="submit" class="btn btn-primary w-full py-1.5 text-sm">Sign In</button>
|
||||
</form>
|
||||
<div id="login-result"></div>
|
||||
<div class="border-t my-2" style="border-color: var(--border);"></div>
|
||||
<a href="/register" class="block text-center text-sm hover:opacity-80" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Don't have an account? Sign Up
|
||||
<a href="/register" class="block text-center text-xs mt-2 hover:underline" style="color: var(--text-secondary);">
|
||||
Create an account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
type="button"
|
||||
class="nav:hidden p-2 rounded-lg hover:opacity-80 transition-opacity"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary);"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg class="h-6 w-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>
|
||||
</div>
|
||||
<div
|
||||
x-show="mobileMenuOpen"
|
||||
@click.outside="mobileMenuOpen = false"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
class="nav:hidden border-t"
|
||||
style="border-color: var(--border); background-color: var(--bg-secondary); display: none;"
|
||||
x-data="{ mobileThemeOpen: false }"
|
||||
>
|
||||
<div class="px-4 py-3 space-y-1">
|
||||
<div class="relative mb-3">
|
||||
<input
|
||||
type="text"
|
||||
id="header-search-mobile"
|
||||
placeholder="Search your library..."
|
||||
class="w-full px-4 py-2 pl-10 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<svg class="absolute left-3 top-2.5 h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<a href="/dashboard" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Library
|
||||
</a>
|
||||
<a href="/bookshelf" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
All Books
|
||||
</a>
|
||||
<a href="/series" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Series
|
||||
</a>
|
||||
<a href="/collections" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Collections
|
||||
</a>
|
||||
<a href="/progress" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Progress
|
||||
</a>
|
||||
<a href="/devices" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Devices
|
||||
</a>
|
||||
<div class="border-t my-2" style="border-color: var(--border);"></div>
|
||||
<button
|
||||
@click="mobileThemeOpen = !mobileThemeOpen"
|
||||
type="button"
|
||||
class="flex items-center space-x-2 w-full px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-secondary);"
|
||||
>
|
||||
<svg class="h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
|
||||
</svg>
|
||||
<span>Theme</span>
|
||||
<svg class="h-4 w-4 ml-auto transform transition-transform" :class="{ 'rotate-180': mobileThemeOpen }" 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>
|
||||
</button>
|
||||
<div x-show="mobileThemeOpen" x-transition class="pl-8 pr-2 py-1 space-y-1">
|
||||
<button
|
||||
@click="changeTheme('tokyo-night'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #7aa2f7;"></span>
|
||||
Tokyo Night
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('dracula'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #bd93f9;"></span>
|
||||
Dracula
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('nord'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #88c0d0;"></span>
|
||||
Nord
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('solarized-dark'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #2aa198;"></span>
|
||||
Solarized Dark
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('monokai'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #a6e22e;"></span>
|
||||
Monokai
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('one-dark-pro'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #61dafb;"></span>
|
||||
One Dark Pro
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('material-dark'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #80cbc4;"></span>
|
||||
Material Dark
|
||||
</button>
|
||||
<div class="border-t pt-2 mt-2" style="border-color: var(--border);">
|
||||
<p class="text-xs mb-2 px-3" style="color: var(--text-secondary)">Bookshelf Background</p>
|
||||
<button
|
||||
@click="changeWoodPaneling('none'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="none"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
<button
|
||||
@click="changeWoodPaneling('wood-light'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-light"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-light.png'); background-size: cover;"></span>
|
||||
Wood Light
|
||||
</button>
|
||||
<button
|
||||
@click="changeWoodPaneling('wood-dark'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-dark"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-dark.png'); background-size: cover;"></span>
|
||||
Wood Dark
|
||||
</button>
|
||||
<button
|
||||
@click="changeWoodPaneling('wood-mahogany'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-mahogany"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-mahogany.png'); background-size: cover;"></span>
|
||||
Wood Mahogany
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t my-2" style="border-color: var(--border);"></div>
|
||||
if user.ID != "" {
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center space-x-2 px-3 py-2">
|
||||
<svg class="h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||||
</svg>
|
||||
<span class="text-sm" style="color: var(--text-primary)">{ user.Username }</span>
|
||||
</div>
|
||||
<a href="/profile" class="block px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Profile
|
||||
</a>
|
||||
if user.Role == "admin" {
|
||||
<a href="/admin" class="block px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Admin Panel
|
||||
</a>
|
||||
}
|
||||
<button
|
||||
@click="logout()"
|
||||
type="button"
|
||||
class="block w-full text-left px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-secondary);"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
} else {
|
||||
<div class="space-y-3">
|
||||
<form hx-post="/api/auth/login" hx-target="#login-result-mobile" hx-swap="innerHTML" class="space-y-3">
|
||||
<input type="hidden" name="redirect" value={ currentPath }/>
|
||||
<div>
|
||||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label>
|
||||
<input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label>
|
||||
<input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
|
||||
</div>
|
||||
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
<div id="login-result-mobile"></div>
|
||||
<a href="/register" class="block text-center text-sm hover:opacity-80" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Don't have an account? Sign Up
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<script type="module" src="/static/main.js"></script>
|
||||
}
|
||||
|
||||
+451
-47
File diff suppressed because one or more lines are too long
@@ -0,0 +1,175 @@
|
||||
package templates
|
||||
|
||||
// Icon renders an inline SVG from the built-in icon set. All icons share the
|
||||
// same 24x24 viewBox, 1.75 stroke width, and round line caps so the UI has one
|
||||
// consistent visual language (replacing the mixed emoji/SVG iconography).
|
||||
// `extra` is appended to the class list (e.g. "h-5 w-5").
|
||||
templ Icon(name string, extra string) {
|
||||
<svg class={ "reader-icon " + extra } width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
switch name {
|
||||
case "search":
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<path d="m20 20-3.5-3.5"></path>
|
||||
case "user":
|
||||
<circle cx="12" cy="8" r="4"></circle>
|
||||
<path d="M4 20c0-4 4-6 8-6s8 2 8 6"></path>
|
||||
case "users":
|
||||
<circle cx="9" cy="8" r="3.5"></circle>
|
||||
<path d="M2 20c0-3.5 3-5 7-5s7 1.5 7 5"></path>
|
||||
<path d="M16 4.5a3.5 3.5 0 0 1 0 7"></path>
|
||||
<path d="M22 20c0-3-2-4.5-5-4.5"></path>
|
||||
case "palette":
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<circle cx="8" cy="10" r="1"></circle>
|
||||
<circle cx="12" cy="7.5" r="1"></circle>
|
||||
<circle cx="15.5" cy="10" r="1"></circle>
|
||||
<path d="M12 21a3 3 0 0 0 0-6 2 2 0 0 1 0-4h1"></path>
|
||||
case "menu":
|
||||
<path d="M3 6h18M3 12h18M3 18h18"></path>
|
||||
case "chevron-down":
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
case "chevron-up":
|
||||
<path d="m6 15 6-6 6 6"></path>
|
||||
case "chevron-left":
|
||||
<path d="m15 6-6 6 6 6"></path>
|
||||
case "chevron-right":
|
||||
<path d="m9 6 6 6-6 6"></path>
|
||||
case "chevrons-left":
|
||||
<path d="m11 6-6 6 6 6M19 6l-6 6 6 6"></path>
|
||||
case "chevrons-right":
|
||||
<path d="m13 6 6 6-6 6M5 6l6 6-6 6"></path>
|
||||
case "close":
|
||||
<path d="M6 6l12 12M18 6 6 18"></path>
|
||||
case "grip":
|
||||
<circle cx="9" cy="6" r="1"></circle>
|
||||
<circle cx="15" cy="6" r="1"></circle>
|
||||
<circle cx="9" cy="12" r="1"></circle>
|
||||
<circle cx="15" cy="12" r="1"></circle>
|
||||
<circle cx="9" cy="18" r="1"></circle>
|
||||
<circle cx="15" cy="18" r="1"></circle>
|
||||
case "settings":
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"></path>
|
||||
case "gear":
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
case "refresh":
|
||||
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
|
||||
<path d="M21 3v5h-5"></path>
|
||||
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
|
||||
<path d="M3 21v-5h5"></path>
|
||||
case "book":
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
|
||||
case "book-open":
|
||||
<path d="M12 7v14"></path>
|
||||
<path d="M3 4h6a3 3 0 0 1 3 3v14a2 2 0 0 0-2-2H3z"></path>
|
||||
<path d="M21 4h-6a3 3 0 0 0-3 3v14a2 2 0 0 1 2-2h7z"></path>
|
||||
case "library":
|
||||
<path d="M3 21h18"></path>
|
||||
<path d="M5 21V7l7-4 7 4v14"></path>
|
||||
case "layers":
|
||||
<path d="m12 3 9 5-9 5-9-5 9-5z"></path>
|
||||
<path d="m3 13 9 5 9-5"></path>
|
||||
case "folder":
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
|
||||
case "chart":
|
||||
<path d="M3 3v18h18"></path>
|
||||
<path d="M7 15v3M12 11v7M17 7v11"></path>
|
||||
case "device":
|
||||
<rect x="7" y="2" width="10" height="20" rx="2"></rect>
|
||||
<path d="M11 18h2"></path>
|
||||
case "trash":
|
||||
<path d="M4 7h16M9 7V4h6v3M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13"></path>
|
||||
case "save":
|
||||
<path d="M5 3h11l3 3v15H5z"></path>
|
||||
<path d="M8 3v6h7V3"></path>
|
||||
<path d="M8 14h8v7H8z"></path>
|
||||
case "filter":
|
||||
<path d="M3 4h18l-7 8v6l-4 2v-8z"></path>
|
||||
case "check":
|
||||
<path d="m4 12 5 5 11-11"></path>
|
||||
case "check-circle":
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="m8 12 3 3 5-6"></path>
|
||||
case "x-circle":
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="m9 9 6 6M15 9l-6 6"></path>
|
||||
case "circle":
|
||||
<circle cx="12" cy="12" r="8"></circle>
|
||||
case "bookmark":
|
||||
<path d="M7 3h10a1 1 0 0 1 1 1v17l-6-4-6 4V4a1 1 0 0 1 1-1z"></path>
|
||||
case "star":
|
||||
<path d="m12 3 2.7 5.5 6 .9-4.3 4.2 1 6L12 17l-5.4 2.6 1-6L3.3 9.4l6-.9z"></path>
|
||||
case "arrow-right":
|
||||
<path d="M5 12h14M13 6l6 6-6 6"></path>
|
||||
case "arrow-left":
|
||||
<path d="M19 12H5M11 6l-6 6 6 6"></path>
|
||||
case "home":
|
||||
<path d="M3 11 12 3l9 8"></path>
|
||||
<path d="M5 10v10h14V10"></path>
|
||||
case "logout":
|
||||
<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3"></path>
|
||||
<path d="M10 17l5-5-5-5"></path>
|
||||
<path d="M15 12H3"></path>
|
||||
case "plus":
|
||||
<path d="M12 5v14M5 12h14"></path>
|
||||
case "edit":
|
||||
<path d="M4 20h4L18 10l-4-4L4 16z"></path>
|
||||
<path d="m14 6 4 4"></path>
|
||||
case "info":
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="M12 11v5M12 8h.01"></path>
|
||||
case "alert":
|
||||
<path d="M12 3 2 20h20z"></path>
|
||||
<path d="M12 10v4M12 17h.01"></path>
|
||||
case "clock":
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="M12 7v5l3 2"></path>
|
||||
case "sync":
|
||||
<path d="M3 8a9 9 0 0 1 15-3l3 3"></path>
|
||||
<path d="M21 16a9 9 0 0 1-15 3l-3-3"></path>
|
||||
case "download":
|
||||
<path d="M12 3v12M7 10l5 5 5-5"></path>
|
||||
<path d="M5 21h14"></path>
|
||||
case "upload":
|
||||
<path d="M12 21V9M7 14l5-5 5 5"></path>
|
||||
<path d="M5 3h14"></path>
|
||||
case "copy":
|
||||
<rect x="9" y="9" width="12" height="12" rx="2"></rect>
|
||||
<path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"></path>
|
||||
case "external":
|
||||
<path d="M14 4h6v6"></path>
|
||||
<path d="M20 4 10 14"></path>
|
||||
<path d="M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5"></path>
|
||||
case "list":
|
||||
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"></path>
|
||||
case "grid":
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"></rect>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"></rect>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"></rect>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1"></rect>
|
||||
case "play":
|
||||
<path d="M6 4l14 8-14 8z"></path>
|
||||
case "heart":
|
||||
<path d="M12 21s-7-4.5-9.5-9A5 5 0 0 1 12 6a5 5 0 0 1 9.5 6c-2.5 4.5-9.5 9-9.5 9z"></path>
|
||||
case "calendar":
|
||||
<rect x="3" y="4" width="18" height="18" rx="2"></rect>
|
||||
<path d="M3 9h18M8 2v4M16 2v4"></path>
|
||||
case "tag":
|
||||
<path d="M3 11V5a2 2 0 0 1 2-2h6l9 9-8 8-9-9z"></path>
|
||||
<circle cx="7.5" cy="7.5" r="1"></circle>
|
||||
case "globe":
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"></path>
|
||||
case "shield":
|
||||
<path d="M12 3 5 6v6c0 4 3 7 7 9 4-2 7-5 7-9V6z"></path>
|
||||
case "database":
|
||||
<ellipse cx="12" cy="5" rx="8" ry="3"></ellipse>
|
||||
<path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5"></path>
|
||||
<path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"></path>
|
||||
default:
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
}
|
||||
</svg>
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// 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"
|
||||
|
||||
// Icon renders an inline SVG from the built-in icon set. All icons share the
|
||||
// same 24x24 viewBox, 1.75 stroke width, and round line caps so the UI has one
|
||||
// consistent visual language (replacing the mixed emoji/SVG iconography).
|
||||
// `extra` is appended to the class list (e.g. "h-5 w-5").
|
||||
func Icon(name string, extra 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)
|
||||
var templ_7745c5c3_Var2 = []any{"reader-icon " + extra}
|
||||
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, 1, "<svg 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/icons.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, 2, "\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
switch name {
|
||||
case "search":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<circle cx=\"11\" cy=\"11\" r=\"7\"></circle> <path d=\"m20 20-3.5-3.5\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "user":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<circle cx=\"12\" cy=\"8\" r=\"4\"></circle> <path d=\"M4 20c0-4 4-6 8-6s8 2 8 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "users":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<circle cx=\"9\" cy=\"8\" r=\"3.5\"></circle> <path d=\"M2 20c0-3.5 3-5 7-5s7 1.5 7 5\"></path> <path d=\"M16 4.5a3.5 3.5 0 0 1 0 7\"></path> <path d=\"M22 20c0-3-2-4.5-5-4.5\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "palette":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <circle cx=\"8\" cy=\"10\" r=\"1\"></circle> <circle cx=\"12\" cy=\"7.5\" r=\"1\"></circle> <circle cx=\"15.5\" cy=\"10\" r=\"1\"></circle> <path d=\"M12 21a3 3 0 0 0 0-6 2 2 0 0 1 0-4h1\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "menu":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<path d=\"M3 6h18M3 12h18M3 18h18\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "chevron-down":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<path d=\"m6 9 6 6 6-6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "chevron-up":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<path d=\"m6 15 6-6 6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "chevron-left":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<path d=\"m15 6-6 6 6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "chevron-right":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<path d=\"m9 6 6 6-6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "chevrons-left":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<path d=\"m11 6-6 6 6 6M19 6l-6 6 6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "chevrons-right":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<path d=\"m13 6 6 6-6 6M5 6l6 6-6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "close":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<path d=\"M6 6l12 12M18 6 6 18\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "grip":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<circle cx=\"9\" cy=\"6\" r=\"1\"></circle> <circle cx=\"15\" cy=\"6\" r=\"1\"></circle> <circle cx=\"9\" cy=\"12\" r=\"1\"></circle> <circle cx=\"15\" cy=\"12\" r=\"1\"></circle> <circle cx=\"9\" cy=\"18\" r=\"1\"></circle> <circle cx=\"15\" cy=\"18\" r=\"1\"></circle>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "settings":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<circle cx=\"12\" cy=\"12\" r=\"3\"></circle> <path d=\"M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "gear":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<circle cx=\"12\" cy=\"12\" r=\"3\"></circle> <path d=\"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "refresh":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<path d=\"M3 12a9 9 0 0 1 15-6.7L21 8\"></path> <path d=\"M21 3v5h-5\"></path> <path d=\"M21 12a9 9 0 0 1-15 6.7L3 16\"></path> <path d=\"M3 21v-5h5\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "book":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<path d=\"M4 19.5A2.5 2.5 0 0 1 6.5 17H20\"></path> <path d=\"M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "book-open":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<path d=\"M12 7v14\"></path> <path d=\"M3 4h6a3 3 0 0 1 3 3v14a2 2 0 0 0-2-2H3z\"></path> <path d=\"M21 4h-6a3 3 0 0 0-3 3v14a2 2 0 0 1 2-2h7z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "library":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<path d=\"M3 21h18\"></path> <path d=\"M5 21V7l7-4 7 4v14\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "layers":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<path d=\"m12 3 9 5-9 5-9-5 9-5z\"></path> <path d=\"m3 13 9 5 9-5\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "folder":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<path d=\"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "chart":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<path d=\"M3 3v18h18\"></path> <path d=\"M7 15v3M12 11v7M17 7v11\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "device":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<rect x=\"7\" y=\"2\" width=\"10\" height=\"20\" rx=\"2\"></rect> <path d=\"M11 18h2\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "trash":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<path d=\"M4 7h16M9 7V4h6v3M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "save":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<path d=\"M5 3h11l3 3v15H5z\"></path> <path d=\"M8 3v6h7V3\"></path> <path d=\"M8 14h8v7H8z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "filter":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<path d=\"M3 4h18l-7 8v6l-4 2v-8z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "check":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<path d=\"m4 12 5 5 11-11\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "check-circle":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m8 12 3 3 5-6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "x-circle":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m9 9 6 6M15 9l-6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "circle":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<circle cx=\"12\" cy=\"12\" r=\"8\"></circle>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "bookmark":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<path d=\"M7 3h10a1 1 0 0 1 1 1v17l-6-4-6 4V4a1 1 0 0 1 1-1z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "star":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<path d=\"m12 3 2.7 5.5 6 .9-4.3 4.2 1 6L12 17l-5.4 2.6 1-6L3.3 9.4l6-.9z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "arrow-right":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<path d=\"M5 12h14M13 6l6 6-6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "arrow-left":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<path d=\"M19 12H5M11 6l-6 6 6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "home":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<path d=\"M3 11 12 3l9 8\"></path> <path d=\"M5 10v10h14V10\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "logout":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<path d=\"M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3\"></path> <path d=\"M10 17l5-5-5-5\"></path> <path d=\"M15 12H3\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "plus":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<path d=\"M12 5v14M5 12h14\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "edit":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<path d=\"M4 20h4L18 10l-4-4L4 16z\"></path> <path d=\"m14 6 4 4\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "info":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 11v5M12 8h.01\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "alert":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<path d=\"M12 3 2 20h20z\"></path> <path d=\"M12 10v4M12 17h.01\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "clock":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 7v5l3 2\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "sync":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<path d=\"M3 8a9 9 0 0 1 15-3l3 3\"></path> <path d=\"M21 16a9 9 0 0 1-15 3l-3-3\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "download":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<path d=\"M12 3v12M7 10l5 5 5-5\"></path> <path d=\"M5 21h14\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "upload":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<path d=\"M12 21V9M7 14l5-5 5 5\"></path> <path d=\"M5 3h14\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "copy":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<rect x=\"9\" y=\"9\" width=\"12\" height=\"12\" rx=\"2\"></rect> <path d=\"M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "external":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<path d=\"M14 4h6v6\"></path> <path d=\"M20 4 10 14\"></path> <path d=\"M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "list":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<path d=\"M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "grid":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"></rect>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "play":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<path d=\"M6 4l14 8-14 8z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "heart":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<path d=\"M12 21s-7-4.5-9.5-9A5 5 0 0 1 12 6a5 5 0 0 1 9.5 6c-2.5 4.5-9.5 9-9.5 9z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "calendar":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<rect x=\"3\" y=\"4\" width=\"18\" height=\"18\" rx=\"2\"></rect> <path d=\"M3 9h18M8 2v4M16 2v4\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "tag":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<path d=\"M3 11V5a2 2 0 0 1 2-2h6l9 9-8 8-9-9z\"></path> <circle cx=\"7.5\" cy=\"7.5\" r=\"1\"></circle>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "globe":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "shield":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<path d=\"M12 3 5 6v6c0 4 3 7 7 9 4-2 7-5 7-9V6z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "database":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<ellipse cx=\"12\" cy=\"5\" rx=\"8\" ry=\"3\"></ellipse> <path d=\"M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5\"></path> <path d=\"M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
default:
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</svg>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user