Compare commits
123
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
122038123c | ||
|
|
19fc222fb3 | ||
|
|
e047a627a8 | ||
|
|
668aee4e22 | ||
|
|
2e2121515e | ||
|
|
3c3f16ea1b | ||
|
|
2d01ec52fe | ||
|
|
6e2d304b35 | ||
|
|
43d8afc6cd | ||
|
|
9b31dc6f68 | ||
|
|
0e8b11504e | ||
|
|
59cb0e3439 | ||
|
|
158fcb510b | ||
|
|
8cc9f75a1a | ||
|
|
a6e6913ec4 | ||
|
|
c41e4af8b0 | ||
|
|
55a299540c | ||
|
|
c4f972aba1 | ||
|
|
da3287ef4d | ||
|
|
797726b68e | ||
|
|
ec6844bed0 | ||
|
|
dea952020c | ||
|
|
13bb2975f8 | ||
|
|
4b3433af7d | ||
|
|
d225e1dff4 | ||
|
|
e57daed448 | ||
|
|
6782d7a741 | ||
|
|
fdf4c9ac35 | ||
|
|
413503b9b3 | ||
|
|
37177516e0 | ||
|
|
5bdddc6538 | ||
|
|
be1c373b19 | ||
|
|
fb9c471959 | ||
|
|
3e5d3fe043 | ||
|
|
b741f4f32d | ||
|
|
647dec676f | ||
|
|
0acacc1aa0 | ||
|
|
1724bc0767 | ||
|
|
2ef1f9580f | ||
|
|
4a26e79a20 | ||
|
|
1ae9e08f43 | ||
|
|
e242ad3e00 | ||
|
|
0682d0a1cb | ||
|
|
69872b48b5 | ||
|
|
5f3b392168 | ||
|
|
7b9d3562ef | ||
|
|
93991dfb0a | ||
|
|
52464581dc | ||
|
|
9471c4a599 | ||
|
|
905a5d769c | ||
|
|
9cc371572e | ||
|
|
9c1c5a66ac | ||
|
|
4cb72fc9ae | ||
|
|
004416a009 | ||
|
|
2fdc8751b4 | ||
|
|
81c1267dfa | ||
|
|
864f2cc6b9 | ||
|
|
06985474d0 | ||
|
|
8c7266e93b | ||
|
|
d22446d9b9 | ||
|
|
ae61acf478 | ||
|
|
19a85a3390 | ||
|
|
7534d7c30a | ||
|
|
55e668079c | ||
|
|
be4ed15dad | ||
|
|
ad0bfcac2c | ||
|
|
13edfdcf1a | ||
|
|
6b958cab39 | ||
|
|
df989d4c8c | ||
|
|
59d0389d45 | ||
|
|
f69479db44 | ||
|
|
17281e4ce7 | ||
|
|
3b5af3beae | ||
|
|
330df97cfd | ||
|
|
19701dc659 | ||
|
|
c3d900512c | ||
|
|
d97b144ce9 | ||
|
|
d222257797 | ||
|
|
7f66a62d45 | ||
|
|
630283ab3f | ||
|
|
9a32d89a9b | ||
|
|
82d0d378a6 | ||
|
|
a635c6d46e | ||
|
|
1cda4e5191 | ||
|
|
d8330e8d0a | ||
|
|
9ac24a1eac | ||
|
|
e66308c323 | ||
|
|
f24e354549 | ||
|
|
2a1ff77173 | ||
|
|
9863b2082c | ||
|
|
981911077b | ||
|
|
3bc6b0f477 | ||
|
|
be4f89fdd2 | ||
|
|
93197b2e31 | ||
|
|
163b3162b9 | ||
|
|
2ee37c657c | ||
|
|
065099cfc2 | ||
|
|
dac7c03ce7 | ||
|
|
abef40575f | ||
|
|
5cdae6cc4b | ||
|
|
72c8ba7a4f | ||
|
|
70ecfe59ff | ||
|
|
9f778452d6 | ||
|
|
65c860bb99 | ||
|
|
ca315e8913 | ||
|
|
bc199ca268 | ||
|
|
01fa49ee1d | ||
|
|
0842ae6efa | ||
|
|
6519338822 | ||
|
|
2569136d3e | ||
|
|
96d05886ef | ||
|
|
c1d3f1ae1d | ||
|
|
48725544f5 | ||
|
|
2fe5fea3d8 | ||
|
|
a670d379e3 | ||
|
|
a19f77c535 | ||
|
|
be5718de52 | ||
|
|
a42f0e3899 | ||
|
|
4128734362 | ||
|
|
a56061f2b3 | ||
|
|
533abaf9d7 | ||
|
|
441d30a63a | ||
|
|
d4e65a79f9 |
@@ -10,21 +10,6 @@ JWT_SECRET=your-secure-jwt-secret-key-here
|
|||||||
# Generate with: openssl rand -hex 16
|
# Generate with: openssl rand -hex 16
|
||||||
DBPASS=your-secure-database-password-here
|
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)
|
# Optional: Override Defaults (defaults are set in docker-compose.yml)
|
||||||
# Test Mode: WARNING - Only set to true for integration testing
|
# Test Mode: WARNING - Only set to true for integration testing
|
||||||
# TEST_MODE=true
|
# TEST_MODE=true
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
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
|
|
||||||
+2
-3
@@ -16,7 +16,7 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
|
|||||||
# Copy package files and install npm dependencies (cached unless package.json changes)
|
# Copy package files and install npm dependencies (cached unless package.json changes)
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
npm install
|
npm ci
|
||||||
|
|
||||||
# Copy Go mod files (cached unless go.mod changes)
|
# Copy Go mod files (cached unless go.mod changes)
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
@@ -36,8 +36,7 @@ RUN npm run build:ts
|
|||||||
|
|
||||||
# Build Go binary (cached unless Go files or generated code changes)
|
# Build Go binary (cached unless Go files or generated code changes)
|
||||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||||
--mount=type=cache,target=/root/.cache/go-build \
|
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/server
|
||||||
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo -o main ./cmd/server
|
|
||||||
|
|
||||||
# Test runner stage - includes Go runtime and test dependencies
|
# Test runner stage - includes Go runtime and test dependencies
|
||||||
# This stage is ONLY used for running tests, never deployed to production
|
# This stage is ONLY used for running tests, never deployed to production
|
||||||
|
|||||||
@@ -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 release
|
.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
|
||||||
|
|
||||||
# Include .env file for environment variables (single source of truth)
|
# Include .env file for environment variables (single source of truth)
|
||||||
# Ignore if .env doesn't exist yet
|
# Ignore if .env doesn't exist yet
|
||||||
@@ -7,14 +7,6 @@ ifneq (,$(wildcard ./.env))
|
|||||||
export
|
export
|
||||||
endif
|
endif
|
||||||
|
|
||||||
# Auto-detect container runtime: prefer docker, fall back to podman
|
|
||||||
# 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
|
# Default target
|
||||||
help:
|
help:
|
||||||
@echo "Available targets:"
|
@echo "Available targets:"
|
||||||
@@ -44,9 +36,6 @@ help:
|
|||||||
@echo "Verification:"
|
@echo "Verification:"
|
||||||
@echo " make verify-guidelines - Run comprehensive guidelines check"
|
@echo " make verify-guidelines - Run comprehensive guidelines check"
|
||||||
@echo " make verify-quick - Run quick guidelines check"
|
@echo " make verify-quick - Run quick guidelines check"
|
||||||
@echo ""
|
|
||||||
@echo "Release:"
|
|
||||||
@echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)"
|
|
||||||
|
|
||||||
# Run unit tests locally (fast, no containers)
|
# Run unit tests locally (fast, no containers)
|
||||||
test:
|
test:
|
||||||
@@ -55,23 +44,23 @@ test:
|
|||||||
# Run integration tests in containers (matches production environment)
|
# Run integration tests in containers (matches production environment)
|
||||||
test-integration:
|
test-integration:
|
||||||
@echo "Building test containers..."
|
@echo "Building test containers..."
|
||||||
$(COMPOSE) --profile tests build
|
podman compose --profile tests build
|
||||||
@echo "Starting application containers..."
|
@echo "Starting application containers..."
|
||||||
$(COMPOSE) up -d db app
|
podman compose up -d db app
|
||||||
@echo "Waiting for services to be healthy..."
|
@echo "Waiting for services to be healthy..."
|
||||||
@until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \
|
@until podman exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \
|
||||||
echo " Database not ready yet..."; \
|
echo " Database not ready yet..."; \
|
||||||
sleep 2; \
|
sleep 2; \
|
||||||
done; \
|
done; \
|
||||||
echo " ✓ Database is ready"
|
echo " ✓ Database is ready"
|
||||||
@until $(CONTAINER_RUNTIME) exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do \
|
@until podman exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do \
|
||||||
echo " Application not ready yet..."; \
|
echo " Application not ready yet..."; \
|
||||||
sleep 2; \
|
sleep 2; \
|
||||||
done; \
|
done; \
|
||||||
echo " ✓ Application is ready"
|
echo " ✓ Application is ready"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Running integration tests in container..."
|
@echo "Running integration tests in container..."
|
||||||
$(COMPOSE) --profile tests run --rm tests
|
podman compose --profile tests run --rm tests
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "✅ Integration tests completed!"
|
@echo "✅ Integration tests completed!"
|
||||||
@echo "📝 Containers are still running. Use 'make logs' to view logs or 'make clean' to stop."
|
@echo "📝 Containers are still running. Use 'make logs' to view logs or 'make clean' to stop."
|
||||||
@@ -82,76 +71,76 @@ test-all: test test-integration
|
|||||||
# Rebuild app container only (preserve DB, with cache)
|
# Rebuild app container only (preserve DB, with cache)
|
||||||
rebuild-app:
|
rebuild-app:
|
||||||
@echo "Rebuilding app container (database stays running)..."
|
@echo "Rebuilding app container (database stays running)..."
|
||||||
$(COMPOSE) up --build --force-recreate -d app
|
podman compose up --build --force-recreate -d app
|
||||||
@echo "✓ App container rebuilt and restarted"
|
@echo "✓ App container rebuilt and restarted"
|
||||||
|
|
||||||
# Rebuild app container only (preserve DB, no cache)
|
# Rebuild app container only (preserve DB, no cache)
|
||||||
rebuild-app-force:
|
rebuild-app-force:
|
||||||
@echo "Force rebuilding app container (database stays running, no cache)..."
|
@echo "Force rebuilding app container (database stays running, no cache)..."
|
||||||
$(COMPOSE) build --no-cache app
|
podman compose build --no-cache app
|
||||||
$(COMPOSE) up --force-recreate -d app
|
podman compose up --force-recreate -d app
|
||||||
@echo "✓ App container rebuilt and restarted"
|
@echo "✓ App container rebuilt and restarted"
|
||||||
|
|
||||||
# Rebuild all containers (preserve DB, with cache)
|
# Rebuild all containers (preserve DB, with cache)
|
||||||
rebuild:
|
rebuild:
|
||||||
@echo "Rebuilding all containers (database preserved)..."
|
@echo "Rebuilding all containers (database preserved)..."
|
||||||
$(COMPOSE) up --build --force-recreate -d
|
podman compose up --build --force-recreate -d
|
||||||
@echo "✓ All containers rebuilt and restarted"
|
@echo "✓ All containers rebuilt and restarted"
|
||||||
|
|
||||||
# Rebuild all containers (preserve DB, no cache)
|
# Rebuild all containers (preserve DB, no cache)
|
||||||
rebuild-force:
|
rebuild-force:
|
||||||
@echo "Force rebuilding all containers (database preserved, no cache)..."
|
@echo "Force rebuilding all containers (database preserved, no cache)..."
|
||||||
$(COMPOSE) build --no-cache
|
podman compose build --no-cache
|
||||||
$(COMPOSE) up --force-recreate -d
|
podman compose up --force-recreate -d
|
||||||
@echo "✓ All containers rebuilt and restarted"
|
@echo "✓ All containers rebuilt and restarted"
|
||||||
|
|
||||||
# Rebuild all containers (remove DB, no cache)
|
# Rebuild all containers (remove DB, no cache)
|
||||||
rebuild-force-db:
|
rebuild-force-db:
|
||||||
@echo "Force rebuilding all containers (database will be DELETED, no cache)..."
|
@echo "Force rebuilding all containers (database will be DELETED, no cache)..."
|
||||||
$(COMPOSE) down -v
|
podman compose down -v
|
||||||
$(COMPOSE) build --no-cache
|
podman compose build --no-cache
|
||||||
$(COMPOSE) up --force-recreate -d
|
podman compose up --force-recreate -d
|
||||||
@echo "✓ All containers rebuilt and restarted"
|
@echo "✓ All containers rebuilt and restarted"
|
||||||
|
|
||||||
# Stop and remove containers
|
# Stop and remove containers
|
||||||
clean:
|
clean:
|
||||||
$(COMPOSE) down -v
|
podman compose down -v
|
||||||
|
|
||||||
# Quick start (if already built)
|
# Quick start (if already built)
|
||||||
up:
|
up:
|
||||||
$(COMPOSE) up -d
|
podman compose up -d
|
||||||
|
|
||||||
# Stop all containers (alias for clean)
|
# Stop all containers (alias for clean)
|
||||||
down:
|
down:
|
||||||
$(COMPOSE) down
|
podman compose down
|
||||||
|
|
||||||
# Restart app container (preserves database)
|
# Restart app container (preserves database)
|
||||||
restart:
|
restart:
|
||||||
@echo "Restarting app container (database stays running)..."
|
@echo "Restarting app container (database stays running)..."
|
||||||
$(COMPOSE) restart app
|
podman compose restart app
|
||||||
@echo "✓ App container restarted"
|
@echo "✓ App container restarted"
|
||||||
|
|
||||||
# Show container status
|
# Show container status
|
||||||
ps:
|
ps:
|
||||||
$(COMPOSE) ps
|
podman compose ps
|
||||||
|
|
||||||
# Show container logs
|
# Show container logs
|
||||||
logs:
|
logs:
|
||||||
$(COMPOSE) logs -f
|
podman compose logs -f
|
||||||
|
|
||||||
# Start containers with test mode enabled for manual testing
|
# Start containers with test mode enabled for manual testing
|
||||||
test-env-up:
|
test-env-up:
|
||||||
@echo "Starting containers with test mode enabled..."
|
@echo "Starting containers with test mode enabled..."
|
||||||
TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 $(COMPOSE) up --build --force-recreate -d
|
TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 podman compose up --build --force-recreate -d
|
||||||
@echo "Waiting for services to be ready..."
|
@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 podman 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
|
@until podman exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do sleep 1; done
|
||||||
@echo "✓ Test environment is ready!"
|
@echo "✓ Test environment is ready!"
|
||||||
@echo "Application available at http://localhost:8765"
|
@echo "Application available at http://localhost:8765"
|
||||||
|
|
||||||
# Stop test environment
|
# Stop test environment
|
||||||
test-env-down:
|
test-env-down:
|
||||||
$(COMPOSE) down -v
|
podman compose down -v
|
||||||
|
|
||||||
# Verify project guidelines compliance
|
# Verify project guidelines compliance
|
||||||
verify-guidelines:
|
verify-guidelines:
|
||||||
@@ -161,27 +150,3 @@ verify-guidelines:
|
|||||||
verify-quick:
|
verify-quick:
|
||||||
@echo "Running quick project guidelines verification..."
|
@echo "Running quick project guidelines verification..."
|
||||||
@./scripts/verify-quick.sh
|
@./scripts/verify-quick.sh
|
||||||
|
|
||||||
# Create an annotated version tag carrying auto-generated release notes (git-cliff)
|
|
||||||
# and push it. The tag push triggers .gitea/workflows/release.yml, which builds the
|
|
||||||
# image and publishes a Gitea Release whose body is this tag's message. Notes come
|
|
||||||
# entirely from Conventional Commits — no hand-written message required.
|
|
||||||
#
|
|
||||||
# git-cliff's --latest needs the tag to exist to scope the notes, so we create a
|
|
||||||
# throwaway lightweight tag, generate the notes, replace it with an annotated tag,
|
|
||||||
# then push. --cleanup=verbatim keeps the markdown "###" group headers (git's
|
|
||||||
# default cleanup would strip lines starting with "#").
|
|
||||||
#
|
|
||||||
# Requires git-cliff: https://git-cliff.org/install
|
|
||||||
# Usage: make release VERSION=v0.3.0
|
|
||||||
release:
|
|
||||||
@test -n "$(VERSION)" || { echo "Usage: make release VERSION=v0.3.0"; exit 1; }
|
|
||||||
@command -v git-cliff >/dev/null 2>&1 || { echo "git-cliff not found — install: https://git-cliff.org/install"; exit 1; }
|
|
||||||
@if git rev-parse "$(VERSION)" >/dev/null 2>&1; then echo "Tag $(VERSION) already exists locally — delete it first: git tag -d $(VERSION)"; exit 1; fi
|
|
||||||
@echo "Generating release notes for $(VERSION)..."
|
|
||||||
@git tag "$(VERSION)" HEAD && \
|
|
||||||
(git cliff --latest --config cliff.toml > .release-notes.tmp && git tag -d "$(VERSION)" >/dev/null) || \
|
|
||||||
{ git tag -d "$(VERSION)" >/dev/null 2>&1; rm -f .release-notes.tmp; echo "git-cliff failed"; exit 1; }
|
|
||||||
@git tag -a --cleanup=verbatim -F .release-notes.tmp "$(VERSION)" HEAD && rm -f .release-notes.tmp
|
|
||||||
@git push origin "$(VERSION)"
|
|
||||||
@echo "Pushed $(VERSION) — Gitea Actions will build the image and publish the Release."
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone the repository
|
# 1. Clone the repository
|
||||||
git clone https://git.linuxhg.com/Bookhoard/bookhoard.git
|
git clone https://github.com/yourusername/bookhoard.git
|
||||||
cd bookhoard
|
cd bookhoard
|
||||||
|
|
||||||
# 2. Set up environment
|
# 2. Set up environment
|
||||||
@@ -35,10 +35,8 @@ cp .env.example .env
|
|||||||
# DBPASS: openssl rand -hex 16
|
# DBPASS: openssl rand -hex 16
|
||||||
# Edit .env with your generated values
|
# Edit .env with your generated values
|
||||||
|
|
||||||
# 3. Pull images and start the server
|
# 3. Start the server
|
||||||
docker compose pull
|
podman-compose up --build -d # or: docker-compose up --build -d
|
||||||
docker compose up -d
|
|
||||||
# Optionally pin a specific version: set IMAGE_TAG in .env (defaults to "latest")
|
|
||||||
|
|
||||||
# 4. Open your browser
|
# 4. Open your browser
|
||||||
open http://localhost:8765
|
open http://localhost:8765
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
# Timezone Implementation Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add per-user timezone support with system-wide fallback (set via docker-compose), defaulting to UTC. The database already stores all timestamps as UTC via `TIMESTAMPTZ` columns, so this is primarily a display-layer feature.
|
||||||
|
|
||||||
|
**Display format:** MM-DD-YYYY HH:MM (US convention, no timezone abbreviation shown)
|
||||||
|
|
||||||
|
**Timezone selection:** Manual dropdown only (no browser auto-detect)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Database Schema
|
||||||
|
|
||||||
|
**File:** `database/schema/schema.sql`
|
||||||
|
|
||||||
|
1. Add `timezone` column directly to the `users` table definition (line ~36):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
username VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
first_name VARCHAR(255),
|
||||||
|
last_name VARCHAR(255),
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
|
||||||
|
theme VARCHAR(50) DEFAULT 'tokyo-night',
|
||||||
|
max_devices INTEGER DEFAULT 10,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
timezone VARCHAR(50) DEFAULT 'UTC'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: The `timezone` column is already present at line 36 in the current schema. No change needed for this step.
|
||||||
|
|
||||||
|
1. Add `default_timezone` to the `system_settings` INSERT block (line ~49-52):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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')
|
||||||
|
ON CONFLICT (setting_key) DO NOTHING;
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Add index in the indexes section (after line ~460, with other user indexes):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone);
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Regenerate sqlc code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd internal/database && sqlc generate
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Database Queries
|
||||||
|
|
||||||
|
**File:** `internal/database/queries/queries.sql`
|
||||||
|
|
||||||
|
Add new queries:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- name: UpdateUserTimezone :exec
|
||||||
|
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: GetSystemTimezone :one
|
||||||
|
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: `UpdateSystemTimezone` is omitted because the existing `UpdateSystemSetting` query handles it by passing `'default_timezone'` as the key parameter.
|
||||||
|
|
||||||
|
Regenerate after adding queries:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd internal/database && sqlc generate
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Template Utilities
|
||||||
|
|
||||||
|
**File:** `templates/utils.go`
|
||||||
|
|
||||||
|
Add timezone-aware time formatting helpers:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatInTimezone formats a time.Time in the specified timezone as MM-DD-YYYY HH:MM
|
||||||
|
func FormatInTimezone(t time.Time, timezone string) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, err := time.LoadLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
loc = time.UTC
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.In(loc).Format("01-02-2006 03:04 PM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTimestamptzInTimezone formats a pgtype.Timestamptz in the specified timezone
|
||||||
|
func FormatTimestamptzInTimezone(t pgtype.Timestamptz, timezone string) string {
|
||||||
|
if !t.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return FormatInTimezone(t.Time, timezone)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Context Update
|
||||||
|
|
||||||
|
**File:** `templates/types.go`
|
||||||
|
|
||||||
|
Add `Timezone` field to the `User` struct:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type User struct {
|
||||||
|
ID string
|
||||||
|
Email string
|
||||||
|
Username string
|
||||||
|
Role string
|
||||||
|
Theme string
|
||||||
|
FirstName string
|
||||||
|
LastName string
|
||||||
|
CreatedAt time.Time
|
||||||
|
Token string
|
||||||
|
Timezone string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `internal/router/helpers.go`
|
||||||
|
|
||||||
|
Update `getTemplateUserWithTheme()` to include timezone:
|
||||||
|
|
||||||
|
```go
|
||||||
|
userTimezone := "UTC"
|
||||||
|
if userDB.Timezone.Valid {
|
||||||
|
userTimezone = userDB.Timezone.String
|
||||||
|
}
|
||||||
|
|
||||||
|
return templates.User{
|
||||||
|
// ... existing fields ...
|
||||||
|
Timezone: userTimezone,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Handlers
|
||||||
|
|
||||||
|
**File:** `internal/handlers/auth.go`
|
||||||
|
|
||||||
|
Update `UpdateProfileRequest` struct:
|
||||||
|
|
||||||
|
```go
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add timezone update logic in `UpdateProfile()`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if req.Timezone != "" {
|
||||||
|
if _, err := time.LoadLocation(req.Timezone); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||||
|
"error": "Invalid timezone",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
err := h.db.UpdateUserTimezone(ctx, database.UpdateUserTimezoneParams{
|
||||||
|
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
Timezone: pgtype.Text{String: req.Timezone, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `internal/handlers/system_settings.go`
|
||||||
|
|
||||||
|
Add timezone settings handler:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type UpdateTimezoneSettingsRequest struct {
|
||||||
|
DefaultTimezone string `json:"default_timezone" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
|
||||||
|
var req UpdateTimezoneSettingsRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
||||||
|
}
|
||||||
|
if _, err := time.LoadLocation(req.DefaultTimezone); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid timezone"})
|
||||||
|
}
|
||||||
|
err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{
|
||||||
|
SettingKey: "default_timezone",
|
||||||
|
SettingValue: req.DefaultTimezone,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: User Profile UI
|
||||||
|
|
||||||
|
**File:** `templates/profile_form.templ`
|
||||||
|
|
||||||
|
Add timezone dropdown after the theme field:
|
||||||
|
|
||||||
|
```templ
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="timezone">Timezone</label>
|
||||||
|
<select name="timezone" id="timezone" class="form-select">
|
||||||
|
<option value="UTC" selected?={ user.Timezone == "UTC" }>UTC (Coordinated Universal Time)</option>
|
||||||
|
<option value="America/New_York" selected?={ user.Timezone == "America/New_York" }>Eastern Time</option>
|
||||||
|
<option value="America/Chicago" selected?={ user.Timezone == "America/Chicago" }>Central Time</option>
|
||||||
|
<option value="America/Denver" selected?={ user.Timezone == "America/Denver" }>Mountain Time</option>
|
||||||
|
<option value="America/Los_Angeles" selected?={ user.Timezone == "America/Los_Angeles" }>Pacific Time</option>
|
||||||
|
<option value="America/Phoenix" selected?={ user.Timezone == "America/Phoenix" }>Mountain Time (no DST)</option>
|
||||||
|
<option value="America/Anchorage" selected?={ user.Timezone == "America/Anchorage" }>Alaska Time</option>
|
||||||
|
<option value="Pacific/Honolulu" selected?={ user.Timezone == "Pacific/Honolulu" }>Hawaii Time</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Include timezone in the HTMX form submission payload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Admin Settings UI
|
||||||
|
|
||||||
|
**File:** `templates/admin_settings.templ`
|
||||||
|
|
||||||
|
Add system default timezone setting:
|
||||||
|
|
||||||
|
```templ
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>System Defaults</h3>
|
||||||
|
<label for="default_timezone">Default Timezone</label>
|
||||||
|
<select name="default_timezone" id="default_timezone">
|
||||||
|
<option value="UTC">UTC (Coordinated Universal Time)</option>
|
||||||
|
<option value="America/New_York">Eastern Time</option>
|
||||||
|
<option value="America/Chicago">Central Time</option>
|
||||||
|
<option value="America/Denver">Mountain Time</option>
|
||||||
|
<option value="America/Los_Angeles">Pacific Time</option>
|
||||||
|
<option value="America/Phoenix">Mountain Time (no DST)</option>
|
||||||
|
<option value="America/Anchorage">Alaska Time</option>
|
||||||
|
<option value="Pacific/Honolulu">Hawaii Time</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: Template Time Display Updates
|
||||||
|
|
||||||
|
### Files to update
|
||||||
|
|
||||||
|
| Template | Line(s) | Field(s) |
|
||||||
|
| ------------------------------------ | -------------- | ----------------------------------- |
|
||||||
|
| `templates/book_detail.templ` | ~253, ~282 | `LastReadAt`, `DatePublished` |
|
||||||
|
| `templates/book_detail_modals.templ` | ~68, ~113 | `Timestamp`, `LastReadAt` |
|
||||||
|
| `templates/devices.templ` | ~83, ~91, ~174 | `LastSync`, `LastSeen`, `ExpiresAt` |
|
||||||
|
| `templates/conflicts.templ` | ~114 | `CreatedAt` |
|
||||||
|
| `templates/admin_users.templ` | ~89 | `CreatedAt` |
|
||||||
|
| `templates/queue.templ` | ~138 | `CreatedAt` |
|
||||||
|
|
||||||
|
### Change pattern
|
||||||
|
|
||||||
|
```templ
|
||||||
|
<!-- Before -->
|
||||||
|
{ book.ReadingProgress.LastReadAt.Time.Format("01-02-2006 03:04 PM") }
|
||||||
|
|
||||||
|
<!-- After -->
|
||||||
|
{ templates.FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }
|
||||||
|
```
|
||||||
|
|
||||||
|
For `time.Time` fields:
|
||||||
|
|
||||||
|
```templ
|
||||||
|
<!-- Before -->
|
||||||
|
{ device.LastSync.Format("01-02-2006 03:04 PM") }
|
||||||
|
|
||||||
|
<!-- After -->
|
||||||
|
{ templates.FormatInTimezone(device.LastSync, user.Timezone) }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9: Docker Configuration
|
||||||
|
|
||||||
|
**File:** `docker-compose.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
server:
|
||||||
|
environment:
|
||||||
|
- TZ=UTC
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `.env.example`
|
||||||
|
|
||||||
|
```
|
||||||
|
# System default timezone (fallback if not set in DB)
|
||||||
|
TZ=UTC
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Modified Summary
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| --------------------------------------- | ------------------------------------------------------------------------------- |
|
||||||
|
| `database/schema/schema.sql` | Add timezone column to users, system_setting row |
|
||||||
|
| `internal/database/queries/queries.sql` | Add UpdateUserTimezone, GetSystemTimezone (reuses existing UpdateSystemSetting) |
|
||||||
|
| `templates/utils.go` | Add FormatInTimezone, FormatTimestamptzInTimezone |
|
||||||
|
| `templates/types.go` | Add Timezone field to User struct |
|
||||||
|
| `internal/router/helpers.go` | Pass timezone to template User |
|
||||||
|
| `internal/handlers/auth.go` | Handle timezone updates in UpdateProfile |
|
||||||
|
| `internal/handlers/system_settings.go` | Add timezone settings handler |
|
||||||
|
| `templates/profile_form.templ` | Add timezone dropdown |
|
||||||
|
| `templates/admin_settings.templ` | Add default timezone setting |
|
||||||
|
| `templates/book_detail.templ` | Update time displays |
|
||||||
|
| `templates/book_detail_modals.templ` | Update time displays |
|
||||||
|
| `templates/devices.templ` | Update time displays |
|
||||||
|
| `templates/conflicts.templ` | Update time displays |
|
||||||
|
| `templates/admin_users.templ` | Update time displays |
|
||||||
|
| `templates/queue.templ` | Update time displays |
|
||||||
|
| `docker-compose.yml` | Add TZ env var |
|
||||||
|
| `.env.example` | Add TZ example |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Create user, set timezone to Eastern, verify times display in MM-DD-YYYY HH:MM format
|
||||||
|
- [ ] Create user, set timezone to Pacific, verify different offset
|
||||||
|
- [ ] Test system default timezone fallback for users with no timezone set
|
||||||
|
- [ ] Verify invalid timezone values are rejected by the API
|
||||||
|
- [ ] Verify existing users (no timezone set) fall back to system default
|
||||||
|
- [ ] Verify all templates show consistent MM-DD-YYYY HH:MM format
|
||||||
|
- [ ] Run `make test-integration` to verify no regressions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Steps
|
||||||
|
|
||||||
|
1. Update `database/schema/schema.sql` with new column and settings
|
||||||
|
2. Regenerate sqlc: `cd internal/database && sqlc generate`
|
||||||
|
3. Apply schema changes (restart database container with `make rebuild-force-db`)
|
||||||
|
4. Deploy backend code changes
|
||||||
|
5. Verify with existing data
|
||||||
@@ -47,7 +47,7 @@ docs: |-
|
|||||||
- `id` (string, required): Media item UUID
|
- `id` (string, required): Media item UUID
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
- `rating` (number, required): Rating value (1-10 integer scale; displayed as 1-5 stars with half-star precision)
|
- `rating` (number, required): Rating value (typically 1-5)
|
||||||
- `review` (string, optional): Review text
|
- `review` (string, optional): Review text
|
||||||
|
|
||||||
**Response:** Updated rating object
|
**Response:** Updated rating object
|
||||||
|
|||||||
@@ -83,10 +83,9 @@ docs:
|
|||||||
- **Update Highlight**: PUT /api/highlights/:id - Update highlight
|
- **Update Highlight**: PUT /api/highlights/:id - Update highlight
|
||||||
- **Delete Highlight**: DELETE /api/highlights/:id - Remove highlight
|
- **Delete Highlight**: DELETE /api/highlights/:id - Remove highlight
|
||||||
Ratings (All Users)
|
Ratings (All Users)
|
||||||
- **Get Rating**: GET /api/media-items/:id/rating - User's rating (returns null if unrated)
|
- **Get Rating**: GET /api/ratings/:media_id - User's rating (returns 0 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.
|
- **Create/Update Rating**: POST /api/ratings - Rate media item (1-5 stars, half-star precision)
|
||||||
- **Update Rating**: PUT /api/media-items/:id/rating - Update rating (upsert)
|
- **Delete Rating**: DELETE /api/ratings/:media_id - Remove rating
|
||||||
- **Delete Rating**: DELETE /api/media-items/:id/rating - Remove rating
|
|
||||||
Collections (All Users)
|
Collections (All Users)
|
||||||
- **List Collections**: GET /api/collections - Get user's collections
|
- **List Collections**: GET /api/collections - Get user's collections
|
||||||
- **Get Collection**: GET /api/collections/:id - Collection details with media items
|
- **Get Collection**: GET /api/collections/:id - Collection details with media items
|
||||||
|
|||||||
-37
@@ -1,37 +0,0 @@
|
|||||||
# 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"
|
|
||||||
@@ -63,13 +63,9 @@ func main() {
|
|||||||
connManager := sync.NewConnectionManager()
|
connManager := sync.NewConnectionManager()
|
||||||
|
|
||||||
progressService := sync.NewProgressService(queries, connManager)
|
progressService := sync.NewProgressService(queries, connManager)
|
||||||
annotationService := sync.NewAnnotationService(queries, connManager)
|
|
||||||
tombstonePurgerCancel := annotationService.StartTombstonePurger()
|
|
||||||
defer tombstonePurgerCancel()
|
|
||||||
|
|
||||||
queueProcessor := sync.NewSyncQueueProcessor(queries)
|
queueProcessor := sync.NewSyncQueueProcessor(queries)
|
||||||
queueProcessor.SetProgressService(progressService)
|
queueProcessor.SetProgressService(progressService)
|
||||||
queueProcessor.SetAnnotationService(annotationService)
|
|
||||||
|
|
||||||
// Create library service
|
// Create library service
|
||||||
libraryService := services.NewLibraryService(queries)
|
libraryService := services.NewLibraryService(queries)
|
||||||
@@ -83,8 +79,6 @@ func main() {
|
|||||||
|
|
||||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||||
koreaderHandler.SetProgressService(progressService)
|
koreaderHandler.SetProgressService(progressService)
|
||||||
koreaderHandler.SetAnnotationService(annotationService)
|
|
||||||
koreaderHandler.SetLibraryService(libraryService)
|
|
||||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||||
@@ -100,7 +94,6 @@ func main() {
|
|||||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||||
mediaHandler.SetProgressService(progressService)
|
mediaHandler.SetProgressService(progressService)
|
||||||
mediaHandler.SetAnnotationService(annotationService)
|
|
||||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||||
|
|
||||||
@@ -168,11 +161,9 @@ func main() {
|
|||||||
ConnManager: connManager,
|
ConnManager: connManager,
|
||||||
QueueProcessor: queueProcessor,
|
QueueProcessor: queueProcessor,
|
||||||
ProgressService: progressService,
|
ProgressService: progressService,
|
||||||
AnnotationService: annotationService,
|
|
||||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||||
JobsHandler: jobsHandler,
|
JobsHandler: jobsHandler,
|
||||||
LoginTracker: loginAttemptTracker,
|
LoginTracker: loginAttemptTracker,
|
||||||
LibraryService: libraryService,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register all routes and get ebook handler
|
// Register all routes and get ebook handler
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ func TestCalibreLibraryScan(t *testing.T) {
|
|||||||
// Create scanner and configure it
|
// Create scanner and configure it
|
||||||
scanner := services.NewMediaScanner(setup.DB)
|
scanner := services.NewMediaScanner(setup.DB)
|
||||||
scanner.SetAdminID(adminID)
|
scanner.SetAdminID(adminID)
|
||||||
err = scanner.SetFolders([]string{tmpDir}, false)
|
err = scanner.SetFolders([]string{tmpDir})
|
||||||
require.NoError(t, err, "Failed to set scanner folders")
|
require.NoError(t, err, "Failed to set scanner folders")
|
||||||
|
|
||||||
// Scan library
|
// Scan library
|
||||||
@@ -167,7 +167,7 @@ func TestCalibreLibraryScanWithoutSidecar(t *testing.T) {
|
|||||||
// Create scanner and configure it
|
// Create scanner and configure it
|
||||||
scanner := services.NewMediaScanner(setup.DB)
|
scanner := services.NewMediaScanner(setup.DB)
|
||||||
scanner.SetAdminID(adminID)
|
scanner.SetAdminID(adminID)
|
||||||
err = scanner.SetFolders([]string{tmpDir}, false)
|
err = scanner.SetFolders([]string{tmpDir})
|
||||||
require.NoError(t, err, "Failed to set scanner folders")
|
require.NoError(t, err, "Failed to set scanner folders")
|
||||||
|
|
||||||
// Scan library
|
// Scan library
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ type TestServerSetup struct {
|
|||||||
ConnManager *wsync.ConnectionManager
|
ConnManager *wsync.ConnectionManager
|
||||||
QueueProcessor *wsync.SyncQueueProcessor
|
QueueProcessor *wsync.SyncQueueProcessor
|
||||||
ProgressService *wsync.ProgressService
|
ProgressService *wsync.ProgressService
|
||||||
AnnotationService *wsync.AnnotationService
|
|
||||||
CleanupCancel context.CancelFunc
|
CleanupCancel context.CancelFunc
|
||||||
QueueCtx context.Context
|
QueueCtx context.Context
|
||||||
QueueCancel context.CancelFunc
|
QueueCancel context.CancelFunc
|
||||||
@@ -456,7 +455,6 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
cleanupCancel := connManager.StartCleanupTask()
|
cleanupCancel := connManager.StartCleanupTask()
|
||||||
|
|
||||||
progressService := wsync.NewProgressService(queries, connManager)
|
progressService := wsync.NewProgressService(queries, connManager)
|
||||||
annotationService := wsync.NewAnnotationService(queries, connManager)
|
|
||||||
|
|
||||||
queueProcessor := wsync.NewSyncQueueProcessor(queries)
|
queueProcessor := wsync.NewSyncQueueProcessor(queries)
|
||||||
queueProcessor.SetProgressService(progressService)
|
queueProcessor.SetProgressService(progressService)
|
||||||
@@ -465,7 +463,6 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
|
|
||||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||||
koreaderHandler.SetProgressService(progressService)
|
koreaderHandler.SetProgressService(progressService)
|
||||||
koreaderHandler.SetAnnotationService(annotationService)
|
|
||||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||||
@@ -485,7 +482,6 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||||
mediaHandler.SetProgressService(progressService)
|
mediaHandler.SetProgressService(progressService)
|
||||||
mediaHandler.SetAnnotationService(annotationService)
|
|
||||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
|
|
||||||
// Create conversion service for OPDS
|
// Create conversion service for OPDS
|
||||||
@@ -541,7 +537,6 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
ConnManager: connManager,
|
ConnManager: connManager,
|
||||||
QueueProcessor: queueProcessor,
|
QueueProcessor: queueProcessor,
|
||||||
ProgressService: progressService,
|
ProgressService: progressService,
|
||||||
AnnotationService: annotationService,
|
|
||||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||||
LoginTracker: loginAttemptTracker,
|
LoginTracker: loginAttemptTracker,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,7 +245,6 @@ CREATE TABLE IF NOT EXISTS reading_progress (
|
|||||||
percentage FLOAT CHECK (percentage >= 0 AND percentage <= 1),
|
percentage FLOAT CHECK (percentage >= 0 AND percentage <= 1),
|
||||||
character_offset BIGINT,
|
character_offset BIGINT,
|
||||||
epubcfi TEXT,
|
epubcfi TEXT,
|
||||||
context_text TEXT,
|
|
||||||
chapter INTEGER,
|
chapter INTEGER,
|
||||||
chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1),
|
chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1),
|
||||||
viewport_x FLOAT DEFAULT 0,
|
viewport_x FLOAT DEFAULT 0,
|
||||||
@@ -938,7 +937,6 @@ BEGIN
|
|||||||
percentage = (book_record->>'percentage')::FLOAT,
|
percentage = (book_record->>'percentage')::FLOAT,
|
||||||
character_offset = CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE existing_progress.character_offset END,
|
character_offset = CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE existing_progress.character_offset END,
|
||||||
epubcfi = CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE existing_progress.epubcfi END,
|
epubcfi = CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE existing_progress.epubcfi END,
|
||||||
context_text = CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE existing_progress.context_text END,
|
|
||||||
chapter = CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE existing_progress.chapter END,
|
chapter = CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE existing_progress.chapter END,
|
||||||
chapter_progress = (book_record->>'percentage')::FLOAT,
|
chapter_progress = (book_record->>'percentage')::FLOAT,
|
||||||
last_sync_device = 'koreader',
|
last_sync_device = 'koreader',
|
||||||
@@ -957,7 +955,6 @@ BEGIN
|
|||||||
percentage,
|
percentage,
|
||||||
character_offset,
|
character_offset,
|
||||||
epubcfi,
|
epubcfi,
|
||||||
context_text,
|
|
||||||
chapter,
|
chapter,
|
||||||
chapter_progress,
|
chapter_progress,
|
||||||
last_sync_device,
|
last_sync_device,
|
||||||
@@ -974,7 +971,6 @@ BEGIN
|
|||||||
(book_record->>'percentage')::FLOAT,
|
(book_record->>'percentage')::FLOAT,
|
||||||
CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE NULL END,
|
CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE NULL END,
|
||||||
CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE NULL END,
|
CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE NULL END,
|
||||||
CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE NULL END,
|
|
||||||
CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE NULL END,
|
CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE NULL END,
|
||||||
(book_record->>'percentage')::FLOAT,
|
(book_record->>'percentage')::FLOAT,
|
||||||
'koreader',
|
'koreader',
|
||||||
@@ -1312,48 +1308,3 @@ 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_media ON media_bookmarks(media_item_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_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;
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
# 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
|
|
||||||
+55
-14
@@ -1,3 +1,5 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# PostgreSQL Database
|
# PostgreSQL Database
|
||||||
db:
|
db:
|
||||||
@@ -7,15 +9,14 @@ services:
|
|||||||
POSTGRES_DB: bookhoard
|
POSTGRES_DB: bookhoard
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: ${DBPASS}
|
POSTGRES_PASSWORD: ${DBPASS}
|
||||||
# PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT)
|
COOKIE_SECURE: false # make true in production with HTTPS
|
||||||
PGPORT: ${DB_PORT:-5432}
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ./database/schema:/docker-entrypoint-initdb.d
|
- ./database/schema:/docker-entrypoint-initdb.d
|
||||||
# Make other volumes as needed
|
# Make other volumes as needed
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
ports:
|
ports:
|
||||||
- "${DB_PORT:-5432}:${DB_PORT:-5432}"
|
- "5432:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -26,30 +27,27 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
|
|
||||||
# Bookhoard Application
|
# 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:
|
app:
|
||||||
image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./Dockerfile
|
||||||
container_name: bookhoard
|
container_name: bookhoard
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
environment:
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DATABASE_HOST: db
|
DATABASE_HOST: db
|
||||||
DATABASE_PORT: ${DB_PORT:-5432}
|
DATABASE_PORT: 5432
|
||||||
DATABASE_USER: postgres
|
DATABASE_USER: postgres
|
||||||
DATABASE_PASSWORD: ${DBPASS}
|
DATABASE_PASSWORD: ${DBPASS}
|
||||||
DATABASE_NAME: bookhoard
|
DATABASE_NAME: bookhoard
|
||||||
|
|
||||||
# Application Configuration
|
# Application Configuration
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
SERVER_PORT: ${SERVER_PORT:-8765}
|
SERVER_PORT: 8765
|
||||||
# IMPORTANT: Device sync requires full URL with protocol
|
# IMPORTANT: Device sync requires full URL with protocol
|
||||||
# Local: http://localhost:8765
|
# Local: http://localhost:8765
|
||||||
# Local network: http://192.168.1.X:8765
|
# Local network: http://192.168.1.X:8765
|
||||||
# Domain: https://bookhoard.example.com
|
# Domain: https://bookhoard.example.com
|
||||||
BASE_URL: ${BASE_URL:-http://localhost:8765}
|
BASE_URL: http://localhost:${SERVER_PORT}
|
||||||
# Mark session cookies Secure; set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
|
|
||||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
|
||||||
|
|
||||||
# Rate Limiting Configuration
|
# Rate Limiting Configuration
|
||||||
TEST_MODE: ${TEST_MODE:-false}
|
TEST_MODE: ${TEST_MODE:-false}
|
||||||
@@ -64,7 +62,7 @@ services:
|
|||||||
# System timezone (fallback for server-side time operations)
|
# System timezone (fallback for server-side time operations)
|
||||||
TZ: ${TZ:-UTC}
|
TZ: ${TZ:-UTC}
|
||||||
ports:
|
ports:
|
||||||
- "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}"
|
- "8765:8765"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -72,12 +70,55 @@ services:
|
|||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
- bookhoard_conversion_cache:/app/cache/kepub
|
- bookhoard_conversion_cache:/app/cache/kepub
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"]
|
test: ["CMD-SHELL", "curl -f http://localhost:8765/health || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
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
|
# Named Volumes
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -986,39 +986,25 @@ GET /opds/devices/{deviceId}/catalog?page={page}&per_page={per_page}
|
|||||||
- `page` (optional): Page number (default: 1)
|
- `page` (optional): Page number (default: 1)
|
||||||
- `per_page` (optional): Items per page (default: 50, max: 200)
|
- `per_page` (optional): Items per page (default: 50, max: 200)
|
||||||
|
|
||||||
The feed is paginated via standard OPDS link relations. Clients (e.g. KOReader)
|
|
||||||
walk pages by following the `rel="next"` link until it is absent. OpenSearch
|
|
||||||
paging metadata (`totalResults`, `itemsPerPage`, `startIndex`) is also included.
|
|
||||||
|
|
||||||
**Response** (200 - OPDS 1.2 XML):
|
**Response** (200 - OPDS 1.2 XML):
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom"
|
<feed xmlns="http://www.w3.org/2005/Atom"
|
||||||
xmlns:opds="http://opds-spec.org/2010/"
|
xmlns:opds="http://opds-spec.org/2010/"
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">
|
|
||||||
<id>urn:uuid:device-id</id>
|
<id>urn:uuid:device-id</id>
|
||||||
<title>Bookhoard Library</title>
|
<title>Bookhoard Library</title>
|
||||||
<updated>2026-02-01T12:00:00Z</updated>
|
<updated>2026-02-01T12:00:00Z</updated>
|
||||||
|
|
||||||
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=2&per_page=50"/>
|
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog"/>
|
||||||
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
<link rel="search" href="http://localhost:8765/opds/devices/kobo-id/search"/>
|
||||||
<link rel="first" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/nav"/>
|
||||||
<link rel="previous" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
|
||||||
<link rel="next" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=3&per_page=50"/>
|
|
||||||
<link rel="last" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=37&per_page=50"/>
|
|
||||||
<link rel="search" type="application/opensearchdescription+xml"
|
|
||||||
href="http://localhost:8765/opds/devices/kobo-id/search"/>
|
|
||||||
|
|
||||||
<opensearch:totalResults>1814</opensearch:totalResults>
|
|
||||||
<opensearch:itemsPerPage>50</opensearch:itemsPerPage>
|
|
||||||
<opensearch:startIndex>51</opensearch:startIndex>
|
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
<id>urn:uuid:bookhoard-uuid-123</id>
|
<id>urn:uuid:bookhoard-uuid-123</id>
|
||||||
<title>The Hobbit</title>
|
<dc:title>The Hobbit</dc:title>
|
||||||
<author><name>J.R.R. Tolkien</name></author>
|
<dc:creator>J.R.R. Tolkien</dc:creator>
|
||||||
<updated>2026-02-01T10:00:00Z</updated>
|
<updated>2026-02-01T10:00:00Z</updated>
|
||||||
|
|
||||||
<link href="http://localhost:8765/opds/devices/kobo-id/download/uuid-123"
|
<link href="http://localhost:8765/opds/devices/kobo-id/download/uuid-123"
|
||||||
@@ -1057,28 +1043,10 @@ GET /opds/devices/{deviceId}/download/{bookId}?format={format}
|
|||||||
### Search OPDS Catalog
|
### Search OPDS Catalog
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /opds/devices/{deviceId}/search # OpenSearch description
|
GET /opds/devices/{deviceId}/search?q={query}
|
||||||
GET /opds/devices/{deviceId}/search?q={query} # search results feed
|
|
||||||
```
|
```
|
||||||
|
|
||||||
When called **without** a `q` parameter, returns an OpenSearch description
|
**Response** (200 - OPDS 1.2 XML with search results)
|
||||||
document (`application/opensearchdescription+xml`). OPDS clients fetch this to
|
|
||||||
learn the search URL template, then substitute `{searchTerms}`:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
|
||||||
<ShortName>Bookhoard</ShortName>
|
|
||||||
<Description>Search the Bookhoard library</Description>
|
|
||||||
<InputEncoding>UTF-8</InputEncoding>
|
|
||||||
<OutputEncoding>UTF-8</OutputEncoding>
|
|
||||||
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"
|
|
||||||
template="http://localhost:8765/opds/devices/kobo-id/search?q={searchTerms}"/>
|
|
||||||
</OpenSearchDescription>
|
|
||||||
```
|
|
||||||
|
|
||||||
When called **with** a `q` parameter, **Response** (200 - OPDS 1.2 XML with
|
|
||||||
search results, including `opensearch:totalResults`).
|
|
||||||
|
|
||||||
### List Available Formats
|
### List Available Formats
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ require (
|
|||||||
github.com/yuin/goldmark v1.8.2
|
github.com/yuin/goldmark v1.8.2
|
||||||
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
|
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
|
||||||
golang.org/x/crypto v0.50.0
|
golang.org/x/crypto v0.50.0
|
||||||
golang.org/x/net v0.53.0
|
|
||||||
golang.org/x/text v0.36.0
|
golang.org/x/text v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,6 +57,7 @@ require (
|
|||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/xyproto/randomstring v1.2.0 // indirect
|
github.com/xyproto/randomstring v1.2.0 // indirect
|
||||||
golang.org/x/image v0.39.0 // indirect
|
golang.org/x/image v0.39.0 // indirect
|
||||||
|
golang.org/x/net v0.53.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
|
|||||||
@@ -165,15 +165,6 @@ type MediaBookmarks struct {
|
|||||||
Position pgtype.Text `db:"position" json:"position"`
|
Position pgtype.Text `db:"position" json:"position"`
|
||||||
Notes pgtype.Text `db:"notes" json:"notes"`
|
Notes pgtype.Text `db:"notes" json:"notes"`
|
||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
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 {
|
type MediaHighlights struct {
|
||||||
@@ -198,12 +189,6 @@ type MediaHighlights struct {
|
|||||||
ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"`
|
ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"`
|
||||||
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
|
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
|
||||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
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 {
|
type MediaItemFormats struct {
|
||||||
@@ -319,11 +304,6 @@ type MediaNotes struct {
|
|||||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||||
ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"`
|
ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"`
|
||||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
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 {
|
type MediaRatings struct {
|
||||||
@@ -400,7 +380,6 @@ type ReadingProgress struct {
|
|||||||
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
|
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
|
||||||
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
|
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
|
||||||
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
|
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
|
||||||
ContextText pgtype.Text `db:"context_text" json:"context_text"`
|
|
||||||
Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
|
Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
|
||||||
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
|
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
|
||||||
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
|
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
|
||||||
|
|||||||
@@ -30,11 +30,9 @@ type Querier interface {
|
|||||||
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
||||||
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
||||||
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
||||||
CountAdmins(ctx context.Context) (int64, error)
|
|
||||||
// Count unlinked books for a device
|
// Count unlinked books for a device
|
||||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||||
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||||
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
|
|
||||||
// COLLECTIONS QUERIES
|
// COLLECTIONS QUERIES
|
||||||
// Create collection
|
// Create collection
|
||||||
CreateCollection(ctx context.Context, arg CreateCollectionParams) (Collections, error)
|
CreateCollection(ctx context.Context, arg CreateCollectionParams) (Collections, error)
|
||||||
@@ -56,10 +54,8 @@ type Querier interface {
|
|||||||
// Libraries queries
|
// Libraries queries
|
||||||
CreateLibrary(ctx context.Context, arg CreateLibraryParams) (Libraries, error)
|
CreateLibrary(ctx context.Context, arg CreateLibraryParams) (Libraries, error)
|
||||||
CreateMediaBookmark(ctx context.Context, arg CreateMediaBookmarkParams) (MediaBookmarks, error)
|
CreateMediaBookmark(ctx context.Context, arg CreateMediaBookmarkParams) (MediaBookmarks, error)
|
||||||
CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBookmarkFullParams) (MediaBookmarks, error)
|
|
||||||
// Media Highlights queries
|
// Media Highlights queries
|
||||||
CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error)
|
CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error)
|
||||||
CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error)
|
|
||||||
// Media Items queries
|
// Media Items queries
|
||||||
CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error)
|
CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error)
|
||||||
// MEDIA ITEM FORMATS QUERIES
|
// MEDIA ITEM FORMATS QUERIES
|
||||||
@@ -67,7 +63,6 @@ type Querier interface {
|
|||||||
CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error)
|
CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error)
|
||||||
// Media Notes queries
|
// Media Notes queries
|
||||||
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
|
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
|
||||||
CreateMediaNoteFull(ctx context.Context, arg CreateMediaNoteFullParams) (MediaNotes, error)
|
|
||||||
CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error)
|
CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error)
|
||||||
// OPDS TOKENS QUERIES
|
// OPDS TOKENS QUERIES
|
||||||
// Create OPDS token
|
// Create OPDS token
|
||||||
@@ -130,10 +125,6 @@ type Querier interface {
|
|||||||
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
||||||
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
||||||
// ============================================
|
|
||||||
// ANNOTATION SERVE QUERIES
|
|
||||||
// ============================================
|
|
||||||
GetActiveAnnotationsForBook(ctx context.Context, arg GetActiveAnnotationsForBookParams) ([]GetActiveAnnotationsForBookRow, error)
|
|
||||||
// Get all system config
|
// Get all system config
|
||||||
GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error)
|
GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error)
|
||||||
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
||||||
@@ -205,17 +196,8 @@ type Querier interface {
|
|||||||
// LIBRARY WITH TYPE INFO QUERIES
|
// LIBRARY WITH TYPE INFO QUERIES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLibraryWithTypeRow, error)
|
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)
|
GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksParams) ([]MediaBookmarks, error)
|
||||||
GetMediaHighlight(ctx context.Context, id pgtype.UUID) (MediaHighlights, 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)
|
GetMediaHighlights(ctx context.Context, arg GetMediaHighlightsParams) ([]MediaHighlights, error)
|
||||||
GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error)
|
GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error)
|
||||||
GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemByFilePathParams) (MediaItems, error)
|
GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemByFilePathParams) (MediaItems, error)
|
||||||
@@ -238,10 +220,6 @@ type Querier interface {
|
|||||||
// Get media item formats
|
// Get media item formats
|
||||||
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
|
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
|
||||||
GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, 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)
|
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
|
||||||
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
|
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
|
||||||
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
|
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
|
||||||
@@ -279,7 +257,6 @@ type Querier interface {
|
|||||||
// System Settings queries
|
// System Settings queries
|
||||||
GetSystemSetting(ctx context.Context, settingKey string) (string, error)
|
GetSystemSetting(ctx context.Context, settingKey string) (string, error)
|
||||||
GetSystemTimezone(ctx context.Context) (string, error)
|
GetSystemTimezone(ctx context.Context) (string, error)
|
||||||
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
|
|
||||||
// Get universal progress for a book
|
// Get universal progress for a book
|
||||||
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
|
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
|
||||||
// Get unlinked book by ContentId
|
// Get unlinked book by ContentId
|
||||||
@@ -303,8 +280,6 @@ type Querier interface {
|
|||||||
// Get user reading history for analytics
|
// Get user reading history for analytics
|
||||||
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
|
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
|
||||||
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
||||||
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
|
||||||
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
|
||||||
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
|
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
|
||||||
// Check if book is in collection
|
// Check if book is in collection
|
||||||
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
|
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
|
||||||
@@ -327,9 +302,6 @@ type Querier interface {
|
|||||||
// List unresolved unlinked books with pagination
|
// List unresolved unlinked books with pagination
|
||||||
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
||||||
ListUsers(ctx context.Context) ([]ListUsersRow, 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
|
// Query media items by multiple identifiers with confidence scoring
|
||||||
QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error)
|
QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error)
|
||||||
ReassignLibraries(ctx context.Context, arg ReassignLibrariesParams) error
|
ReassignLibraries(ctx context.Context, arg ReassignLibrariesParams) error
|
||||||
@@ -360,12 +332,6 @@ type Querier interface {
|
|||||||
// Set system config
|
// Set system config
|
||||||
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
|
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
|
||||||
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) 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
|
// Update collection
|
||||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
||||||
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||||
@@ -389,9 +355,7 @@ type Querier interface {
|
|||||||
UpdateKoboShelfCollection(ctx context.Context, arg UpdateKoboShelfCollectionParams) (KoboShelves, error)
|
UpdateKoboShelfCollection(ctx context.Context, arg UpdateKoboShelfCollectionParams) (KoboShelves, error)
|
||||||
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
|
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
|
||||||
UpdateMediaBookmark(ctx context.Context, arg UpdateMediaBookmarkParams) (MediaBookmarks, 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)
|
UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighlightParams) (MediaHighlights, error)
|
||||||
UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error)
|
|
||||||
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error)
|
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error)
|
||||||
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
|
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
|
||||||
// Update media item format
|
// Update media item format
|
||||||
@@ -409,7 +373,6 @@ type Querier interface {
|
|||||||
UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMediaItemIdentifiersParams) (MediaItems, error)
|
UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMediaItemIdentifiersParams) (MediaItems, error)
|
||||||
UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMediaItemKoboMetadataParams) (MediaItems, error)
|
UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMediaItemKoboMetadataParams) (MediaItems, error)
|
||||||
UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams) (MediaNotes, 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)
|
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
|
||||||
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
||||||
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
||||||
|
|||||||
+18
-1141
File diff suppressed because it is too large
Load Diff
@@ -137,14 +137,6 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
ORDER BY l.created_at ASC;
|
ORDER BY l.created_at ASC;
|
||||||
|
|
||||||
-- name: GetVisibleLibraryMediaCounts :many
|
|
||||||
SELECT l.id, COUNT(mi.id) as media_count
|
|
||||||
FROM libraries l
|
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
||||||
LEFT JOIN media_items mi ON mi.library_id = l.id
|
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
|
||||||
GROUP BY l.id;
|
|
||||||
|
|
||||||
-- Media Items queries
|
-- Media Items queries
|
||||||
-- name: CreateMediaItem :one
|
-- name: CreateMediaItem :one
|
||||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
||||||
@@ -354,9 +346,6 @@ WHERE role = 'admin'
|
|||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: CountAdmins :one
|
|
||||||
SELECT COUNT(*) FROM users WHERE role = 'admin';
|
|
||||||
|
|
||||||
-- name: ReassignLibraries :exec
|
-- name: ReassignLibraries :exec
|
||||||
UPDATE libraries SET created_by_admin_id = $2, updated_at = NOW() WHERE created_by_admin_id = $1;
|
UPDATE libraries SET created_by_admin_id = $2, updated_at = NOW() WHERE created_by_admin_id = $1;
|
||||||
|
|
||||||
@@ -704,7 +693,7 @@ RETURNING *;
|
|||||||
SELECT * FROM media_notes WHERE id = $1;
|
SELECT * FROM media_notes WHERE id = $1;
|
||||||
|
|
||||||
-- name: GetMediaNotes :many
|
-- name: GetMediaNotes :many
|
||||||
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
|
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: UpdateMediaNote :one
|
-- name: UpdateMediaNote :one
|
||||||
UPDATE media_notes SET
|
UPDATE media_notes SET
|
||||||
@@ -727,7 +716,7 @@ RETURNING *;
|
|||||||
SELECT * FROM media_highlights WHERE id = $1;
|
SELECT * FROM media_highlights WHERE id = $1;
|
||||||
|
|
||||||
-- name: GetMediaHighlights :many
|
-- name: GetMediaHighlights :many
|
||||||
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
|
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: UpdateMediaHighlight :one
|
-- name: UpdateMediaHighlight :one
|
||||||
UPDATE media_highlights SET
|
UPDATE media_highlights SET
|
||||||
@@ -743,251 +732,6 @@ RETURNING *;
|
|||||||
-- name: DeleteMediaHighlight :exec
|
-- name: DeleteMediaHighlight :exec
|
||||||
DELETE FROM media_highlights WHERE id = $1;
|
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
|
-- Refresh Tokens queries
|
||||||
-- name: CreateRefreshToken :one
|
-- name: CreateRefreshToken :one
|
||||||
INSERT INTO refresh_tokens (user_id, token, expires_at)
|
INSERT INTO refresh_tokens (user_id, token, expires_at)
|
||||||
@@ -1049,7 +793,6 @@ SELECT
|
|||||||
rp.percentage,
|
rp.percentage,
|
||||||
rp.character_offset,
|
rp.character_offset,
|
||||||
rp.epubcfi,
|
rp.epubcfi,
|
||||||
rp.context_text,
|
|
||||||
rp.chapter,
|
rp.chapter,
|
||||||
rp.chapter_progress,
|
rp.chapter_progress,
|
||||||
rp.viewport_x,
|
rp.viewport_x,
|
||||||
@@ -1082,7 +825,6 @@ INSERT INTO reading_progress (
|
|||||||
percentage,
|
percentage,
|
||||||
character_offset,
|
character_offset,
|
||||||
epubcfi,
|
epubcfi,
|
||||||
context_text,
|
|
||||||
chapter,
|
chapter,
|
||||||
chapter_progress,
|
chapter_progress,
|
||||||
viewport_x,
|
viewport_x,
|
||||||
@@ -1100,14 +842,13 @@ INSERT INTO reading_progress (
|
|||||||
last_read_at
|
last_read_at
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, NOW()
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW(), $17, $18, NOW()
|
||||||
)
|
)
|
||||||
ON CONFLICT (media_item_id, user_id)
|
ON CONFLICT (media_item_id, user_id)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
percentage = EXCLUDED.percentage,
|
percentage = EXCLUDED.percentage,
|
||||||
character_offset = EXCLUDED.character_offset,
|
character_offset = EXCLUDED.character_offset,
|
||||||
epubcfi = EXCLUDED.epubcfi,
|
epubcfi = EXCLUDED.epubcfi,
|
||||||
context_text = EXCLUDED.context_text,
|
|
||||||
chapter = EXCLUDED.chapter,
|
chapter = EXCLUDED.chapter,
|
||||||
chapter_progress = EXCLUDED.chapter_progress,
|
chapter_progress = EXCLUDED.chapter_progress,
|
||||||
viewport_x = EXCLUDED.viewport_x,
|
viewport_x = EXCLUDED.viewport_x,
|
||||||
@@ -1379,11 +1120,6 @@ INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data
|
|||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING *;
|
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
|
-- name: GetSyncConflict :one
|
||||||
SELECT * FROM sync_conflicts WHERE id = $1;
|
SELECT * FROM sync_conflicts WHERE id = $1;
|
||||||
|
|
||||||
@@ -1426,15 +1162,6 @@ JOIN media_items mi ON sc.media_item_id = mi.id
|
|||||||
WHERE sc.user_id = $1
|
WHERE sc.user_id = $1
|
||||||
ORDER BY sc.created_at DESC;
|
ORDER BY sc.created_at DESC;
|
||||||
|
|
||||||
-- name: HasRecentConflictResolution :one
|
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1 FROM sync_conflicts
|
|
||||||
WHERE media_item_id = $1
|
|
||||||
AND user_id = $2
|
|
||||||
AND resolution_status != 'unresolved'
|
|
||||||
AND resolved_at > NOW() - INTERVAL '10 minutes'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================
|
-- ============================================
|
||||||
-- KOREADER SYNC PROTOCOL
|
-- KOREADER SYNC PROTOCOL
|
||||||
-- ============================================
|
-- ============================================
|
||||||
@@ -1483,7 +1210,7 @@ SELECT
|
|||||||
mh.epubcfi_start,
|
mh.epubcfi_start,
|
||||||
mh.epubcfi_end
|
mh.epubcfi_end
|
||||||
FROM media_highlights mh
|
FROM media_highlights mh
|
||||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND COALESCE(mh.deleted, FALSE) = FALSE
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT
|
SELECT
|
||||||
mn.id,
|
mn.id,
|
||||||
@@ -1499,7 +1226,7 @@ SELECT
|
|||||||
mn.epubcfi_location as epubcfi_start,
|
mn.epubcfi_location as epubcfi_start,
|
||||||
NULL as epubcfi_end
|
NULL as epubcfi_end
|
||||||
FROM media_notes mn
|
FROM media_notes mn
|
||||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND COALESCE(mn.deleted, FALSE) = FALSE
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2
|
||||||
ORDER BY created_at DESC;
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: UpdateDeviceSyncTimestamp :one
|
-- name: UpdateDeviceSyncTimestamp :one
|
||||||
@@ -2370,12 +2097,9 @@ RETURNING *;
|
|||||||
|
|
||||||
-- name: GetMediaBookmarks :many
|
-- name: GetMediaBookmarks :many
|
||||||
SELECT * FROM media_bookmarks
|
SELECT * FROM media_bookmarks
|
||||||
WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE
|
WHERE media_item_id = $1 AND user_id = $2
|
||||||
ORDER BY created_at DESC;
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: GetMediaBookmark :one
|
|
||||||
SELECT * FROM media_bookmarks WHERE id = $1;
|
|
||||||
|
|
||||||
-- name: CreateMediaBookmark :one
|
-- name: CreateMediaBookmark :one
|
||||||
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes)
|
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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
|||||||
+17
-28
@@ -6,7 +6,6 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/middleware"
|
"bookhoard/internal/middleware"
|
||||||
"bookhoard/internal/setupstatus"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -83,22 +82,22 @@ type UserProfile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProfileRequest struct {
|
type UpdateProfileRequest struct {
|
||||||
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
|
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
||||||
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
|
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
||||||
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
|
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
|
||||||
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
|
||||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
Theme string `json:"theme,omitempty" validate:"omitempty"`
|
||||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminUpdateUserRequest struct {
|
type AdminUpdateUserRequest struct {
|
||||||
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
|
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
||||||
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
|
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
||||||
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
|
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
|
||||||
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
|
||||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
Theme string `json:"theme,omitempty" validate:"omitempty"`
|
||||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
|
||||||
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
|
Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register handles POST /api/auth/register
|
// Register handles POST /api/auth/register
|
||||||
@@ -191,7 +190,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var userRole string
|
var userRole string
|
||||||
if !adminExists {
|
if len(users) == 0 {
|
||||||
userRole = "admin"
|
userRole = "admin"
|
||||||
} else {
|
} else {
|
||||||
userRole = req.Role
|
userRole = req.Role
|
||||||
@@ -233,10 +232,6 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to create default collections</div>`)
|
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to create default collections</div>`)
|
||||||
@@ -553,9 +548,6 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
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)
|
// Update username (if provided)
|
||||||
@@ -793,9 +785,9 @@ func (h *AuthHandler) UpdatePassword(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PasswordRequest struct {
|
type PasswordRequest struct {
|
||||||
CurrentPassword string `json:"current_password,omitempty" form:"current_password"`
|
CurrentPassword string `json:"current_password,omitempty"`
|
||||||
NewPassword string `json:"new_password" form:"new_password" validate:"required,passwordcomplex"`
|
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
|
||||||
ConfirmPassword string `json:"confirm_password" form:"confirm_password" validate:"required"`
|
ConfirmPassword string `json:"confirm_password" validate:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var req PasswordRequest
|
var req PasswordRequest
|
||||||
@@ -942,9 +934,6 @@ func (h *AuthHandler) DeleteUser(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletion may have changed the admin count, so refresh the setup-status cache.
|
|
||||||
setupstatus.Invalidate()
|
|
||||||
|
|
||||||
// Create success message based on context
|
// Create success message based on context
|
||||||
var message string
|
var message string
|
||||||
if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes {
|
if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func NewConflictHandler(db *database.Queries, connManager *wsync.ConnectionManag
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ConflictResolutionRequest struct {
|
type ConflictResolutionRequest struct {
|
||||||
Winner string `json:"winner" validate:"required"`
|
Winner string `json:"winner" validate:"required,oneof=koreader kobo web manual"`
|
||||||
ManualData map[string]interface{} `json:"manual_data"`
|
ManualData map[string]interface{} `json:"manual_data"`
|
||||||
ApplyToAll bool `json:"apply_to_all_future_conflicts"`
|
ApplyToAll bool `json:"apply_to_all_future_conflicts"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
@@ -225,7 +225,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
|||||||
return echo.NewHTTPError(http.StatusForbidden, "access denied")
|
return echo.NewHTTPError(http.StatusForbidden, "access denied")
|
||||||
}
|
}
|
||||||
|
|
||||||
if conflict.ResolutionStatus.String == "user_resolved" {
|
if conflict.ResolutionStatus.String != "unresolved" {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "conflict already resolved")
|
return echo.NewHTTPError(http.StatusBadRequest, "conflict already resolved")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,10 +235,8 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
winnerData := map[string]interface{}{}
|
winnerData := map[string]interface{}{}
|
||||||
winnerSource := req.Winner
|
|
||||||
if req.Winner == "manual" {
|
if req.Winner == "manual" {
|
||||||
winnerData = req.ManualData
|
winnerData = req.ManualData
|
||||||
winnerSource = "manual"
|
|
||||||
} else {
|
} else {
|
||||||
source, ok := conflictData[req.Winner]
|
source, ok := conflictData[req.Winner]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -253,17 +251,11 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if conflict.ConflictType == "progress" {
|
if conflict.ConflictType == "progress" {
|
||||||
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerSource, winnerData); err == nil {
|
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerData); err == nil {
|
||||||
appliedTo["progress"] = true
|
appliedTo["progress"] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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{}{
|
resolutionData := map[string]interface{}{
|
||||||
"winner": req.Winner,
|
"winner": req.Winner,
|
||||||
"applied_to": appliedTo,
|
"applied_to": appliedTo,
|
||||||
@@ -294,7 +286,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, response)
|
return c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
|
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, data map[string]interface{}) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
@@ -350,7 +342,7 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
|
|||||||
CurrentPage: currentPage,
|
CurrentPage: currentPage,
|
||||||
TotalPages: totalPages,
|
TotalPages: totalPages,
|
||||||
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
|
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||||
LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
|
LastSyncSource: pgtype.Text{String: "manual", Valid: true},
|
||||||
ViewportY: pgtype.Float8{},
|
ViewportY: pgtype.Float8{},
|
||||||
ScrollPositionX: pgtype.Float8{},
|
ScrollPositionX: pgtype.Float8{},
|
||||||
ScrollPositionY: pgtype.Float8{},
|
ScrollPositionY: pgtype.Float8{},
|
||||||
@@ -362,143 +354,6 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
|
|||||||
return err
|
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 {
|
func (h *ConflictHandler) notifyDevicesOfResolution(mediaItemID pgtype.UUID, data map[string]interface{}) []string {
|
||||||
devices, err := h.db.ListDevicesByType(context.Background(), "koreader")
|
devices, err := h.db.ListDevicesByType(context.Background(), "koreader")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -696,7 +551,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil {
|
if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winnerData); err != nil {
|
||||||
results = append(results, ConflictResult{
|
results = append(results, ConflictResult{
|
||||||
ConflictID: conflictIDStr,
|
ConflictID: conflictIDStr,
|
||||||
Status: "error",
|
Status: "error",
|
||||||
@@ -780,7 +635,7 @@ func (h *ConflictHandler) getHighestProgressSource(conflictData map[string]Confl
|
|||||||
return highestSource, highestData
|
return highestSource, highestData
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
|
func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, data map[string]interface{}) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
@@ -835,8 +690,8 @@ func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype
|
|||||||
CharacterOffset: characterOffset,
|
CharacterOffset: characterOffset,
|
||||||
CurrentPage: currentPage,
|
CurrentPage: currentPage,
|
||||||
TotalPages: totalPages,
|
TotalPages: totalPages,
|
||||||
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
|
LastSyncDevice: pgtype.Text{String: "bulk_resolution", Valid: true},
|
||||||
LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
|
LastSyncSource: pgtype.Text{String: "bulk", Valid: true},
|
||||||
ViewportY: pgtype.Float8{},
|
ViewportY: pgtype.Float8{},
|
||||||
ScrollPositionX: pgtype.Float8{},
|
ScrollPositionX: pgtype.Float8{},
|
||||||
ScrollPositionY: pgtype.Float8{},
|
ScrollPositionY: pgtype.Float8{},
|
||||||
|
|||||||
@@ -100,10 +100,6 @@ type PendingRegistration struct {
|
|||||||
UserID uuid.UUID
|
UserID uuid.UUID
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Approved bool
|
|
||||||
AuthToken string
|
|
||||||
DeviceID [16]byte
|
|
||||||
SyncEndpoints map[string]string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var pendingRegistrations = make(map[string]*PendingRegistration)
|
var pendingRegistrations = make(map[string]*PendingRegistration)
|
||||||
@@ -177,17 +173,7 @@ func (h *DeviceHandler) CheckRegistrationStatus(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if registration.Approved {
|
if registration.UserID == (uuid.UUID{}) {
|
||||||
delete(pendingRegistrations, req.RegistrationID)
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
|
||||||
Status: "approved",
|
|
||||||
AuthToken: registration.AuthToken,
|
|
||||||
DeviceID: registration.DeviceID,
|
|
||||||
SyncEndpoints: registration.SyncEndpoints,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||||
Status: "pending",
|
Status: "pending",
|
||||||
Message: "awaiting user approval",
|
Message: "awaiting user approval",
|
||||||
@@ -195,6 +181,55 @@ func (h *DeviceHandler) CheckRegistrationStatus(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authToken, err := generateDeviceToken()
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
|
||||||
|
}
|
||||||
|
|
||||||
|
userUUID := registration.UserID
|
||||||
|
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
||||||
|
|
||||||
|
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
|
||||||
|
autoSync := pgtype.Bool{Bool: true, Valid: true}
|
||||||
|
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
|
||||||
|
|
||||||
|
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
|
||||||
|
UserID: pgUserID,
|
||||||
|
DeviceName: registration.DeviceName,
|
||||||
|
DeviceType: registration.DeviceType,
|
||||||
|
DeviceIdentifier: registration.DeviceIdentifier,
|
||||||
|
AuthToken: authToken,
|
||||||
|
SyncEnabled: syncEnabled,
|
||||||
|
AutoSync: autoSync,
|
||||||
|
SyncFrequencyMinutes: syncFreq,
|
||||||
|
DeviceMetadata: []byte("{}"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"})
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(pendingRegistrations, req.RegistrationID)
|
||||||
|
|
||||||
|
syncEndpoints := map[string]string{}
|
||||||
|
switch registration.DeviceType {
|
||||||
|
case "koreader":
|
||||||
|
syncEndpoints["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", h.cfg.BaseURL)
|
||||||
|
syncEndpoints["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", h.cfg.BaseURL)
|
||||||
|
syncEndpoints["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", h.cfg.BaseURL)
|
||||||
|
case "kobo":
|
||||||
|
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
|
||||||
|
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||||
|
Status: "approved",
|
||||||
|
AuthToken: authToken,
|
||||||
|
DeviceID: device.ID.Bytes,
|
||||||
|
SyncEndpoints: syncEndpoints,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *DeviceHandler) ListDevices(c *echo.Context) error {
|
func (h *DeviceHandler) ListDevices(c *echo.Context) error {
|
||||||
userID := c.Get("user_id").(string)
|
userID := c.Get("user_id").(string)
|
||||||
userUUID, err := uuid.Parse(userID)
|
userUUID, err := uuid.Parse(userID)
|
||||||
@@ -562,63 +597,14 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if registration.Approved {
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
||||||
"message": "device already approved",
|
|
||||||
"device_name": registration.DeviceName,
|
|
||||||
"device_type": registration.DeviceType,
|
|
||||||
"approved": true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
authToken, err := generateDeviceToken()
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
|
|
||||||
}
|
|
||||||
|
|
||||||
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
|
||||||
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
|
|
||||||
autoSync := pgtype.Bool{Bool: true, Valid: true}
|
|
||||||
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
|
|
||||||
|
|
||||||
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
|
|
||||||
UserID: pgUserID,
|
|
||||||
DeviceName: registration.DeviceName,
|
|
||||||
DeviceType: registration.DeviceType,
|
|
||||||
DeviceIdentifier: registration.DeviceIdentifier,
|
|
||||||
AuthToken: authToken,
|
|
||||||
SyncEnabled: syncEnabled,
|
|
||||||
AutoSync: autoSync,
|
|
||||||
SyncFrequencyMinutes: syncFreq,
|
|
||||||
DeviceMetadata: []byte("{}"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"})
|
|
||||||
}
|
|
||||||
|
|
||||||
syncEndpoints := map[string]string{}
|
|
||||||
switch registration.DeviceType {
|
|
||||||
case "koreader":
|
|
||||||
syncEndpoints["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", h.cfg.BaseURL)
|
|
||||||
syncEndpoints["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", h.cfg.BaseURL)
|
|
||||||
syncEndpoints["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", h.cfg.BaseURL)
|
|
||||||
case "kobo":
|
|
||||||
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
|
|
||||||
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
registration.UserID = userUUID
|
registration.UserID = userUUID
|
||||||
registration.Approved = true
|
|
||||||
registration.AuthToken = authToken
|
|
||||||
registration.DeviceID = device.ID.Bytes
|
|
||||||
registration.SyncEndpoints = syncEndpoints
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"message": "device approved successfully",
|
"message": "device approved successfully",
|
||||||
"device_name": registration.DeviceName,
|
"device_name": registration.DeviceName,
|
||||||
"device_type": registration.DeviceType,
|
"device_type": registration.DeviceType,
|
||||||
"registration_id": registrationID,
|
"registration_id": registrationID,
|
||||||
"approved": true,
|
"approved": true, // Fixed: Add confirmation field for test compatibility
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,9 +624,15 @@ func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
|
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
registrations := []map[string]interface{}{}
|
registrations := []map[string]interface{}{}
|
||||||
for _, reg := range pendingRegistrations {
|
for _, reg := range pendingRegistrations {
|
||||||
if reg.UserID == (uuid.UUID{}) {
|
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
|
||||||
registrations = append(registrations, map[string]interface{}{
|
registrations = append(registrations, map[string]interface{}{
|
||||||
"registration_id": reg.RegistrationID,
|
"registration_id": reg.RegistrationID,
|
||||||
"device_name": reg.DeviceName,
|
"device_name": reg.DeviceName,
|
||||||
@@ -648,7 +640,7 @@ func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[stri
|
|||||||
"device_identifier": reg.DeviceIdentifier,
|
"device_identifier": reg.DeviceIdentifier,
|
||||||
"expires_at": reg.ExpiresAt,
|
"expires_at": reg.ExpiresAt,
|
||||||
"created_at": reg.CreatedAt,
|
"created_at": reg.CreatedAt,
|
||||||
"is_approved": false,
|
"is_approved": reg.UserID != (uuid.UUID{}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-250
@@ -3,9 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
wsync "bookhoard/internal/sync"
|
wsync "bookhoard/internal/sync"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -20,8 +18,6 @@ type KoboHandler struct {
|
|||||||
db *database.Queries
|
db *database.Queries
|
||||||
connManager *wsync.ConnectionManager
|
connManager *wsync.ConnectionManager
|
||||||
progressSvc *wsync.ProgressService
|
progressSvc *wsync.ProgressService
|
||||||
annotationSvc *wsync.AnnotationService
|
|
||||||
libraryService LibraryPathResolver
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
|
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
|
||||||
@@ -32,14 +28,6 @@ func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) {
|
|||||||
h.progressSvc = svc
|
h.progressSvc = svc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *KoboHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
|
||||||
h.annotationSvc = svc
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *KoboHandler) SetLibraryService(svc LibraryPathResolver) {
|
|
||||||
h.libraryService = svc
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapContentIdToBookhoardUUID maps Kobo ContentId to Bookhoard UUID with multiple fallback strategies
|
// mapContentIdToBookhoardUUID maps Kobo ContentId to Bookhoard UUID with multiple fallback strategies
|
||||||
// Enhanced Kobo Sync - ContentId Mapping Logic
|
// Enhanced Kobo Sync - ContentId Mapping Logic
|
||||||
func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId string, deviceID uuid.UUID) (uuid.UUID, error, string) {
|
func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId string, deviceID uuid.UUID) (uuid.UUID, error, string) {
|
||||||
@@ -254,13 +242,6 @@ type KoboSyncStatus struct {
|
|||||||
Status string `json:"Status"`
|
Status string `json:"Status"`
|
||||||
MarkupsSynced int `json:"MarkupsSynced"`
|
MarkupsSynced int `json:"MarkupsSynced"`
|
||||||
BookmarksSynced int `json:"BookmarksSynced"`
|
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 {
|
type KoboServerSyncData struct {
|
||||||
@@ -324,7 +305,7 @@ func (h *KoboHandler) Initialization(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bookmarkCount := 0
|
bookmarkCount := 0
|
||||||
annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
|
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
||||||
MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true},
|
MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true},
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
})
|
})
|
||||||
@@ -416,7 +397,6 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
markupsSynced := 0
|
markupsSynced := 0
|
||||||
bookmarksSynced := 0
|
bookmarksSynced := 0
|
||||||
unlinkedBooks := 0
|
unlinkedBooks := 0
|
||||||
processedBooks := make(map[pgtype.UUID]string)
|
|
||||||
|
|
||||||
for _, readingSync := range req.ReadingSync {
|
for _, readingSync := range req.ReadingSync {
|
||||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
|
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
|
||||||
@@ -426,23 +406,8 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||||
processedBooks[pgMediaUUID] = readingSync.ContentId
|
|
||||||
percentage := readingSync.PercentRead / 100.0
|
percentage := readingSync.PercentRead / 100.0
|
||||||
|
|
||||||
// Kobo only sends a percentage. For fixed-layout & comic formats the page
|
|
||||||
// index is the canonical locator, so derive it from the known page count.
|
|
||||||
var currentPage, totalPages *int
|
|
||||||
if mediaItem, mErr := h.db.GetMediaItem(c.Request().Context(), pgMediaUUID); mErr == nil {
|
|
||||||
if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) || mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) {
|
|
||||||
if mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
|
|
||||||
total := int(mediaItem.PageCount.Int32)
|
|
||||||
page := wsync.PercentageToPage(percentage, total)
|
|
||||||
currentPage = &page
|
|
||||||
totalPages = &total
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.progressSvc != nil {
|
if h.progressSvc != nil {
|
||||||
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||||
MediaItemID: pgMediaUUID,
|
MediaItemID: pgMediaUUID,
|
||||||
@@ -450,8 +415,6 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
Source: "kobo",
|
Source: "kobo",
|
||||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||||
Percentage: &percentage,
|
Percentage: &percentage,
|
||||||
CurrentPage: currentPage,
|
|
||||||
TotalPages: totalPages,
|
|
||||||
DeviceType: "kobo",
|
DeviceType: "kobo",
|
||||||
DeviceName: device.DeviceName,
|
DeviceName: device.DeviceName,
|
||||||
Broadcast: true,
|
Broadcast: true,
|
||||||
@@ -480,32 +443,10 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||||
processedBooks[pgMediaUUID] = bookmarkSync.ContentId
|
|
||||||
|
|
||||||
switch bookmarkSync.BookmarkType {
|
switch bookmarkSync.BookmarkType {
|
||||||
case "annotation":
|
case "annotation":
|
||||||
if bookmarkSync.BookmarkText != "" {
|
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{
|
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||||
MediaItemID: pgMediaUUID,
|
MediaItemID: pgMediaUUID,
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
@@ -516,28 +457,8 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
bookmarksSynced++
|
bookmarksSynced++
|
||||||
}
|
}
|
||||||
}
|
|
||||||
case "bookmark":
|
case "bookmark":
|
||||||
if bookmarkSync.BookmarkText != "" {
|
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{
|
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||||
MediaItemID: pgMediaUUID,
|
MediaItemID: pgMediaUUID,
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
@@ -546,7 +467,6 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
bookmarksSynced++
|
bookmarksSynced++
|
||||||
}
|
}
|
||||||
}
|
|
||||||
case "last-read-place":
|
case "last-read-place":
|
||||||
if bookmarkSync.BookmarkId != "" {
|
if bookmarkSync.BookmarkId != "" {
|
||||||
var epubcfi string
|
var epubcfi string
|
||||||
@@ -559,23 +479,6 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
chapter := bookmarkSync.Chapter
|
chapter := bookmarkSync.Chapter
|
||||||
chapterProgress := 0.5
|
chapterProgress := 0.5
|
||||||
|
|
||||||
var convertedCFI *string
|
|
||||||
var contextText *string
|
|
||||||
|
|
||||||
if epubcfi != "" && h.libraryService != nil {
|
|
||||||
mediaItem, mErr := h.db.GetMediaItem(c.Request().Context(), pgMediaUUID)
|
|
||||||
if mErr == nil {
|
|
||||||
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
|
|
||||||
if formatGroup != wsync.FormatGroupFixedLayout && formatGroup != wsync.FormatGroupComicArchive {
|
|
||||||
convertedCFI, contextText = h.convertKoboCFIToStandard(c, mediaItem, epubcfi)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if convertedCFI != nil {
|
|
||||||
epubcfi = *convertedCFI
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.progressSvc != nil {
|
if h.progressSvc != nil {
|
||||||
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||||
MediaItemID: pgMediaUUID,
|
MediaItemID: pgMediaUUID,
|
||||||
@@ -583,7 +486,6 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
Source: "kobo",
|
Source: "kobo",
|
||||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||||
Epubcfi: &epubcfi,
|
Epubcfi: &epubcfi,
|
||||||
ContextText: contextText,
|
|
||||||
Chapter: &chapter,
|
Chapter: &chapter,
|
||||||
ChapterProgress: &chapterProgress,
|
ChapterProgress: &chapterProgress,
|
||||||
DeviceType: "kobo",
|
DeviceType: "kobo",
|
||||||
@@ -622,32 +524,6 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
BookmarksSynced: bookmarksSynced,
|
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
|
// Include unlinked books count if any
|
||||||
if unlinkedBooks > 0 {
|
if unlinkedBooks > 0 {
|
||||||
// For now, just log it. In production, this should trigger an alert
|
// For now, just log it. In production, this should trigger an alert
|
||||||
@@ -690,27 +566,6 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error {
|
|||||||
switch bookmarkSync.BookmarkType {
|
switch bookmarkSync.BookmarkType {
|
||||||
case "annotation":
|
case "annotation":
|
||||||
if bookmarkSync.BookmarkText != "" {
|
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{
|
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||||
MediaItemID: pgMediaUUID,
|
MediaItemID: pgMediaUUID,
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
@@ -721,28 +576,8 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
bookmarksSynced++
|
bookmarksSynced++
|
||||||
}
|
}
|
||||||
}
|
|
||||||
case "bookmark":
|
case "bookmark":
|
||||||
if bookmarkSync.BookmarkText != "" {
|
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{
|
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||||
MediaItemID: pgMediaUUID,
|
MediaItemID: pgMediaUUID,
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
@@ -753,7 +588,6 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -882,18 +716,6 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
|||||||
|
|
||||||
for _, bookmark := range syncData.Bookmarks {
|
for _, bookmark := range syncData.Bookmarks {
|
||||||
if bookmark.BookmarkType == "bookmark" {
|
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{
|
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||||
MediaItemID: pgMediaUUID,
|
MediaItemID: pgMediaUUID,
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
@@ -901,22 +723,7 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
|||||||
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||||
})
|
})
|
||||||
bookmarksSent++
|
bookmarksSent++
|
||||||
}
|
|
||||||
} else if bookmark.BookmarkType == "annotation" {
|
} 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{
|
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||||
MediaItemID: pgMediaUUID,
|
MediaItemID: pgMediaUUID,
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
@@ -928,23 +735,8 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
|||||||
highlightsSent++
|
highlightsSent++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for _, highlight := range syncData.Highlights {
|
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{
|
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||||
MediaItemID: pgMediaUUID,
|
MediaItemID: pgMediaUUID,
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
@@ -956,7 +748,6 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
|||||||
highlightsSent++
|
highlightsSent++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -971,43 +762,3 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
|||||||
HighlightsSent: highlightsSent,
|
HighlightsSent: highlightsSent,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *KoboHandler) convertKoboCFIToStandard(c *echo.Context, mediaItem database.MediaItems, kepubCFI string) (*string, *string) {
|
|
||||||
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Bookhoard: KEPUB→CFI failed to resolve EPUB path: %v", err)
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if epubPath == "" {
|
|
||||||
log.Printf("Bookhoard: KEPUB→CFI resolved empty EPUB path for %s", mediaItem.FilePath)
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
kepubFormat, err := h.db.GetMediaItemFormatByType(c.Request().Context(), database.GetMediaItemFormatByTypeParams{
|
|
||||||
MediaItemID: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
|
|
||||||
FormatType: "kepub",
|
|
||||||
})
|
|
||||||
if err != nil || !kepubFormat.FilePath.Valid {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
converter := wsync.NewKEPUBCFIConverter(epubPath, kepubFormat.FilePath.String)
|
|
||||||
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.0, "")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Bookhoard: KEPUB→CFI conversion error: %v", err)
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var cfi *string
|
|
||||||
if result.CFI != "" {
|
|
||||||
cfi = &result.CFI
|
|
||||||
log.Printf("Bookhoard: KEPUB→CFI converted (precision=%s)", result.Precision)
|
|
||||||
}
|
|
||||||
|
|
||||||
var ctx *string
|
|
||||||
if result.ExtractedContext != "" {
|
|
||||||
ctx = &result.ExtractedContext
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfi, ctx
|
|
||||||
}
|
|
||||||
|
|||||||
+14
-438
@@ -3,10 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
wsync "bookhoard/internal/sync"
|
wsync "bookhoard/internal/sync"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -20,12 +17,6 @@ type KOReaderHandler struct {
|
|||||||
connManager *wsync.ConnectionManager
|
connManager *wsync.ConnectionManager
|
||||||
queue *wsync.SyncQueueProcessor
|
queue *wsync.SyncQueueProcessor
|
||||||
progressSvc *wsync.ProgressService
|
progressSvc *wsync.ProgressService
|
||||||
annotationSvc *wsync.AnnotationService
|
|
||||||
libraryService LibraryPathResolver
|
|
||||||
}
|
|
||||||
|
|
||||||
type LibraryPathResolver interface {
|
|
||||||
ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler {
|
func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler {
|
||||||
@@ -36,31 +27,6 @@ func (h *KOReaderHandler) SetProgressService(svc *wsync.ProgressService) {
|
|||||||
h.progressSvc = svc
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
type KOReaderProgressRequest struct {
|
type KOReaderProgressRequest struct {
|
||||||
LibraryID *string `json:"library_id,omitempty"`
|
LibraryID *string `json:"library_id,omitempty"`
|
||||||
Books []KOReaderBookProgress `json:"books" validate:"required"`
|
Books []KOReaderBookProgress `json:"books" validate:"required"`
|
||||||
@@ -83,7 +49,6 @@ type KOReaderBookProgress struct {
|
|||||||
Chapter *int `json:"chapter,omitempty"`
|
Chapter *int `json:"chapter,omitempty"`
|
||||||
Character *int64 `json:"character,omitempty"`
|
Character *int64 `json:"character,omitempty"`
|
||||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||||
ContextText *string `json:"context_text,omitempty"`
|
|
||||||
Page *int `json:"page,omitempty"`
|
Page *int `json:"page,omitempty"`
|
||||||
TotalPages *int `json:"total_pages,omitempty"`
|
TotalPages *int `json:"total_pages,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -136,18 +101,11 @@ type KOReaderNote struct {
|
|||||||
type KOReaderSyncResponse struct {
|
type KOReaderSyncResponse struct {
|
||||||
SyncStatus string `json:"sync_status"`
|
SyncStatus string `json:"sync_status"`
|
||||||
BooksSynced int `json:"books_synced"`
|
BooksSynced int `json:"books_synced"`
|
||||||
BookResults []KOReaderBookSyncResult `json:"book_results,omitempty"`
|
|
||||||
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
|
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
|
||||||
Timestamp string `json:"timestamp"`
|
Timestamp string `json:"timestamp"`
|
||||||
DeviceUpdated bool `json:"device_updated"`
|
DeviceUpdated bool `json:"device_updated"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KOReaderBookSyncResult struct {
|
|
||||||
SHA256 string `json:"sha256"`
|
|
||||||
BookUUID string `json:"book_uuid"`
|
|
||||||
Synced bool `json:"synced"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type KOReaderConflict struct {
|
type KOReaderConflict struct {
|
||||||
BookUUID string `json:"book_uuid"`
|
BookUUID string `json:"book_uuid"`
|
||||||
ConflictType string `json:"conflict_type"`
|
ConflictType string `json:"conflict_type"`
|
||||||
@@ -169,7 +127,6 @@ type KOReaderProgressData struct {
|
|||||||
Percentage float64 `json:"percentage"`
|
Percentage float64 `json:"percentage"`
|
||||||
Character *int64 `json:"character,omitempty"`
|
Character *int64 `json:"character,omitempty"`
|
||||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||||
KoreaderXPointer *string `json:"koreader_xpointer,omitempty"`
|
|
||||||
Chapter *int `json:"chapter,omitempty"`
|
Chapter *int `json:"chapter,omitempty"`
|
||||||
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
|
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
|
||||||
Page *int `json:"page,omitempty"`
|
Page *int `json:"page,omitempty"`
|
||||||
@@ -180,8 +137,6 @@ type KOReaderAnnotations struct {
|
|||||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||||
Bookmarks []KOReaderBookmark `json:"bookmarks,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 {
|
type KOReaderLibraryResponse struct {
|
||||||
@@ -232,28 +187,17 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
|
|||||||
|
|
||||||
booksSynced := 0
|
booksSynced := 0
|
||||||
conflicts := []KOReaderConflict{}
|
conflicts := []KOReaderConflict{}
|
||||||
bookResults := []KOReaderBookSyncResult{}
|
|
||||||
|
|
||||||
for _, book := range req.Books {
|
for _, book := range req.Books {
|
||||||
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
|
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
|
||||||
if !mediaItemID.Valid {
|
if !mediaItemID.Valid {
|
||||||
bookResults = append(bookResults, KOReaderBookSyncResult{
|
|
||||||
SHA256: book.SHA256,
|
|
||||||
Synced: false,
|
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.updateProgressForBook(c, device.ID, pgUserID, mediaItemID, book)
|
err := h.updateProgressForBook(c, device.ID, pgUserID, mediaItemID, book)
|
||||||
synced := err == nil
|
if err == nil {
|
||||||
if synced {
|
|
||||||
booksSynced++
|
booksSynced++
|
||||||
}
|
}
|
||||||
bookResults = append(bookResults, KOReaderBookSyncResult{
|
|
||||||
SHA256: book.SHA256,
|
|
||||||
BookUUID: uuid.UUID(mediaItemID.Bytes).String(),
|
|
||||||
Synced: synced,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
||||||
@@ -267,7 +211,6 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
|
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
|
||||||
SyncStatus: "accepted",
|
SyncStatus: "accepted",
|
||||||
BooksSynced: booksSynced,
|
BooksSynced: booksSynced,
|
||||||
BookResults: bookResults,
|
|
||||||
Conflicts: conflicts,
|
Conflicts: conflicts,
|
||||||
Timestamp: time.Now().Format(time.RFC3339),
|
Timestamp: time.Now().Format(time.RFC3339),
|
||||||
DeviceUpdated: true,
|
DeviceUpdated: true,
|
||||||
@@ -277,7 +220,6 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, KOReaderSyncResponse{
|
return c.JSON(http.StatusOK, KOReaderSyncResponse{
|
||||||
SyncStatus: "completed",
|
SyncStatus: "completed",
|
||||||
BooksSynced: booksSynced,
|
BooksSynced: booksSynced,
|
||||||
BookResults: bookResults,
|
|
||||||
Conflicts: conflicts,
|
Conflicts: conflicts,
|
||||||
Timestamp: time.Now().Format(time.RFC3339),
|
Timestamp: time.Now().Format(time.RFC3339),
|
||||||
DeviceUpdated: true,
|
DeviceUpdated: true,
|
||||||
@@ -404,29 +346,17 @@ func (h *KOReaderHandler) createDeviceFileAlias(c *echo.Context, deviceID pgtype
|
|||||||
|
|
||||||
func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.Devices, userID pgtype.UUID, req KOReaderProgressRequest) error {
|
func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.Devices, userID pgtype.UUID, req KOReaderProgressRequest) error {
|
||||||
booksEnqueued := 0
|
booksEnqueued := 0
|
||||||
bookResults := []KOReaderBookSyncResult{}
|
|
||||||
|
|
||||||
for _, book := range req.Books {
|
for _, book := range req.Books {
|
||||||
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, userID, book)
|
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, userID, book)
|
||||||
if !mediaItemID.Valid {
|
if !mediaItemID.Valid {
|
||||||
bookResults = append(bookResults, KOReaderBookSyncResult{
|
|
||||||
SHA256: book.SHA256,
|
|
||||||
Synced: false,
|
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.enqueueProgressForBook(c, device.ID, userID, mediaItemID, book)
|
err := h.enqueueProgressForBook(c, device.ID, userID, mediaItemID, book)
|
||||||
synced := err == nil
|
if err == nil {
|
||||||
if synced {
|
|
||||||
booksEnqueued++
|
booksEnqueued++
|
||||||
}
|
}
|
||||||
h.processBookAnnotations(c.Request().Context(), device.ID, userID, mediaItemID, book)
|
|
||||||
bookResults = append(bookResults, KOReaderBookSyncResult{
|
|
||||||
SHA256: book.SHA256,
|
|
||||||
BookUUID: uuid.UUID(mediaItemID.Bytes).String(),
|
|
||||||
Synced: synced,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
||||||
@@ -436,11 +366,11 @@ func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
|
return c.JSON(http.StatusAccepted, map[string]interface{}{
|
||||||
SyncStatus: "checkpoint_enqueued",
|
"sync_status": "checkpoint_enqueued",
|
||||||
BooksSynced: booksEnqueued,
|
"books_enqueued": booksEnqueued,
|
||||||
BookResults: bookResults,
|
"message": "Sync will be processed in the background",
|
||||||
Timestamp: time.Now().Format(time.RFC3339),
|
"timestamp": time.Now().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,7 +385,6 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp
|
|||||||
UserID: userID,
|
UserID: userID,
|
||||||
Percentage: book.Percentage,
|
Percentage: book.Percentage,
|
||||||
Epubcfi: book.Epubcfi,
|
Epubcfi: book.Epubcfi,
|
||||||
ContextText: book.ContextText,
|
|
||||||
Chapter: book.Chapter,
|
Chapter: book.Chapter,
|
||||||
Character: book.Character,
|
Character: book.Character,
|
||||||
Page: book.Page,
|
Page: book.Page,
|
||||||
@@ -467,102 +396,6 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp
|
|||||||
return h.queue.EnqueueProgress(update)
|
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 {
|
func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
@@ -573,67 +406,13 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
|||||||
}
|
}
|
||||||
|
|
||||||
if h.progressSvc != nil {
|
if h.progressSvc != nil {
|
||||||
epubcfi := book.Epubcfi
|
|
||||||
if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) {
|
|
||||||
log.Printf("Bookhoard: CRE→CFI attempting conversion for %s", *epubcfi)
|
|
||||||
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Bookhoard: CRE→CFI failed to get media item: %v", err)
|
|
||||||
} else if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) ||
|
|
||||||
mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) {
|
|
||||||
// Image-based fixed content (fixed-layout comic EPUBs, PDF,
|
|
||||||
// comic archives) has no extractable text, so CRE→CFI conversion
|
|
||||||
// cannot succeed. The page index (page/total_pages) is the
|
|
||||||
// canonical locator. Keep the incoming xpointer for device-native
|
|
||||||
// restore; the web reader restores by page.
|
|
||||||
log.Printf("Bookhoard: CRE→CFI skipped for %s format", mediaItem.FormatGroup)
|
|
||||||
} else if h.libraryService == nil {
|
|
||||||
log.Printf("Bookhoard: CRE→CFI libraryService is nil, skipping conversion")
|
|
||||||
} else {
|
|
||||||
epubPath, resolveErr := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
|
|
||||||
if resolveErr != nil {
|
|
||||||
log.Printf("Bookhoard: CRE→CFI failed to resolve media path: %v", resolveErr)
|
|
||||||
} else if epubPath == "" {
|
|
||||||
log.Printf("Bookhoard: CRE→CFI resolved empty epub path for %s", mediaItem.FilePath)
|
|
||||||
} else {
|
|
||||||
log.Printf("Bookhoard: CRE→CFI resolved epub path: %s", epubPath)
|
|
||||||
converter := wsync.NewCFIConverter(epubPath)
|
|
||||||
pct := 0.0
|
|
||||||
if book.Percentage >= 0 {
|
|
||||||
pct = book.Percentage
|
|
||||||
}
|
|
||||||
contextText := ""
|
|
||||||
if book.ContextText != nil {
|
|
||||||
contextText = *book.ContextText
|
|
||||||
}
|
|
||||||
result, convErr := converter.ConvertCREToStandard(*epubcfi, pct, contextText)
|
|
||||||
if convErr != nil {
|
|
||||||
log.Printf("Bookhoard: CRE→CFI conversion error: %v", convErr)
|
|
||||||
} else if result != nil {
|
|
||||||
if result.EPUBCFI != "" {
|
|
||||||
convertedCFI := result.EPUBCFI
|
|
||||||
epubcfi = &convertedCFI
|
|
||||||
log.Printf("Bookhoard: CRE→CFI converted to epubcfi: %s", convertedCFI)
|
|
||||||
} else if result.Href != "" {
|
|
||||||
convertedHref := result.Href
|
|
||||||
epubcfi = &convertedHref
|
|
||||||
log.Printf("Bookhoard: CRE→CFI converted to href: %s", convertedHref)
|
|
||||||
} else {
|
|
||||||
log.Printf("Bookhoard: CRE→CFI conversion: %s precision for %s", result.Precision, *epubcfi)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveReq := wsync.SaveProgressRequest{
|
saveReq := wsync.SaveProgressRequest{
|
||||||
MediaItemID: mediaItemID,
|
MediaItemID: mediaItemID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Source: "koreader",
|
Source: "koreader",
|
||||||
DeviceID: deviceID,
|
DeviceID: deviceID,
|
||||||
Percentage: &book.Percentage,
|
Percentage: &book.Percentage,
|
||||||
Epubcfi: epubcfi,
|
Epubcfi: book.Epubcfi,
|
||||||
ContextText: book.ContextText,
|
|
||||||
Chapter: book.Chapter,
|
Chapter: book.Chapter,
|
||||||
CharacterOffset: book.Character,
|
CharacterOffset: book.Character,
|
||||||
CurrentPage: book.Page,
|
CurrentPage: book.Page,
|
||||||
@@ -644,12 +423,8 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err := h.progressSvc.SaveProgress(ctx, saveReq)
|
_, err := h.progressSvc.SaveProgress(ctx, saveReq)
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
|
_, err := h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
|
||||||
MediaItemID: mediaItemID,
|
MediaItemID: mediaItemID,
|
||||||
@@ -684,8 +459,6 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,20 +523,9 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
|||||||
Percentage: progress.Percentage.Float64,
|
Percentage: progress.Percentage.Float64,
|
||||||
}
|
}
|
||||||
|
|
||||||
// CFI/xpointer are meaningless for image-based fixed-layout content; the
|
|
||||||
// page index is the canonical locator. Only return them for reflowable docs.
|
|
||||||
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
|
|
||||||
isFixed := formatGroup == wsync.FormatGroupFixedLayout ||
|
|
||||||
formatGroup == wsync.FormatGroupComicArchive
|
|
||||||
|
|
||||||
if !isFixed {
|
|
||||||
if progress.Epubcfi.Valid {
|
if progress.Epubcfi.Valid {
|
||||||
progressData.Epubcfi = &progress.Epubcfi.String
|
progressData.Epubcfi = &progress.Epubcfi.String
|
||||||
}
|
}
|
||||||
if progress.Epubcfi.Valid && wsync.IsStandardEPUBCFI(progress.Epubcfi.String) {
|
|
||||||
h.convertCFIToXPointer(c, mediaItem, progress, &progressData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if progress.Chapter.Valid {
|
if progress.Chapter.Valid {
|
||||||
progress := int(progress.Chapter.Int32)
|
progress := int(progress.Chapter.Int32)
|
||||||
progressData.Chapter = &progress
|
progressData.Chapter = &progress
|
||||||
@@ -785,7 +547,7 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
|||||||
progressData.TotalPages = &progress
|
progressData.TotalPages = &progress
|
||||||
}
|
}
|
||||||
|
|
||||||
annotations, err := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
|
annotations, err := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
||||||
MediaItemID: pgBookUUID,
|
MediaItemID: pgBookUUID,
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
})
|
})
|
||||||
@@ -798,29 +560,13 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
|||||||
|
|
||||||
for _, ann := range annotations {
|
for _, ann := range annotations {
|
||||||
if ann.AnnotationType == "highlight" {
|
if ann.AnnotationType == "highlight" {
|
||||||
pos0 := ann.StartPosition.String
|
annotationsResponse.Highlights = append(annotationsResponse.Highlights, KOReaderHighlight{
|
||||||
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,
|
Text: ann.SelectionText,
|
||||||
Pos0: pos0,
|
Pos0: ann.StartPosition.String,
|
||||||
Pos1: pos1,
|
Pos1: ann.EndPosition.String,
|
||||||
Color: ann.Color.String,
|
Color: ann.Color.String,
|
||||||
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
|
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" {
|
} else if ann.AnnotationType == "note" {
|
||||||
annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{
|
annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{
|
||||||
Text: ann.SelectionText,
|
Text: ann.SelectionText,
|
||||||
@@ -830,52 +576,6 @@ 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"
|
lastSync := "never"
|
||||||
if progress.LastSyncTimestamp.Valid {
|
if progress.LastSyncTimestamp.Valid {
|
||||||
lastSync = progress.LastSyncTimestamp.Time.Format(time.RFC3339)
|
lastSync = progress.LastSyncTimestamp.Time.Format(time.RFC3339)
|
||||||
@@ -893,55 +593,6 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, metadata)
|
return c.JSON(http.StatusOK, metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem database.MediaItems, progress database.GetUniversalProgressRow, progressData *KOReaderProgressData) {
|
|
||||||
if h.libraryService == nil {
|
|
||||||
log.Printf("Bookhoard: CFI→CRE libraryService is nil, skipping reverse conversion")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Bookhoard: CFI→CRE failed to resolve media path: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if epubPath == "" {
|
|
||||||
log.Printf("Bookhoard: CFI→CRE resolved empty epub path for %s", mediaItem.FilePath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
converter := wsync.NewCFIConverter(epubPath)
|
|
||||||
contextText := ""
|
|
||||||
if progress.ContextText.Valid {
|
|
||||||
contextText = progress.ContextText.String
|
|
||||||
}
|
|
||||||
pct := progress.Percentage.Float64
|
|
||||||
|
|
||||||
result, err := converter.ConvertStandardToCRE(progress.Epubcfi.String, pct, contextText)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Bookhoard: CFI→CRE conversion error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if result != nil && result.XPointer != "" {
|
|
||||||
progressData.KoreaderXPointer = &result.XPointer
|
|
||||||
log.Printf("Bookhoard: CFI→CRE converted to XPointer: %s", result.XPointer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
|
||||||
device := c.Get("device").(database.Devices)
|
device := c.Get("device").(database.Devices)
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
@@ -981,7 +632,7 @@ func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
|
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
||||||
MediaItemID: pgItemUUID,
|
MediaItemID: pgItemUUID,
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
})
|
})
|
||||||
@@ -1081,26 +732,6 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
|||||||
position = fmt.Sprintf("page:%d", bookmark.Page)
|
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{
|
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
||||||
MediaItemID: mediaItemID,
|
MediaItemID: mediaItemID,
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
@@ -1112,7 +743,6 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
|||||||
bookmarksSynced++
|
bookmarksSynced++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for _, note := range req.Notes {
|
for _, note := range req.Notes {
|
||||||
mediaItemID := pgBookUUID
|
mediaItemID := pgBookUUID
|
||||||
@@ -1132,25 +762,6 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
|||||||
position = fmt.Sprintf("page:%d", note.Page)
|
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{
|
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
||||||
MediaItemID: mediaItemID,
|
MediaItemID: mediaItemID,
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
@@ -1162,7 +773,6 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
|||||||
notesSynced++
|
notesSynced++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for _, highlight := range req.Highlights {
|
for _, highlight := range req.Highlights {
|
||||||
mediaItemID := pgBookUUID
|
mediaItemID := pgBookUUID
|
||||||
@@ -1187,39 +797,6 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
|||||||
color = highlight.Color
|
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{
|
_, err := h.db.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
|
||||||
MediaItemID: mediaItemID,
|
MediaItemID: mediaItemID,
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
@@ -1233,7 +810,6 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
|||||||
highlightsSynced++
|
highlightsSynced++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"sync_status": "completed",
|
"sync_status": "completed",
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -137,7 +136,6 @@ type MediaHandler struct {
|
|||||||
libraryService *services.LibraryService
|
libraryService *services.LibraryService
|
||||||
searchService *services.SearchService
|
searchService *services.SearchService
|
||||||
progressSvc *wsync.ProgressService
|
progressSvc *wsync.ProgressService
|
||||||
annotationSvc *wsync.AnnotationService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler {
|
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler {
|
||||||
@@ -156,10 +154,6 @@ func (mh *MediaHandler) SetProgressService(svc *wsync.ProgressService) {
|
|||||||
mh.progressSvc = svc
|
mh.progressSvc = svc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
|
||||||
mh.annotationSvc = svc
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
|
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
|
||||||
bookUUID, err := uuid.Parse(c.Param("uuid"))
|
bookUUID, err := uuid.Parse(c.Param("uuid"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -981,7 +975,6 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
|||||||
CurrentPage *int32 `json:"current_page"`
|
CurrentPage *int32 `json:"current_page"`
|
||||||
TotalPages *int32 `json:"total_pages"`
|
TotalPages *int32 `json:"total_pages"`
|
||||||
Epubcfi *string `json:"epubcfi"`
|
Epubcfi *string `json:"epubcfi"`
|
||||||
ContextText *string `json:"context_text"`
|
|
||||||
Percentage *float64 `json:"percentage"`
|
Percentage *float64 `json:"percentage"`
|
||||||
Chapter *int `json:"chapter"`
|
Chapter *int `json:"chapter"`
|
||||||
ChapterProgress *float64 `json:"chapter_progress"`
|
ChapterProgress *float64 `json:"chapter_progress"`
|
||||||
@@ -1003,7 +996,6 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
|||||||
DeviceID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
DeviceID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
Percentage: req.Percentage,
|
Percentage: req.Percentage,
|
||||||
Epubcfi: req.Epubcfi,
|
Epubcfi: req.Epubcfi,
|
||||||
ContextText: req.ContextText,
|
|
||||||
CharacterOffset: req.CharacterOffset,
|
CharacterOffset: req.CharacterOffset,
|
||||||
Chapter: req.Chapter,
|
Chapter: req.Chapter,
|
||||||
ChapterProgress: req.ChapterProgress,
|
ChapterProgress: req.ChapterProgress,
|
||||||
@@ -1026,16 +1018,6 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
|||||||
saveReq.TotalPages = &tp
|
saveReq.TotalPages = &tp
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Percentage != nil && *req.Percentage < 0.005 {
|
|
||||||
existing, err := mh.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
|
|
||||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
||||||
})
|
|
||||||
if err == nil && existing.Percentage.Valid && existing.Percentage.Float64 > 0.01 {
|
|
||||||
return c.JSON(http.StatusOK, map[string]string{"status": "ignored"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := mh.progressSvc.SaveProgress(c.Request().Context(), saveReq)
|
result, err := mh.progressSvc.SaveProgress(c.Request().Context(), saveReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
@@ -1394,23 +1376,7 @@ func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
var note database.MediaNotes
|
note, err := mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||||
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},
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
@@ -1419,7 +1385,6 @@ func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusCreated, note)
|
return c.JSON(http.StatusCreated, note)
|
||||||
}
|
}
|
||||||
@@ -1479,11 +1444,7 @@ func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
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})
|
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
@@ -1552,29 +1513,9 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error {
|
|||||||
color = req.Color
|
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{
|
highlight, err := mh.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||||
MediaItemID: pgMediaID,
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
UserID: pgUserID,
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
SelectionText: req.SelectionText,
|
SelectionText: req.SelectionText,
|
||||||
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
|
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
|
||||||
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
|
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
|
||||||
@@ -1660,16 +1601,7 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
|
||||||
}
|
}
|
||||||
|
|
||||||
pgHighlightID := pgtype.UUID{Bytes: highlightUUID, Valid: true}
|
err = mh.db.DeleteMediaHighlight(c.Request().Context(), 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 {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
@@ -1791,10 +1723,6 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
|||||||
"results": []interface{}{},
|
"results": []interface{}{},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for i := range results {
|
|
||||||
resolved := utils.ResolveMediaURL(results[i].LibraryID, results[i].CoverImagePath)
|
|
||||||
results[i].CoverImagePath = pgtype.Text{String: resolved, Valid: resolved != ""}
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, results)
|
return c.JSON(http.StatusOK, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-168
@@ -49,109 +49,6 @@ func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) {
|
|||||||
return baseURL.Value, opdsBaseURL, nil
|
return baseURL.Value, opdsBaseURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *OPDSHandler) getAuthToken(c *echo.Context) string {
|
|
||||||
token := c.QueryParam("token")
|
|
||||||
if token == "" {
|
|
||||||
token = strings.TrimPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
|
|
||||||
}
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
func appendToken(url, token string) string {
|
|
||||||
if token == "" {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
if strings.Contains(url, "?") {
|
|
||||||
return url + "&token=" + token
|
|
||||||
}
|
|
||||||
return url + "?token=" + token
|
|
||||||
}
|
|
||||||
|
|
||||||
// catalogMediaType is the OPDS media type for an acquisition catalog feed.
|
|
||||||
const catalogMediaType = "application/atom+xml;profile=opds-catalog;kind=acquisition"
|
|
||||||
|
|
||||||
// addCatalogPaginationLinks adds OPDS pagination links (self, start, first,
|
|
||||||
// previous, next, last) and OpenSearch paging metadata (totalResults,
|
|
||||||
// itemsPerPage, startIndex) to a feed based on the current page position.
|
|
||||||
// catalogBase is the device catalog URL without query parameters. The token
|
|
||||||
// (device auth) is appended to every generated link.
|
|
||||||
func addCatalogPaginationLinks(feed *opds.Feed, catalogBase string, pageNum, perPageNum, totalItems int, token string) {
|
|
||||||
totalPages := 0
|
|
||||||
if totalItems > 0 {
|
|
||||||
totalPages = (totalItems + perPageNum - 1) / perPageNum
|
|
||||||
}
|
|
||||||
startIdx := (pageNum - 1) * perPageNum
|
|
||||||
|
|
||||||
pagedURL := func(page int) string {
|
|
||||||
return appendToken(fmt.Sprintf("%s?page=%d&per_page=%d", catalogBase, page, perPageNum), token)
|
|
||||||
}
|
|
||||||
|
|
||||||
// self reflects the current page; start/first point to the first page
|
|
||||||
feed.AddLink(pagedURL(pageNum), catalogMediaType, "self")
|
|
||||||
feed.AddLink(pagedURL(1), catalogMediaType, "start")
|
|
||||||
feed.AddLink(pagedURL(1), catalogMediaType, "first")
|
|
||||||
if totalPages > 0 {
|
|
||||||
feed.AddLink(pagedURL(totalPages), catalogMediaType, "last")
|
|
||||||
}
|
|
||||||
if pageNum > 1 {
|
|
||||||
feed.AddLink(pagedURL(pageNum-1), catalogMediaType, "previous")
|
|
||||||
}
|
|
||||||
if pageNum < totalPages {
|
|
||||||
feed.AddLink(pagedURL(pageNum+1), catalogMediaType, "next")
|
|
||||||
}
|
|
||||||
|
|
||||||
feed.SetPagination(totalItems, perPageNum, startIdx+1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveMimeType returns the mime type for a media item, preferring the stored
|
|
||||||
// mime_type, then format_mimetype, and finally falling back to EPUB.
|
|
||||||
func resolveMimeType(mime, formatMime pgtype.Text) string {
|
|
||||||
if mime.Valid && mime.String != "" {
|
|
||||||
return mime.String
|
|
||||||
}
|
|
||||||
if formatMime.Valid && formatMime.String != "" {
|
|
||||||
return formatMime.String
|
|
||||||
}
|
|
||||||
return "application/epub+zip"
|
|
||||||
}
|
|
||||||
|
|
||||||
// isComicArchive reports whether a format group represents a comic/manga
|
|
||||||
// archive (cbz/cbr/cb7/cbt). Comic archives are served in their native format
|
|
||||||
// and should not be offered as EPUB/KEPUB/PDF conversions.
|
|
||||||
func isComicArchive(formatGroup string) bool {
|
|
||||||
return strings.EqualFold(formatGroup, "comic_archive")
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatLabelFromPath derives a short format label (e.g. "epub", "cbz") from a
|
|
||||||
// file path's extension, defaulting to "epub" when it cannot be determined.
|
|
||||||
func formatLabelFromPath(path string) string {
|
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
|
||||||
switch ext {
|
|
||||||
case ".epub":
|
|
||||||
return "epub"
|
|
||||||
case ".pdf":
|
|
||||||
return "pdf"
|
|
||||||
case ".cbz":
|
|
||||||
return "cbz"
|
|
||||||
case ".cbr":
|
|
||||||
return "cbr"
|
|
||||||
case ".cb7":
|
|
||||||
return "cb7"
|
|
||||||
case ".cbt":
|
|
||||||
return "cbt"
|
|
||||||
case ".mobi":
|
|
||||||
return "mobi"
|
|
||||||
case ".azw", ".azw3":
|
|
||||||
return "azw3"
|
|
||||||
case ".txt":
|
|
||||||
return "txt"
|
|
||||||
case "":
|
|
||||||
return "epub"
|
|
||||||
default:
|
|
||||||
return strings.TrimPrefix(ext, ".")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDeviceCatalog returns the OPDS catalog feed for a device
|
// GetDeviceCatalog returns the OPDS catalog feed for a device
|
||||||
func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||||
deviceID := c.Param("deviceId")
|
deviceID := c.Param("deviceId")
|
||||||
@@ -242,17 +139,13 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
"Bookhoard Library",
|
"Bookhoard Library",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Feed links, including OPDS pagination links (first/previous/next/last) and
|
// Add feed links
|
||||||
// OpenSearch paging metadata (totalResults/itemsPerPage/startIndex).
|
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||||
token := h.getAuthToken(c)
|
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||||
catalogBase := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||||
addCatalogPaginationLinks(feed, catalogBase, pageNum, perPageNum, totalItems, token)
|
|
||||||
|
|
||||||
// OpenSearch: the search link points to an OpenSearch description document
|
searchURL := fmt.Sprintf("%s/opds/devices/%s/search", opdsBaseURL, deviceID)
|
||||||
// (served by the same /search endpoint when no query is supplied) so that
|
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "search")
|
||||||
// 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/opensearchdescription+xml", "search")
|
|
||||||
|
|
||||||
// Add entries
|
// Add entries
|
||||||
for _, item := range allItems {
|
for _, item := range allItems {
|
||||||
@@ -277,21 +170,16 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
entry.SetSummary(item.Description.String)
|
entry.SetSummary(item.Description.String)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add acquisition link using the item's real mime type
|
// Add acquisition links
|
||||||
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
|
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID)
|
||||||
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
|
entry.AddAcquisitionLink(downloadURL, "application/epub+zip")
|
||||||
|
|
||||||
// Only offer reflowable conversions (kepub/pdf) for ebooks; comic
|
// Add format variants
|
||||||
// archives are served as-is in their native format.
|
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL)
|
||||||
if !isComicArchive(item.FormatGroup) {
|
|
||||||
if device.DeviceType == "kobo" {
|
|
||||||
kepubURL := downloadURL + "&format=kepub"
|
|
||||||
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
||||||
}
|
|
||||||
|
|
||||||
pdfURL := downloadURL + "&format=pdf"
|
pdfURL := fmt.Sprintf("%s?format=pdf", downloadURL)
|
||||||
entry.AddAlternateLink(pdfURL, "application/pdf")
|
entry.AddAlternateLink(pdfURL, "application/pdf")
|
||||||
}
|
|
||||||
|
|
||||||
// Add canonical identifier
|
// Add canonical identifier
|
||||||
entry.SetIdentifier(bookUUID)
|
entry.SetIdentifier(bookUUID)
|
||||||
@@ -325,17 +213,16 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
return c.String(http.StatusOK, xmlString)
|
return c.String(http.StatusOK, xmlString)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchDeviceCatalog searches the OPDS catalog for a device.
|
// SearchDeviceCatalog searches the OPDS catalog for a device
|
||||||
//
|
|
||||||
// When no "q" query parameter is supplied it returns an OpenSearch description
|
|
||||||
// document (application/opensearchdescription+xml) so that OPDS clients such as
|
|
||||||
// KOReader can discover the search URL template (which contains the
|
|
||||||
// {searchTerms} placeholder). When "q" is supplied it returns an OPDS
|
|
||||||
// acquisition feed of matching books.
|
|
||||||
func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||||
deviceID := c.Param("deviceId")
|
deviceID := c.Param("deviceId")
|
||||||
|
|
||||||
query := c.QueryParam("q")
|
query := c.QueryParam("q")
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Missing search query"))
|
||||||
|
}
|
||||||
|
|
||||||
// Get base URLs
|
// Get base URLs
|
||||||
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
|
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -356,30 +243,12 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
|||||||
|
|
||||||
// Get user's visible libraries
|
// Get user's visible libraries
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
|
|
||||||
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||||
}
|
}
|
||||||
|
|
||||||
token := h.getAuthToken(c)
|
|
||||||
|
|
||||||
// No query: serve the OpenSearch description document so clients can learn
|
|
||||||
// the search template (contains the {searchTerms} placeholder).
|
|
||||||
if query == "" {
|
|
||||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token)
|
|
||||||
desc := opds.NewSearchDescription(
|
|
||||||
"Bookhoard",
|
|
||||||
"Search the Bookhoard library",
|
|
||||||
searchURL,
|
|
||||||
)
|
|
||||||
xmlString, err := desc.GenerateXMLString()
|
|
||||||
if err != nil {
|
|
||||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate search description"))
|
|
||||||
}
|
|
||||||
c.Response().Header().Set("Content-Type", "application/opensearchdescription+xml")
|
|
||||||
return c.String(http.StatusOK, xmlString)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search media items
|
// Search media items
|
||||||
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
|
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
|
||||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
@@ -398,14 +267,11 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Add feed links
|
// Add feed links
|
||||||
catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
|
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||||
feed.AddLink(catalogURL, catalogMediaType, "start")
|
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||||
|
|
||||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q=%s", opdsBaseURL, deviceID, query), token)
|
searchURL := fmt.Sprintf("%s/opds/devices/%s/search?q=%s", opdsBaseURL, deviceID, query)
|
||||||
feed.AddLink(searchURL, catalogMediaType, "self")
|
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||||
|
|
||||||
// OpenSearch paging metadata (search results are a single page)
|
|
||||||
feed.SetPagination(len(allItems), len(allItems), 1)
|
|
||||||
|
|
||||||
// Add entries (same as catalog)
|
// Add entries (same as catalog)
|
||||||
userUUID := uuid.UUID(userID)
|
userUUID := uuid.UUID(userID)
|
||||||
@@ -430,15 +296,11 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
|||||||
entry.SetSummary(item.Description.String)
|
entry.SetSummary(item.Description.String)
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
|
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID)
|
||||||
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
|
entry.AddAcquisitionLink(downloadURL, "application/epub+zip")
|
||||||
|
|
||||||
// Only offer kepub conversion for ebooks; comic archives are served
|
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL)
|
||||||
// as-is in their native format.
|
|
||||||
if !isComicArchive(item.FormatGroup) && device.DeviceType == "kobo" {
|
|
||||||
kepubURL := downloadURL + "&format=kepub"
|
|
||||||
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
||||||
}
|
|
||||||
|
|
||||||
entry.SetIdentifier(bookUUID)
|
entry.SetIdentifier(bookUUID)
|
||||||
|
|
||||||
@@ -737,7 +599,7 @@ func (h *OPDSHandler) GetDeviceNavigation(c *echo.Context) error {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Add feed links
|
// Add feed links
|
||||||
catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||||
|
|
||||||
@@ -820,14 +682,14 @@ func (h *OPDSHandler) ListFormats(c *echo.Context) error {
|
|||||||
|
|
||||||
formatList := []FormatInfo{}
|
formatList := []FormatInfo{}
|
||||||
|
|
||||||
// Add the primary/native format (always available if media item exists)
|
// Add EPUB format (always available if media item exists)
|
||||||
fileSize := int64(0)
|
fileSize := int64(0)
|
||||||
if mediaItem.FileSize.Valid {
|
if mediaItem.FileSize.Valid {
|
||||||
fileSize = mediaItem.FileSize.Int64
|
fileSize = mediaItem.FileSize.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
formatList = append(formatList, FormatInfo{
|
formatList = append(formatList, FormatInfo{
|
||||||
FormatType: formatLabelFromPath(mediaItem.FilePath),
|
FormatType: "epub",
|
||||||
FilePath: mediaItem.FilePath,
|
FilePath: mediaItem.FilePath,
|
||||||
FileSha256: func() string {
|
FileSha256: func() string {
|
||||||
if mediaItem.FileSha256.Valid {
|
if mediaItem.FileSha256.Valid {
|
||||||
@@ -929,7 +791,7 @@ func (h *OPDSHandler) RegisterOPDS(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create OPDS token"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create OPDS token"})
|
||||||
}
|
}
|
||||||
|
|
||||||
catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"opds_token": map[string]interface{}{
|
"opds_token": map[string]interface{}{
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -128,8 +128,8 @@ func (h *Handler) StartScanner(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the folder paths (watch=true: this long-lived scanner reads events)
|
// Set the folder paths
|
||||||
if err := h.scanner.SetFolders(req.FolderPaths, true); err != nil {
|
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ func (h *Handler) StartWatchModeForLibrary(ctx context.Context, libraryID pgtype
|
|||||||
}
|
}
|
||||||
|
|
||||||
scanner := services.NewMediaScanner(h.db)
|
scanner := services.NewMediaScanner(h.db)
|
||||||
if err := scanner.SetFolders(folderPaths, true); err != nil {
|
if err := scanner.SetFolders(folderPaths); err != nil {
|
||||||
return fmt.Errorf("failed to set scanner folders: %v", err)
|
return fmt.Errorf("failed to set scanner folders: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -417,25 +417,6 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if newBaseURL, ok := req["base_url"]; ok && newBaseURL != "" {
|
|
||||||
derivedConfigs := map[string]string{
|
|
||||||
"opds_base_url": newBaseURL + "/opds",
|
|
||||||
"api_base_url": newBaseURL + "/api",
|
|
||||||
}
|
|
||||||
for derivedKey, derivedValue := range derivedConfigs {
|
|
||||||
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
|
|
||||||
Key: derivedKey,
|
|
||||||
Value: derivedValue,
|
|
||||||
UpdatedBy: pgUserID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
||||||
"error": fmt.Sprintf("failed to update derived config key: %s", derivedKey),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for HTMX request
|
// Check for HTMX request
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
// Fetch updated base_url for template
|
// Fetch updated base_url for template
|
||||||
|
|||||||
+4
-85
@@ -13,21 +13,17 @@ type Feed struct {
|
|||||||
Xmlns string `xml:"xmlns,attr"`
|
Xmlns string `xml:"xmlns,attr"`
|
||||||
OpdsNS string `xml:"xmlns:opds,attr"`
|
OpdsNS string `xml:"xmlns:opds,attr"`
|
||||||
DcNS string `xml:"xmlns:dc,attr"`
|
DcNS string `xml:"xmlns:dc,attr"`
|
||||||
OpenSearchNS string `xml:"xmlns:opensearch,attr,omitempty"`
|
|
||||||
ID string `xml:"id"`
|
ID string `xml:"id"`
|
||||||
Title string `xml:"title"`
|
Title string `xml:"title"`
|
||||||
Updated string `xml:"updated"`
|
Updated string `xml:"updated"`
|
||||||
Links []Link `xml:"link"`
|
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"`
|
Entries []Entry `xml:"entry"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
ID string `xml:"id"`
|
ID string `xml:"id"`
|
||||||
Title string `xml:"title"`
|
Title string `xml:"dc:title"`
|
||||||
Author *Author `xml:"author,omitempty"`
|
Creator string `xml:"dc:creator,omitempty"`
|
||||||
Updated string `xml:"updated"`
|
Updated string `xml:"updated"`
|
||||||
Summary string `xml:"summary,omitempty"`
|
Summary string `xml:"summary,omitempty"`
|
||||||
Links []Link `xml:"link"`
|
Links []Link `xml:"link"`
|
||||||
@@ -36,12 +32,6 @@ type Entry struct {
|
|||||||
Categories []Category `xml:"category,omitempty"`
|
Categories []Category `xml:"category,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Author struct {
|
|
||||||
XMLName xml.Name `xml:"author"`
|
|
||||||
Name string `xml:"name"`
|
|
||||||
URI string `xml:"uri,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Link struct {
|
type Link struct {
|
||||||
Href string `xml:"href,attr"`
|
Href string `xml:"href,attr"`
|
||||||
Type string `xml:"type,attr"`
|
Type string `xml:"type,attr"`
|
||||||
@@ -71,7 +61,6 @@ func NewFeed(feedID, title string) *Feed {
|
|||||||
Xmlns: "http://www.w3.org/2005/Atom",
|
Xmlns: "http://www.w3.org/2005/Atom",
|
||||||
OpdsNS: "http://opds-spec.org/2010/",
|
OpdsNS: "http://opds-spec.org/2010/",
|
||||||
DcNS: "http://purl.org/dc/elements/1.1/",
|
DcNS: "http://purl.org/dc/elements/1.1/",
|
||||||
OpenSearchNS: "http://a9.com/-/spec/opensearch/1.1/",
|
|
||||||
ID: feedID,
|
ID: feedID,
|
||||||
Title: title,
|
Title: title,
|
||||||
Updated: now,
|
Updated: now,
|
||||||
@@ -80,17 +69,6 @@ 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
|
// AddLink adds a link to the feed
|
||||||
func (f *Feed) AddLink(href, linkType, rel string) {
|
func (f *Feed) AddLink(href, linkType, rel string) {
|
||||||
f.Links = append(f.Links, Link{
|
f.Links = append(f.Links, Link{
|
||||||
@@ -107,17 +85,14 @@ func (f *Feed) AddEntry(entry Entry) {
|
|||||||
|
|
||||||
// NewEntry creates a new OPDS entry
|
// NewEntry creates a new OPDS entry
|
||||||
func NewEntry(id, title, creator, updated string) Entry {
|
func NewEntry(id, title, creator, updated string) Entry {
|
||||||
e := Entry{
|
return Entry{
|
||||||
ID: id,
|
ID: id,
|
||||||
Title: title,
|
Title: title,
|
||||||
|
Creator: creator,
|
||||||
Updated: updated,
|
Updated: updated,
|
||||||
Links: []Link{},
|
Links: []Link{},
|
||||||
Metadata: []Meta{},
|
Metadata: []Meta{},
|
||||||
}
|
}
|
||||||
if creator != "" {
|
|
||||||
e.Author = &Author{Name: creator}
|
|
||||||
}
|
|
||||||
return e
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddAcquisitionLink adds an acquisition link to the entry
|
// AddAcquisitionLink adds an acquisition link to the entry
|
||||||
@@ -194,62 +169,6 @@ func (f *Feed) GenerateXMLString() (string, error) {
|
|||||||
return xml.Header + string(output), nil
|
return xml.Header + string(output), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenSearchUrl is a single <Url> element in an OpenSearch description.
|
|
||||||
type OpenSearchUrl struct {
|
|
||||||
XMLName xml.Name `xml:"Url"`
|
|
||||||
Type string `xml:"type,attr"`
|
|
||||||
Template string `xml:"template,attr"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenSearchDescription is an OpenSearch description document used by OPDS
|
|
||||||
// clients (e.g. KOReader) to discover how to perform catalog searches. Clients
|
|
||||||
// fetch this document at the catalog's rel="search" link, then substitute
|
|
||||||
// {searchTerms} in the Url template to execute a query.
|
|
||||||
type OpenSearchDescription struct {
|
|
||||||
XMLName xml.Name `xml:"OpenSearchDescription"`
|
|
||||||
Xmlns string `xml:"xmlns,attr"`
|
|
||||||
ShortName string `xml:"ShortName"`
|
|
||||||
Description string `xml:"Description"`
|
|
||||||
InputEncoding string `xml:"InputEncoding"`
|
|
||||||
OutputEncoding string `xml:"OutputEncoding"`
|
|
||||||
Url OpenSearchUrl `xml:"Url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSearchDescription creates an OpenSearch description document whose Url
|
|
||||||
// template points clients back to the search results endpoint. The template
|
|
||||||
// must contain the {searchTerms} placeholder.
|
|
||||||
func NewSearchDescription(shortName, description, template string) *OpenSearchDescription {
|
|
||||||
return &OpenSearchDescription{
|
|
||||||
Xmlns: "http://a9.com/-/spec/opensearch/1.1/",
|
|
||||||
ShortName: shortName,
|
|
||||||
Description: description,
|
|
||||||
InputEncoding: "UTF-8",
|
|
||||||
OutputEncoding: "UTF-8",
|
|
||||||
Url: OpenSearchUrl{
|
|
||||||
Type: "application/atom+xml;profile=opds-catalog;kind=acquisition",
|
|
||||||
Template: template,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateXML generates the OpenSearch description XML
|
|
||||||
func (d *OpenSearchDescription) GenerateXML() ([]byte, error) {
|
|
||||||
output, err := xml.MarshalIndent(d, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to marshal OpenSearch description: %w", err)
|
|
||||||
}
|
|
||||||
return output, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateXMLString generates the OpenSearch description XML as a string
|
|
||||||
func (d *OpenSearchDescription) GenerateXMLString() (string, error) {
|
|
||||||
output, err := d.GenerateXML()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return xml.Header + string(output), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewErrorFeed creates an error feed
|
// NewErrorFeed creates an error feed
|
||||||
func NewErrorFeed(message string) *Feed {
|
func NewErrorFeed(message string) *Feed {
|
||||||
feed := NewFeed(
|
feed := NewFeed(
|
||||||
|
|||||||
+4
-105
@@ -71,8 +71,8 @@ func TestNewEntry(t *testing.T) {
|
|||||||
t.Errorf("expected Title to be 'Test Title', got '%s'", entry.Title)
|
t.Errorf("expected Title to be 'Test Title', got '%s'", entry.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.Author == nil || entry.Author.Name != "Test Author" {
|
if entry.Creator != "Test Author" {
|
||||||
t.Errorf("expected Author.Name to be 'Test Author', got %v", entry.Author)
|
t.Errorf("expected Creator to be 'Test Author', got '%s'", entry.Creator)
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.Updated != "2023-01-01T00:00:00Z" {
|
if entry.Updated != "2023-01-01T00:00:00Z" {
|
||||||
@@ -201,8 +201,8 @@ func TestFeedGenerateXML(t *testing.T) {
|
|||||||
`<title>Test Feed</title>`,
|
`<title>Test Feed</title>`,
|
||||||
`<entry>`,
|
`<entry>`,
|
||||||
`<id>urn:uuid:book-id</id>`,
|
`<id>urn:uuid:book-id</id>`,
|
||||||
`<title>Test Book</title>`,
|
`<dc:title>Test Book</dc:title>`,
|
||||||
`<name>Test Author</name>`,
|
`<dc:creator>Test Author</dc:creator>`,
|
||||||
`<link href="http://example.com/book.epub"`,
|
`<link href="http://example.com/book.epub"`,
|
||||||
`rel="http://opds-spec.org/acquisition/open-access"`,
|
`rel="http://opds-spec.org/acquisition/open-access"`,
|
||||||
`<dc:identifier id="bookhoard">book-uuid-123</dc:identifier>`,
|
`<dc:identifier id="bookhoard">book-uuid-123</dc:identifier>`,
|
||||||
@@ -232,107 +232,6 @@ func TestNewErrorFeed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFeedSetPagination(t *testing.T) {
|
|
||||||
feed := NewFeed("urn:uuid:test-id", "Test Feed")
|
|
||||||
feed.SetPagination(1814, 50, 51)
|
|
||||||
|
|
||||||
if feed.TotalResults == nil || *feed.TotalResults != 1814 {
|
|
||||||
t.Errorf("expected TotalResults to be 1814, got %v", feed.TotalResults)
|
|
||||||
}
|
|
||||||
if feed.ItemsPerPage == nil || *feed.ItemsPerPage != 50 {
|
|
||||||
t.Errorf("expected ItemsPerPage to be 50, got %v", feed.ItemsPerPage)
|
|
||||||
}
|
|
||||||
if feed.StartIndex == nil || *feed.StartIndex != 51 {
|
|
||||||
t.Errorf("expected StartIndex to be 51, got %v", feed.StartIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFeedGenerateXMLPagination(t *testing.T) {
|
|
||||||
feed := NewFeed("urn:uuid:test-id", "Test Feed")
|
|
||||||
feed.AddLink("http://example.com/catalog?page=1", "application/atom+xml", "first")
|
|
||||||
feed.AddLink("http://example.com/catalog?page=1", "application/atom+xml", "previous")
|
|
||||||
feed.AddLink("http://example.com/catalog?page=2", "application/atom+xml", "self")
|
|
||||||
feed.AddLink("http://example.com/catalog?page=3", "application/atom+xml", "next")
|
|
||||||
feed.AddLink("http://example.com/catalog?page=37", "application/atom+xml", "last")
|
|
||||||
feed.SetPagination(1814, 50, 51)
|
|
||||||
|
|
||||||
output, err := feed.GenerateXML()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to generate XML: %v", err)
|
|
||||||
}
|
|
||||||
outputStr := string(output)
|
|
||||||
|
|
||||||
requiredStrings := []string{
|
|
||||||
`xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/"`,
|
|
||||||
`<opensearch:totalResults>1814</opensearch:totalResults>`,
|
|
||||||
`<opensearch:itemsPerPage>50</opensearch:itemsPerPage>`,
|
|
||||||
`<opensearch:startIndex>51</opensearch:startIndex>`,
|
|
||||||
`rel="first"`,
|
|
||||||
`rel="previous"`,
|
|
||||||
`rel="next"`,
|
|
||||||
`rel="last"`,
|
|
||||||
`page=3`,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, required := range requiredStrings {
|
|
||||||
if !contains(outputStr, required) {
|
|
||||||
t.Errorf("generated XML missing required string: %s", required)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFeedGenerateXMLOmitsPaginationWhenUnset(t *testing.T) {
|
|
||||||
feed := NewFeed("urn:uuid:test-id", "Test Feed")
|
|
||||||
|
|
||||||
output, err := feed.GenerateXML()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to generate XML: %v", err)
|
|
||||||
}
|
|
||||||
outputStr := string(output)
|
|
||||||
|
|
||||||
if contains(outputStr, "opensearch:totalResults") {
|
|
||||||
t.Errorf("expected no totalResults when pagination unset, but found it")
|
|
||||||
}
|
|
||||||
if contains(outputStr, "opensearch:itemsPerPage") {
|
|
||||||
t.Errorf("expected no itemsPerPage when pagination unset, but found it")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewSearchDescription(t *testing.T) {
|
|
||||||
template := "http://example.com/opds/devices/abc/search?q={searchTerms}&token=xyz"
|
|
||||||
desc := NewSearchDescription("Bookhoard", "Search the library", template)
|
|
||||||
|
|
||||||
if desc.ShortName != "Bookhoard" {
|
|
||||||
t.Errorf("expected ShortName 'Bookhoard', got '%s'", desc.ShortName)
|
|
||||||
}
|
|
||||||
if desc.Url.Template != template {
|
|
||||||
t.Errorf("expected template '%s', got '%s'", template, desc.Url.Template)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSearchDescriptionGenerateXML(t *testing.T) {
|
|
||||||
template := "http://example.com/opds/devices/abc/search?q={searchTerms}"
|
|
||||||
desc := NewSearchDescription("Bookhoard", "Search the library", template)
|
|
||||||
|
|
||||||
output, err := desc.GenerateXMLString()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to generate XML: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
requiredStrings := []string{
|
|
||||||
`<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">`,
|
|
||||||
`<ShortName>Bookhoard</ShortName>`,
|
|
||||||
`<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"`,
|
|
||||||
`template="http://example.com/opds/devices/abc/search?q={searchTerms}"`,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, required := range requiredStrings {
|
|
||||||
if !contains(output, required) {
|
|
||||||
t.Errorf("generated OpenSearch XML missing required string: %s", required)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(s, substr string) bool {
|
func contains(s, substr string) bool {
|
||||||
return len(s) >= len(substr) && indexOf(s, substr) >= 0
|
return len(s) >= len(substr) && indexOf(s, substr) >= 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
|
||||||
|
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
@@ -67,7 +66,7 @@ func convertPending(pending []map[string]interface{}) []templates.PendingRegistr
|
|||||||
RegistrationID: p["registration_id"].(string),
|
RegistrationID: p["registration_id"].(string),
|
||||||
DeviceName: p["device_name"].(string),
|
DeviceName: p["device_name"].(string),
|
||||||
DeviceType: p["device_type"].(string),
|
DeviceType: p["device_type"].(string),
|
||||||
ExpiresAt: p["expires_at"].(time.Time).Format(time.RFC3339),
|
ExpiresAt: p["expires_at"].(string),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -118,17 +117,6 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
|
|||||||
libraries = []database.GetUserVisibleLibrariesRow{}
|
libraries = []database.GetUserVisibleLibrariesRow{}
|
||||||
}
|
}
|
||||||
|
|
||||||
counts, countErr := cfg.Queries.GetVisibleLibraryMediaCounts(c.Request().Context(), uuidToPGType(userU))
|
|
||||||
if countErr != nil {
|
|
||||||
log.Printf("GetVisibleLibraryMediaCounts failed: %v", countErr)
|
|
||||||
counts = []database.GetVisibleLibraryMediaCountsRow{}
|
|
||||||
}
|
|
||||||
countMap := make(map[string]int64, len(counts))
|
|
||||||
for _, mc := range counts {
|
|
||||||
mcUUID, _ := uuid.FromBytes(mc.ID.Bytes[0:16])
|
|
||||||
countMap[mcUUID.String()] = mc.MediaCount
|
|
||||||
}
|
|
||||||
|
|
||||||
res.Libraries = make([]templates.LibraryData, len(libraries))
|
res.Libraries = make([]templates.LibraryData, len(libraries))
|
||||||
for i, lib := range libraries {
|
for i, lib := range libraries {
|
||||||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||||
@@ -137,7 +125,6 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
|
|||||||
Name: lib.Name,
|
Name: lib.Name,
|
||||||
Description: getText(lib.Description),
|
Description: getText(lib.Description),
|
||||||
TypeName: lib.TypeName,
|
TypeName: lib.TypeName,
|
||||||
MediaCount: countMap[libUUID.String()],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,14 +62,12 @@ type Config struct {
|
|||||||
ConnManager *sync.ConnectionManager
|
ConnManager *sync.ConnectionManager
|
||||||
QueueProcessor *sync.SyncQueueProcessor
|
QueueProcessor *sync.SyncQueueProcessor
|
||||||
ProgressService *sync.ProgressService
|
ProgressService *sync.ProgressService
|
||||||
AnnotationService *sync.AnnotationService
|
|
||||||
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
|
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
|
||||||
LoginTracker *ratelimit.LoginAttemptTracker
|
LoginTracker *ratelimit.LoginAttemptTracker
|
||||||
ScannerHandler *handlers.Handler
|
ScannerHandler *handlers.Handler
|
||||||
JobsHandler *handlers.JobsHandler
|
JobsHandler *handlers.JobsHandler
|
||||||
SidecarHandler *handlers.SidecarHandler
|
SidecarHandler *handlers.SidecarHandler
|
||||||
ReaderHandler *handlers.ReaderHandler
|
ReaderHandler *handlers.ReaderHandler
|
||||||
LibraryService *services.LibraryService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// createJWTMiddleware creates a JWT middleware with proper user context setup
|
// createJWTMiddleware creates a JWT middleware with proper user context setup
|
||||||
@@ -190,9 +188,6 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
}
|
}
|
||||||
e.Validator = &CustomValidator{validator: v}
|
e.Validator = &CustomValidator{validator: v}
|
||||||
|
|
||||||
// Setup redirect middleware - must run before all routes
|
|
||||||
e.Pre(setupRedirectMiddleware(cfg))
|
|
||||||
|
|
||||||
// Rate limiter
|
// Rate limiter
|
||||||
rateLimiterConfig := ratelimit.RateLimiterConfig{
|
rateLimiterConfig := ratelimit.RateLimiterConfig{
|
||||||
Enabled: cfg.Cfg.RateLimitEnabled,
|
Enabled: cfg.Cfg.RateLimitEnabled,
|
||||||
@@ -211,7 +206,6 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
cfg.ScannerHandler = scannerHandler
|
cfg.ScannerHandler = scannerHandler
|
||||||
|
|
||||||
// Register route groups
|
// Register route groups
|
||||||
registerSetupRoutes(cfg)
|
|
||||||
registerAuthRoutes(cfg, rateLimitMiddleware)
|
registerAuthRoutes(cfg, rateLimitMiddleware)
|
||||||
registerLibraryRoutes(cfg)
|
registerLibraryRoutes(cfg)
|
||||||
registerDeviceRoutes(cfg)
|
registerDeviceRoutes(cfg)
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"bookhoard/internal/setupstatus"
|
|
||||||
"bookhoard/templates"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
func isSetupComplete(cfg *Config) bool {
|
|
||||||
return setupstatus.IsSetupComplete(context.Background(), cfg.Queries)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
|
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
||||||
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" {
|
|
||||||
return next(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isSetupComplete(cfg) {
|
|
||||||
return c.Redirect(http.StatusFound, "/setup")
|
|
||||||
}
|
|
||||||
|
|
||||||
return next(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerSetupRoutes(cfg *Config) {
|
|
||||||
e := cfg.Echo
|
|
||||||
|
|
||||||
e.GET("/setup", func(c *echo.Context) error {
|
|
||||||
if isSetupComplete(cfg) {
|
|
||||||
return c.Redirect(http.StatusFound, "/")
|
|
||||||
}
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := templates.Setup().Render(c.Request().Context(), &buf); err != nil {
|
|
||||||
log.Printf("Failed to render setup template: %v", err)
|
|
||||||
return c.HTML(http.StatusInternalServerError, "Failed to render setup page")
|
|
||||||
}
|
|
||||||
return c.HTML(http.StatusOK, buf.String())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -33,8 +33,6 @@ func registerSyncRoutes(cfg *Config) {
|
|||||||
// API clients can use Authorization header: Authorization: Bearer {token}
|
// API clients can use Authorization header: Authorization: Bearer {token}
|
||||||
koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager)
|
koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager)
|
||||||
koboHandler.SetProgressService(cfg.ProgressService)
|
koboHandler.SetProgressService(cfg.ProgressService)
|
||||||
koboHandler.SetAnnotationService(cfg.AnnotationService)
|
|
||||||
koboHandler.SetLibraryService(cfg.LibraryService)
|
|
||||||
koboSync := e.Group("/api/sync/kobo/:token")
|
koboSync := e.Group("/api/sync/kobo/:token")
|
||||||
koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup))
|
koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup))
|
||||||
koboSync.POST("/bookmark", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Bookmark))
|
koboSync.POST("/bookmark", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Bookmark))
|
||||||
|
|||||||
@@ -146,18 +146,16 @@ type CalibreOPFMetadata struct {
|
|||||||
Timestamp *time.Time
|
Timestamp *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMediaScanner creates a new media scanner instance.
|
// NewMediaScanner creates a new media scanner instance
|
||||||
//
|
|
||||||
// The fsnotify watcher is NOT created here. It is created lazily inside
|
|
||||||
// SetFolders only when watch=true (the long-lived watch-mode scanner).
|
|
||||||
// Ephemeral one-off scan jobs pass watch=false, so they never allocate a
|
|
||||||
// watcher (and thus can never panic on EMFILE/ENOSPC). This fixes the
|
|
||||||
// fd/inotify-watch leak where every scan job created a watcher that was
|
|
||||||
// never closed.
|
|
||||||
func NewMediaScanner(db *database.Queries) *MediaScanner {
|
func NewMediaScanner(db *database.Queries) *MediaScanner {
|
||||||
|
watcher, err := fsnotify.NewWatcher()
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("Failed to create file watcher: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
return &MediaScanner{
|
return &MediaScanner{
|
||||||
db: db,
|
db: db,
|
||||||
watcher: nil,
|
watcher: watcher,
|
||||||
settingsCache: NewSettingsCache(30 * time.Second),
|
settingsCache: NewSettingsCache(30 * time.Second),
|
||||||
dirtyDirs: make(map[string]time.Time),
|
dirtyDirs: make(map[string]time.Time),
|
||||||
fileStability: make(map[string]*atomic.Bool),
|
fileStability: make(map[string]*atomic.Bool),
|
||||||
@@ -240,34 +238,24 @@ func (s *MediaScanner) GetStats() (int, int, int) {
|
|||||||
return s.totalFiles, s.newItems, s.errors
|
return s.totalFiles, s.newItems, s.errors
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetFolders configures the scanner's folders and (optionally) sets up an
|
func (s *MediaScanner) SetFolders(folders []string) error {
|
||||||
// fsnotify watcher over the full directory tree.
|
|
||||||
//
|
|
||||||
// watch should be true only for the single long-lived watch-mode scanner that
|
|
||||||
// actually consumes watcher.Events. Ephemeral scan jobs must pass false so no
|
|
||||||
// watcher (and thus no fd/inotify watches) is allocated — the watcher is never
|
|
||||||
// read by scan jobs and previously leaked one watcher per job.
|
|
||||||
func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
|
|
||||||
s.folders = folders
|
s.folders = folders
|
||||||
|
|
||||||
// Always close any previously-owned watcher so reconfiguration doesn't leak.
|
// Remove old watch if exists
|
||||||
|
if s.watcher != nil {
|
||||||
if s.watcher != nil {
|
if s.watcher != nil {
|
||||||
if err := s.watcher.Close(); err != nil {
|
if err := s.watcher.Close(); err != nil {
|
||||||
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
||||||
}
|
}
|
||||||
s.watcher = nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create + populate a fresh watcher only when the caller intends to read events.
|
// Create new watcher
|
||||||
if watch {
|
|
||||||
watcher, err := fsnotify.NewWatcher()
|
watcher, err := fsnotify.NewWatcher()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Return an error instead of panicking so a failed watcher can't
|
return fmt.Errorf("failed to create watcher: %v", err)
|
||||||
// take down the whole process.
|
|
||||||
return fmt.Errorf("failed to create watcher: %w", err)
|
|
||||||
}
|
}
|
||||||
s.watcher = watcher
|
s.watcher = watcher
|
||||||
}
|
|
||||||
|
|
||||||
// Build cache of allowed extensions per folder
|
// Build cache of allowed extensions per folder
|
||||||
// Uses Go AllowedExtensions map as source of truth (not DB)
|
// Uses Go AllowedExtensions map as source of truth (not DB)
|
||||||
@@ -298,9 +286,7 @@ func (s *MediaScanner) SetFolders(folders []string, watch bool) 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
|
watchCount := 0
|
||||||
for _, folder := range folders {
|
for _, folder := range folders {
|
||||||
if err := s.watcher.Add(folder); err != nil {
|
if err := s.watcher.Add(folder); err != nil {
|
||||||
@@ -325,9 +311,6 @@ func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[WATCHER] Now watching %d directories across %d root folders\n", watchCount, len(folders))
|
fmt.Printf("[WATCHER] Now watching %d directories across %d root folders\n", watchCount, len(folders))
|
||||||
} else {
|
|
||||||
fmt.Printf("[SCANNER] Configured %d root folders (watch mode disabled, no inotify watcher)\n", len(folders))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -434,11 +417,9 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
if s.watcher != nil {
|
|
||||||
if err := s.watcher.Add(path); err != nil {
|
if err := s.watcher.Add(path); err != nil {
|
||||||
fmt.Printf("Warning: failed to watch subdirectory %s: %v\n", path, err)
|
fmt.Printf("Warning: failed to watch subdirectory %s: %v\n", path, err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2620,13 +2601,6 @@ func (s *MediaScanner) WatchChanges(ctx context.Context) error {
|
|||||||
go s.startBackupScan(ctx)
|
go s.startBackupScan(ctx)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
// The event loop only runs if a real watcher was set up (watch=true).
|
|
||||||
// If watching with no watcher (e.g. inotify unavailable through a Docker
|
|
||||||
// bind mount), polling via startBackupScan above still handles detection.
|
|
||||||
if s.watcher == nil {
|
|
||||||
fmt.Printf("[WATCHER] No inotify watcher configured; relying on periodic polling for change detection\n")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Printf("[WATCHER] Event loop started for %d folders\n", len(s.folders))
|
fmt.Printf("[WATCHER] Event loop started for %d folders\n", len(s.folders))
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -205,23 +205,7 @@ func (w *Worker) worker() {
|
|||||||
return
|
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)
|
w.processJob(job)
|
||||||
}()
|
|
||||||
|
|
||||||
case <-w.ctx.Done():
|
case <-w.ctx.Done():
|
||||||
return
|
return
|
||||||
@@ -364,7 +348,6 @@ func (w *Worker) processScanJob(job *Job) (interface{}, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
scanner := NewMediaScanner(db)
|
scanner := NewMediaScanner(db)
|
||||||
defer scanner.Close()
|
|
||||||
scanner.job = job
|
scanner.job = job
|
||||||
|
|
||||||
job.ProgressCallback = func(progress float64, filesScanned, newItems, errors int) {
|
job.ProgressCallback = func(progress float64, filesScanned, newItems, errors int) {
|
||||||
@@ -394,7 +377,7 @@ func (w *Worker) processScanJob(job *Job) (interface{}, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := scanner.SetFolders(folders, false); err != nil {
|
if err := scanner.SetFolders(folders); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,8 +521,7 @@ func (w *Worker) processSetFoldersJob(job *Job) (interface{}, error) {
|
|||||||
|
|
||||||
// Create scanner and configure folders
|
// Create scanner and configure folders
|
||||||
scanner := NewMediaScanner(db)
|
scanner := NewMediaScanner(db)
|
||||||
defer scanner.Close()
|
if err := scanner.SetFolders(folders); err != nil {
|
||||||
if err := scanner.SetFolders(folders, false); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to set folders: %w", err)
|
return nil, fmt.Errorf("failed to set folders: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,7 +900,6 @@ func (w *Worker) processDirectoryScanJob(job *Job) (interface{}, error) {
|
|||||||
|
|
||||||
// Create temporary scanner instance for this job
|
// Create temporary scanner instance for this job
|
||||||
scanner := NewMediaScanner(db)
|
scanner := NewMediaScanner(db)
|
||||||
defer scanner.Close()
|
|
||||||
scanner.job = job
|
scanner.job = job
|
||||||
// Find which library owns this directory (prefix match for subdirectories)
|
// Find which library owns this directory (prefix match for subdirectories)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -937,7 +918,7 @@ func (w *Worker) processDirectoryScanJob(job *Job) (interface{}, error) {
|
|||||||
folderPaths = append(folderPaths, f.FolderPath)
|
folderPaths = append(folderPaths, f.FolderPath)
|
||||||
}
|
}
|
||||||
// Configure scanner with folders
|
// Configure scanner with folders
|
||||||
if err := scanner.SetFolders(folderPaths, false); err != nil {
|
if err := scanner.SetFolders(folderPaths); err != nil {
|
||||||
return nil, fmt.Errorf("failed to set folders: %w", err)
|
return nil, fmt.Errorf("failed to set folders: %w", err)
|
||||||
}
|
}
|
||||||
// Now scan the directory
|
// Now scan the directory
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
// Package setupstatus reports whether the application's initial setup has been
|
|
||||||
// completed. Setup is considered complete as soon as at least one admin user
|
|
||||||
// exists, regardless of how that user was 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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. 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) bool {
|
|
||||||
cacheMu.RLock()
|
|
||||||
if time.Now().Before(cacheExpiry) {
|
|
||||||
complete := cacheComplete
|
|
||||||
cacheMu.RUnlock()
|
|
||||||
return complete
|
|
||||||
}
|
|
||||||
cacheMu.RUnlock()
|
|
||||||
|
|
||||||
count, err := q.CountAdmins(ctx)
|
|
||||||
complete := true
|
|
||||||
if err == nil {
|
|
||||||
complete = count > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
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).
|
|
||||||
func Invalidate() {
|
|
||||||
cacheMu.Lock()
|
|
||||||
cacheComplete = true
|
|
||||||
cacheExpiry = time.Time{}
|
|
||||||
cacheMu.Unlock()
|
|
||||||
}
|
|
||||||
@@ -1,754 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
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},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,449 +0,0 @@
|
|||||||
package sync
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"golang.org/x/net/html"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseCREXPointer(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
wantFrag int
|
|
||||||
wantPath int
|
|
||||||
wantChar int
|
|
||||||
}{
|
|
||||||
{"/body/DocFragment[0]/body/div[4]/p[38]/text().541", 0, 2, 541},
|
|
||||||
{"/body/DocFragment[2]/body/div/p[5]/text()[2].16", 2, 2, 16},
|
|
||||||
{"/body/DocFragment[1]/body", 1, 0, 0},
|
|
||||||
{"/body/DocFragment[5]/body/div[3]/p[28]", 5, 2, 0},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
xp, err := ParseCREXPointer(tt.input)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("ParseCREXPointer(%q) error: %v", tt.input, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if xp.FragmentIndex != tt.wantFrag {
|
|
||||||
t.Errorf("FragmentIndex = %d, want %d", xp.FragmentIndex, tt.wantFrag)
|
|
||||||
}
|
|
||||||
if len(xp.ElementPath) != tt.wantPath {
|
|
||||||
t.Errorf("len(ElementPath) = %d, want %d (got %+v)", len(xp.ElementPath), tt.wantPath, xp.ElementPath)
|
|
||||||
}
|
|
||||||
if xp.CharOffset != tt.wantChar {
|
|
||||||
t.Errorf("CharOffset = %d, want %d", xp.CharOffset, tt.wantChar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseCREXPointerInvalid(t *testing.T) {
|
|
||||||
_, err := ParseCREXPointer("epubcfi(/6/4!/4/2/1:0)")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for standard epubcfi")
|
|
||||||
}
|
|
||||||
_, err = ParseCREXPointer("")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for empty string")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsCREXPointer(t *testing.T) {
|
|
||||||
if !IsCREXPointer("/body/DocFragment[0]/body/div/p") {
|
|
||||||
t.Error("should recognize CRE XPointer")
|
|
||||||
}
|
|
||||||
if !IsCREXPointer("#_doc_fragment_5_ link2HCH0002") {
|
|
||||||
t.Error("should recognize CRE fragment ID")
|
|
||||||
}
|
|
||||||
if IsCREXPointer("epubcfi(/6/4!/4/2/1:0)") {
|
|
||||||
t.Error("should not recognize standard epubcfi as CRE")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsCREFragmentID(t *testing.T) {
|
|
||||||
if !IsCREFragmentID("#_doc_fragment_5_ link2HCH0002") {
|
|
||||||
t.Error("should recognize fragment ID")
|
|
||||||
}
|
|
||||||
if IsCREFragmentID("/body/DocFragment[2]/body") {
|
|
||||||
t.Error("should not recognize XPointer as fragment ID")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseCREFragmentID(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
wantSpine int
|
|
||||||
wantAnchor string
|
|
||||||
}{
|
|
||||||
{"#_doc_fragment_5_ link2HCH0002", 5, "link2HCH0002"},
|
|
||||||
{"#_doc_fragment_0_", 0, ""},
|
|
||||||
{"#_doc_fragment_12_someid123", 12, "someid123"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
frag, err := ParseCREFragmentID(tt.input)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("ParseCREFragmentID(%q) error: %v", tt.input, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if frag.SpineIndex != tt.wantSpine {
|
|
||||||
t.Errorf("SpineIndex = %d, want %d", frag.SpineIndex, tt.wantSpine)
|
|
||||||
}
|
|
||||||
if frag.Anchor != tt.wantAnchor {
|
|
||||||
t.Errorf("Anchor = %q, want %q", frag.Anchor, tt.wantAnchor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseCREFragmentIDInvalid(t *testing.T) {
|
|
||||||
_, err := ParseCREFragmentID("/body/DocFragment[2]/body")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for XPointer input")
|
|
||||||
}
|
|
||||||
_, err = ParseCREFragmentID("#_doc_fragment_")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for missing index")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsStandardEPUBCFI(t *testing.T) {
|
|
||||||
if !IsStandardEPUBCFI("epubcfi(/6/4!/4/2/1:0)") {
|
|
||||||
t.Error("should recognize standard epubcfi")
|
|
||||||
}
|
|
||||||
if IsStandardEPUBCFI("/body/DocFragment[0]/body") {
|
|
||||||
t.Error("should not recognize CRE as standard")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvert1984(t *testing.T) {
|
|
||||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub"
|
|
||||||
c := NewCFIConverter(epubPath)
|
|
||||||
|
|
||||||
xp := "/body/DocFragment[2]/body/div/p[5]/text().500"
|
|
||||||
result, err := c.ConvertCREToStandard(xp, 0.01, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ConvertCREToStandard error: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("Input: %s", xp)
|
|
||||||
t.Logf("EPUBCFI: %s", result.EPUBCFI)
|
|
||||||
t.Logf("Href: %s", result.Href)
|
|
||||||
t.Logf("Precision: %s", result.Precision)
|
|
||||||
t.Logf("Percentage: %.4f", result.Percentage)
|
|
||||||
|
|
||||||
if result.Precision == "percentage" {
|
|
||||||
t.Error("expected better than percentage precision")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvertCrimeAndPunishmentFragmentID(t *testing.T) {
|
|
||||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub"
|
|
||||||
c := NewCFIConverter(epubPath)
|
|
||||||
|
|
||||||
xp := "#_doc_fragment_5_ link2HCH0002"
|
|
||||||
result, err := c.ConvertCREToStandard(xp, 0.0303, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ConvertCREToStandard error: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("Input: %s", xp)
|
|
||||||
t.Logf("EPUBCFI: %s", result.EPUBCFI)
|
|
||||||
t.Logf("Href: %s", result.Href)
|
|
||||||
t.Logf("Precision: %s", result.Precision)
|
|
||||||
t.Logf("Percentage: %.4f", result.Percentage)
|
|
||||||
|
|
||||||
if result.Precision == "percentage" {
|
|
||||||
t.Error("expected better than percentage precision")
|
|
||||||
}
|
|
||||||
if result.Href == "" {
|
|
||||||
t.Error("expected non-empty href")
|
|
||||||
}
|
|
||||||
if result.Precision != "element" {
|
|
||||||
t.Errorf("expected element precision, got %s", result.Precision)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseEPUBCFI(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
wantSpine int
|
|
||||||
wantSteps int
|
|
||||||
}{
|
|
||||||
{"epubcfi(/6/12!/4/2/90/1:7)", 5, 4},
|
|
||||||
{"epubcfi(/6/4!/4/2/1:0)", 1, 3},
|
|
||||||
{"epubcfi(/6/2!/4)", 0, 1},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
spineIndex, steps, err := parseEPUBCFI(tt.input)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("parseEPUBCFI(%q) error: %v", tt.input, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if spineIndex != tt.wantSpine {
|
|
||||||
t.Errorf("spineIndex = %d, want %d", spineIndex, tt.wantSpine)
|
|
||||||
}
|
|
||||||
if len(steps) != tt.wantSteps {
|
|
||||||
t.Errorf("len(steps) = %d, want %d", len(steps), tt.wantSteps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseEPUBCFIRange(t *testing.T) {
|
|
||||||
spineIndex, steps, err := parseEPUBCFI("epubcfi(/6/40!/4,/24/20,/40/5:61)")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parseEPUBCFI range error: %v", err)
|
|
||||||
}
|
|
||||||
if spineIndex != 19 {
|
|
||||||
t.Errorf("spineIndex = %d, want 19", spineIndex)
|
|
||||||
}
|
|
||||||
t.Logf("Range CFI steps: %d", len(steps))
|
|
||||||
for i, s := range steps {
|
|
||||||
t.Logf(" step %d: index=%d offset=%d hasOffset=%v", i, s.Index, s.Offset, s.HasOffset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseEPUBCFIInvalid(t *testing.T) {
|
|
||||||
_, _, err := parseEPUBCFI("not-a-cfi")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for invalid CFI")
|
|
||||||
}
|
|
||||||
_, _, err = parseEPUBCFI("epubcfi(/6/12)")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for CFI without indirection")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRoundTrip1984(t *testing.T) {
|
|
||||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub"
|
|
||||||
c := NewCFIConverter(epubPath)
|
|
||||||
|
|
||||||
originalXP := "/body/DocFragment[2]/body/div/p[5]/text().500"
|
|
||||||
forward, err := c.ConvertCREToStandard(originalXP, 0.01, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("forward conversion error: %v", err)
|
|
||||||
}
|
|
||||||
if forward.EPUBCFI == "" {
|
|
||||||
t.Fatal("forward conversion produced empty epubcfi")
|
|
||||||
}
|
|
||||||
t.Logf("Forward: %s → %s", originalXP, forward.EPUBCFI)
|
|
||||||
|
|
||||||
reverse, err := c.ConvertStandardToCRE(forward.EPUBCFI, forward.Percentage, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reverse conversion error: %v", err)
|
|
||||||
}
|
|
||||||
if reverse.XPointer == "" {
|
|
||||||
t.Fatal("reverse conversion produced empty XPointer")
|
|
||||||
}
|
|
||||||
t.Logf("Reverse: %s → %s", forward.EPUBCFI, reverse.XPointer)
|
|
||||||
t.Logf("Reverse precision: %s", reverse.Precision)
|
|
||||||
|
|
||||||
if reverse.Precision != "exact" {
|
|
||||||
t.Errorf("expected exact precision, got %s", reverse.Precision)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRoundTripCP(t *testing.T) {
|
|
||||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub"
|
|
||||||
c := NewCFIConverter(epubPath)
|
|
||||||
|
|
||||||
originalXP := "/body/DocFragment[6]/body/div/p[47]/text().2399"
|
|
||||||
contextText := "Raskolnikov was not used to crowds, and, as we said before, he avoided society of every sort, more especially of l"
|
|
||||||
forward, err := c.ConvertCREToStandard(originalXP, 0.0579, contextText)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("forward conversion error: %v", err)
|
|
||||||
}
|
|
||||||
if forward.EPUBCFI == "" {
|
|
||||||
t.Fatal("forward conversion produced empty epubcfi")
|
|
||||||
}
|
|
||||||
t.Logf("Forward: %s → %s", originalXP, forward.EPUBCFI)
|
|
||||||
|
|
||||||
reverse, err := c.ConvertStandardToCRE(forward.EPUBCFI, forward.Percentage, contextText)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reverse conversion error: %v", err)
|
|
||||||
}
|
|
||||||
if reverse.XPointer == "" {
|
|
||||||
t.Fatal("reverse conversion produced empty XPointer")
|
|
||||||
}
|
|
||||||
t.Logf("Reverse: %s → %s", forward.EPUBCFI, reverse.XPointer)
|
|
||||||
t.Logf("Reverse precision: %s", reverse.Precision)
|
|
||||||
|
|
||||||
if reverse.Precision != "exact" {
|
|
||||||
t.Errorf("expected exact precision, got %s", reverse.Precision)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReverseTextSearchFallback(t *testing.T) {
|
|
||||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub"
|
|
||||||
c := NewCFIConverter(epubPath)
|
|
||||||
|
|
||||||
contextText := "Raskolnikov was not used to crowds, and, as we said before, he avoided society of every sort, more especially of l"
|
|
||||||
reverse, err := c.ConvertStandardToCRE("epubcfi(/6/12!/4/99999/1:0)", 0.0579, contextText)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reverse conversion error: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("Text search fallback XPointer: %s", reverse.XPointer)
|
|
||||||
t.Logf("Precision: %s", reverse.Precision)
|
|
||||||
|
|
||||||
if reverse.Precision != "exact" {
|
|
||||||
t.Errorf("expected exact precision from text search, got %s", reverse.Precision)
|
|
||||||
}
|
|
||||||
if reverse.XPointer == "" {
|
|
||||||
t.Error("expected non-empty XPointer from text search")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReversePercentageFallback(t *testing.T) {
|
|
||||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub"
|
|
||||||
c := NewCFIConverter(epubPath)
|
|
||||||
|
|
||||||
reverse, err := c.ConvertStandardToCRE("epubcfi(/6/12!/4/99999/1:0)", 0.5, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reverse conversion error: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("Percentage fallback precision: %s", reverse.Precision)
|
|
||||||
|
|
||||||
if reverse.Precision != "percentage" {
|
|
||||||
t.Errorf("expected percentage precision, got %s with XPointer %s", reverse.Precision, reverse.XPointer)
|
|
||||||
}
|
|
||||||
if reverse.XPointer != "" {
|
|
||||||
t.Error("expected empty XPointer for percentage fallback")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindTextInNode_SingleTextNode(t *testing.T) {
|
|
||||||
doc := parseTestHTML(`<html><body><p>Hello world this is a test</p></body></html>`)
|
|
||||||
body := findBody(doc)
|
|
||||||
|
|
||||||
node, offset := findTextInNode(body, "Hello world")
|
|
||||||
if node == nil {
|
|
||||||
t.Fatal("expected to find text")
|
|
||||||
}
|
|
||||||
if offset != 0 {
|
|
||||||
t.Errorf("offset = %d, want 0", offset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindTextInNode_CrossEmElement(t *testing.T) {
|
|
||||||
doc := parseTestHTML(`<html><body><p>the countries <em>Vokalia</em> and <em>Consonantia</em> live here</p></body></html>`)
|
|
||||||
body := findBody(doc)
|
|
||||||
|
|
||||||
node, offset := findTextInNode(body, "Vokalia and Consonantia")
|
|
||||||
if node == nil {
|
|
||||||
t.Fatal("expected to find text across <em> elements")
|
|
||||||
}
|
|
||||||
if node.Data != "Vokalia" {
|
|
||||||
t.Errorf("expected match in 'Vokalia' text node, got %q", node.Data)
|
|
||||||
}
|
|
||||||
if offset != 0 {
|
|
||||||
t.Errorf("offset = %d, want 0", offset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindTextInNode_CrossStrongElement(t *testing.T) {
|
|
||||||
doc := parseTestHTML(`<html><body><p>Some <strong>bold and italic</strong> text here</p></body></html>`)
|
|
||||||
body := findBody(doc)
|
|
||||||
|
|
||||||
node, _ := findTextInNode(body, "bold and italic text")
|
|
||||||
if node == nil {
|
|
||||||
t.Fatal("expected to find text across <strong> boundary")
|
|
||||||
}
|
|
||||||
if node.Data != "bold and italic" {
|
|
||||||
t.Errorf("expected match in 'bold and italic' text node, got %q", node.Data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindTextInNode_DoesNotCrossParagraphs(t *testing.T) {
|
|
||||||
doc := parseTestHTML(`<html><body><p>first paragraph</p><p>second paragraph</p></body></html>`)
|
|
||||||
body := findBody(doc)
|
|
||||||
|
|
||||||
node, _ := findTextInNode(body, "paragraph second")
|
|
||||||
if node != nil {
|
|
||||||
t.Error("should not match text across <p> boundaries")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindTextInNode_NestedFormatting(t *testing.T) {
|
|
||||||
doc := parseTestHTML(`<html><body><p>before <em><strong>bold italic</strong></em> after</p></body></html>`)
|
|
||||||
body := findBody(doc)
|
|
||||||
|
|
||||||
node, offset := findTextInNode(body, "bold italic after")
|
|
||||||
if node == nil {
|
|
||||||
t.Fatal("expected to find text across nested formatting")
|
|
||||||
}
|
|
||||||
if node.Data != "bold italic" {
|
|
||||||
t.Errorf("expected match in 'bold italic' text node, got %q", node.Data)
|
|
||||||
}
|
|
||||||
_ = offset
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindBlockParent(t *testing.T) {
|
|
||||||
doc := parseTestHTML(`<html><body><p>text <em>inside <strong>deep</strong></em></p></body></html>`)
|
|
||||||
body := findBody(doc)
|
|
||||||
|
|
||||||
var deepNode *html.Node
|
|
||||||
var walk func(*html.Node)
|
|
||||||
walk = func(n *html.Node) {
|
|
||||||
if n.Type == html.TextNode && n.Data == "deep" {
|
|
||||||
deepNode = n
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
||||||
walk(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk(body)
|
|
||||||
|
|
||||||
if deepNode == nil {
|
|
||||||
t.Fatal("could not find 'deep' text node")
|
|
||||||
}
|
|
||||||
|
|
||||||
block := findBlockParent(deepNode)
|
|
||||||
if block == nil {
|
|
||||||
t.Fatal("expected block parent")
|
|
||||||
}
|
|
||||||
if block.Data != "p" {
|
|
||||||
t.Errorf("block parent = %q, want 'p'", block.Data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCollectInlineText(t *testing.T) {
|
|
||||||
doc := parseTestHTML(`<html><body><p>the countries <em>Vokalia</em> and <em>Consonantia</em> live</p></body></html>`)
|
|
||||||
body := findBody(doc)
|
|
||||||
|
|
||||||
var p *html.Node
|
|
||||||
var walk func(*html.Node)
|
|
||||||
walk = func(n *html.Node) {
|
|
||||||
if n.Type == html.ElementNode && n.Data == "p" {
|
|
||||||
p = n
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
||||||
walk(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk(body)
|
|
||||||
|
|
||||||
if p == nil {
|
|
||||||
t.Fatal("could not find <p> element")
|
|
||||||
}
|
|
||||||
|
|
||||||
segments := collectInlineText(p)
|
|
||||||
|
|
||||||
var collected []rune
|
|
||||||
for _, seg := range segments {
|
|
||||||
collected = append(collected, seg.runes...)
|
|
||||||
}
|
|
||||||
|
|
||||||
flattened := strings.TrimSpace(string(collected))
|
|
||||||
if flattened != "the countries Vokalia and Consonantia live" {
|
|
||||||
t.Errorf("collected text = %q", flattened)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseTestHTML(s string) *html.Node {
|
|
||||||
doc, err := html.Parse(strings.NewReader(s))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return doc
|
|
||||||
}
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
package sync
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"golang.org/x/net/html"
|
|
||||||
)
|
|
||||||
|
|
||||||
type KEPUBCFIConverter struct {
|
|
||||||
epubConverter *CFIConverter
|
|
||||||
kepubConverter *CFIConverter
|
|
||||||
}
|
|
||||||
|
|
||||||
type KEPUBConversionResult struct {
|
|
||||||
CFI string
|
|
||||||
ExtractedContext string
|
|
||||||
Percentage float64
|
|
||||||
Precision string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewKEPUBCFIConverter(epubPath, kepubPath string) *KEPUBCFIConverter {
|
|
||||||
return &KEPUBCFIConverter{
|
|
||||||
epubConverter: NewCFIConverter(epubPath),
|
|
||||||
kepubConverter: NewCFIConverter(kepubPath),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *KEPUBCFIConverter) ConvertKEPUBCFIToStandard(kepubCFI string, percentage float64, contextText string) (*KEPUBConversionResult, error) {
|
|
||||||
if !IsStandardEPUBCFI(kepubCFI) {
|
|
||||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
spineIndex, steps, err := parseEPUBCFI(kepubCFI)
|
|
||||||
if err != nil {
|
|
||||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1)
|
|
||||||
if docErr != nil {
|
|
||||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
kepubTextNode, kepubOffset, resolveErr := resolveCFIToNode(kepubDoc, steps)
|
|
||||||
if resolveErr != nil {
|
|
||||||
return k.kepubToStandardByPercentage(spineIndex, percentage)
|
|
||||||
}
|
|
||||||
|
|
||||||
var searchText string
|
|
||||||
if contextText != "" {
|
|
||||||
searchText = normalizeWhitespace(contextText)
|
|
||||||
} else if kepubTextNode != nil && kepubTextNode.Type == html.TextNode {
|
|
||||||
searchText = extractSurroundingText(kepubTextNode, kepubOffset, 80)
|
|
||||||
}
|
|
||||||
|
|
||||||
if searchText != "" {
|
|
||||||
epubDoc, _, docErr := k.epubConverter.getContentDoc(spineIndex + 1)
|
|
||||||
if docErr == nil {
|
|
||||||
epubBody := findBody(epubDoc)
|
|
||||||
if epubBody != nil {
|
|
||||||
matchNode, matchOffset := findTextInNode(epubBody, searchText)
|
|
||||||
if matchNode != nil {
|
|
||||||
cfi, buildErr := buildCFI(spineIndex, matchNode, matchOffset)
|
|
||||||
if buildErr == nil && cfi != "" {
|
|
||||||
return &KEPUBConversionResult{
|
|
||||||
CFI: cfi,
|
|
||||||
ExtractedContext: searchText,
|
|
||||||
Percentage: percentage,
|
|
||||||
Precision: "exact",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result, _ := k.kepubToStandardByPercentage(spineIndex, percentage)
|
|
||||||
result.ExtractedContext = searchText
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *KEPUBCFIConverter) ConvertStandardCFIToKEPUB(standardCFI string, percentage float64, contextText string) (*KEPUBConversionResult, error) {
|
|
||||||
if !IsStandardEPUBCFI(standardCFI) {
|
|
||||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
spineIndex, steps, err := parseEPUBCFI(standardCFI)
|
|
||||||
if err != nil {
|
|
||||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
epubDoc, _, docErr := k.epubConverter.getContentDoc(spineIndex + 1)
|
|
||||||
if docErr != nil {
|
|
||||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
epubTextNode, epubOffset, resolveErr := resolveCFIToNode(epubDoc, steps)
|
|
||||||
if resolveErr != nil {
|
|
||||||
return k.standardToKEPUBByPercentage(spineIndex, percentage)
|
|
||||||
}
|
|
||||||
|
|
||||||
var searchText string
|
|
||||||
if contextText != "" {
|
|
||||||
searchText = normalizeWhitespace(contextText)
|
|
||||||
} else if epubTextNode != nil && epubTextNode.Type == html.TextNode {
|
|
||||||
searchText = extractSurroundingText(epubTextNode, epubOffset, 80)
|
|
||||||
}
|
|
||||||
|
|
||||||
if searchText != "" {
|
|
||||||
kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1)
|
|
||||||
if docErr == nil {
|
|
||||||
kepubBody := findBody(kepubDoc)
|
|
||||||
if kepubBody != nil {
|
|
||||||
matchNode, matchOffset := findTextInNode(kepubBody, searchText)
|
|
||||||
if matchNode != nil {
|
|
||||||
cfi, buildErr := buildCFI(spineIndex, matchNode, matchOffset)
|
|
||||||
if buildErr == nil && cfi != "" {
|
|
||||||
return &KEPUBConversionResult{
|
|
||||||
CFI: cfi,
|
|
||||||
ExtractedContext: searchText,
|
|
||||||
Percentage: percentage,
|
|
||||||
Precision: "exact",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result, _ := k.standardToKEPUBByPercentage(spineIndex, percentage)
|
|
||||||
result.ExtractedContext = searchText
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *KEPUBCFIConverter) kepubToStandardByPercentage(spineIndex int, percentage float64) (*KEPUBConversionResult, error) {
|
|
||||||
if percentage <= 0 {
|
|
||||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
epubDoc, _, docErr := k.epubConverter.getContentDoc(spineIndex + 1)
|
|
||||||
if docErr != nil {
|
|
||||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
epubBody := findBody(epubDoc)
|
|
||||||
if epubBody == nil {
|
|
||||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
totalChars := countTextChars(epubBody)
|
|
||||||
if totalChars <= 0 {
|
|
||||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
targetOffset := int(float64(totalChars) * percentage)
|
|
||||||
targetNode, foundOffset := findNodeAtCharOffset(epubBody, targetOffset)
|
|
||||||
if targetNode == nil {
|
|
||||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
charInNode := targetOffset - foundOffset
|
|
||||||
cfi, err := buildCFI(spineIndex, targetNode, charInNode)
|
|
||||||
if err != nil || cfi == "" {
|
|
||||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &KEPUBConversionResult{
|
|
||||||
CFI: cfi,
|
|
||||||
Percentage: percentage,
|
|
||||||
Precision: "percentage",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *KEPUBCFIConverter) standardToKEPUBByPercentage(spineIndex int, percentage float64) (*KEPUBConversionResult, error) {
|
|
||||||
if percentage <= 0 {
|
|
||||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1)
|
|
||||||
if docErr != nil {
|
|
||||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
kepubBody := findBody(kepubDoc)
|
|
||||||
if kepubBody == nil {
|
|
||||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
totalChars := countTextChars(kepubBody)
|
|
||||||
if totalChars <= 0 {
|
|
||||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
targetOffset := int(float64(totalChars) * percentage)
|
|
||||||
targetNode, foundOffset := findNodeAtCharOffset(kepubBody, targetOffset)
|
|
||||||
if targetNode == nil {
|
|
||||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
charInNode := targetOffset - foundOffset
|
|
||||||
cfi, err := buildCFI(spineIndex, targetNode, charInNode)
|
|
||||||
if err != nil || cfi == "" {
|
|
||||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &KEPUBConversionResult{
|
|
||||||
CFI: cfi,
|
|
||||||
Percentage: percentage,
|
|
||||||
Precision: "percentage",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractSurroundingText(textNode *html.Node, offset int, window int) string {
|
|
||||||
if textNode == nil || textNode.Type != html.TextNode {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
block := findBlockParent(textNode)
|
|
||||||
if block == nil {
|
|
||||||
text := textNode.Data
|
|
||||||
runes := []rune(text)
|
|
||||||
start := offset - window
|
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
end := offset + window
|
|
||||||
if end > len(runes) {
|
|
||||||
end = len(runes)
|
|
||||||
}
|
|
||||||
if start >= end {
|
|
||||||
return normalizeWhitespace(text)
|
|
||||||
}
|
|
||||||
return normalizeWhitespace(string(runes[start:end]))
|
|
||||||
}
|
|
||||||
|
|
||||||
segments := collectInlineText(block)
|
|
||||||
|
|
||||||
globalOffset := 0
|
|
||||||
for _, seg := range segments {
|
|
||||||
if seg.node == textNode {
|
|
||||||
globalOffset += offset
|
|
||||||
break
|
|
||||||
}
|
|
||||||
globalOffset += len(seg.runes)
|
|
||||||
}
|
|
||||||
|
|
||||||
var allRunes []rune
|
|
||||||
for _, seg := range segments {
|
|
||||||
allRunes = append(allRunes, seg.runes...)
|
|
||||||
}
|
|
||||||
|
|
||||||
start := globalOffset - window
|
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
end := globalOffset + window
|
|
||||||
if end > len(allRunes) {
|
|
||||||
end = len(allRunes)
|
|
||||||
}
|
|
||||||
|
|
||||||
if start >= end {
|
|
||||||
return normalizeWhitespace(string(allRunes))
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizeWhitespace(string(allRunes[start:end]))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *KEPUBCFIConverter) ComputeKEPUBPercentage(kepubCFI string) (float64, error) {
|
|
||||||
if !IsStandardEPUBCFI(kepubCFI) {
|
|
||||||
return -1, fmt.Errorf("not a standard epubcfi")
|
|
||||||
}
|
|
||||||
|
|
||||||
spineIndex, steps, err := parseEPUBCFI(kepubCFI)
|
|
||||||
if err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1)
|
|
||||||
if docErr != nil {
|
|
||||||
return -1, docErr
|
|
||||||
}
|
|
||||||
|
|
||||||
kepubBody := findBody(kepubDoc)
|
|
||||||
if kepubBody == nil {
|
|
||||||
return -1, fmt.Errorf("no body in kepub doc")
|
|
||||||
}
|
|
||||||
|
|
||||||
textNode, textOffset, resolveErr := resolveCFIToNode(kepubBody, steps)
|
|
||||||
if resolveErr != nil {
|
|
||||||
return -1, resolveErr
|
|
||||||
}
|
|
||||||
|
|
||||||
charOffset := countTextCharsBefore(textNode) + textOffset
|
|
||||||
|
|
||||||
total := 0
|
|
||||||
kepubSpine, spineErr := k.kepubConverter.loadSpine()
|
|
||||||
if spineErr != nil {
|
|
||||||
return -1, spineErr
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range kepubSpine.items {
|
|
||||||
doc, _, dErr := k.kepubConverter.getContentDoc(i + 1)
|
|
||||||
if dErr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b := findBody(doc)
|
|
||||||
if b != nil {
|
|
||||||
total += countTextChars(b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if total <= 0 {
|
|
||||||
return -1, fmt.Errorf("no text in kepub")
|
|
||||||
}
|
|
||||||
|
|
||||||
return float64(charOffset) / float64(total), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
_ = log.Printf
|
|
||||||
}
|
|
||||||
@@ -1,884 +0,0 @@
|
|||||||
package sync
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"golang.org/x/net/html"
|
|
||||||
)
|
|
||||||
|
|
||||||
const epubChapter1 = `<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
|
||||||
<head><title>Test Book</title></head>
|
|
||||||
<body>
|
|
||||||
<h1 id="ch1">Chapter One</h1>
|
|
||||||
<p>It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, it was the epoch of belief, it was the epoch of incredulity, it was the season of Light, it was the season of Darkness, it was the spring of hope, it was the winter of despair.</p>
|
|
||||||
<p id="p2">The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. How vexingly quick daft zebras jump.</p>
|
|
||||||
<p>Far far away, behind the word mountains, far from the countries <em>Vokalia</em> and <em>Consonantia</em>, there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean.</p>
|
|
||||||
<p>A wonderful serenity has taken possession of my entire soul, like these sweet mornings of spring which I enjoy with my whole heart. I am alone, and feel the charm of existence in this spot, which was created for the bliss of souls like mine.</p>
|
|
||||||
<p>I should be incapable of drawing a single stroke at the present moment; and yet I feel that I never was a greater artist than now. When, while the lovely valley teems with vapour around me.</p>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
|
|
||||||
const epubChapter2 = `<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
|
||||||
<head><title>Test Book</title></head>
|
|
||||||
<body>
|
|
||||||
<h1 id="ch2">Chapter Two</h1>
|
|
||||||
<p>Call me Ishmael. Some years ago—never mind how long precisely—having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.</p>
|
|
||||||
<p>It is a way I have of driving off the spleen and regulating the circulation. Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul.</p>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
|
|
||||||
const kepubChapter1 = `<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
|
||||||
<head><title>Test Book</title></head>
|
|
||||||
<body>
|
|
||||||
<h1 id="ch1">Chapter One</h1>
|
|
||||||
<p><span class="koboSpan" id="kobo.1.1">It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, it was the epoch of belief, it was the epoch of incredulity, it was the season of Light, it was the season of Darkness, it was the spring of hope, it was the winter of despair.</span></p>
|
|
||||||
<p id="p2"><span class="koboSpan" id="kobo.2.1">The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. How vexingly quick daft zebras jump.</span></p>
|
|
||||||
<p><span class="koboSpan" id="kobo.3.1">Far far away, behind the word mountains, far from the countries </span><em><span class="koboSpan" id="kobo.3.2">Vokalia</span></em><span class="koboSpan" id="kobo.3.3"> and </span><em><span class="koboSpan" id="kobo.3.4">Consonantia</span></em><span class="koboSpan" id="kobo.3.5">, there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean.</span></p>
|
|
||||||
<p><span class="koboSpan" id="kobo.4.1">A wonderful serenity has taken possession of my entire soul, like these sweet mornings of spring which I enjoy with my whole heart. I am alone, and feel the charm of existence in this spot, which was created for the bliss of souls like mine.</span></p>
|
|
||||||
<p><span class="koboSpan" id="kobo.5.1">I should be incapable of drawing a single stroke at the present moment; and yet I feel that I never was a greater artist than now. When, while the lovely valley teems with vapour around me.</span></p>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
|
|
||||||
const kepubChapter2 = `<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
|
||||||
<head><title>Test Book</title></head>
|
|
||||||
<body>
|
|
||||||
<h1 id="ch2">Chapter Two</h1>
|
|
||||||
<p><span class="koboSpan" id="kobo.6.1">Call me Ishmael. Some years ago—never mind how long precisely—having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.</span></p>
|
|
||||||
<p><span class="koboSpan" id="kobo.7.1">It is a way I have of driving off the spleen and regulating the circulation. Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul.</span></p>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
|
|
||||||
const containerXML = `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
|
||||||
<rootfiles>
|
|
||||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
|
||||||
</rootfiles>
|
|
||||||
</container>`
|
|
||||||
|
|
||||||
const contentOPF = `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
|
|
||||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
||||||
<dc:identifier id="uid">urn:uuid:test-book-00000001</dc:identifier>
|
|
||||||
<dc:title>Test Book</dc:title>
|
|
||||||
<dc:language>en</dc:language>
|
|
||||||
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
|
|
||||||
</metadata>
|
|
||||||
<manifest>
|
|
||||||
<item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
|
|
||||||
<item id="ch2" href="chapter2.xhtml" media-type="application/xhtml+xml"/>
|
|
||||||
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
|
|
||||||
</manifest>
|
|
||||||
<spine>
|
|
||||||
<itemref idref="ch1"/>
|
|
||||||
<itemref idref="ch2"/>
|
|
||||||
</spine>
|
|
||||||
</package>`
|
|
||||||
|
|
||||||
const navXHTML = `<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
|
||||||
<head><title>TOC</title></head>
|
|
||||||
<body>
|
|
||||||
<nav epub:type="toc"><ol><li><a href="chapter1.xhtml">Chapter 1</a></li><li><a href="chapter2.xhtml">Chapter 2</a></li></ol></nav>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
|
|
||||||
func createTestEPUB(t *testing.T, dir, name, ch1Content, ch2Content string) string {
|
|
||||||
t.Helper()
|
|
||||||
epubPath := filepath.Join(dir, name)
|
|
||||||
f, err := os.Create(epubPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
w := zip.NewWriter(f)
|
|
||||||
|
|
||||||
files := map[string]string{
|
|
||||||
"mimetype": "application/epub+zip",
|
|
||||||
"META-INF/container.xml": containerXML,
|
|
||||||
"OEBPS/content.opf": contentOPF,
|
|
||||||
"OEBPS/nav.xhtml": navXHTML,
|
|
||||||
"OEBPS/chapter1.xhtml": ch1Content,
|
|
||||||
"OEBPS/chapter2.xhtml": ch2Content,
|
|
||||||
}
|
|
||||||
|
|
||||||
mimetype, _ := w.Create("mimetype")
|
|
||||||
mimetype.Write([]byte("application/epub+zip"))
|
|
||||||
|
|
||||||
for path, content := range files {
|
|
||||||
if path == "mimetype" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fw, err := w.Create(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
fw.Write([]byte(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := w.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return epubPath
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupKEPUBTestEnv(t *testing.T) (epubPath, kepubPath string) {
|
|
||||||
t.Helper()
|
|
||||||
dir := t.TempDir()
|
|
||||||
epubPath = createTestEPUB(t, dir, "test.epub", epubChapter1, epubChapter2)
|
|
||||||
kepubPath = createTestEPUB(t, dir, "test.kepub.epub", kepubChapter1, kepubChapter2)
|
|
||||||
return epubPath, kepubPath
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKEPUBConvertKEPUBToStandard_TextSearch(t *testing.T) {
|
|
||||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
|
||||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
|
||||||
|
|
||||||
epubOnly := NewCFIConverter(epubPath)
|
|
||||||
epubSpine, err := epubOnly.loadSpine()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load epub spine: %v", err)
|
|
||||||
}
|
|
||||||
if len(epubSpine.items) < 1 {
|
|
||||||
t.Fatal("expected at least 1 spine item")
|
|
||||||
}
|
|
||||||
|
|
||||||
epubDoc, _, err := epubOnly.getContentDoc(1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get epub content doc: %v", err)
|
|
||||||
}
|
|
||||||
epubBody := findBody(epubDoc)
|
|
||||||
if epubBody == nil {
|
|
||||||
t.Fatal("no body in epub doc")
|
|
||||||
}
|
|
||||||
|
|
||||||
searchText := normalizeWhitespace("it was the worst of times")
|
|
||||||
epubNode, epubOffset := findTextInNode(epubBody, searchText)
|
|
||||||
if epubNode == nil {
|
|
||||||
t.Fatal("could not find target text in EPUB")
|
|
||||||
}
|
|
||||||
|
|
||||||
standardCFI, err := buildCFI(0, epubNode, epubOffset)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build standard CFI: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("Standard EPUB CFI: %s", standardCFI)
|
|
||||||
|
|
||||||
kepubOnly := NewCFIConverter(kepubPath)
|
|
||||||
kepubDoc, _, err := kepubOnly.getContentDoc(1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get kepub content doc: %v", err)
|
|
||||||
}
|
|
||||||
kepubBody := findBody(kepubDoc)
|
|
||||||
if kepubBody == nil {
|
|
||||||
t.Fatal("no body in kepub doc")
|
|
||||||
}
|
|
||||||
|
|
||||||
kepubNode, kepubOffset := findTextInNode(kepubBody, searchText)
|
|
||||||
if kepubNode == nil {
|
|
||||||
t.Fatal("could not find target text in KEPUB")
|
|
||||||
}
|
|
||||||
|
|
||||||
kepubCFI, err := buildCFI(0, kepubNode, kepubOffset)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build kepub CFI: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("KEPUB CFI: %s", kepubCFI)
|
|
||||||
|
|
||||||
if standardCFI == kepubCFI {
|
|
||||||
t.Error("KEPUB and standard CFIs should differ due to koboSpan wrappers")
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.05, searchText)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ConvertKEPUBCFIToStandard error: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("Converted CFI: %s (precision: %s)", result.CFI, result.Precision)
|
|
||||||
|
|
||||||
if result.Precision != "exact" {
|
|
||||||
t.Errorf("expected exact precision, got %s", result.Precision)
|
|
||||||
}
|
|
||||||
if result.CFI == "" {
|
|
||||||
t.Fatal("expected non-empty CFI")
|
|
||||||
}
|
|
||||||
|
|
||||||
spineIdx1, steps1, _ := parseEPUBCFI(result.CFI)
|
|
||||||
spineIdx2, steps2, _ := parseEPUBCFI(standardCFI)
|
|
||||||
if spineIdx1 != spineIdx2 {
|
|
||||||
t.Errorf("spine indices differ: %d vs %d", spineIdx1, spineIdx2)
|
|
||||||
}
|
|
||||||
|
|
||||||
resultNode, _, err := resolveCFIToNode(epubDoc, steps1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve converted CFI: %v", err)
|
|
||||||
}
|
|
||||||
expectedNode, _, _ := resolveCFIToNode(epubDoc, steps2)
|
|
||||||
|
|
||||||
if resultNode != expectedNode {
|
|
||||||
t.Errorf("resolved to different text nodes: got %q, want %q",
|
|
||||||
truncateText(resultNode.Data, 40),
|
|
||||||
truncateText(expectedNode.Data, 40))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKEPUBConvertStandardToKEPUB_TextSearch(t *testing.T) {
|
|
||||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
|
||||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
|
||||||
|
|
||||||
epubOnly := NewCFIConverter(epubPath)
|
|
||||||
epubDoc, _, err := epubOnly.getContentDoc(1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get epub doc: %v", err)
|
|
||||||
}
|
|
||||||
epubBody := findBody(epubDoc)
|
|
||||||
|
|
||||||
searchText := normalizeWhitespace("The quick brown fox jumps over the lazy dog")
|
|
||||||
epubNode, epubOffset := findTextInNode(epubBody, searchText)
|
|
||||||
if epubNode == nil {
|
|
||||||
t.Fatal("could not find target text in EPUB")
|
|
||||||
}
|
|
||||||
|
|
||||||
standardCFI, err := buildCFI(0, epubNode, epubOffset)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build CFI: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("Standard CFI: %s", standardCFI)
|
|
||||||
|
|
||||||
result, err := converter.ConvertStandardCFIToKEPUB(standardCFI, 0.1, searchText)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ConvertStandardCFIToKEPUB error: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("KEPUB CFI: %s (precision: %s)", result.CFI, result.Precision)
|
|
||||||
|
|
||||||
if result.Precision != "exact" {
|
|
||||||
t.Errorf("expected exact precision, got %s", result.Precision)
|
|
||||||
}
|
|
||||||
if result.CFI == "" {
|
|
||||||
t.Fatal("expected non-empty CFI")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(result.CFI, "kobo") {
|
|
||||||
t.Errorf("KEPUB CFI should contain koboSpan step: %s", result.CFI)
|
|
||||||
}
|
|
||||||
|
|
||||||
backResult, err := converter.ConvertKEPUBCFIToStandard(result.CFI, 0.1, searchText)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("KEPUB→standard round-trip: %v", err)
|
|
||||||
}
|
|
||||||
if backResult.CFI != standardCFI {
|
|
||||||
t.Errorf("round-trip mismatch: got %s, want %s", backResult.CFI, standardCFI)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKEPUBRoundTrip(t *testing.T) {
|
|
||||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
|
||||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
|
||||||
|
|
||||||
epubOnly := NewCFIConverter(epubPath)
|
|
||||||
epubDoc, _, err := epubOnly.getContentDoc(1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get epub doc: %v", err)
|
|
||||||
}
|
|
||||||
epubBody := findBody(epubDoc)
|
|
||||||
|
|
||||||
phrases := []string{
|
|
||||||
"It was the best of times",
|
|
||||||
"The quick brown fox jumps",
|
|
||||||
"A wonderful serenity has taken possession",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, phrase := range phrases {
|
|
||||||
t.Run(phrase, func(t *testing.T) {
|
|
||||||
searchText := normalizeWhitespace(phrase)
|
|
||||||
epubNode, epubOffset := findTextInNode(epubBody, searchText)
|
|
||||||
if epubNode == nil {
|
|
||||||
t.Fatalf("could not find %q in epub", phrase)
|
|
||||||
}
|
|
||||||
|
|
||||||
originalCFI, err := buildCFI(0, epubNode, epubOffset)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build CFI: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("Original standard CFI: %s", originalCFI)
|
|
||||||
|
|
||||||
toKEPUB, err := converter.ConvertStandardCFIToKEPUB(originalCFI, 0.1, searchText)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("standard→KEPUB: %v", err)
|
|
||||||
}
|
|
||||||
if toKEPUB.Precision != "exact" {
|
|
||||||
t.Fatalf("expected exact precision in standard→KEPUB, got %s", toKEPUB.Precision)
|
|
||||||
}
|
|
||||||
t.Logf("KEPUB CFI: %s", toKEPUB.CFI)
|
|
||||||
|
|
||||||
backToStandard, err := converter.ConvertKEPUBCFIToStandard(toKEPUB.CFI, 0.1, searchText)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("KEPUB→standard: %v", err)
|
|
||||||
}
|
|
||||||
if backToStandard.Precision != "exact" {
|
|
||||||
t.Fatalf("expected exact precision in KEPUB→standard, got %s", backToStandard.Precision)
|
|
||||||
}
|
|
||||||
t.Logf("Round-trip standard CFI: %s", backToStandard.CFI)
|
|
||||||
|
|
||||||
origSpine, origSteps, _ := parseEPUBCFI(originalCFI)
|
|
||||||
rtSpine, rtSteps, _ := parseEPUBCFI(backToStandard.CFI)
|
|
||||||
|
|
||||||
if origSpine != rtSpine {
|
|
||||||
t.Errorf("spine mismatch: original=%d roundtrip=%d", origSpine, rtSpine)
|
|
||||||
}
|
|
||||||
|
|
||||||
origNode, _, _ := resolveCFIToNode(epubDoc, origSteps)
|
|
||||||
rtNode, _, _ := resolveCFIToNode(epubDoc, rtSteps)
|
|
||||||
|
|
||||||
if origNode != rtNode {
|
|
||||||
t.Errorf("resolved to different nodes:\n original: %q\n roundtrip: %q",
|
|
||||||
truncateText(origNode.Data, 50),
|
|
||||||
truncateText(rtNode.Data, 50))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKEPUBConvertKEPUBToStandard_Chapter2(t *testing.T) {
|
|
||||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
|
||||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
|
||||||
|
|
||||||
searchText := normalizeWhitespace("Call me Ishmael")
|
|
||||||
|
|
||||||
kepubOnly := NewCFIConverter(kepubPath)
|
|
||||||
kepubDoc, _, err := kepubOnly.getContentDoc(2)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get kepub doc ch2: %v", err)
|
|
||||||
}
|
|
||||||
kepubBody := findBody(kepubDoc)
|
|
||||||
kepubNode, kepubOffset := findTextInNode(kepubBody, searchText)
|
|
||||||
if kepubNode == nil {
|
|
||||||
t.Fatal("could not find text in kepub ch2")
|
|
||||||
}
|
|
||||||
|
|
||||||
kepubCFI, err := buildCFI(1, kepubNode, kepubOffset)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build kepub CFI: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("KEPUB CFI (ch2): %s", kepubCFI)
|
|
||||||
|
|
||||||
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.55, searchText)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ConvertKEPUBCFIToStandard: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Precision != "exact" {
|
|
||||||
t.Errorf("expected exact precision, got %s", result.Precision)
|
|
||||||
}
|
|
||||||
|
|
||||||
epubOnly := NewCFIConverter(epubPath)
|
|
||||||
epubDoc, _, _ := epubOnly.getContentDoc(2)
|
|
||||||
epubBody := findBody(epubDoc)
|
|
||||||
|
|
||||||
epubNode, epubOffset := findTextInNode(epubBody, searchText)
|
|
||||||
expectedCFI, _ := buildCFI(1, epubNode, epubOffset)
|
|
||||||
t.Logf("Expected standard CFI: %s", expectedCFI)
|
|
||||||
t.Logf("Got standard CFI: %s", result.CFI)
|
|
||||||
|
|
||||||
_, resultSteps, _ := parseEPUBCFI(result.CFI)
|
|
||||||
resultNode, _, resolveErr := resolveCFIToNode(epubDoc, resultSteps)
|
|
||||||
if resolveErr != nil {
|
|
||||||
t.Fatalf("resolve result CFI: %v", resolveErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resultNode == nil {
|
|
||||||
t.Fatal("resolved to nil node")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(strings.ToLower(resultNode.Data), "call me ishmael") {
|
|
||||||
t.Errorf("resolved to wrong text: %q", truncateText(resultNode.Data, 50))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKEPUBConvertWithEmElements(t *testing.T) {
|
|
||||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
|
||||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
|
||||||
|
|
||||||
searchText := normalizeWhitespace("Vokalia and Consonantia")
|
|
||||||
|
|
||||||
epubOnly := NewCFIConverter(epubPath)
|
|
||||||
epubDoc, _, err := epubOnly.getContentDoc(1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get epub doc: %v", err)
|
|
||||||
}
|
|
||||||
epubBody := findBody(epubDoc)
|
|
||||||
|
|
||||||
epubNode, epubOffset := findTextInNode(epubBody, searchText)
|
|
||||||
if epubNode == nil {
|
|
||||||
t.Fatalf("findTextInNode should find text spanning <em> elements: %q", searchText)
|
|
||||||
}
|
|
||||||
|
|
||||||
standardCFI, err := buildCFI(0, epubNode, epubOffset)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build CFI: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("Standard CFI (em): %s", standardCFI)
|
|
||||||
|
|
||||||
result, err := converter.ConvertStandardCFIToKEPUB(standardCFI, 0.2, searchText)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ConvertStandardCFIToKEPUB: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("KEPUB CFI (em elements): %s (precision: %s)", result.CFI, result.Precision)
|
|
||||||
|
|
||||||
if result.CFI == "" {
|
|
||||||
t.Error("expected non-empty CFI")
|
|
||||||
}
|
|
||||||
if result.Precision != "exact" {
|
|
||||||
t.Errorf("expected exact precision for cross-element text, got %s", result.Precision)
|
|
||||||
}
|
|
||||||
|
|
||||||
backResult, err := converter.ConvertKEPUBCFIToStandard(result.CFI, 0.2, searchText)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("KEPUB→standard round-trip: %v", err)
|
|
||||||
}
|
|
||||||
if backResult.Precision != "exact" {
|
|
||||||
t.Errorf("expected exact precision on round-trip, got %s", backResult.Precision)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKEPUBConvertInvalidCFI(t *testing.T) {
|
|
||||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
|
||||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
|
||||||
|
|
||||||
result, err := converter.ConvertKEPUBCFIToStandard("not-a-cfi", 0.5, "some text")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if result.Precision != "percentage" {
|
|
||||||
t.Errorf("expected percentage fallback, got %s", result.Precision)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err = converter.ConvertStandardCFIToKEPUB("not-a-cfi", 0.5, "some text")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if result.Precision != "percentage" {
|
|
||||||
t.Errorf("expected percentage fallback, got %s", result.Precision)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKEPUBConvertNoContextText(t *testing.T) {
|
|
||||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
|
||||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
|
||||||
|
|
||||||
epubOnly := NewCFIConverter(epubPath)
|
|
||||||
epubDoc, _, err := epubOnly.getContentDoc(1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get epub doc: %v", err)
|
|
||||||
}
|
|
||||||
epubBody := findBody(epubDoc)
|
|
||||||
|
|
||||||
epubNode, epubOffset := findTextInNode(epubBody, normalizeWhitespace("It was the best of times"))
|
|
||||||
if epubNode == nil {
|
|
||||||
t.Fatal("could not find text")
|
|
||||||
}
|
|
||||||
|
|
||||||
standardCFI, err := buildCFI(0, epubNode, epubOffset)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build CFI: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := converter.ConvertStandardCFIToKEPUB(standardCFI, 0.1, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ConvertStandardCFIToKEPUB (no context): %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("No-context result: CFI=%s precision=%s", result.CFI, result.Precision)
|
|
||||||
|
|
||||||
if result.CFI == "" {
|
|
||||||
t.Error("expected non-empty CFI even without context text")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKEPUBConvertKEPUBToStandard_NoContextText(t *testing.T) {
|
|
||||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
|
||||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
|
||||||
|
|
||||||
kepubOnly := NewCFIConverter(kepubPath)
|
|
||||||
kepubDoc, _, err := kepubOnly.getContentDoc(1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get kepub doc: %v", err)
|
|
||||||
}
|
|
||||||
kepubBody := findBody(kepubDoc)
|
|
||||||
|
|
||||||
kepubNode, kepubOffset := findTextInNode(kepubBody, normalizeWhitespace("It was the best of times"))
|
|
||||||
if kepubNode == nil {
|
|
||||||
t.Fatal("could not find text in kepub")
|
|
||||||
}
|
|
||||||
|
|
||||||
kepubCFI, err := buildCFI(0, kepubNode, kepubOffset)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build CFI: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("KEPUB CFI: %s", kepubCFI)
|
|
||||||
|
|
||||||
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.05, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ConvertKEPUBCFIToStandard (no context): %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("No-context result: CFI=%s precision=%s", result.CFI, result.Precision)
|
|
||||||
|
|
||||||
if result.CFI == "" {
|
|
||||||
t.Error("expected non-empty CFI even without context text")
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Precision == "exact" {
|
|
||||||
epubOnly := NewCFIConverter(epubPath)
|
|
||||||
epubDoc, _, _ := epubOnly.getContentDoc(1)
|
|
||||||
epubBody := findBody(epubDoc)
|
|
||||||
|
|
||||||
expectedNode, _ := findTextInNode(epubBody, normalizeWhitespace("It was the best of times"))
|
|
||||||
_, resultSteps, _ := parseEPUBCFI(result.CFI)
|
|
||||||
actualNode, _, _ := resolveCFIToNode(epubDoc, resultSteps)
|
|
||||||
|
|
||||||
if actualNode != expectedNode {
|
|
||||||
t.Errorf("resolved to wrong node: got %q, want text containing 'It was the best of times'",
|
|
||||||
truncateText(actualNode.Data, 40))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKEPUBPercentageFallback(t *testing.T) {
|
|
||||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
|
||||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
|
||||||
|
|
||||||
badCFI := "epubcfi(/6/2!/4/99999/1:0)"
|
|
||||||
result, err := converter.ConvertKEPUBCFIToStandard(badCFI, 0.5, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Precision != "percentage" {
|
|
||||||
t.Errorf("expected percentage precision for unresolvable CFI, got %s", result.Precision)
|
|
||||||
}
|
|
||||||
|
|
||||||
result2, err := converter.ConvertStandardCFIToKEPUB(badCFI, 0.5, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result2.Precision != "percentage" {
|
|
||||||
t.Errorf("expected percentage precision for unresolvable CFI, got %s", result2.Precision)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractSurroundingText(t *testing.T) {
|
|
||||||
text := "Hello world, this is a test of the surrounding text extraction function."
|
|
||||||
runes := []rune(text)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
offset int
|
|
||||||
window int
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{30, 10, "a test of the surro"},
|
|
||||||
{0, 5, "Hello"},
|
|
||||||
{len(runes) - 1, 5, "ction."},
|
|
||||||
{15, 100, text},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
node := &html.Node{Type: html.TextNode, Data: text}
|
|
||||||
got := extractSurroundingText(node, tt.offset, tt.window)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("extractSurroundingText(offset=%d, window=%d) = %q, want %q", tt.offset, tt.window, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKEPUBMultipleParagraphsRoundTrip(t *testing.T) {
|
|
||||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
|
||||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
|
||||||
|
|
||||||
epubOnly := NewCFIConverter(epubPath)
|
|
||||||
epubDoc, _, _ := epubOnly.getContentDoc(1)
|
|
||||||
epubBody := findBody(epubDoc)
|
|
||||||
|
|
||||||
type testCase struct {
|
|
||||||
name string
|
|
||||||
searchText string
|
|
||||||
percentage float64
|
|
||||||
}
|
|
||||||
|
|
||||||
cases := []testCase{
|
|
||||||
{"first paragraph", "It was the best of times", 0.01},
|
|
||||||
{"second paragraph", "Pack my box with five dozen", 0.15},
|
|
||||||
{"third paragraph", "Far far away, behind the word mountains", 0.25},
|
|
||||||
{"fourth paragraph", "A wonderful serenity has taken possession", 0.55},
|
|
||||||
{"fifth paragraph", "I should be incapable of drawing", 0.80},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
text := normalizeWhitespace(tc.searchText)
|
|
||||||
epubNode, epubOffset := findTextInNode(epubBody, text)
|
|
||||||
if epubNode == nil {
|
|
||||||
t.Fatalf("could not find %q in epub", tc.searchText)
|
|
||||||
}
|
|
||||||
|
|
||||||
standardCFI, _ := buildCFI(0, epubNode, epubOffset)
|
|
||||||
|
|
||||||
kepubResult, err := converter.ConvertStandardCFIToKEPUB(standardCFI, tc.percentage, text)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("standard→KEPUB: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
backResult, err := converter.ConvertKEPUBCFIToStandard(kepubResult.CFI, tc.percentage, text)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("KEPUB→standard: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if backResult.Precision != "exact" {
|
|
||||||
t.Errorf("round-trip precision = %s, want exact (CFI: %s)", backResult.Precision, backResult.CFI)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, origSteps, _ := parseEPUBCFI(standardCFI)
|
|
||||||
_, rtSteps, _ := parseEPUBCFI(backResult.CFI)
|
|
||||||
|
|
||||||
origNode, _, _ := resolveCFIToNode(epubDoc, origSteps)
|
|
||||||
rtNode, _, _ := resolveCFIToNode(epubDoc, rtSteps)
|
|
||||||
|
|
||||||
if origNode != rtNode {
|
|
||||||
t.Errorf("round-trip resolved to different nodes:\n orig: %q\n rt: %q",
|
|
||||||
truncateText(origNode.Data, 40),
|
|
||||||
truncateText(rtNode.Data, 40))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKEPUBCFIsDifferFromStandard(t *testing.T) {
|
|
||||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
|
||||||
|
|
||||||
epubOnly := NewCFIConverter(epubPath)
|
|
||||||
epubDoc, _, _ := epubOnly.getContentDoc(1)
|
|
||||||
epubBody := findBody(epubDoc)
|
|
||||||
|
|
||||||
kepubOnly := NewCFIConverter(kepubPath)
|
|
||||||
kepubDoc, _, _ := kepubOnly.getContentDoc(1)
|
|
||||||
kepubBody := findBody(kepubDoc)
|
|
||||||
|
|
||||||
text := normalizeWhitespace("It was the best of times")
|
|
||||||
|
|
||||||
epubNode, epubOffset := findTextInNode(epubBody, text)
|
|
||||||
kepubNode, kepubOffset := findTextInNode(kepubBody, text)
|
|
||||||
|
|
||||||
if epubNode == nil || kepubNode == nil {
|
|
||||||
t.Fatal("could not find text in one or both docs")
|
|
||||||
}
|
|
||||||
|
|
||||||
epubCFI, _ := buildCFI(0, epubNode, epubOffset)
|
|
||||||
kepubCFI, _ := buildCFI(0, kepubNode, kepubOffset)
|
|
||||||
|
|
||||||
t.Logf("EPUB CFI: %s", epubCFI)
|
|
||||||
t.Logf("KEPUB CFI: %s", kepubCFI)
|
|
||||||
|
|
||||||
if epubCFI == kepubCFI {
|
|
||||||
t.Error("EPUB and KEPUB CFIs should differ due to koboSpan wrappers")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, epubSteps, _ := parseEPUBCFI(epubCFI)
|
|
||||||
_, kepubSteps, _ := parseEPUBCFI(kepubCFI)
|
|
||||||
|
|
||||||
if len(kepubSteps) <= len(epubSteps) {
|
|
||||||
t.Errorf("KEPUB CFI should have more steps than EPUB CFI (koboSpan adds nesting): epub=%d kepub=%d",
|
|
||||||
len(epubSteps), len(kepubSteps))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func truncateText(s string, maxRunes int) string {
|
|
||||||
runes := []rune(s)
|
|
||||||
if len(runes) <= maxRunes {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return string(runes[:maxRunes]) + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKEPUBChapterSpineIndices(t *testing.T) {
|
|
||||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
|
||||||
|
|
||||||
epubOnly := NewCFIConverter(epubPath)
|
|
||||||
kepubOnly := NewCFIConverter(kepubPath)
|
|
||||||
|
|
||||||
epubSpine, err := epubOnly.loadSpine()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load epub spine: %v", err)
|
|
||||||
}
|
|
||||||
kepubSpine, err := kepubOnly.loadSpine()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load kepub spine: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(epubSpine.items) != len(kepubSpine.items) {
|
|
||||||
t.Errorf("spine count mismatch: epub=%d kepub=%d", len(epubSpine.items), len(kepubSpine.items))
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range epubSpine.items {
|
|
||||||
if epubSpine.items[i].href != kepubSpine.items[i].href {
|
|
||||||
t.Errorf("spine item %d href mismatch: epub=%s kepub=%s",
|
|
||||||
i, epubSpine.items[i].href, kepubSpine.items[i].href)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Both files have %d spine items, matching correctly", len(epubSpine.items))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKEPUBRealBookConvert(t *testing.T) {
|
|
||||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Charles Dickens/A Tale of Two Cities (111)/A Tale of Two Cities - Charles Dickens.epub"
|
|
||||||
if _, err := os.Stat(epubPath); err != nil {
|
|
||||||
t.Skipf("EPUB not found: %s", epubPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
kepubPath := filepath.Join(dir, "test.kepub.epub")
|
|
||||||
|
|
||||||
epubOnly := NewCFIConverter(epubPath)
|
|
||||||
epubDoc, _, err := epubOnly.getContentDoc(1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get epub doc: %v", err)
|
|
||||||
}
|
|
||||||
epubBody := findBody(epubDoc)
|
|
||||||
if epubBody == nil {
|
|
||||||
t.Fatal("no body")
|
|
||||||
}
|
|
||||||
|
|
||||||
createMinimalKEPUBFromEPUB(t, epubPath, kepubPath)
|
|
||||||
|
|
||||||
kepubOnly := NewCFIConverter(kepubPath)
|
|
||||||
kepubDoc, _, err := kepubOnly.getContentDoc(1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get kepub doc: %v", err)
|
|
||||||
}
|
|
||||||
kepubBody := findBody(kepubDoc)
|
|
||||||
|
|
||||||
searchText := normalizeWhitespace("It was the best of times")
|
|
||||||
epubNode, epubOffset := findTextInNode(epubBody, searchText)
|
|
||||||
if epubNode == nil {
|
|
||||||
t.Skip("phrase not found in epub")
|
|
||||||
}
|
|
||||||
|
|
||||||
standardCFI, err := buildCFI(0, epubNode, epubOffset)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build standard CFI: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("Standard CFI: %s", standardCFI)
|
|
||||||
|
|
||||||
kepubNode, kepubOffset := findTextInNode(kepubBody, searchText)
|
|
||||||
if kepubNode == nil {
|
|
||||||
t.Skip("phrase not found in kepub")
|
|
||||||
}
|
|
||||||
kepubCFI, _ := buildCFI(0, kepubNode, kepubOffset)
|
|
||||||
t.Logf("KEPUB CFI: %s", kepubCFI)
|
|
||||||
|
|
||||||
if standardCFI == kepubCFI {
|
|
||||||
t.Log("Note: CFIs are same (koboSpan wrappers may not have changed structure in this position)")
|
|
||||||
}
|
|
||||||
|
|
||||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
|
||||||
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.01, searchText)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ConvertKEPUBCFIToStandard: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("Converted: CFI=%s precision=%s", result.CFI, result.Precision)
|
|
||||||
|
|
||||||
if result.Precision != "exact" {
|
|
||||||
t.Errorf("expected exact precision, got %s", result.Precision)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createMinimalKEPUBFromEPUB(t *testing.T, epubPath, kepubPath string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
r, err := zip.OpenReader(epubPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open epub: %v", err)
|
|
||||||
}
|
|
||||||
defer r.Close()
|
|
||||||
|
|
||||||
f, err := os.Create(kepubPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create kepub: %v", err)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
w := zip.NewWriter(f)
|
|
||||||
|
|
||||||
for _, file := range r.File {
|
|
||||||
rc, err := file.Open()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open %s: %v", file.Name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fw, err := w.Create(file.Name)
|
|
||||||
if err != nil {
|
|
||||||
rc.Close()
|
|
||||||
t.Fatalf("create %s: %v", file.Name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isXHTML(file.Name) {
|
|
||||||
var buf []byte
|
|
||||||
buf, err = readZipFileData(rc)
|
|
||||||
if err != nil {
|
|
||||||
rc.Close()
|
|
||||||
t.Fatalf("read %s: %v", file.Name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
kepubContent := addKoboSpans(string(buf))
|
|
||||||
fw.Write([]byte(kepubContent))
|
|
||||||
} else {
|
|
||||||
buf := make([]byte, 4096)
|
|
||||||
for {
|
|
||||||
n, err := rc.Read(buf)
|
|
||||||
if n > 0 {
|
|
||||||
fw.Write(buf[:n])
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rc.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := w.Close(); err != nil {
|
|
||||||
t.Fatalf("close kepub writer: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isXHTML(name string) bool {
|
|
||||||
return strings.HasSuffix(strings.ToLower(name), ".xhtml") ||
|
|
||||||
strings.HasSuffix(strings.ToLower(name), ".html") ||
|
|
||||||
strings.HasSuffix(strings.ToLower(name), ".htm")
|
|
||||||
}
|
|
||||||
|
|
||||||
func addKoboSpans(xhtmlContent string) string {
|
|
||||||
result := xhtmlContent
|
|
||||||
result = strings.ReplaceAll(result, "</p>", "</span></p>")
|
|
||||||
result = strings.ReplaceAll(result, "<p>", "<p><span class=\"koboSpan\">")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func readZipFileData(rc io.ReadCloser) ([]byte, error) {
|
|
||||||
defer rc.Close()
|
|
||||||
return io.ReadAll(rc)
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = io.ReadAll
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
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}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -288,7 +288,6 @@ type SaveProgressRequest struct {
|
|||||||
|
|
||||||
Percentage *float64
|
Percentage *float64
|
||||||
Epubcfi *string
|
Epubcfi *string
|
||||||
ContextText *string
|
|
||||||
CharacterOffset *int64
|
CharacterOffset *int64
|
||||||
Chapter *int
|
Chapter *int
|
||||||
ChapterProgress *float64
|
ChapterProgress *float64
|
||||||
@@ -335,7 +334,6 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
|||||||
params.Percentage = existing.Percentage
|
params.Percentage = existing.Percentage
|
||||||
params.CharacterOffset = existing.CharacterOffset
|
params.CharacterOffset = existing.CharacterOffset
|
||||||
params.Epubcfi = existing.Epubcfi
|
params.Epubcfi = existing.Epubcfi
|
||||||
params.ContextText = existing.ContextText
|
|
||||||
params.Chapter = existing.Chapter
|
params.Chapter = existing.Chapter
|
||||||
params.ChapterProgress = existing.ChapterProgress
|
params.ChapterProgress = existing.ChapterProgress
|
||||||
params.ViewportX = existing.ViewportX
|
params.ViewportX = existing.ViewportX
|
||||||
@@ -357,9 +355,6 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
|||||||
if req.Epubcfi != nil {
|
if req.Epubcfi != nil {
|
||||||
params.Epubcfi = pgtype.Text{String: *req.Epubcfi, Valid: *req.Epubcfi != ""}
|
params.Epubcfi = pgtype.Text{String: *req.Epubcfi, Valid: *req.Epubcfi != ""}
|
||||||
}
|
}
|
||||||
if req.ContextText != nil {
|
|
||||||
params.ContextText = pgtype.Text{String: *req.ContextText, Valid: *req.ContextText != ""}
|
|
||||||
}
|
|
||||||
if req.CharacterOffset != nil {
|
if req.CharacterOffset != nil {
|
||||||
params.CharacterOffset = pgtype.Int8{Int64: *req.CharacterOffset, Valid: true}
|
params.CharacterOffset = pgtype.Int8{Int64: *req.CharacterOffset, Valid: true}
|
||||||
}
|
}
|
||||||
@@ -400,23 +395,6 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
|||||||
params.LastSyncDevice = pgtype.Text{String: req.Source, Valid: true}
|
params.LastSyncDevice = pgtype.Text{String: req.Source, Valid: true}
|
||||||
params.LastSyncSource = pgtype.Text{String: req.Source, Valid: true}
|
params.LastSyncSource = pgtype.Text{String: req.Source, Valid: true}
|
||||||
|
|
||||||
formatGroup := FormatGroup(mediaItem.FormatGroup)
|
|
||||||
isFixed := formatGroup == FormatGroupFixedLayout || formatGroup == FormatGroupComicArchive
|
|
||||||
if isFixed {
|
|
||||||
// Fixed-layout & comic formats: the page index is the canonical locator.
|
|
||||||
// CFI/character-offset are meaningless for image-based content, so clear
|
|
||||||
// any stale value that may have been stored by the web reader (which
|
|
||||||
// generates fake CFIs for comics).
|
|
||||||
params.Epubcfi = pgtype.Text{}
|
|
||||||
params.CharacterOffset = pgtype.Int8{}
|
|
||||||
if params.CurrentPage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 {
|
|
||||||
pct := PageToPercentage(int(params.CurrentPage.Int32), int(params.TotalPages.Int32))
|
|
||||||
params.Percentage = pgtype.Float8{Float64: pct, Valid: true}
|
|
||||||
} else if params.Percentage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 && !params.CurrentPage.Valid {
|
|
||||||
page := PercentageToPage(params.Percentage.Float64, int(params.TotalPages.Int32))
|
|
||||||
params.CurrentPage = pgtype.Int4{Int32: int32(page), Valid: true}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if params.Percentage.Valid && !params.CharacterOffset.Valid && mediaItem.TotalCharacters.Valid && mediaItem.TotalCharacters.Int64 > 0 {
|
if params.Percentage.Valid && !params.CharacterOffset.Valid && mediaItem.TotalCharacters.Valid && mediaItem.TotalCharacters.Int64 > 0 {
|
||||||
charOff := PercentageToCharacter(params.Percentage.Float64, mediaItem.TotalCharacters.Int64)
|
charOff := PercentageToCharacter(params.Percentage.Float64, mediaItem.TotalCharacters.Int64)
|
||||||
params.CharacterOffset = pgtype.Int8{Int64: charOff, Valid: true}
|
params.CharacterOffset = pgtype.Int8{Int64: charOff, Valid: true}
|
||||||
@@ -433,7 +411,6 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
|||||||
pct := CharacterToPercentage(params.CharacterOffset.Int64, mediaItem.TotalCharacters.Int64)
|
pct := CharacterToPercentage(params.CharacterOffset.Int64, mediaItem.TotalCharacters.Int64)
|
||||||
params.Percentage = pgtype.Float8{Float64: pct, Valid: true}
|
params.Percentage = pgtype.Float8{Float64: pct, Valid: true}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
conflictDetected := false
|
conflictDetected := false
|
||||||
if hasExisting && existing.LastSyncSource.Valid && existing.LastSyncSource.String != req.Source {
|
if hasExisting && existing.LastSyncSource.Valid && existing.LastSyncSource.String != req.Source {
|
||||||
@@ -453,17 +430,11 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
|||||||
diff = -diff
|
diff = -diff
|
||||||
}
|
}
|
||||||
if diff > 0.01 {
|
if diff > 0.01 {
|
||||||
recentlyResolved, _ := s.db.HasRecentConflictResolution(ctx, database.HasRecentConflictResolutionParams{
|
|
||||||
MediaItemID: req.MediaItemID,
|
|
||||||
UserID: req.UserID,
|
|
||||||
})
|
|
||||||
if !recentlyResolved {
|
|
||||||
conflictDetected = true
|
conflictDetected = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
result, err := s.db.UpdateUniversalProgress(ctx, params)
|
result, err := s.db.UpdateUniversalProgress(ctx, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -471,13 +442,12 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
|||||||
}
|
}
|
||||||
|
|
||||||
if conflictDetected {
|
if conflictDetected {
|
||||||
conflictData := map[string]interface{}{
|
newData := map[string]interface{}{
|
||||||
req.Source: map[string]interface{}{
|
|
||||||
"source": req.Source,
|
"source": req.Source,
|
||||||
"timestamp": time.Now().Format(time.RFC3339),
|
"timestamp": time.Now().Format(time.RFC3339),
|
||||||
"data": buildProgressSnapshot(req),
|
"data": buildProgressSnapshot(req),
|
||||||
},
|
}
|
||||||
existing.LastSyncSource.String: map[string]interface{}{
|
existingData := map[string]interface{}{
|
||||||
"source": existing.LastSyncSource.String,
|
"source": existing.LastSyncSource.String,
|
||||||
"timestamp": existing.LastSyncTimestamp.Time.Format(time.RFC3339),
|
"timestamp": existing.LastSyncTimestamp.Time.Format(time.RFC3339),
|
||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
@@ -488,7 +458,10 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
|||||||
"page": int32Ptr(existing.CurrentPage),
|
"page": int32Ptr(existing.CurrentPage),
|
||||||
"total_pages": int32Ptr(existing.TotalPages),
|
"total_pages": int32Ptr(existing.TotalPages),
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
|
conflictData := map[string]interface{}{
|
||||||
|
"new": newData,
|
||||||
|
"existing": existingData,
|
||||||
}
|
}
|
||||||
conflictJSON, _ := json.Marshal(conflictData)
|
conflictJSON, _ := json.Marshal(conflictData)
|
||||||
_, err := s.db.CreateSyncConflict(ctx, database.CreateSyncConflictParams{
|
_, err := s.db.CreateSyncConflict(ctx, database.CreateSyncConflictParams{
|
||||||
@@ -535,9 +508,6 @@ func buildProgressSnapshot(req SaveProgressRequest) map[string]interface{} {
|
|||||||
if req.Epubcfi != nil {
|
if req.Epubcfi != nil {
|
||||||
data["epubcfi"] = *req.Epubcfi
|
data["epubcfi"] = *req.Epubcfi
|
||||||
}
|
}
|
||||||
if req.ContextText != nil {
|
|
||||||
data["context_text"] = *req.ContextText
|
|
||||||
}
|
|
||||||
if req.Chapter != nil {
|
if req.Chapter != nil {
|
||||||
data["chapter"] = *req.Chapter
|
data["chapter"] = *req.Chapter
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-237
@@ -37,7 +37,6 @@ const (
|
|||||||
type SyncQueueProcessor struct {
|
type SyncQueueProcessor struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
progressSvc *ProgressService
|
progressSvc *ProgressService
|
||||||
annotationSvc *AnnotationService
|
|
||||||
progressChan chan *ProgressUpdate
|
progressChan chan *ProgressUpdate
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
batchSize int
|
batchSize int
|
||||||
@@ -49,7 +48,6 @@ type ProgressUpdate struct {
|
|||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
Percentage float64
|
Percentage float64
|
||||||
Epubcfi *string
|
Epubcfi *string
|
||||||
ContextText *string
|
|
||||||
Chapter *int
|
Chapter *int
|
||||||
Character *int64
|
Character *int64
|
||||||
Page *int
|
Page *int
|
||||||
@@ -86,10 +84,6 @@ func (p *SyncQueueProcessor) SetProgressService(svc *ProgressService) {
|
|||||||
p.progressSvc = svc
|
p.progressSvc = svc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SyncQueueProcessor) SetAnnotationService(svc *AnnotationService) {
|
|
||||||
p.annotationSvc = svc
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SyncQueueProcessor) Start(ctx context.Context) {
|
func (p *SyncQueueProcessor) Start(ctx context.Context) {
|
||||||
log.Printf("Starting sync queue processor (interval: %v, batch: %d)", p.interval, p.batchSize)
|
log.Printf("Starting sync queue processor (interval: %v, batch: %d)", p.interval, p.batchSize)
|
||||||
|
|
||||||
@@ -118,100 +112,6 @@ 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) {
|
func (p *SyncQueueProcessor) enqueueProgressUpdate(ctx context.Context, update *ProgressUpdate) {
|
||||||
syncData := map[string]interface{}{
|
syncData := map[string]interface{}{
|
||||||
"percentage": update.Percentage,
|
"percentage": update.Percentage,
|
||||||
@@ -222,9 +122,6 @@ func (p *SyncQueueProcessor) enqueueProgressUpdate(ctx context.Context, update *
|
|||||||
if update.Epubcfi != nil {
|
if update.Epubcfi != nil {
|
||||||
syncData["epubcfi"] = *update.Epubcfi
|
syncData["epubcfi"] = *update.Epubcfi
|
||||||
}
|
}
|
||||||
if update.ContextText != nil {
|
|
||||||
syncData["context_text"] = *update.ContextText
|
|
||||||
}
|
|
||||||
if update.Chapter != nil {
|
if update.Chapter != nil {
|
||||||
syncData["chapter"] = *update.Chapter
|
syncData["chapter"] = *update.Chapter
|
||||||
}
|
}
|
||||||
@@ -407,8 +304,6 @@ func (p *SyncQueueProcessor) executeSync(ctx context.Context, item SyncQueueItem
|
|||||||
return p.syncNote(ctx, device.UserID, item.MediaItemID, syncData)
|
return p.syncNote(ctx, device.UserID, item.MediaItemID, syncData)
|
||||||
case SyncTypeHighlight:
|
case SyncTypeHighlight:
|
||||||
return p.syncHighlight(ctx, device.UserID, item.MediaItemID, syncData)
|
return p.syncHighlight(ctx, device.UserID, item.MediaItemID, syncData)
|
||||||
case SyncTypeBookmark:
|
|
||||||
return p.syncBookmark(ctx, device.UserID, item.MediaItemID, syncData)
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported sync type: %s", item.SyncType)
|
return fmt.Errorf("unsupported sync type: %s", item.SyncType)
|
||||||
}
|
}
|
||||||
@@ -436,9 +331,6 @@ func (p *SyncQueueProcessor) syncProgress(ctx context.Context, userID pgtype.UUI
|
|||||||
if v, ok := syncData["epubcfi"].(string); ok {
|
if v, ok := syncData["epubcfi"].(string); ok {
|
||||||
req.Epubcfi = &v
|
req.Epubcfi = &v
|
||||||
}
|
}
|
||||||
if v, ok := syncData["context_text"].(string); ok {
|
|
||||||
req.ContextText = &v
|
|
||||||
}
|
|
||||||
if v, ok := syncData["chapter"].(float64); ok {
|
if v, ok := syncData["chapter"].(float64); ok {
|
||||||
ch := int(v)
|
ch := int(v)
|
||||||
req.Chapter = &ch
|
req.Chapter = &ch
|
||||||
@@ -508,138 +400,11 @@ 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 {
|
func (p *SyncQueueProcessor) syncNote(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error {
|
||||||
if p.annotationSvc == nil {
|
return fmt.Errorf("note sync not yet implemented")
|
||||||
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 {
|
func (p *SyncQueueProcessor) syncHighlight(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error {
|
||||||
if p.annotationSvc == nil {
|
return fmt.Errorf("highlight sync not yet implemented")
|
||||||
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) {
|
func (p *SyncQueueProcessor) markItemFailed(ctx context.Context, item SyncQueueItem, errMsg string) {
|
||||||
|
|||||||
@@ -4,8 +4,14 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"golang.org/x/text/cases"
|
||||||
|
"golang.org/x/text/language"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// titleCaser is a global caser for titlecase conversion
|
||||||
|
var titleCaser = cases.Title(language.Und)
|
||||||
|
|
||||||
// titlecase converts a string to title case while preserving hyphenation and apostrophes
|
// titlecase converts a string to title case while preserving hyphenation and apostrophes
|
||||||
// Example: "science fiction" → "Science Fiction", "non-fiction" → "Non-Fiction", "o'reilly" → "O'Reilly"
|
// Example: "science fiction" → "Science Fiction", "non-fiction" → "Non-Fiction", "o'reilly" → "O'Reilly"
|
||||||
func titlecase(s string) string {
|
func titlecase(s string) string {
|
||||||
@@ -28,12 +34,7 @@ func titlecase(s string) string {
|
|||||||
}
|
}
|
||||||
words[i] = result.String()
|
words[i] = result.String()
|
||||||
} else {
|
} else {
|
||||||
runes := []rune(word)
|
words[i] = titleCaser.String(word)
|
||||||
runes[0] = unicode.ToUpper(runes[0])
|
|
||||||
for j := 1; j < len(runes); j++ {
|
|
||||||
runes[j] = unicode.ToLower(runes[j])
|
|
||||||
}
|
|
||||||
words[i] = string(runes)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return strings.Join(words, " ")
|
return strings.Join(words, " ")
|
||||||
|
|||||||
+4
-4
@@ -5,14 +5,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build:css": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --watch",
|
"build:css": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --watch",
|
||||||
"build:css:prod": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --minify",
|
"build:css:prod": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --minify",
|
||||||
"build:ts": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build",
|
"build:ts": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && mkdir -p web/static/vendor/pdfjs && cp -r node_modules/@bookhoard/foliate-js/vendor/pdfjs/standard_fonts web/static/vendor/pdfjs/ && vite build",
|
||||||
"build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build --mode development",
|
"build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && mkdir -p web/static/vendor/pdfjs && cp -r node_modules/@bookhoard/foliate-js/vendor/pdfjs/standard_fonts web/static/vendor/pdfjs/ && vite build --mode development",
|
||||||
"build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build --watch",
|
"build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && mkdir -p web/static/vendor/pdfjs && cp -r node_modules/@bookhoard/foliate-js/vendor/pdfjs/standard_fonts web/static/vendor/pdfjs/ && vite build --watch",
|
||||||
"build": "npm run build:ts && npm run build:css:prod",
|
"build": "npm run build:ts && npm run build:css:prod",
|
||||||
"dev": "npm run build:ts:dev && npm run build:css"
|
"dev": "npm run build:ts:dev && npm run build:css"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bookhoard/foliate-js": "github:john-okeefe/foliate-js#d164d6f",
|
"@bookhoard/foliate-js": "github:john-okeefe/foliate-js#74c317d58c2cedb53811da2973d0bf9bb6a70dcb",
|
||||||
"alpinejs": "^3.15.8",
|
"alpinejs": "^3.15.8",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
# Convert Fish config.fish to Zsh .zshrc
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Translate all functional content from `~/.config/fish/config.fish` into equivalent Zsh syntax, append it **below** the existing Forge-managed block in `~/.config/zsh/.zshrc`, and leave the existing content untouched.
|
||||||
|
|
||||||
|
## Source Analysis
|
||||||
|
|
||||||
|
### Fish config breakdown (`~/.config/fish/config.fish`, 178 lines)
|
||||||
|
|
||||||
|
| Lines | Category | Notes |
|
||||||
|
|-------|----------|-------|
|
||||||
|
| 1 | Comment (collapsed) | Multi-line comment got collapsed into one line; contains `set fish_greeting`, `set VIRTUAL_ENV_DISABLE_PROMPT`, `set -x SHELL /usr/bin/fish` |
|
||||||
|
| 2-3 | Man pager (bat) | `set -xU` universal env vars |
|
||||||
|
| 5-6 | Paru pager | `set -x` exported env var |
|
||||||
|
| 8-10 | Done plugin settings | `set -U` universal vars for `done` notification plugin |
|
||||||
|
| 12-16 | Source `~/.fish_profile` | Fish-specific profile file |
|
||||||
|
| 18-23 | PATH: `~/.local/bin` | Already in .zshrc (line 2) |
|
||||||
|
| 25-30 | PATH: `depot_tools` | Conditional PATH prepend |
|
||||||
|
| 32-37 | Starship + zoxide + atuin | Interactive-only init |
|
||||||
|
| 39-40 | find-the-command hook | Fish-specific (`ftc.fish`) |
|
||||||
|
| 42-76 | Bang-bang + history functions | `!!` and `!$` support + history formatting |
|
||||||
|
| 78-80 | `backup` function | Simple `cp` backup |
|
||||||
|
| 83-92 | `copy` function | Smart `cp` with directory detection |
|
||||||
|
| 94-102 | `cleanup` function | Remove orphaned pacman packages |
|
||||||
|
| 104-163 | Aliases & abbreviations | Many aliases, some with Fish-specific syntax |
|
||||||
|
| 165-168 | Fastfetch on interactive | Run fastfetch if available |
|
||||||
|
| 170-172 | PATH: opencode + local bin | PATH additions (local bin already covered) |
|
||||||
|
| 174-178 | Qt/KDE theming | Conditional `QT_STYLE_OVERRIDE` |
|
||||||
|
|
||||||
|
### Existing .zshrc breakdown (`~/.config/zsh/.zshrc`, 48 lines)
|
||||||
|
|
||||||
|
Lines 1-47 are Forge-managed and must remain untouched. The new content should be appended **after line 48** (end of file).
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
- [ ] **Step 1.** Append all converted content below the existing Forge block (after line 48). Do not modify any existing lines.
|
||||||
|
|
||||||
|
## Converted Zsh Code (to append after line 48 of `~/.config/zsh/.zshrc`)
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
# ============================================================
|
||||||
|
# Converted from ~/.config/fish/config.fish
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# --- Environment Variables ---
|
||||||
|
|
||||||
|
# Hide welcome message (Zsh equivalent: set empty PS1 greeting or just skip)
|
||||||
|
VIRTUAL_ENV_DISABLE_PROMPT="1"
|
||||||
|
export VIRTUAL_ENV_DISABLE_PROMPT
|
||||||
|
|
||||||
|
# Use bat for man pages
|
||||||
|
export MANPAGER="sh -c 'col -bx | bat -l man -p'"
|
||||||
|
export MANROFFOPT="-c"
|
||||||
|
|
||||||
|
# Hint to exit PKGBUILD review in Paru
|
||||||
|
export PARU_PAGER="less -P \"Press 'q' to exit the PKGBUILD review.\""
|
||||||
|
|
||||||
|
# --- PATH Additions ---
|
||||||
|
|
||||||
|
# Add ~/.local/bin to PATH (already present via Forge, but kept for completeness)
|
||||||
|
if [[ -d ~/.local/bin ]]; then
|
||||||
|
case ":${PATH}:" in
|
||||||
|
*:"$HOME/.local/bin":*) ;;
|
||||||
|
*) export PATH="$HOME/.local/bin:$PATH" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add depot_tools to PATH
|
||||||
|
if [[ -d ~/Applications/depot_tools ]]; then
|
||||||
|
case ":${PATH}:" in
|
||||||
|
*:"$HOME/Applications/depot_tools":*) ;;
|
||||||
|
*) export PATH="$HOME/Applications/depot_tools:$PATH" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add opencode to PATH
|
||||||
|
if [[ -d ~/.opencode/bin ]]; then
|
||||||
|
case ":${PATH}:" in
|
||||||
|
*:"$HOME/.opencode/bin":*) ;;
|
||||||
|
*) export PATH="$HOME/.opencode/bin:$PATH" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Interactive Shell Setup ---
|
||||||
|
|
||||||
|
if [[ -o interactive ]]; then
|
||||||
|
# Zoxide (smart cd)
|
||||||
|
eval "$(zoxide init zsh)"
|
||||||
|
|
||||||
|
# Atuin (shell history)
|
||||||
|
eval "$(atuin init zsh)"
|
||||||
|
|
||||||
|
# Starship prompt
|
||||||
|
eval "$(starship init zsh)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Bang-Bang Support (!!) ---
|
||||||
|
|
||||||
|
# Zsh has built-in bang-bang via `setopt bang_hist` (default on).
|
||||||
|
# Enable history expansion:
|
||||||
|
setopt bang_hist
|
||||||
|
setopt hist_expand
|
||||||
|
|
||||||
|
# --- History Settings ---
|
||||||
|
|
||||||
|
HISTFILE=~/.config/zsh/.zsh_history
|
||||||
|
HISTSIZE=10000
|
||||||
|
SAVEHIST=10000
|
||||||
|
setopt extended_history # Record timestamps (equivalent to --show-time='%F %T ')
|
||||||
|
setopt share_history # Share history across sessions
|
||||||
|
setopt hist_ignore_all_dups # Remove older duplicate entries
|
||||||
|
setopt hist_ignore_space # Ignore commands starting with space
|
||||||
|
setopt hist_save_no_dups # Don't save duplicates
|
||||||
|
|
||||||
|
# --- Functions ---
|
||||||
|
|
||||||
|
# Create a backup of a file
|
||||||
|
backup() {
|
||||||
|
cp "$1" "${1}.bak"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Smart copy: if source is a directory and exactly 2 args, copy recursively
|
||||||
|
copy() {
|
||||||
|
if [[ $# -eq 2 && -d "$1" ]]; then
|
||||||
|
command cp -r "${1%/}" "$2"
|
||||||
|
else
|
||||||
|
command cp "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cleanup orphaned packages
|
||||||
|
cleanup() {
|
||||||
|
local orphans
|
||||||
|
orphans=$(pacman -Qdtq 2>/dev/null)
|
||||||
|
while [[ -n "$orphans" ]]; do
|
||||||
|
sudo pacman -R $orphans || break
|
||||||
|
orphans=$(pacman -Qdtq 2>/dev/null)
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Aliases ---
|
||||||
|
|
||||||
|
# Replace ls with eza
|
||||||
|
alias ls='eza -al --color=always --group-directories-first --icons'
|
||||||
|
alias lsz='eza -al --color=always --total-size --group-directories-first --icons'
|
||||||
|
alias la='eza -a --color=always --group-directories-first --icons'
|
||||||
|
alias ll='eza -l --color=always --group-directories-first --icons'
|
||||||
|
alias lt='eza -aT --color=always --group-directories-first --icons'
|
||||||
|
alias l.='eza -ald --color=always --group-directories-first --icons .*'
|
||||||
|
|
||||||
|
# Replace cat with bat
|
||||||
|
alias cat='bat --style header,snip,changes'
|
||||||
|
|
||||||
|
# Use paru as yay if yay is not installed
|
||||||
|
if ! command -v yay &>/dev/null && command -v paru &>/dev/null; then
|
||||||
|
alias yay='paru'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Directory navigation
|
||||||
|
alias ..='cd ..'
|
||||||
|
alias ...='cd ../..'
|
||||||
|
alias ....='cd ../../..'
|
||||||
|
alias .....='cd ../../../..'
|
||||||
|
alias ......='cd ../../../../..'
|
||||||
|
|
||||||
|
# Package management
|
||||||
|
alias big='expac -H M "%m\t%n" | sort -h | nl'
|
||||||
|
alias fixpacman='sudo rm /var/lib/pacman/db.lck'
|
||||||
|
alias gitpkg='pacman -Q | grep -i "\-git" | wc -l'
|
||||||
|
alias rmpkg='sudo pacman -Rdd'
|
||||||
|
alias upd='/usr/bin/garuda-update'
|
||||||
|
|
||||||
|
# Grep replacements (ugrep)
|
||||||
|
alias grep='ugrep --color=auto'
|
||||||
|
alias egrep='ugrep -E --color=auto'
|
||||||
|
alias fgrep='ugrep -F --color=auto'
|
||||||
|
|
||||||
|
# Tar
|
||||||
|
alias tarnow='tar -acf '
|
||||||
|
alias untar='tar -zxvf '
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
alias dir='dir --color=auto'
|
||||||
|
alias grubup='sudo update-grub'
|
||||||
|
alias hw='hwinfo --short'
|
||||||
|
alias ip='ip -color'
|
||||||
|
alias psmem='ps auxf | sort -nr -k 4'
|
||||||
|
alias psmem10='ps auxf | sort -nr -k 4 | head -10'
|
||||||
|
alias vdir='vdir --color=auto'
|
||||||
|
alias wget='wget -c '
|
||||||
|
|
||||||
|
# Mirror management (reflector)
|
||||||
|
alias mirror='sudo reflector -f 30 -l 30 --number 10 --verbose --save /etc/pacman.d/mirrorlist'
|
||||||
|
alias mirrora='sudo reflector --latest 50 --number 20 --sort age --save /etc/pacman.d/mirrorlist'
|
||||||
|
alias mirrord='sudo reflector --latest 50 --number 20 --sort delay --save /etc/pacman.d/mirrorlist'
|
||||||
|
alias mirrors='sudo reflector --latest 50 --number 20 --sort score --save /etc/pacman.d/mirrorlist'
|
||||||
|
|
||||||
|
# Help for newcomers to Arch
|
||||||
|
alias apt='man pacman'
|
||||||
|
alias apt-get='man pacman'
|
||||||
|
alias please='sudo'
|
||||||
|
alias tb='nc termbin.com 9999'
|
||||||
|
alias helpme='echo "To print basic information about a command use tldr <command>"'
|
||||||
|
alias pacdiff='sudo -H DIFFPROG=meld pacdiff'
|
||||||
|
|
||||||
|
# Journalctl errors
|
||||||
|
alias jctl='journalctl -p 3 -xb'
|
||||||
|
|
||||||
|
# Recent installed packages
|
||||||
|
alias rip='expac --timefmt="%Y-%m-%d %T" "%l\t%n %v" | sort | tail -200 | nl'
|
||||||
|
|
||||||
|
# --- Run fastfetch if session is interactive ---
|
||||||
|
|
||||||
|
if [[ -o interactive ]] && command -v fastfetch &>/dev/null; then
|
||||||
|
fastfetch --config dr460nized.jsonc
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Qt/KDE Theming ---
|
||||||
|
|
||||||
|
# Set for non-Plasma sessions (Hyprland, etc.)
|
||||||
|
if [[ -z "$XDG_CURRENT_DESKTOP" ]] || [[ "$XDG_CURRENT_DESKTOP" != "KDE" ]]; then
|
||||||
|
export QT_STYLE_OVERRIDE=kvantum
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Items Intentionally Excluded
|
||||||
|
|
||||||
|
| Fish Line(s) | Item | Reason for Exclusion |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `set -x SHELL /usr/bin/fish` | Fish-specific; should be `SHELL=/usr/bin/zsh` if anything, but zsh sets this automatically |
|
||||||
|
| 1 | `set fish_greeting` | Fish-specific; Zsh has no equivalent greeting mechanism |
|
||||||
|
| 8-10 | `__done_*` settings | These are Fish `done` plugin variables; Zsh equivalent would need a different notification plugin |
|
||||||
|
| 12-16 | Source `~/.fish_profile` | Fish-specific profile file; if needed, convert to `~/.zprofile` or source a zsh-compatible profile |
|
||||||
|
| 39-40 | `source /usr/share/doc/find-the-command/ftc.fish` | Fish-specific command-not-found hook; Zsh has `command-not-found-handler` or uses pkgfile |
|
||||||
|
| 42-76 | `__history_previous_command` / `__history_previous_command_arguments` + bind | Fish-specific bang-bang plugin; Zsh has built-in `!!` and `!$` via `setopt bang_hist hist_expand` |
|
||||||
|
|
||||||
|
## Verification Criteria
|
||||||
|
|
||||||
|
- [ ] All aliases work: open a new zsh shell and run `alias` to confirm they are loaded
|
||||||
|
- [ ] `eza` aliases produce colored output with icons
|
||||||
|
- [ ] `bat` alias shows headers/snippets
|
||||||
|
- [ ] `backup testfile.txt` creates `testfile.txt.bak`
|
||||||
|
- [ ] `copy dir1 dir2` copies recursively; `copy file1 file2` copies normally
|
||||||
|
- [ ] `cleanup` removes orphaned pacman packages without error
|
||||||
|
- [ ] `fastfetch` runs on interactive shell launch
|
||||||
|
- [ ] `QT_STYLE_OVERRIDE=kvantum` is set when not in KDE
|
||||||
|
- [ ] PATH includes `~/.local/bin`, `~/Applications/depot_tools`, and `~/.opencode/bin` (when dirs exist)
|
||||||
|
- [ ] `zoxide`, `atuin`, and `starship` initialize in interactive sessions
|
||||||
|
- [ ] Existing Forge-managed block (lines 1-47) is completely untouched
|
||||||
|
- [ ] History expansion works: try `!!` to repeat last command
|
||||||
|
|
||||||
|
## Potential Risks and Mitigations
|
||||||
|
|
||||||
|
1. **Alias name conflicts with existing commands**
|
||||||
|
Mitigation: All aliases use the same names as the Fish config, which was already working. The `cat` alias overriding `cat` with `bat` is intentional but could break scripts that call `cat` and expect raw output. Use `\cat` or `command cat` when needed.
|
||||||
|
|
||||||
|
2. **Forge-managed block interference**
|
||||||
|
Mitigation: The Forge block already sets up `compinit`, zsh-autosuggestions, zsh-syntax-highlighting, and a prompt theme. The converted Starship init will override the Forge prompt theme. If you want to keep the Forge prompt, remove the `eval "$(starship init zsh)"` line.
|
||||||
|
|
||||||
|
3. **`cleanup` function subshell variable scope**
|
||||||
|
Mitigation: The Fish version used a while loop with `$status`. The Zsh version captures orphan list in a variable and uses the `-n` test instead, which is more reliable.
|
||||||
|
|
||||||
|
4. **`garuda-update` alias may not exist**
|
||||||
|
Mitigation: The alias is a direct path reference. If the binary doesn't exist, the alias simply won't work when invoked — same behavior as Fish.
|
||||||
|
|
||||||
|
5. **Fastfetch config path**
|
||||||
|
Mitigation: `fastfetch --config dr460nized.jsonc` uses a relative config name. Ensure the config file is discoverable by fastfetch (typically in `~/.config/fastfetch/`).
|
||||||
|
|
||||||
|
## Alternative Approaches
|
||||||
|
|
||||||
|
1. **Use a framework (oh-my-zsh / zinit)**: Instead of manual conversion, use a Zsh framework that provides bang-bang, aliases, and plugin management out of the box. Trade-off: adds framework dependency and complexity.
|
||||||
|
|
||||||
|
2. **Use `babelfish` or `fish2zsh` tools**: Automated Fish-to-Zsh converters exist but may not handle all Fish-specific constructs correctly. Trade-off: faster but less reliable for edge cases.
|
||||||
|
|
||||||
|
3. **Source a separate file**: Instead of appending to `.zshrc`, place converted content in `~/.config/zsh/.zshrc.fish-converted` and source it from `.zshrc`. Trade-off: cleaner separation but adds an extra file to manage.
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Series Cover Layout Options</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
background: #1a1b26;
|
||||||
|
color: #c0caf5;
|
||||||
|
padding: 40px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
h1 { margin-bottom: 10px; }
|
||||||
|
.subtitle { color: #9aa5ce; margin-bottom: 40px; }
|
||||||
|
|
||||||
|
.options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
.option {
|
||||||
|
background: #24283b;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.option h2 { margin: 0 0 8px 0; font-size: 1.1rem; color: #7aa2f7; }
|
||||||
|
.option .desc { color: #9aa5ce; font-size: 0.85rem; margin-bottom: 20px; }
|
||||||
|
|
||||||
|
/* === Common book cover placeholder === */
|
||||||
|
.cover {
|
||||||
|
width: 80px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||||
|
background: linear-gradient(135deg, #414868, #1a1b26);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #565f89;
|
||||||
|
font-weight: bold;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
OPTION A: Stacked Cascade (pile of books, top-left offset)
|
||||||
|
============================================================ */
|
||||||
|
.stacked-cascade {
|
||||||
|
position: relative;
|
||||||
|
width: 260px;
|
||||||
|
height: 200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.stacked-cascade .cover {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
.stacked-cascade .cover:nth-child(1) { top: 0; left: 0; z-index: 7; }
|
||||||
|
.stacked-cascade .cover:nth-child(2) { top: 8px; left: 8px; z-index: 6; opacity: 0.92; }
|
||||||
|
.stacked-cascade .cover:nth-child(3) { top: 16px; left: 16px; z-index: 5; opacity: 0.85; }
|
||||||
|
.stacked-cascade .cover:nth-child(4) { top: 24px; left: 24px; z-index: 4; opacity: 0.78; }
|
||||||
|
.stacked-cascade .cover:nth-child(5) { top: 32px; left: 32px; z-index: 3; opacity: 0.7; }
|
||||||
|
.stacked-cascade .cover:nth-child(6) { top: 40px; left: 40px; z-index: 2; opacity: 0.65; }
|
||||||
|
.stacked-cascade .cover:nth-child(7) { top: 48px; left: 48px; z-index: 1; opacity: 0.6; }
|
||||||
|
|
||||||
|
.stacked-cascade:hover .cover:nth-child(1) { top: 0; left: 0; }
|
||||||
|
.stacked-cascade:hover .cover:nth-child(2) { top: 6px; left: 12px; }
|
||||||
|
.stacked-cascade:hover .cover:nth-child(3) { top: 14px; left: 24px; }
|
||||||
|
.stacked-cascade:hover .cover:nth-child(4) { top: 22px; left: 36px; }
|
||||||
|
.stacked-cascade:hover .cover:nth-child(5) { top: 30px; left: 50px; }
|
||||||
|
.stacked-cascade:hover .cover:nth-child(6) { top: 40px; left: 64px; }
|
||||||
|
.stacked-cascade:hover .cover:nth-child(7) { top: 50px; left: 78px; }
|
||||||
|
.stacked-cascade .cover { transition: all 0.3s ease; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
OPTION B: Grid Mosaic (mini bookshelf grid)
|
||||||
|
============================================================ */
|
||||||
|
.grid-mosaic {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
grid-template-rows: repeat(3, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #1a1b26;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: inset 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.grid-mosaic .cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
.grid-mosaic .cover.spacer {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
OPTION C: Fanned Bottom (arc from bottom center, upward)
|
||||||
|
============================================================ */
|
||||||
|
.fanned-bottom {
|
||||||
|
position: relative;
|
||||||
|
width: 280px;
|
||||||
|
height: 200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.fanned-bottom .cover {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -40px; /* half cover width */
|
||||||
|
transform-origin: bottom center;
|
||||||
|
}
|
||||||
|
.fanned-bottom .cover:nth-child(1) { z-index: 7; transform: rotate(0deg); }
|
||||||
|
.fanned-bottom .cover:nth-child(2) { z-index: 6; transform: rotate(-12deg); opacity: 0.92; }
|
||||||
|
.fanned-bottom .cover:nth-child(3) { z-index: 5; transform: rotate(12deg); opacity: 0.92; }
|
||||||
|
.fanned-bottom .cover:nth-child(4) { z-index: 4; transform: rotate(-24deg); opacity: 0.82; }
|
||||||
|
.fanned-bottom .cover:nth-child(5) { z-index: 3; transform: rotate(24deg); opacity: 0.82; }
|
||||||
|
.fanned-bottom .cover:nth-child(6) { z-index: 2; transform: rotate(-36deg); opacity: 0.72; }
|
||||||
|
.fanned-bottom .cover:nth-child(7) { z-index: 1; transform: rotate(36deg); opacity: 0.72; }
|
||||||
|
|
||||||
|
.fanned-bottom:hover .cover:nth-child(1) { transform: rotate(0deg); }
|
||||||
|
.fanned-bottom:hover .cover:nth-child(2) { transform: rotate(-15deg); }
|
||||||
|
.fanned-bottom:hover .cover:nth-child(3) { transform: rotate(15deg); }
|
||||||
|
.fanned-bottom:hover .cover:nth-child(4) { transform: rotate(-30deg); }
|
||||||
|
.fanned-bottom:hover .cover:nth-child(5) { transform: rotate(30deg); }
|
||||||
|
.fanned-bottom:hover .cover:nth-child(6) { transform: rotate(-42deg); }
|
||||||
|
.fanned-bottom:hover .cover:nth-child(7) { transform: rotate(42deg); }
|
||||||
|
.fanned-bottom .cover { transition: all 0.3s ease; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
OPTION D: Perspective Shelf (3D standing books)
|
||||||
|
============================================================ */
|
||||||
|
.perspective-shelf {
|
||||||
|
width: 280px;
|
||||||
|
height: 200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
perspective: 600px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
.perspective-shelf .cover {
|
||||||
|
transform: rotateY(-15deg);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.perspective-shelf:hover .cover {
|
||||||
|
transform: rotateY(0deg);
|
||||||
|
}
|
||||||
|
.perspective-shelf .cover:nth-child(odd) {
|
||||||
|
transform: rotateY(-18deg) translateZ(2px);
|
||||||
|
}
|
||||||
|
.perspective-shelf .cover:nth-child(even) {
|
||||||
|
transform: rotateY(-12deg) translateZ(-1px);
|
||||||
|
}
|
||||||
|
.perspective-shelf:hover .cover:nth-child(odd) {
|
||||||
|
transform: rotateY(-5deg) translateZ(4px);
|
||||||
|
}
|
||||||
|
.perspective-shelf:hover .cover:nth-child(even) {
|
||||||
|
transform: rotateY(-5deg) translateZ(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Demo colors for covers === */
|
||||||
|
.c1 { background: linear-gradient(135deg, #f7768e, #c53b53) !important; color: #fff !important; }
|
||||||
|
.c2 { background: linear-gradient(135deg, #ff9e64, #c97423) !important; color: #fff !important; }
|
||||||
|
.c3 { background: linear-gradient(135deg, #e0af68, #b08830) !important; color: #fff !important; }
|
||||||
|
.c4 { background: linear-gradient(135deg, #9ece6a, #6d9c3a) !important; color: #fff !important; }
|
||||||
|
.c5 { background: linear-gradient(135deg, #73daca, #3fb0a5) !important; color: #fff !important; }
|
||||||
|
.c6 { background: linear-gradient(135deg, #7aa2f7, #4070d4) !important; color: #fff !important; }
|
||||||
|
.c7 { background: linear-gradient(135deg, #bb9af7, #8b6fd4) !important; color: #fff !important; }
|
||||||
|
|
||||||
|
.series-label {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #c0caf5;
|
||||||
|
}
|
||||||
|
.series-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #565f89;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #565f89;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Series Card Cover Layouts</h1>
|
||||||
|
<p class="subtitle">4 options for displaying multi-cover composites on the /series browse page. Hover to see animation.</p>
|
||||||
|
|
||||||
|
<div class="options">
|
||||||
|
<!-- Option A -->
|
||||||
|
<div class="option">
|
||||||
|
<h2>A. Stacked Cascade</h2>
|
||||||
|
<p class="desc">Covers stacked diagonally with small offset, like a pile of books on a table</p>
|
||||||
|
<div class="stacked-cascade">
|
||||||
|
<div class="cover c1">#1</div>
|
||||||
|
<div class="cover c2">#2</div>
|
||||||
|
<div class="cover c3">#3</div>
|
||||||
|
<div class="cover c4">#4</div>
|
||||||
|
<div class="cover c5">#5</div>
|
||||||
|
<div class="cover c6">#6</div>
|
||||||
|
<div class="cover c7">#7</div>
|
||||||
|
</div>
|
||||||
|
<div class="series-label">The Expanse</div>
|
||||||
|
<div class="series-meta">7 of 9 books</div>
|
||||||
|
<div class="hover-hint">hover to spread</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Option B -->
|
||||||
|
<div class="option">
|
||||||
|
<h2>B. Grid Mosaic</h2>
|
||||||
|
<p class="desc">Covers tiled in a 3x3 grid, like a mini bookshelf. Clean and dense.</p>
|
||||||
|
<div class="grid-mosaic">
|
||||||
|
<div class="cover c1">#1</div>
|
||||||
|
<div class="cover c2">#2</div>
|
||||||
|
<div class="cover c3">#3</div>
|
||||||
|
<div class="cover c4">#4</div>
|
||||||
|
<div class="cover c5">#5</div>
|
||||||
|
<div class="cover c6">#6</div>
|
||||||
|
<div class="cover c7">#7</div>
|
||||||
|
<div class="cover spacer"></div>
|
||||||
|
<div class="cover spacer"></div>
|
||||||
|
</div>
|
||||||
|
<div class="series-label">The Expanse</div>
|
||||||
|
<div class="series-meta">7 of 9 books</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Option C -->
|
||||||
|
<div class="option">
|
||||||
|
<h2>C. Fanned Bottom Arc</h2>
|
||||||
|
<p class="desc">Like a hand of cards fanned from the bottom center, spreading upward</p>
|
||||||
|
<div class="fanned-bottom">
|
||||||
|
<div class="cover c1">#1</div>
|
||||||
|
<div class="cover c2">#2</div>
|
||||||
|
<div class="cover c3">#3</div>
|
||||||
|
<div class="cover c4">#4</div>
|
||||||
|
<div class="cover c5">#5</div>
|
||||||
|
<div class="cover c6">#6</div>
|
||||||
|
<div class="cover c7">#7</div>
|
||||||
|
</div>
|
||||||
|
<div class="series-label">The Expanse</div>
|
||||||
|
<div class="series-meta">7 of 9 books</div>
|
||||||
|
<div class="hover-hint">hover to spread wider</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Option D -->
|
||||||
|
<div class="option">
|
||||||
|
<h2>D. Perspective Shelf</h2>
|
||||||
|
<p class="desc">Books standing side-by-side with CSS 3D perspective, like a real shelf viewed at angle</p>
|
||||||
|
<div class="perspective-shelf">
|
||||||
|
<div class="cover c1">#1</div>
|
||||||
|
<div class="cover c2">#2</div>
|
||||||
|
<div class="cover c3">#3</div>
|
||||||
|
<div class="cover c4">#4</div>
|
||||||
|
<div class="cover c5">#5</div>
|
||||||
|
<div class="cover c6">#6</div>
|
||||||
|
<div class="cover c7">#7</div>
|
||||||
|
</div>
|
||||||
|
<div class="series-label">The Expanse</div>
|
||||||
|
<div class="series-meta">7 of 9 books</div>
|
||||||
|
<div class="hover-hint">hover to flatten</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/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}"
|
|
||||||
+18
-58
@@ -21,66 +21,26 @@ const config: Config = {
|
|||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
screens: {
|
|
||||||
nav: "970px",
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: [
|
|
||||||
"ui-sans-serif",
|
|
||||||
"system-ui",
|
|
||||||
"sans-serif",
|
|
||||||
'"Noto Sans SC"',
|
|
||||||
'"Noto Sans TC"',
|
|
||||||
'"Noto Sans JP"',
|
|
||||||
'"Noto Sans KR"',
|
|
||||||
'"PingFang SC"',
|
|
||||||
'"Microsoft YaHei"',
|
|
||||||
'"Hiragino Sans"',
|
|
||||||
'"Apple SD Gothic Neo"',
|
|
||||||
"Apple Color Emoji",
|
|
||||||
"Segoe UI Emoji",
|
|
||||||
"Segoe UI Symbol",
|
|
||||||
"Noto Color Emoji",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
colors: {
|
colors: {
|
||||||
// Semantic surface tokens (page bg, cards, raised layers)
|
primary: {
|
||||||
surface: {
|
DEFAULT: "#7aa2f7",
|
||||||
DEFAULT: "var(--bg-primary)",
|
50: "#f0f9ff",
|
||||||
raised: "var(--bg-secondary)",
|
100: "#e0f2fe",
|
||||||
hover: "var(--surface-hover)",
|
200: "#bae6fd",
|
||||||
overlay: "var(--surface-overlay)",
|
300: "#7dd3fc",
|
||||||
|
400: "#38bdf8",
|
||||||
|
500: "#0ea5e9",
|
||||||
|
600: "#0284c7",
|
||||||
|
700: "#0369a1",
|
||||||
|
800: "#075985",
|
||||||
|
900: "#0c4a6e",
|
||||||
},
|
},
|
||||||
// Text tokens
|
"bg-primary": "var(--bg-primary)",
|
||||||
content: {
|
"bg-secondary": "var(--bg-secondary)",
|
||||||
DEFAULT: "var(--text-primary)",
|
"text-primary": "var(--text-primary)",
|
||||||
muted: "var(--text-secondary)",
|
"text-secondary": "var(--text-secondary)",
|
||||||
},
|
accent: "var(--accent)",
|
||||||
// Accent / brand
|
border: "var(--border)",
|
||||||
brand: {
|
|
||||||
DEFAULT: "var(--accent)",
|
|
||||||
muted: "var(--accent-muted)",
|
|
||||||
},
|
|
||||||
// Borders / hairlines
|
|
||||||
line: {
|
|
||||||
DEFAULT: "var(--border)",
|
|
||||||
strong: "var(--border-strong)",
|
|
||||||
},
|
|
||||||
// Status colors (theme-aware via vars, fall back to fixed)
|
|
||||||
success: "var(--status-success)",
|
|
||||||
warning: "var(--status-warning)",
|
|
||||||
danger: "var(--status-danger)",
|
|
||||||
info: "var(--status-info)",
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
xl: "0.875rem",
|
|
||||||
"2xl": "1.25rem",
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
card: "var(--shadow-card)",
|
|
||||||
"card-hover": "var(--shadow-card-hover)",
|
|
||||||
pop: "var(--shadow-pop)",
|
|
||||||
bar: "var(--shadow-bar)",
|
|
||||||
},
|
},
|
||||||
backgroundImage: {
|
backgroundImage: {
|
||||||
"wood-light": "url('/static/textures/wood-light.png')",
|
"wood-light": "url('/static/textures/wood-light.png')",
|
||||||
|
|||||||
+36
-37
@@ -11,77 +11,76 @@ templ Admin(user User) {
|
|||||||
</head>
|
</head>
|
||||||
<body x-data="admin" x-init="loadWatchStatus(); initializeScanWebSocket()" class="theme-tokyo-night">
|
<body x-data="admin" x-init="loadWatchStatus(); initializeScanWebSocket()" class="theme-tokyo-night">
|
||||||
@Header(user, "/admin")
|
@Header(user, "/admin")
|
||||||
<div class="flex min-h-screen bg-surface">
|
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
|
||||||
@AdminSidebar(user, "/admin")
|
@AdminSidebar(user, "/admin")
|
||||||
<main class="flex-1 p-6 sm:p-8">
|
<main class="flex-1 p-8">
|
||||||
<div class="max-w-4xl">
|
<div class="max-w-4xl">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-content mb-2">Dashboard</h1>
|
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Dashboard</h1>
|
||||||
<p class="text-content-muted">Overview of your Bookhoard library and settings</p>
|
<p style="color: var(--text-secondary)">Overview of your Bookhoard library and settings</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||||
<div class="card p-6">
|
<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="flex items-center space-x-3">
|
||||||
<div class="text-content-muted">@Icon("library", "h-7 w-7")</div>
|
<div class="text-3xl">📖</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold text-content">Library</h3>
|
<h3 class="font-semibold" style="color: var(--text-primary)">Library</h3>
|
||||||
<p class="text-content-muted text-sm">Manage your ebook collection</p>
|
<p style="color: var(--text-secondary)" class="text-sm">Manage your ebook collection</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="/" class="btn btn-secondary mt-4 text-sm">View Library</a>
|
<a href="/" class="mt-4 inline-block text-sm btn-secondary px-3 py-1 rounded">View Library</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-6">
|
<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="flex items-center space-x-3">
|
||||||
<div class="text-content-muted">@Icon("sync", "h-7 w-7")</div>
|
<div class="text-3xl">👁️</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold text-content">Scan Watch Status</h3>
|
<h3 class="font-semibold" style="color: var(--text-primary)">Scan Watch Status</h3>
|
||||||
<p class="text-content-muted text-sm">Auto-detecting new files</p>
|
<p style="color: var(--text-secondary)" class="text-sm">Auto-detecting new files</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="watch-status" class="mt-4 text-sm text-content-muted">
|
<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 bg-green-500 mr-2"></span>
|
||||||
Watching <span id="watch-count">0</span> libraries
|
Watching <span id="watch-count">0</span> libraries
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-6">
|
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
<h3 class="text-xl font-semibold mb-4 text-content">Quick Actions</h3>
|
<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">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<button @click="scanAllLibraries()" class="btn btn-primary p-4 text-left">
|
<button @click="scanAllLibraries()" class="btn-primary p-4 rounded-lg text-left">
|
||||||
<div class="font-medium">Rescan Library</div>
|
<div class="font-medium">Rescan Library</div>
|
||||||
<div class="text-content-muted text-sm">Re-scan existing files and fix metadata</div>
|
<div style="color: var(--text-secondary)" class="text-sm">Re-scan existing files and fix metadata</div>
|
||||||
</button>
|
</button>
|
||||||
<a href="/admin/library" class="btn btn-secondary p-4 text-left block">
|
<a href="/admin/library" class="btn-secondary p-4 rounded-lg text-left block">
|
||||||
<div class="font-medium">Manage Libraries and Folders</div>
|
<div class="font-medium">Manage Libraries and Folders</div>
|
||||||
<div class="text-content-muted text-sm">Add or remove libraries and scan directories</div>
|
<div style="color: var(--text-secondary)" class="text-sm">Add or remove libraries and scan directories</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Scan Progress Section -->
|
<!-- Scan Progress Section -->
|
||||||
<div id="scan-progress-container" class="card mt-6 opacity-0 -translate-y-2.5 transition-all duration-300 ease-out">
|
<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">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h3 class="text-lg font-semibold text-content flex items-center gap-2">
|
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">
|
||||||
@Icon("library", "h-5 w-5")
|
📚 Scanning Libraries
|
||||||
Scanning Libraries
|
|
||||||
</h3>
|
</h3>
|
||||||
<button @click="hideScanProgress()" class="p-2 hover:bg-surface-hover rounded">
|
<button @click="hideScanProgress()" class="p-2 hover:bg-gray-700 rounded">
|
||||||
@Icon("close", "h-4 w-4")
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Overall Progress -->
|
<!-- Overall Progress -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="flex justify-between text-sm mb-2">
|
<div class="flex justify-between text-sm mb-2">
|
||||||
<span class="text-content-muted">Overall Progress</span>
|
<span style="color: var(--text-secondary)">Overall Progress</span>
|
||||||
<span id="scan-progress-text" class="text-content">0%</span>
|
<span id="scan-progress-text" style="color: var(--text-primary)">0%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-surface-hover rounded-full h-3">
|
<div class="w-full bg-gray-700 rounded-full h-3">
|
||||||
<div
|
<div
|
||||||
id="scan-progress-bar"
|
id="scan-progress-bar"
|
||||||
class="h-3 rounded-full transition-all duration-500 bg-brand"
|
class="h-3 rounded-full transition-all duration-500"
|
||||||
style="width: 0%;"
|
style="width: 0%; background-color: var(--accent);"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="scan-status" class="text-sm mt-2 text-content-muted">
|
<div id="scan-status" class="text-sm mt-2" style="color: var(--text-secondary)">
|
||||||
Starting scan...
|
Starting scan...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,21 +89,21 @@ templ Admin(user User) {
|
|||||||
<!-- Dynamically populated -->
|
<!-- Dynamically populated -->
|
||||||
</div>
|
</div>
|
||||||
<!-- Results Summary -->
|
<!-- Results Summary -->
|
||||||
<div id="scan-results" class="hidden mt-6 p-4 rounded-lg border border-line bg-surface">
|
<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 text-content flex items-center gap-2">@Icon("check-circle", "h-4 w-4") Scan Complete!</h4>
|
<h4 class="font-semibold mb-2" style="color: var(--text-primary)">✅ Scan Complete!</h4>
|
||||||
<div id="scan-results-content" class="text-content-muted">
|
<div id="scan-results-content" style="color: var(--text-secondary)">
|
||||||
<!-- Results populated by JS -->
|
<!-- Results populated by JS -->
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex gap-2">
|
<div class="mt-4 flex gap-2">
|
||||||
<button
|
<button
|
||||||
@click="window.location.reload()"
|
@click="window.location.reload()"
|
||||||
class="btn btn-primary"
|
class="btn-primary px-4 py-2 rounded-lg"
|
||||||
>
|
>
|
||||||
Refresh to View Books
|
Refresh to View Books
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="hideScanProgress()"
|
@click="hideScanProgress()"
|
||||||
class="btn btn-secondary"
|
class="btn-secondary px-4 py-2 rounded-lg"
|
||||||
>
|
>
|
||||||
Dismiss
|
Dismiss
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -10,51 +10,49 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
|||||||
</head>
|
</head>
|
||||||
<body x-data="library" x-init="initializeLibraryAdmin" class="theme-{ user.Theme }">
|
<body x-data="library" x-init="initializeLibraryAdmin" class="theme-{ user.Theme }">
|
||||||
@Header(user, "/admin/library")
|
@Header(user, "/admin/library")
|
||||||
<div class="flex min-h-screen bg-surface">
|
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
|
||||||
@AdminSidebar(user, "/admin/library")
|
@AdminSidebar(user, "/admin/library")
|
||||||
<main class="flex-1 p-6 sm:p-8">
|
<main class="flex-1 p-8">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<a href="/admin" class="btn btn-secondary font-medium">
|
<a href="/admin" class="btn-secondary px-4 py-2 rounded-lg font-medium">
|
||||||
@Icon("arrow-left", "h-4 w-4")
|
← Back to Dashboard
|
||||||
Back to Dashboard
|
|
||||||
</a>
|
</a>
|
||||||
<button data-action="show-create-modal" class="btn btn-primary font-medium">
|
<button data-action="show-create-modal" class="btn-primary px-4 py-2 rounded-lg font-medium">
|
||||||
@Icon("plus", "h-4 w-4")
|
+ Create Library
|
||||||
Create Library
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-content mb-2">Library Management</h1>
|
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Library Management</h1>
|
||||||
<p class="text-content-muted">Manage libraries and configure media scanning</p>
|
<p style="color: var(--text-secondary)">Manage libraries and configure media scanning</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<!-- Libraries Section -->
|
<!-- Libraries Section -->
|
||||||
<div class="card p-6">
|
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
<h3 class="text-xl font-semibold mb-6 text-content">Libraries</h3>
|
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Libraries</h3>
|
||||||
<p class="text-content-muted mb-4">Manage media libraries and their folders</p>
|
<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 id="libraries-list" class="space-y-3 mb-6">
|
||||||
if len(libraries) == 0 {
|
if len(libraries) == 0 {
|
||||||
<p class="text-content-muted text-center py-8">
|
<p style="color: var(--text-secondary)" class="text-center py-8">
|
||||||
No libraries yet. Create your first library to get started.
|
No libraries yet. Create your first library to get started.
|
||||||
</p>
|
</p>
|
||||||
} else {
|
} else {
|
||||||
for _, library := range libraries {
|
for _, library := range libraries {
|
||||||
<div class="p-4 border border-line rounded-lg bg-surface">
|
<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 class="flex justify-between items-start mb-2">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-semibold text-content">{ library.Name }</h4>
|
<h4 class="font-semibold" style="color: var(--text-primary)">{ library.Name }</h4>
|
||||||
if library.Description != "" {
|
if library.Description != "" {
|
||||||
<p class="text-sm text-content-muted">{ library.Description }</p>
|
<p class="text-sm" style="color: var(--text-secondary)">{ library.Description }</p>
|
||||||
}
|
}
|
||||||
<span class="badge bg-brand text-white">
|
<span class="inline-block px-2 py-1 text-xs rounded" style="background-color: var(--accent); color: var(--bg-primary)">
|
||||||
{ library.TypeName }
|
{ library.TypeName }
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<button data-library-id={ library.ID } data-action="show-folders" class="btn btn-secondary text-xs">Folders</button>
|
<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="btn btn-primary text-xs">Edit</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="btn btn-danger text-xs">Delete</button>
|
<button data-library-id={ library.ID } data-action="delete" class="text-xs px-2 py-1 rounded text-red-500">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id={ "library-folders-" + library.ID } class="hidden mt-3 space-y-2"></div>
|
<div id={ "library-folders-" + library.ID } class="hidden mt-3 space-y-2"></div>
|
||||||
@@ -64,20 +62,20 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- User Library Visibility Section -->
|
<!-- User Library Visibility Section -->
|
||||||
<div class="card p-6">
|
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
<h3 class="text-xl font-semibold mb-6 text-content">Library Visibility</h3>
|
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Library Visibility</h3>
|
||||||
<p class="text-content-muted mb-4">Control which libraries are visible to users</p>
|
<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">
|
<div id="visibility-controls" class="space-y-4">
|
||||||
<!-- Visibility controls will be loaded here -->
|
<!-- Visibility controls will be loaded here -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- User Visibility Management -->
|
<!-- User Visibility Management -->
|
||||||
<div class="mt-8 card p-6">
|
<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 text-content">User Library Access</h3>
|
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">User Library Access</h3>
|
||||||
<p class="text-content-muted mb-4">Manage individual user access to specific libraries</p>
|
<p style="color: var(--text-secondary)" class="mb-4">Manage individual user access to specific libraries</p>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<select id="user-select" onchange="loadUserVisibility()" class="input">
|
<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>
|
<option value="">Select a user...</option>
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
<option value="{ user.ID }">{ user.Username } ({ user.Email })</option>
|
<option value="{ user.ID }">{ user.Username } ({ user.Email })</option>
|
||||||
@@ -93,42 +91,42 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
|||||||
</div>
|
</div>
|
||||||
<!-- Create Library Modal -->
|
<!-- 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 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 p-6 w-full max-w-md mx-4">
|
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-xl font-bold text-content">Create Library</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Library</h2>
|
||||||
<button type="button" data-action="hide-create-modal" class="p-2 hover:bg-surface-hover rounded text-content">@Icon("close", "h-4 w-4")</button>
|
<button type="button" data-action="hide-create-modal" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="create-library-form">
|
<form id="create-library-form">
|
||||||
<input type="hidden" id="library-id" name="id"/>
|
<input type="hidden" id="library-id" name="id"/>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2 text-content">Library Name</label>
|
<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="input" required/>
|
<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>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2 text-content">Description</label>
|
<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="input"></textarea>
|
<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>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium mb-2 text-content">Library Type</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Library Type</label>
|
||||||
<select name="type" class="input" required>
|
<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="ebooks">📚 Ebooks</option>
|
||||||
<option value="comics">📖 Comics</option>
|
<option value="comics">📖 Comics</option>
|
||||||
<option value="manga">🗾 Manga</option>
|
<option value="manga">🗾 Manga</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" data-action="hide-create-modal" class="btn btn-secondary">Cancel</button>
|
<button type="button" data-action="hide-create-modal" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary">Create</button>
|
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Create</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Folder Browser Modal -->
|
<!-- Folder Browser Modal -->
|
||||||
<div id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
<div id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
||||||
<div class="card p-6 w-full max-w-md mx-4">
|
<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">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="text-xl font-bold text-content">Browse Folders</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Browse Folders</h2>
|
||||||
<button type="button" data-action="browse-cancel" class="p-2 hover:bg-surface-hover rounded text-content">@Icon("close", "h-4 w-4")</button>
|
<button type="button" data-action="browse-cancel" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="folder-browser-content">
|
<div id="folder-browser-content">
|
||||||
<!-- Directory listings will be rendered here -->
|
<!-- Directory listings will be rendered here -->
|
||||||
@@ -137,17 +135,17 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
|||||||
</div>
|
</div>
|
||||||
<!-- Delete Library Confirmation Modal -->
|
<!-- 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 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 p-6 w-full max-w-md mx-4">
|
<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">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="text-xl font-bold text-content">Delete Library</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Delete Library</h2>
|
||||||
<button type="button" data-action="hide-delete-modal" class="p-2 hover:bg-surface-hover rounded text-content">@Icon("close", "h-4 w-4")</button>
|
<button type="button" data-action="hide-delete-modal" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="delete-modal-content" class="mb-6 text-content">
|
<div id="delete-modal-content" class="mb-6" style="color: var(--text-primary)">
|
||||||
<!-- Dynamic content will be injected here -->
|
<!-- Dynamic content will be injected here -->
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" data-action="hide-delete-modal" class="btn btn-secondary">Cancel</button>
|
<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 btn-danger">Delete</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
// templ: version: v0.3.1001
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
@@ -37,7 +37,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex min-h-screen bg-surface\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex min-h-screen\" style=\"background-color: var(--bg-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -45,201 +45,161 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-6 sm:p-8\"><div class=\"w-full\"><div class=\"mb-8\"><div class=\"flex items-center justify-between mb-4\"><a href=\"/admin\" class=\"btn btn-secondary font-medium\">")
|
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\">")
|
||||||
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 font-medium\">")
|
|
||||||
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><h1 class=\"text-2xl font-bold tracking-tight text-content mb-2\">Library Management</h1><p class=\"text-content-muted\">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\"><h3 class=\"text-xl font-semibold mb-6 text-content\">Libraries</h3><p class=\"text-content-muted mb-4\">Manage media libraries and their folders</p><div id=\"libraries-list\" class=\"space-y-3 mb-6\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(libraries) == 0 {
|
if len(libraries) == 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<p class=\"text-content-muted text-center py-8\">No libraries yet. Create your first library to get started.</p>")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, library := range libraries {
|
for _, library := range libraries {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"p-4 border border-line rounded-lg bg-surface\"><div class=\"flex justify-between items-start mb-2\"><div><h4 class=\"font-semibold text-content\">")
|
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)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 46, Col: 66}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 44, Col: 89}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</h4>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</h4>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if library.Description != "" {
|
if library.Description != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<p class=\"text-sm text-content-muted\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 48, Col: 73}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 46, Col: 92}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"badge bg-brand text-white\">")
|
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)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 51, Col: 32}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 49, Col: 33}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span></div><div class=\"flex space-x-2\"><button data-library-id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span></div><div class=\"flex space-x-2\"><button data-library-id=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(library.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 55, Col: 49}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 53, Col: 50}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" data-action=\"show-folders\" class=\"btn btn-secondary text-xs\">Folders</button> <button data-library-id=\"")
|
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=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(library.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 56, Col: 49}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 54, Col: 50}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" data-action=\"edit\" class=\"btn btn-primary text-xs\">Edit</button> <button data-library-id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" data-action=\"edit\" class=\"text-xs px-2 py-1 rounded\" style=\"background-color: var(--accent); color: var(--bg-primary)\">Edit</button> <button data-library-id=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(library.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 57, Col: 49}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 55, Col: 50}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" data-action=\"delete\" class=\"btn btn-danger text-xs\">Delete</button></div></div><div id=\"")
|
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=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-folders-" + library.ID)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs("library-folders-" + library.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 60, Col: 52}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 58, Col: 53}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"hidden mt-3 space-y-2\"></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"hidden mt-3 space-y-2\"></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div></div><!-- User Library Visibility Section --><div class=\"card p-6\"><h3 class=\"text-xl font-semibold mb-6 text-content\">Library Visibility</h3><p class=\"text-content-muted 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\"><h3 class=\"text-xl font-semibold mb-6 text-content\">User Library Access</h3><p class=\"text-content-muted mb-4\">Manage individual user access to specific libraries</p><div class=\"mb-4\"><select id=\"user-select\" onchange=\"loadUserVisibility()\" class=\"input\"><option value=\"\">Select a user...</option> ")
|
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> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<option value=\"{ user.ID }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<option value=\"{ user.ID }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 83, Col: 52}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 81, Col: 53}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " (")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " (")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 83, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 81, Col: 69}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, ")</option>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ")</option>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</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 p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold text-content\">Create Library</h2><button type=\"button\" data-action=\"hide-create-modal\" class=\"p-2 hover:bg-surface-hover rounded text-content\">")
|
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>")
|
||||||
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, 22, "</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 text-content\">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 text-content\">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 text-content\">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\">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 p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold text-content\">Browse Folders</h2><button type=\"button\" data-action=\"browse-cancel\" class=\"p-2 hover:bg-surface-hover rounded text-content\">")
|
|
||||||
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, 23, "</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 p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold text-content\">Delete Library</h2><button type=\"button\" data-action=\"hide-delete-modal\" class=\"p-2 hover:bg-surface-hover rounded text-content\">")
|
|
||||||
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, 24, "</button></div><div id=\"delete-modal-content\" class=\"mb-6 text-content\"><!-- 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\">Delete</button></div></div></div><script src=\"/static/htmx.min.js\"></script></body></html>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,16 +11,15 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
|||||||
</head>
|
</head>
|
||||||
<body x-data="processingIssues" x-init="initializeProcessingIssues('{ libraryID }')" class="theme-{ user.Theme }">
|
<body x-data="processingIssues" x-init="initializeProcessingIssues('{ libraryID }')" class="theme-{ user.Theme }">
|
||||||
@Header(user, "/admin/libraries/"+libraryID)
|
@Header(user, "/admin/libraries/"+libraryID)
|
||||||
<main class="flex-1 p-6 sm:p-8">
|
<main class="flex-1 p-8">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-content mb-2">Processing Issues</h1>
|
<h1 class="text-3xl font-bold mb-2">Processing Issues</h1>
|
||||||
<p class="text-content-muted">Items that couldn't be processed in this library</p>
|
<p class="text-gray-600">Items that couldn't be processed in this library</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/admin/libraries/{ libraryID }" class="btn btn-secondary">
|
<a href="/admin/libraries/{ libraryID }" class="btn-secondary px-4 py-2 rounded-lg">
|
||||||
@Icon("arrow-left", "h-4 w-4")
|
← Back to Library
|
||||||
Back to Library
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,39 +27,39 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
|||||||
<!-- Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
if stats.ErrorCount > 0 {
|
if stats.ErrorCount > 0 {
|
||||||
<div class="card p-6 border-l-4 border-red-500">
|
<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>
|
<h3 class="text-lg font-semibold text-red-600 mb-2">Errors</h3>
|
||||||
<p class="text-3xl font-bold text-content">{ stats.ErrorCount }</p>
|
<p class="text-3xl font-bold">{ stats.ErrorCount }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if stats.WarningCount > 0 {
|
if stats.WarningCount > 0 {
|
||||||
<div class="card p-6 border-l-4 border-yellow-500">
|
<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>
|
<h3 class="text-lg font-semibold text-yellow-600 mb-2">Warnings</h3>
|
||||||
<p class="text-3xl font-bold text-content">{ stats.WarningCount }</p>
|
<p class="text-3xl font-bold">{ stats.WarningCount }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if stats.InfoCount > 0 {
|
if stats.InfoCount > 0 {
|
||||||
<div class="card p-6 border-l-4 border-blue-500">
|
<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>
|
<h3 class="text-lg font-semibold text-blue-600 mb-2">Info</h3>
|
||||||
<p class="text-3xl font-bold text-content">{ stats.InfoCount }</p>
|
<p class="text-3xl font-bold">{ stats.InfoCount }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
<div class="card p-8 text-center">
|
<div class="card p-8 rounded-lg text-center">
|
||||||
<p class="text-content-muted">No processing issues found for this library.</p>
|
<p class="text-gray-600">No processing issues found for this library.</p>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<!-- Issues List -->
|
<!-- Issues List -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
for _, issue := range issues {
|
for _, issue := range issues {
|
||||||
<div class="card p-6">
|
<div class="card p-6 rounded-lg">
|
||||||
<div class="flex justify-between items-start mb-4">
|
<div class="flex justify-between items-start mb-4">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h4 class="text-lg font-semibold mb-2 text-content">{ issue.Title }</h4>
|
<h4 class="text-lg font-semibold mb-2">{ issue.Title }</h4>
|
||||||
<p class="text-content mb-3">{ issue.IssueDescription }</p>
|
<p class="text-gray-700 mb-3">{ issue.IssueDescription }</p>
|
||||||
<div class="text-sm text-content-muted space-y-1">
|
<div class="text-sm text-gray-500 space-y-1">
|
||||||
<p><strong>Type:</strong> { issue.IssueType }</p>
|
<p><strong>Type:</strong> { issue.IssueType }</p>
|
||||||
<p><strong>Format:</strong> { issue.FormatGroup }</p>
|
<p><strong>Format:</strong> { issue.FormatGroup }</p>
|
||||||
<p><strong>File:</strong> { issue.FilePath }</p>
|
<p><strong>File:</strong> { issue.FilePath }</p>
|
||||||
@@ -69,7 +68,8 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<span
|
<span
|
||||||
class="inline-block px-3 py-1 text-sm rounded-full font-medium bg-brand text-white"
|
class="inline-block px-3 py-1 text-sm rounded-full font-medium"
|
||||||
|
style="background-color: var(--accent); color: white;"
|
||||||
>
|
>
|
||||||
{ issue.Severity }
|
{ issue.Severity }
|
||||||
</span>
|
</span>
|
||||||
@@ -79,7 +79,7 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
|||||||
if issue.Severity == "warning" || issue.Severity == "info" {
|
if issue.Severity == "warning" || issue.Severity == "info" {
|
||||||
<button
|
<button
|
||||||
@click="dismissIssue('{ issue.ID }', '{ issue.MediaItemID }')"
|
@click="dismissIssue('{ issue.ID }', '{ issue.MediaItemID }')"
|
||||||
class="btn btn-secondary text-sm"
|
class="btn-secondary px-4 py-2 rounded text-sm"
|
||||||
>
|
>
|
||||||
Dismiss
|
Dismiss
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
// templ: version: v0.3.1001
|
||||||
|
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
@@ -38,175 +38,167 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"flex-1 p-6 sm:p-8\"><div class=\"mb-8\"><div class=\"flex items-center justify-between mb-4\"><div><h1 class=\"text-2xl font-bold tracking-tight text-content mb-2\">Processing Issues</h1><p class=\"text-content-muted\">Items that couldn't be processed in this library</p></div><a href=\"/admin/libraries/{ libraryID }\" class=\"btn btn-secondary\">")
|
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>")
|
||||||
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, 3, "Back to Library</a></div></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
|
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<!-- Stats Cards --> <div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 mb-8\">")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if stats.ErrorCount > 0 {
|
if stats.ErrorCount > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"card p-6 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 text-content\">")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 33, Col: 69}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 32, Col: 56}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</p></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</p></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if stats.WarningCount > 0 {
|
if stats.WarningCount > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"card p-6 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 text-content\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"card p-6 rounded-lg border-l-4 border-yellow-500\"><h3 class=\"text-lg font-semibold text-yellow-600 mb-2\">Warnings</h3><p class=\"text-3xl font-bold\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 39, Col: 71}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 38, Col: 58}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</p></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</p></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if stats.InfoCount > 0 {
|
if stats.InfoCount > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"card p-6 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 text-content\">")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 45, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 44, Col: 55}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</p></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</p></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"card p-8 text-center\"><p class=\"text-content-muted\">No processing issues found for this library.</p></div>")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<!-- Issues List --> <div class=\"space-y-4\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<!-- Issues List --> <div class=\"space-y-4\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, issue := range issues {
|
for _, issue := range issues {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"card p-6\"><div class=\"flex justify-between items-start mb-4\"><div class=\"flex-1\"><h4 class=\"text-lg font-semibold mb-2 text-content\">")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 61, Col: 75}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 60, Col: 62}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h4><p class=\"text-content mb-3\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</h4><p class=\"text-gray-700 mb-3\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 62, Col: 63}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 61, Col: 64}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p><div class=\"text-sm text-content-muted space-y-1\"><p><strong>Type:</strong> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</p><div class=\"text-sm text-gray-500 space-y-1\"><p><strong>Type:</strong> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 64, Col: 54}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 63, Col: 54}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</p><p><strong>Format:</strong> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p><p><strong>Format:</strong> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 65, Col: 58}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 64, Col: 58}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p><p><strong>File:</strong> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</p><p><strong>File:</strong> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 66, Col: 53}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 65, Col: 53}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</p><p><strong>Library:</strong> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p><p><strong>Library:</strong> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 67, Col: 63}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 66, Col: 63}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</p></div></div><div class=\"ml-4\"><span class=\"inline-block px-3 py-1 text-sm rounded-full font-medium bg-brand text-white\">")
|
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;\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -219,27 +211,27 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span></div></div><div class=\"flex gap-3 mt-4\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span></div></div><div class=\"flex gap-3 mt-4\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if issue.Severity == "warning" || issue.Severity == "info" {
|
if issue.Severity == "warning" || issue.Severity == "info" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<button @click=\"dismissIssue('{ issue.ID }', '{ issue.MediaItemID }')\" class=\"btn btn-secondary text-sm\">Dismiss</button>")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</main></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</main></body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,54 +11,55 @@ templ AdminSettings(user User, systemConfig map[string]string, errorMessage stri
|
|||||||
</head>
|
</head>
|
||||||
<body class="theme-{ user.Theme }" x-data="adminSettings">
|
<body class="theme-{ user.Theme }" x-data="adminSettings">
|
||||||
@Header(user, "/admin/settings")
|
@Header(user, "/admin/settings")
|
||||||
<div class="flex min-h-screen bg-surface">
|
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
|
||||||
@AdminSidebar(user, "/admin/settings")
|
@AdminSidebar(user, "/admin/settings")
|
||||||
<main class="flex-1 p-6 sm:p-8">
|
<main class="flex-1 p-8">
|
||||||
<div class="max-w-4xl">
|
<div class="max-w-4xl">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<a href="/admin" class="btn btn-secondary font-medium">
|
<a href="/admin" class="btn-secondary px-4 py-2 rounded-lg font-medium">
|
||||||
@Icon("arrow-left", "h-4 w-4")
|
← Back to Dashboard
|
||||||
Back to Dashboard
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-content mb-2">System Settings</h1>
|
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">System Settings</h1>
|
||||||
<p class="text-content-muted">Configure your Bookhoard instance</p>
|
<p style="color: var(--text-secondary)">Configure your Bookhoard instance</p>
|
||||||
</div>
|
</div>
|
||||||
if errorMessage != "" {
|
if errorMessage != "" {
|
||||||
<div class="mb-6 p-4 rounded-lg border border-line bg-surface-raised text-danger">
|
<div class="mb-6 p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--error); color: var(--error);">
|
||||||
{ errorMessage }
|
{ errorMessage }
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<form id="settings-form" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
|
<form id="settings-form" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
|
||||||
<div class="card p-6">
|
<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 text-content">Base URL</h3>
|
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Base URL</h3>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2 text-content">Base URL</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Base URL</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
name="base_url"
|
name="base_url"
|
||||||
value={ systemConfig["base_url"] }
|
value={ systemConfig["base_url"] }
|
||||||
placeholder="https://books.example.com"
|
placeholder="https://books.example.com"
|
||||||
class="input"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
<p class="text-sm mt-1 text-content-muted">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-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>
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button type="submit" class="btn btn-primary font-medium">
|
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
|
||||||
Save Settings
|
Save Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8 card p-6">
|
<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 text-content">System Defaults</h3>
|
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">System Defaults</h3>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2 text-content">Default Timezone</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Default Timezone</label>
|
||||||
<select
|
<select
|
||||||
name="default_timezone"
|
name="default_timezone"
|
||||||
id="default_timezone"
|
id="default_timezone"
|
||||||
class="input"
|
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" selected?={ systemConfig["default_timezone"] == "UTC" }>UTC (UTC+0)</option>
|
<option value="UTC" selected?={ systemConfig["default_timezone"] == "UTC" }>UTC (UTC+0)</option>
|
||||||
<option value="Pacific/Honolulu" selected?={ systemConfig["default_timezone"] == "Pacific/Honolulu" }>Hawaii (UTC-10)</option>
|
<option value="Pacific/Honolulu" selected?={ systemConfig["default_timezone"] == "Pacific/Honolulu" }>Hawaii (UTC-10)</option>
|
||||||
@@ -85,16 +86,16 @@ templ AdminSettings(user User, systemConfig map[string]string, errorMessage stri
|
|||||||
<option value="Australia/Sydney" selected?={ systemConfig["default_timezone"] == "Australia/Sydney" }>Australian Eastern (UTC+10/+11)</option>
|
<option value="Australia/Sydney" selected?={ systemConfig["default_timezone"] == "Australia/Sydney" }>Australian Eastern (UTC+10/+11)</option>
|
||||||
<option value="Pacific/Auckland" selected?={ systemConfig["default_timezone"] == "Pacific/Auckland" }>New Zealand (UTC+12/+13)</option>
|
<option value="Pacific/Auckland" selected?={ systemConfig["default_timezone"] == "Pacific/Auckland" }>New Zealand (UTC+12/+13)</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="text-sm mt-1 text-content-muted">Default timezone for users who haven't set their own.</p>
|
<p class="text-sm mt-1" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="mt-8 card p-6">
|
<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 text-content">URL Paths</h3>
|
<h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">URL Paths</h3>
|
||||||
<div class="space-y-2 text-sm text-content-muted">
|
<div class="space-y-2 text-sm" style="color: var(--text-secondary);">
|
||||||
<p><strong class="text-content">OPDS:</strong> { systemConfig["base_url"] }/opds</p>
|
<p><strong>OPDS:</strong> { systemConfig["base_url"] }/opds</p>
|
||||||
<p><strong class="text-content">API:</strong> { systemConfig["base_url"] }/api</p>
|
<p><strong>API:</strong> { systemConfig["base_url"] }/api</p>
|
||||||
<p><strong class="text-content">Device Sync:</strong> { systemConfig["base_url"] }/api/sync</p>
|
<p><strong>Device Sync:</strong> { systemConfig["base_url"] }/api/sync</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
// templ: version: v0.3.1001
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
@@ -37,7 +37,7 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex min-h-screen bg-surface\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex min-h-screen\" style=\"background-color: var(--bg-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -45,330 +45,322 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-6 sm: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 btn-secondary font-medium\">")
|
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>")
|
||||||
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><h1 class=\"text-2xl font-bold tracking-tight text-content mb-2\">System Settings</h1><p class=\"text-content-muted\">Configure your Bookhoard instance</p></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if errorMessage != "" {
|
if errorMessage != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"mb-6 p-4 rounded-lg border border-line bg-surface-raised text-danger\">")
|
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);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 30, Col: 21}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 29, Col: 22}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<form id=\"settings-form\" hx-put=\"/api/system/config\" hx-target=\"#settings-form\" hx-swap=\"outerHTML\"><div class=\"card p-6\"><h3 class=\"text-xl font-semibold mb-6 text-content\">Base URL</h3><div><label class=\"block text-sm font-medium mb-2 text-content\">Base URL</label> <input type=\"url\" name=\"base_url\" value=\"")
|
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=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 41, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 40, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" placeholder=\"https://books.example.com\" class=\"input\" required><p class=\"text-sm mt-1 text-content-muted\">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 font-medium\">Save Settings</button></div></div><div class=\"mt-8 card p-6\"><h3 class=\"text-xl font-semibold mb-6 text-content\">System Defaults</h3><div><label class=\"block text-sm font-medium mb-2 text-content\">Default Timezone</label> <select name=\"default_timezone\" id=\"default_timezone\" class=\"input\"><option value=\"UTC\"")
|
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\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "UTC" {
|
if systemConfig["default_timezone"] == "UTC" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Pacific/Honolulu" {
|
if systemConfig["default_timezone"] == "Pacific/Honolulu" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "America/Anchorage" {
|
if systemConfig["default_timezone"] == "America/Anchorage" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "America/Los_Angeles" {
|
if systemConfig["default_timezone"] == "America/Los_Angeles" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "America/Denver" {
|
if systemConfig["default_timezone"] == "America/Denver" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "America/Phoenix" {
|
if systemConfig["default_timezone"] == "America/Phoenix" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "America/Chicago" {
|
if systemConfig["default_timezone"] == "America/Chicago" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "America/New_York" {
|
if systemConfig["default_timezone"] == "America/New_York" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "America/Sao_Paulo" {
|
if systemConfig["default_timezone"] == "America/Sao_Paulo" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Europe/London" {
|
if systemConfig["default_timezone"] == "Europe/London" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Europe/Paris" {
|
if systemConfig["default_timezone"] == "Europe/Paris" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Europe/Helsinki" {
|
if systemConfig["default_timezone"] == "Europe/Helsinki" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Europe/Moscow" {
|
if systemConfig["default_timezone"] == "Europe/Moscow" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Asia/Tehran" {
|
if systemConfig["default_timezone"] == "Asia/Tehran" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Asia/Dubai" {
|
if systemConfig["default_timezone"] == "Asia/Dubai" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Asia/Karachi" {
|
if systemConfig["default_timezone"] == "Asia/Karachi" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Asia/Kolkata" {
|
if systemConfig["default_timezone"] == "Asia/Kolkata" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Asia/Dhaka" {
|
if systemConfig["default_timezone"] == "Asia/Dhaka" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Asia/Bangkok" {
|
if systemConfig["default_timezone"] == "Asia/Bangkok" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Asia/Shanghai" {
|
if systemConfig["default_timezone"] == "Asia/Shanghai" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Asia/Tokyo" {
|
if systemConfig["default_timezone"] == "Asia/Tokyo" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Australia/Darwin" {
|
if systemConfig["default_timezone"] == "Australia/Darwin" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Australia/Sydney" {
|
if systemConfig["default_timezone"] == "Australia/Sydney" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if systemConfig["default_timezone"] == "Pacific/Auckland" {
|
if systemConfig["default_timezone"] == "Pacific/Auckland" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, ">New Zealand (UTC+12/+13)</option></select><p class=\"text-sm mt-1 text-content-muted\">Default timezone for users who haven't set their own.</p></div></div></form><div class=\"mt-8 card p-6\"><h3 class=\"text-xl font-semibold mb-4 text-content\">URL Paths</h3><div class=\"space-y-2 text-sm text-content-muted\"><p><strong class=\"text-content\">OPDS:</strong> ")
|
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> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 95, Col: 80}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 96, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "/opds</p><p><strong class=\"text-content\">API:</strong> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "/opds</p><p><strong>API:</strong> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 96, Col: 79}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 97, Col: 59}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "/api</p><p><strong class=\"text-content\">Device Sync:</strong> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "/api</p><p><strong>Device Sync:</strong> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 97, Col: 87}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 98, Col: 67}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "/api/sync</p></div></div></div></main></div></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "/api/sync</p></div></div></div></main></div></body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,21 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
templ AdminSidebar(user User, currentPath string) {
|
templ AdminSidebar(user User, currentPath string) {
|
||||||
<aside class="w-60 shrink-0 border-r border-line bg-surface-raised">
|
<aside class="w-64 border-r" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
<div class="p-5">
|
<div class="p-6">
|
||||||
<h2 class="mb-5 flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-content-muted">
|
<h2 class="text-lg font-semibold mb-6" style="color: var(--text-primary)">Admin Panel</h2>
|
||||||
@Icon("shield", "h-4 w-4")
|
<nav class="space-y-2">
|
||||||
Admin
|
<a href="/admin" class={activeClass(currentPath, "/admin")} style="color: var(--text-primary)">
|
||||||
</h2>
|
🏠 Dashboard
|
||||||
<nav class="space-y-1">
|
|
||||||
<a href="/admin" class={ activeClass(currentPath, "/admin") }>
|
|
||||||
@Icon("home", "h-4 w-4")
|
|
||||||
Dashboard
|
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/users" class={ activeClass(currentPath, "/admin/users") }>
|
<a href="/admin/users" class={activeClass(currentPath, "/admin/users")} style="color: var(--text-primary)">
|
||||||
@Icon("users", "h-4 w-4")
|
👤 User Administration
|
||||||
Users
|
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/library" class={ activeClass(currentPath, "/admin/library") }>
|
<a href="/admin/library" class={activeClass(currentPath, "/admin/library")} style="color: var(--text-primary)">
|
||||||
@Icon("library", "h-4 w-4")
|
📚 Library Management
|
||||||
Libraries
|
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/settings" class={ activeClass(currentPath, "/admin/settings") }>
|
<a href="/admin/settings" class={activeClass(currentPath, "/admin/settings")} style="color: var(--text-primary)">
|
||||||
@Icon("settings", "h-4 w-4")
|
⚙️ System Settings
|
||||||
Settings
|
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
// templ: version: v0.3.1001
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
@@ -29,15 +29,7 @@ func AdminSidebar(user User, currentPath string) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<aside class=\"w-60 shrink-0 border-r border-line bg-surface-raised\"><div class=\"p-5\"><h2 class=\"mb-5 flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-content-muted\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<aside class=\"w-64 border-r\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><div class=\"p-6\"><h2 class=\"text-lg font-semibold mb-6\" style=\"color: var(--text-primary)\">Admin Panel</h2><nav class=\"space-y-2\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
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, "Admin</h2><nav class=\"space-y-1\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -46,28 +38,20 @@ func AdminSidebar(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<a href=\"/admin\" class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<a href=\"/admin\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" style=\"color: var(--text-primary)\">🏠 Dashboard</a> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("home", "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, "Dashboard</a> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -76,28 +60,20 @@ func AdminSidebar(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<a href=\"/admin/users\" class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<a href=\"/admin/users\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var4).String())
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var4).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" style=\"color: var(--text-primary)\">👤 User Administration</a> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("users", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "Users</a> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -106,28 +82,20 @@ func AdminSidebar(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<a href=\"/admin/library\" class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<a href=\"/admin/library\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var6).String())
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var6).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" style=\"color: var(--text-primary)\">📚 Library Management</a> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("library", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Libraries</a> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -136,28 +104,20 @@ func AdminSidebar(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"/admin/settings\" class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"/admin/settings\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String())
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var8).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" style=\"color: var(--text-primary)\">⚙️ System Settings</a></nav></div></aside>")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("settings", "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, "Settings</a></nav></div></aside>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
// templ: version: v0.3.1001
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
@@ -37,7 +37,7 @@ func Admin(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex min-h-screen bg-surface\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex min-h-screen\" style=\"background-color: var(--bg-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -45,47 +45,7 @@ func Admin(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-6 sm:p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><h1 class=\"text-2xl font-bold tracking-tight text-content mb-2\">Dashboard</h1><p class=\"text-content-muted\">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\"><div class=\"flex items-center space-x-3\"><div class=\"text-content-muted\">")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("library", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div><div><h3 class=\"font-semibold text-content\">Library</h3><p class=\"text-content-muted text-sm\">Manage your ebook collection</p></div></div><a href=\"/\" class=\"btn btn-secondary mt-4 text-sm\">View Library</a></div><div class=\"card p-6\"><div class=\"flex items-center space-x-3\"><div class=\"text-content-muted\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("sync", "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, 5, "</div><div><h3 class=\"font-semibold text-content\">Scan Watch Status</h3><p class=\"text-content-muted text-sm\">Auto-detecting new files</p></div></div><div id=\"watch-status\" class=\"mt-4 text-sm text-content-muted\"><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\"><h3 class=\"text-xl font-semibold mb-4 text-content\">Quick Actions</h3><div class=\"grid grid-cols-1 sm:grid-cols-2 gap-4\"><button @click=\"scanAllLibraries()\" class=\"btn btn-primary p-4 text-left\"><div class=\"font-medium\">Rescan Library</div><div class=\"text-content-muted text-sm\">Re-scan existing files and fix metadata</div></button> <a href=\"/admin/library\" class=\"btn btn-secondary p-4 text-left block\"><div class=\"font-medium\">Manage Libraries and Folders</div><div class=\"text-content-muted text-sm\">Add or remove libraries and scan directories</div></a></div></div><!-- Scan Progress Section --><div id=\"scan-progress-container\" class=\"card mt-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 text-content flex items-center gap-2\">")
|
|
||||||
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, "Scanning Libraries</h3><button @click=\"hideScanProgress()\" class=\"p-2 hover:bg-surface-hover rounded\">")
|
|
||||||
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, 7, "</button></div><!-- Overall Progress --><div class=\"mb-4\"><div class=\"flex justify-between text-sm mb-2\"><span class=\"text-content-muted\">Overall Progress</span> <span id=\"scan-progress-text\" class=\"text-content\">0%</span></div><div class=\"w-full bg-surface-hover rounded-full h-3\"><div id=\"scan-progress-bar\" class=\"h-3 rounded-full transition-all duration-500 bg-brand\" style=\"width: 0%;\"></div></div><div id=\"scan-status\" class=\"text-sm mt-2 text-content-muted\">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 border-line bg-surface\"><h4 class=\"font-semibold mb-2 text-content flex items-center gap-2\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("check-circle", "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, "Scan Complete!</h4><div id=\"scan-results-content\" class=\"text-content-muted\"><!-- 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-22
@@ -13,42 +13,42 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
|||||||
@Header(currentUser, "/admin/users")
|
@Header(currentUser, "/admin/users")
|
||||||
<!-- Modal Container (populated by HTMX) -->
|
<!-- Modal Container (populated by HTMX) -->
|
||||||
<div id="modal-container"></div>
|
<div id="modal-container"></div>
|
||||||
<div class="flex min-h-screen bg-surface">
|
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
|
||||||
@AdminSidebar(currentUser, "/admin/users")
|
@AdminSidebar(currentUser, "/admin/users")
|
||||||
<main class="flex-1 p-6 sm:p-8">
|
<main class="flex-1 p-8">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-content mb-2">User Management</h1>
|
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">User Management</h1>
|
||||||
<p class="text-content-muted">Manage user accounts and permissions</p>
|
<p style="color: var(--text-secondary)">Manage user accounts and permissions</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Users Table -->
|
<!-- Users Table -->
|
||||||
<div class="card overflow-hidden">
|
<div class="card rounded-lg border overflow-hidden" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-surface">
|
<thead style="background-color: var(--bg-primary)">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide text-content-muted">Username</th>
|
<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-semibold uppercase tracking-wide text-content-muted">Email</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-semibold uppercase tracking-wide text-content-muted">Role</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-semibold uppercase tracking-wide text-content-muted">Created</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-semibold uppercase tracking-wide text-content-muted">Actions</th>
|
<th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-line">
|
<tbody class="divide-y" style="divide-color: var(--border)">
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
<tr id={ "user-" + user.ID } class="hover:bg-surface-hover" style="transition: background-color 0.2s;">
|
<tr id={ "user-" + user.ID } class="hover:bg-opacity-50" style="transition: background-color 0.2s;">
|
||||||
<!-- Username -->
|
<!-- Username -->
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-medium text-content">{ user.Username }</div>
|
<div class="text-sm font-medium" style="color: var(--text-primary)">{ user.Username }</div>
|
||||||
if user.ID == currentUser.ID {
|
if user.ID == currentUser.ID {
|
||||||
<span class="badge bg-brand text-white">You</span>
|
<span class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: white;">You</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<!-- Email -->
|
<!-- Email -->
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="text-sm text-content">{ user.Email }</div>
|
<div class="text-sm" style="color: var(--text-primary)">{ user.Email }</div>
|
||||||
</td>
|
</td>
|
||||||
<!-- Role Toggle (with last-admin protection) -->
|
<!-- Role Toggle (with last-admin protection) -->
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
@@ -57,7 +57,8 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<select
|
<select
|
||||||
disabled
|
disabled
|
||||||
class="input text-sm cursor-not-allowed opacity-50"
|
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"
|
title="Cannot demote the last admin"
|
||||||
>
|
>
|
||||||
<option value="user">User</option>
|
<option value="user">User</option>
|
||||||
@@ -71,7 +72,8 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
|||||||
hx-target={ "#role-result-" + user.ID }
|
hx-target={ "#role-result-" + user.ID }
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
name="role"
|
name="role"
|
||||||
class="input text-sm"
|
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'))"
|
onchange="this.dispatchEvent(new Event('htmx:trigger'))"
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-vals='{"role": this.value}'
|
hx-vals='{"role": this.value}'
|
||||||
@@ -84,7 +86,7 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
|||||||
</td>
|
</td>
|
||||||
<!-- Created -->
|
<!-- Created -->
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="text-sm text-content-muted">{ FormatInTimezone(user.CreatedAt, currentUser.Timezone) }</div>
|
<div class="text-sm" style="color: var(--text-secondary)">{ FormatInTimezone(user.CreatedAt, currentUser.Timezone) }</div>
|
||||||
</td>
|
</td>
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||||
@@ -92,14 +94,16 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
|||||||
hx-get={ "/admin/users/" + user.ID + "/profile-modal" }
|
hx-get={ "/admin/users/" + user.ID + "/profile-modal" }
|
||||||
hx-target="#modal-container"
|
hx-target="#modal-container"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
class="btn btn-primary text-sm mr-2"
|
class="text-sm px-3 py-1 rounded mr-2"
|
||||||
|
style="background-color: var(--accent); color: white;"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
if user.Role == "admin" && adminCount == 1 {
|
if user.Role == "admin" && adminCount == 1 {
|
||||||
<button
|
<button
|
||||||
disabled
|
disabled
|
||||||
class="btn btn-danger text-sm cursor-not-allowed opacity-50"
|
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"
|
title="Cannot delete the last admin"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@@ -111,7 +115,8 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
|||||||
hx-target={ "#user-" + user.ID }
|
hx-target={ "#user-" + user.ID }
|
||||||
hx-swap="outerHTML swap:0.5s"
|
hx-swap="outerHTML swap:0.5s"
|
||||||
hx-confirm="Are you sure you want to delete this user? This action cannot be undone."
|
hx-confirm="Are you sure you want to delete this user? This action cannot be undone."
|
||||||
class="btn btn-danger text-sm"
|
class="text-sm px-3 py-1 rounded"
|
||||||
|
style="background-color: #dc2626; color: white;"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
// templ: version: v0.3.1001
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
@@ -37,7 +37,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!-- Modal Container (populated by HTMX) --><div id=\"modal-container\"></div><div class=\"flex min-h-screen bg-surface\">")
|
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)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-6 sm:p-8\"><div class=\"w-full\"><h1 class=\"text-2xl font-bold tracking-tight text-content mb-2\">User Management</h1><p class=\"text-content-muted\">Manage user accounts and permissions</p></div><!-- Users Table --><div class=\"card overflow-hidden\"><table class=\"w-full\"><thead class=\"bg-surface\"><tr><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide text-content-muted\">Username</th><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide text-content-muted\">Email</th><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide text-content-muted\">Role</th><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide text-content-muted\">Created</th><th class=\"px-6 py-3 text-right text-xs font-semibold uppercase tracking-wide text-content-muted\">Actions</th></tr></thead> <tbody class=\"divide-y divide-line\">")
|
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)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -55,22 +55,22 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("user-" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 37, Col: 34}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 37, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"hover:bg-surface-hover\" 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 text-content\">")
|
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)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 42, Col: 73}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 42, Col: 96}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -81,19 +81,19 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.ID == currentUser.ID {
|
if user.ID == currentUser.ID {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span class=\"badge bg-brand text-white\">You</span>")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div></div></td><!-- Email --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm text-content\">")
|
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)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 51, Col: 56}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 51, Col: 79}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -104,7 +104,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Role == "admin" && adminCount == 1 {
|
if user.Role == "admin" && adminCount == 1 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<!-- Last admin - disabled --> <div class=\"relative\"><select disabled class=\"input text-sm cursor-not-allowed opacity-50\" 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, 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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -114,11 +114,11 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs("/api/auth/profile/" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 69, Col: 51}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 70, Col: 52}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -127,15 +127,15 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs("#role-result-" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 71, Col: 49}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 72, Col: 50}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" hx-swap=\"innerHTML\" name=\"role\" class=\"input text-sm\" 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, 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\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -160,11 +160,11 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("role-result-" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 82, Col: 45}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 84, Col: 46}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -173,14 +173,14 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td><!-- Created --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm text-content-muted\">")
|
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)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 87, Col: 106}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 89, Col: 125}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -191,20 +191,20 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs("/admin/users/" + user.ID + "/profile-modal")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 92, Col: 64}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 94, Col: 65}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-primary text-sm mr-2\">Edit</button> ")
|
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> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Role == "admin" && adminCount == 1 {
|
if user.Role == "admin" && adminCount == 1 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<button disabled class=\"btn btn-danger text-sm cursor-not-allowed opacity-50\" title=\"Cannot delete the last admin\">Delete</button>")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -214,11 +214,11 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs("/api/auth/profile/" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 109, Col: 54}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 113, Col: 55}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -227,15 +227,15 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs("#user-" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 111, Col: 42}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 115, Col: 43}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" 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-sm\">Delete</button>")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-30
@@ -14,79 +14,78 @@ templ Analytics(user User) {
|
|||||||
@Header(user, "/analytics")
|
@Header(user, "/analytics")
|
||||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="mb-2 flex items-center gap-2 text-2xl font-bold tracking-tight text-content">
|
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">📊 Reading Analytics</h1>
|
||||||
@Icon("chart", "h-6 w-6")
|
<p style="color: var(--text-secondary)">Track your reading habits and device usage</p>
|
||||||
Reading Analytics
|
|
||||||
</h1>
|
|
||||||
<p class="text-content-muted">Track your reading habits and device usage</p>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Date Range Picker -->
|
<!-- Date Range Picker -->
|
||||||
<div class="card p-4 mb-6">
|
<div class="card p-4 rounded-lg border mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<div class="flex flex-wrap gap-4 items-center">
|
<div class="flex flex-wrap gap-4 items-center">
|
||||||
<div class="flex-1 min-w-[200px]">
|
<div class="flex-1 min-w-[200px]">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Start Date</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Start Date</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
id="start-date"
|
id="start-date"
|
||||||
onchange="loadAnalytics()"
|
onchange="loadAnalytics()"
|
||||||
class="input"
|
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>
|
||||||
<div class="flex-1 min-w-[200px]">
|
<div class="flex-1 min-w-[200px]">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">End Date</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">End Date</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
id="end-date"
|
id="end-date"
|
||||||
onchange="loadAnalytics()"
|
onchange="loadAnalytics()"
|
||||||
class="input"
|
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>
|
||||||
<div class="flex items-end">
|
<div class="flex items-end">
|
||||||
<button @click="loadAnalytics()" class="btn btn-primary">Update</button>
|
<button @click="loadAnalytics()" class="btn-primary px-6 py-2 rounded-lg">Update</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8" id="stats-container">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8" id="stats-container">
|
||||||
<div class="card p-5">
|
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-content-muted">Books Read</h3>
|
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Books Read</h3>
|
||||||
<p id="total-books" class="mt-1 text-2xl font-bold text-content">-</p>
|
<p id="total-books" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-5">
|
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-content-muted">Pages Read</h3>
|
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Pages Read</h3>
|
||||||
<p id="total-pages" class="mt-1 text-2xl font-bold text-content">-</p>
|
<p id="total-pages" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-5">
|
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-content-muted">Reading Time</h3>
|
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Reading Time</h3>
|
||||||
<p id="reading-time" class="mt-1 text-2xl font-bold text-content">-</p>
|
<p id="reading-time" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-5">
|
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-content-muted">Completion Rate</h3>
|
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Completion Rate</h3>
|
||||||
<p id="completion-rate" class="mt-1 text-2xl font-bold text-content">-</p>
|
<p id="completion-rate" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Charts Row -->
|
<!-- Charts Row -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
<!-- Daily Reading Chart -->
|
<!-- Daily Reading Chart -->
|
||||||
<div class="card p-6">
|
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<h3 class="text-lg font-semibold mb-4 text-content">Daily Reading Minutes</h3>
|
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Daily Reading Minutes</h3>
|
||||||
<div class="h-80">
|
<div class="h-80">
|
||||||
<canvas id="daily-reading-chart"></canvas>
|
<canvas id="daily-reading-chart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Device Usage Chart -->
|
<!-- Device Usage Chart -->
|
||||||
<div class="card p-6">
|
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<h3 class="text-lg font-semibold mb-4 text-content">Device Usage</h3>
|
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Device Usage</h3>
|
||||||
<div class="h-80">
|
<div class="h-80">
|
||||||
<canvas id="device-usage-chart"></canvas>
|
<canvas id="device-usage-chart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Popular Books -->
|
<!-- Popular Books -->
|
||||||
<div class="card p-6">
|
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<h3 class="text-lg font-semibold mb-4 text-content">Most Read Books</h3>
|
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Most Read Books</h3>
|
||||||
<div id="popular-books" class="space-y-3">
|
<div id="popular-books" class="space-y-3">
|
||||||
<div class="text-center py-8 text-content-muted">
|
<div class="text-center py-8" style="color: var(--text-secondary);">
|
||||||
<div class="loading-spinner mx-auto mb-4"></div>
|
<div class="loading-spinner mx-auto mb-4"></div>
|
||||||
<p>Loading analytics...</p>
|
<p>Loading analytics...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
// templ: version: v0.3.1001
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
@@ -37,15 +37,7 @@ func Analytics(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><h1 class=\"mb-2 flex items-center gap-2 text-2xl font-bold tracking-tight text-content\">")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("chart", "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, 3, "Reading Analytics</h1><p class=\"text-content-muted\">Track your reading habits and device usage</p></div><!-- Date Range Picker --><div class=\"card p-4 mb-6\"><div class=\"flex flex-wrap gap-4 items-center\"><div class=\"flex-1 min-w-[200px]\"><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Start Date</label> <input type=\"date\" id=\"start-date\" onchange=\"loadAnalytics()\" class=\"input\"></div><div class=\"flex-1 min-w-[200px]\"><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">End Date</label> <input type=\"date\" id=\"end-date\" onchange=\"loadAnalytics()\" class=\"input\"></div><div class=\"flex items-end\"><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-5\"><h3 class=\"text-xs font-semibold uppercase tracking-wide text-content-muted\">Books Read</h3><p id=\"total-books\" class=\"mt-1 text-2xl font-bold text-content\">-</p></div><div class=\"card p-5\"><h3 class=\"text-xs font-semibold uppercase tracking-wide text-content-muted\">Pages Read</h3><p id=\"total-pages\" class=\"mt-1 text-2xl font-bold text-content\">-</p></div><div class=\"card p-5\"><h3 class=\"text-xs font-semibold uppercase tracking-wide text-content-muted\">Reading Time</h3><p id=\"reading-time\" class=\"mt-1 text-2xl font-bold text-content\">-</p></div><div class=\"card p-5\"><h3 class=\"text-xs font-semibold uppercase tracking-wide text-content-muted\">Completion Rate</h3><p id=\"completion-rate\" class=\"mt-1 text-2xl font-bold text-content\">-</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\"><h3 class=\"text-lg font-semibold mb-4 text-content\">Daily Reading Minutes</h3><div class=\"h-80\"><canvas id=\"daily-reading-chart\"></canvas></div></div><!-- Device Usage Chart --><div class=\"card p-6\"><h3 class=\"text-lg font-semibold mb-4 text-content\">Device Usage</h3><div class=\"h-80\"><canvas id=\"device-usage-chart\"></canvas></div></div></div><!-- Popular Books --><div class=\"card p-6\"><h3 class=\"text-lg font-semibold mb-4 text-content\">Most Read Books</h3><div id=\"popular-books\" class=\"space-y-3\"><div class=\"text-center py-8 text-content-muted\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading analytics...</p></div></div></div></div></body></html>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,63 +3,63 @@ package templates
|
|||||||
templ APIExplorer(explorer APIExplorerData) {
|
templ APIExplorer(explorer APIExplorerData) {
|
||||||
if !explorer.IsLoggedIn {
|
if !explorer.IsLoggedIn {
|
||||||
// Show mode toggle and mock data
|
// Show mode toggle and mock data
|
||||||
<div x-data="devices" class="card p-6 mt-8">
|
<div x-data="devices" class="border border-border rounded-lg p-6 mt-8 bg-background-secondary">
|
||||||
<div class="flex gap-2 mb-4">
|
<div class="flex gap-2 mb-4">
|
||||||
<button class="btn btn-primary">Mock Data</button>
|
<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="btn btn-secondary opacity-50 cursor-not-allowed" disabled>Login to Try Real</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>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<select disabled class="input w-full">
|
<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="GET" selected?={ explorer.Endpoint.Method == "GET" }>GET</option>
|
||||||
<option value="POST" selected?={ explorer.Endpoint.Method == "POST" }>POST</option>
|
<option value="POST" selected?={ explorer.Endpoint.Method == "POST" }>POST</option>
|
||||||
<option value="PUT" selected?={ explorer.Endpoint.Method == "PUT" }>PUT</option>
|
<option value="PUT" selected?={ explorer.Endpoint.Method == "PUT" }>PUT</option>
|
||||||
<option value="DELETE" selected?={ explorer.Endpoint.Method == "DELETE" }>DELETE</option>
|
<option value="DELETE" selected?={ explorer.Endpoint.Method == "DELETE" }>DELETE</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<h4 class="text-content-muted mb-2 text-sm font-medium">Request Body (Example)</h4>
|
<h4 class="text-text-secondary mb-2 text-sm font-medium">Request Body (Example)</h4>
|
||||||
<pre class="bg-surface p-4 rounded-lg overflow-x-auto"><code class="text-content text-sm">{ explorer.Endpoint.RequestBody }</code></pre>
|
<pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code class="text-text-primary text-sm">{ explorer.Endpoint.RequestBody }</code></pre>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<h4 class="text-content-muted mb-2 text-sm font-medium">Response (Mock)</h4>
|
<h4 class="text-text-secondary mb-2 text-sm font-medium">Response (Mock)</h4>
|
||||||
<pre class="bg-surface p-4 rounded-lg overflow-x-auto"><code class="text-content text-sm">{ explorer.Endpoint.Response }</code></pre>
|
<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>
|
</div>
|
||||||
<div class="flex gap-2 mt-4">
|
<div class="flex gap-2 mt-4">
|
||||||
<button @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))" class="btn btn-secondary text-sm">Copy Request</button>
|
<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="btn btn-secondary text-sm">Copy Response</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>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
// Show interactive explorer with real execution
|
// Show interactive explorer with real execution
|
||||||
<div class="card p-6 mt-8" x-data="apiExplorerDoc" x-init="initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')">
|
<div class="border border-border rounded-lg p-6 mt-8 bg-background-secondary" x-data="apiExplorerDoc" x-init="initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')">
|
||||||
<div class="flex gap-2 mb-4">
|
<div class="flex gap-2 mb-4">
|
||||||
<button class="mode-btn btn btn-secondary text-sm" id="mock-btn" @click="showDocMode('mock')">Mock Data</button>
|
<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 btn btn-primary text-sm" id="real-btn" @click="showDocMode('real')">Try It Out</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>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<select id="http-method" class="input w-full">
|
<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" selected?={ explorer.Endpoint.Method == "GET" }>GET</option>
|
<option value="GET" selected?={ explorer.Endpoint.Method == "GET" }>GET</option>
|
||||||
<option value="POST" selected?={ explorer.Endpoint.Method == "POST" }>POST</option>
|
<option value="POST" selected?={ explorer.Endpoint.Method == "POST" }>POST</option>
|
||||||
<option value="PUT" selected?={ explorer.Endpoint.Method == "PUT" }>PUT</option>
|
<option value="PUT" selected?={ explorer.Endpoint.Method == "PUT" }>PUT</option>
|
||||||
<option value="DELETE" selected?={ explorer.Endpoint.Method == "DELETE" }>DELETE</option>
|
<option value="DELETE" selected?={ explorer.Endpoint.Method == "DELETE" }>DELETE</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<h4 class="text-content-muted mb-2 text-sm font-medium">Request Body</h4>
|
<h4 class="text-text-secondary mb-2 text-sm font-medium">Request Body</h4>
|
||||||
<textarea id="request-body" placeholder="Edit request body..." class="input w-full min-h-[150px] font-mono text-sm">{ explorer.Endpoint.RequestBody }</textarea>
|
<textarea id="request-body" placeholder="Edit request body..." class="w-full min-h-[150px] px-3 py-2 font-mono text-sm bg-background-primary text-text-primary border border-border rounded focus:outline-none focus:ring-2 focus:ring-accent">{ explorer.Endpoint.RequestBody }</textarea>
|
||||||
</div>
|
</div>
|
||||||
<button id="try-it-out" @click="tryDocEndpoint()" class="btn btn-primary mt-4">Try It Out</button>
|
<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="api-response mt-4 hidden">
|
||||||
<div class="flex justify-between mb-2 text-sm">
|
<div class="flex justify-between mb-2 text-sm">
|
||||||
<span id="response-status" class="font-semibold"></span>
|
<span id="response-status" class="font-semibold"></span>
|
||||||
<span id="response-time" class="text-content-muted"></span>
|
<span id="response-time" class="text-text-secondary"></span>
|
||||||
</div>
|
</div>
|
||||||
<pre class="bg-surface p-4 rounded-lg overflow-x-auto"><code id="response-body" class="text-content text-sm"></code></pre>
|
<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>
|
||||||
<div class="flex gap-2 mt-4 flex-wrap">
|
<div class="flex gap-2 mt-4 flex-wrap">
|
||||||
<button @click="copyDocRequest()" class="btn btn-secondary text-sm">Copy Request</button>
|
<button @click="copyDocRequest()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Request</button>
|
||||||
<button @click="copyDocResponse()" class="btn btn-secondary text-sm">Copy Response</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="btn btn-secondary text-sm">Generate cURL</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>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
// templ: version: v0.3.1001
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
@@ -30,7 +30,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
|||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
if !explorer.IsLoggedIn {
|
if !explorer.IsLoggedIn {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, " <div x-data=\"devices\" class=\"card p-6 mt-8\"><div class=\"flex gap-2 mb-4\"><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 class=\"mb-4\"><div class=\"mb-4\"><select disabled class=\"input w-full\"><option value=\"GET\"")
|
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\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -70,38 +70,38 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, ">DELETE</option></select></div><h4 class=\"text-content-muted mb-2 text-sm font-medium\">Request Body (Example)</h4><pre class=\"bg-surface p-4 rounded-lg overflow-x-auto\"><code class=\"text-content text-sm\">")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 21, Col: 125}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 21, Col: 141}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</code></pre></div><div class=\"mt-4\"><h4 class=\"text-content-muted mb-2 text-sm font-medium\">Response (Mock)</h4><pre class=\"bg-surface p-4 rounded-lg overflow-x-auto\"><code class=\"text-content text-sm\">")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 25, Col: 122}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 25, Col: 138}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</code></pre></div><div class=\"flex gap-2 mt-4\"><button @click=\"copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))\" class=\"btn btn-secondary text-sm\">Copy Request</button> <button @click=\"copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))\" class=\"btn btn-secondary text-sm\">Copy Response</button></div></div>")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " <div class=\"card p-6 mt-8\" 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 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 class=\"mb-4\"><div class=\"mb-4\"><select id=\"http-method\" class=\"input w-full\"><option value=\"GET\"")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -141,20 +141,20 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, ">DELETE</option></select></div><h4 class=\"text-content-muted mb-2 text-sm font-medium\">Request Body</h4><textarea id=\"request-body\" placeholder=\"Edit request body...\" class=\"input w-full min-h-[150px] font-mono text-sm\">")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 49, Col: 151}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 49, Col: 274}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</textarea></div><button id=\"try-it-out\" @click=\"tryDocEndpoint()\" class=\"btn btn-primary mt-4\">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-content-muted\"></span></div><pre class=\"bg-surface p-4 rounded-lg overflow-x-auto\"><code id=\"response-body\" class=\"text-content text-sm\"></code></pre></div><div class=\"flex gap-2 mt-4 flex-wrap\"><button @click=\"copyDocRequest()\" class=\"btn btn-secondary text-sm\">Copy Request</button> <button @click=\"copyDocResponse()\" class=\"btn btn-secondary text-sm\">Copy Response</button> <button @click=\"generateDocCURL()\" class=\"btn btn-secondary text-sm\">Generate cURL</button></div></div>")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+373
-184
@@ -4,9 +4,9 @@ import (
|
|||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||||
@@ -19,128 +19,120 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body x-data="bookDetail" class="theme-{ user.Theme }" data-format-group={ book.FormatGroup } data-library-id={ uuidToString(book.LibraryID) }>
|
||||||
x-data="bookDetail"
|
|
||||||
class="theme-{ user.Theme }"
|
|
||||||
data-format-group={ book.FormatGroup }
|
|
||||||
data-library-id={ uuidToString(book.LibraryID) }
|
|
||||||
data-rating={ fmt.Sprintf("%d", getBookRating(book.Rating)) }
|
|
||||||
data-conflict-id={ conflictID(book.ActiveConflict) }
|
|
||||||
data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }
|
|
||||||
>
|
|
||||||
@Header(user, "/media/{ uuidToString(book.ID) }")
|
@Header(user, "/media/{ uuidToString(book.ID) }")
|
||||||
<!-- Back bar -->
|
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
|
||||||
<div class="sticky top-16 z-30 border-b border-line bg-surface/95 backdrop-blur">
|
<div class="w-full px-4 py-3 flex items-center gap-4">
|
||||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-2.5">
|
<a id="back-link" href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||||
<a id="back-link" href="/dashboard" class="inline-flex items-center gap-1.5 text-sm font-medium text-content-muted transition-colors hover:text-content">
|
← Back
|
||||||
@Icon("arrow-left", "h-4 w-4")
|
|
||||||
Back
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8 py-8">
|
<!-- Top Section: Cover + Basic Info + Actions -->
|
||||||
<!-- Cover + header -->
|
<div class="flex flex-col md:flex-row gap-8 mb-8">
|
||||||
<div class="flex flex-col gap-8 md:flex-row">
|
<!-- Left: Cover Image (256x384px) -->
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<div class="overflow-hidden rounded-xl shadow-card-hover">
|
|
||||||
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
||||||
<img
|
<img
|
||||||
src={ book.CoverImagePath.String }
|
src={ book.CoverImagePath.String }
|
||||||
alt={ book.Title }
|
alt={ book.Title }
|
||||||
class="w-48 object-cover md:w-56 lg:w-60"
|
class="w-64 h-96 object-cover rounded-lg shadow-xl"
|
||||||
onerror="this.src='/static/placeholder-book.svg'"
|
onerror="this.src='/static/placeholder-book.svg'"
|
||||||
/>
|
/>
|
||||||
} else {
|
} else {
|
||||||
<img src="/static/placeholder-book.svg" alt={ book.Title } class="w-48 object-cover md:w-56 lg:w-60"/>
|
<img
|
||||||
|
src="/static/placeholder-book.svg"
|
||||||
|
alt="{ book.Title }"
|
||||||
|
class="w-64 h-96 object-cover rounded-lg shadow-xl"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- Right: Details -->
|
||||||
|
<div class="flex-1">
|
||||||
<div class="min-w-0 flex-1">
|
<!-- Title & Author -->
|
||||||
<h1 class="text-3xl font-bold tracking-tight text-content">{ book.Title }</h1>
|
<h1 class="text-4xl font-bold mb-2" style="color: var(--text-primary)">{ book.Title }</h1>
|
||||||
if book.Author.Valid && book.Author.String != "" {
|
if book.Author.Valid && book.Author.String != "" {
|
||||||
<p class="mt-1 text-lg text-content-muted">by { book.Author.String }</p>
|
<h2 class="text-2xl mb-6" style="color: var(--text-secondary)">
|
||||||
|
by { book.Author.String }
|
||||||
|
</h2>
|
||||||
}
|
}
|
||||||
|
<!-- Action Buttons -->
|
||||||
<!-- Actions -->
|
<div class="flex flex-wrap gap-3 mb-6">
|
||||||
<div class="mt-5 flex flex-wrap gap-2">
|
<!-- Read Now (placeholder) -->
|
||||||
<button @click={ "window.location.href = '/readers/" + uuidToString(book.ID) + "'" } class="btn btn-primary">
|
<button
|
||||||
@Icon("book-open", "h-4 w-4")
|
@click={ "window.location.href = '/readers/" + uuidToString(book.ID) + "'" }
|
||||||
Read Now
|
class="px-6 py-3 rounded-lg font-semibold"
|
||||||
|
style="background-color: var(--accent); color: white;"
|
||||||
|
>
|
||||||
|
📖 Read Now
|
||||||
</button>
|
</button>
|
||||||
if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 {
|
<!-- Sync Progress -->
|
||||||
<button @click="toggleRead(false)" :disabled="readSaving" class="btn btn-secondary">
|
|
||||||
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check-circle", "h-4 w-4") Mark as Unread</span>
|
|
||||||
<svg x-show="readSaving" x-cloak class="inline-block h-4 w-4 animate-spin" 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">
|
|
||||||
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("book", "h-4 w-4") Mark as Read</span>
|
|
||||||
<svg x-show="readSaving" x-cloak class="inline-block h-4 w-4 animate-spin" 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 {
|
if book.ActiveConflict != nil || book.ReadingProgress != nil {
|
||||||
<button @click="showProgressSyncModal()" class="btn btn-secondary">
|
<button
|
||||||
@Icon("sync", "h-4 w-4")
|
@click="showProgressSyncModal()"
|
||||||
Sync Progress
|
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>
|
</button>
|
||||||
}
|
}
|
||||||
<button @click="showNotesModal()" class="btn btn-secondary">
|
<!-- View Notes/Highlights -->
|
||||||
@Icon("edit", "h-4 w-4")
|
<button
|
||||||
Notes
|
@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
|
||||||
if book.NotesCount + book.HighlightsCount > 0 {
|
if book.NotesCount + book.HighlightsCount > 0 {
|
||||||
<span class="badge bg-brand/20 text-brand">{ book.NotesCount + book.HighlightsCount }</span>
|
<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>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
<button @click="showMetadataEditor()" class="btn btn-ghost">
|
<!-- Edit Metadata (placeholder) -->
|
||||||
@Icon("settings", "h-4 w-4")
|
<button
|
||||||
Edit
|
@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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Rating Display -->
|
||||||
<!-- Rating -->
|
<div class="mb-6">
|
||||||
<div class="mt-5 flex flex-wrap items-center gap-2" @mouseleave="ratingHover = 0">
|
<span class="text-2xl">
|
||||||
<span class="text-2xl tracking-tight">
|
@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)" class="text-content-muted/40">★</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>
|
||||||
</template>
|
<span class="ml-2 text-sm" style="color: var(--text-secondary);">
|
||||||
|
({ fmt.Sprintf("%.1f", float64(getBookRating(book.Rating))/2.0) } / 5)
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm text-content-muted" x-text="ratingText()"></span>
|
|
||||||
<button type="button" x-show="userRating > 0 && !ratingSaving" x-cloak @click="clearRating()" class="text-xs text-content-muted underline hover:text-content">Clear</button>
|
|
||||||
<svg x-show="ratingSaving" x-cloak class="inline-block h-4 w-4 animate-spin text-content-muted" 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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Community Rating Display (from metadata) -->
|
||||||
<!-- Community rating -->
|
|
||||||
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
|
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
|
||||||
<div class="mt-2 flex items-center gap-2 text-sm text-content-muted">
|
<div class="mb-2">
|
||||||
<span>Community:</span>
|
<span class="text-lg" style="color: var(--text-secondary);">
|
||||||
<span class="text-base">@templ.Raw(renderStars(int32(book.CommunityRating.Float64)))</span>
|
Community Rating:
|
||||||
<span>{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }</span>
|
<span class="font-bold" 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>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<!-- Series Badge -->
|
||||||
<!-- Series badge -->
|
|
||||||
if book.Series.Valid && book.Series.String != "" {
|
if book.Series.Valid && book.Series.String != "" {
|
||||||
<div class="mt-4">
|
<div class="mb-4">
|
||||||
<a
|
<a
|
||||||
href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID) }
|
href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID) }
|
||||||
class="chip hover:opacity-90"
|
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;"
|
||||||
>
|
>
|
||||||
@Icon("layers", "h-3.5 w-3.5")
|
|
||||||
{ book.Series.String }
|
{ book.Series.String }
|
||||||
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
|
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
|
||||||
#{ book.SeriesNumber.Int32 }
|
#{ book.SeriesNumber.Int32 }
|
||||||
@@ -148,200 +140,397 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<!-- Reading Direction Badge (for manga/comics) -->
|
||||||
<!-- Reading direction + comic badges -->
|
|
||||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
|
||||||
if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" {
|
if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" {
|
||||||
<span class="chip">@Icon("book-open", "h-3.5 w-3.5"){ strings.ToUpper(book.ReadingDirection.String) }</span>
|
<div class="mb-4">
|
||||||
|
<span
|
||||||
|
class="px-3 py-1 rounded-full text-sm font-semibold"
|
||||||
|
style="background-color: var(--accent); color: white;"
|
||||||
|
>
|
||||||
|
📖 { strings.ToUpper(book.ReadingDirection.String) }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
<!-- Comic-Specific Badges -->
|
||||||
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
|
<!-- Age Rating Badge -->
|
||||||
if book.AgeRating.Valid && book.AgeRating.String != "" {
|
if book.AgeRating.Valid && book.AgeRating.String != "" {
|
||||||
<span class="badge bg-surface-hover text-content-muted">{ book.AgeRating.String }</span>
|
<span
|
||||||
|
class="px-2 py-1 rounded-full text-xs font-semibold"
|
||||||
|
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
|
||||||
|
>
|
||||||
|
{ book.AgeRating.String }
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
|
<!-- Black and White Badge -->
|
||||||
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
|
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
|
||||||
<span class="badge bg-surface-hover text-content-muted">B&W</span>
|
<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>
|
||||||
}
|
}
|
||||||
|
<!-- Story Arc Badge -->
|
||||||
if book.StoryArc.Valid && book.StoryArc.String != "" {
|
if book.StoryArc.Valid && book.StoryArc.String != "" {
|
||||||
<span class="badge bg-surface-hover text-content-muted">{ book.StoryArc.String }</span>
|
<span
|
||||||
|
class="px-2 py-1 rounded-full text-xs font-semibold"
|
||||||
|
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
|
||||||
|
>
|
||||||
|
📚 { book.StoryArc.String }
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
if len(book.Tags) > 0 {
|
if len(book.Tags) > 0 {
|
||||||
<div class="mt-4 flex flex-wrap gap-1.5">
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
for _, tag := range book.Tags {
|
for _, tag := range book.Tags {
|
||||||
<a href={ "/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID) } class="badge bg-surface-hover text-content-muted transition-colors hover:bg-surface-hover hover:text-content">
|
<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"
|
||||||
|
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary); text-decoration: none;"
|
||||||
|
>
|
||||||
{ tag }
|
{ tag }
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<!-- Description/Synopsis -->
|
||||||
<!-- Synopsis -->
|
|
||||||
if book.Description.Valid && book.Description.String != "" {
|
if book.Description.Valid && book.Description.String != "" {
|
||||||
<div class="mt-5">
|
<div class="mb-6">
|
||||||
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-content-muted">Synopsis</h3>
|
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Synopsis</h3>
|
||||||
<div class="max-h-80 overflow-y-auto pr-2 text-sm leading-relaxed text-content-muted">
|
<div class="max-h-[20rem] overflow-y-auto pr-2" style="color: var(--text-secondary)">
|
||||||
@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Description.String)).ToComponent()
|
@UnsafeHTML(
|
||||||
|
bluemonday.UGCPolicy().Sanitize(book.Description.String),
|
||||||
|
).ToComponent()
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Summary (from ComicInfo.xml, if different from description) -->
|
||||||
<div class="mt-8 space-y-6">
|
|
||||||
<!-- Summary -->
|
|
||||||
if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String {
|
if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String {
|
||||||
<div>
|
<div class="mb-6">
|
||||||
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-content-muted">Comic Summary</h3>
|
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Comic Summary</h3>
|
||||||
<div class="max-h-80 overflow-y-auto pr-2 text-sm leading-relaxed text-content-muted">
|
<div class="max-h-[20rem] overflow-y-auto pr-2" style="color: var(--text-secondary)">
|
||||||
@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Summary.String)).ToComponent()
|
@UnsafeHTML(
|
||||||
|
bluemonday.UGCPolicy().Sanitize(book.Summary.String),
|
||||||
|
).ToComponent()
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<!-- Metadata Notes (technical notes from metadata files) -->
|
||||||
<!-- Metadata notes -->
|
|
||||||
if book.MetadataNotes.Valid && book.MetadataNotes.String != "" {
|
if book.MetadataNotes.Valid && book.MetadataNotes.String != "" {
|
||||||
<div class="card p-5">
|
<div
|
||||||
<h3 class="mb-2 text-sm font-semibold text-content">Metadata Notes</h3>
|
class="card p-6 rounded-lg border mb-6"
|
||||||
<p class="text-sm text-content-muted">{ book.MetadataNotes.String }</p>
|
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
|
<!-- Progress Section -->
|
||||||
<!-- Progress -->
|
|
||||||
if book.ReadingProgress != nil {
|
if book.ReadingProgress != nil {
|
||||||
<div class="card p-5">
|
<div
|
||||||
<h3 class="mb-4 text-sm font-semibold text-content">Reading Progress</h3>
|
class="card p-6 rounded-lg border mb-6"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||||
|
>
|
||||||
|
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Reading Progress</h3>
|
||||||
if book.ActiveConflict != nil {
|
if book.ActiveConflict != nil {
|
||||||
<div class="mb-4 flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 p-3 text-sm text-content">
|
<div
|
||||||
@Icon("alert", "h-4 w-4 mt-0.5 shrink-0 text-warning")
|
class="mb-4 p-3 rounded-lg border"
|
||||||
<span>Progress conflict detected — click "Sync Progress" to review and resolve.</span>
|
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>
|
</div>
|
||||||
}
|
}
|
||||||
|
<!-- Progress Bar -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="h-2 w-full overflow-hidden rounded-full bg-surface-hover">
|
<div class="w-full rounded-full h-3" style="background-color: var(--bg-primary);">
|
||||||
<div class="h-2 rounded-full bg-brand transition-all" style={ fmt.Sprintf("width: %.1f%%", book.ReadingProgress.Percentage.Float64*100) }></div>
|
<div
|
||||||
|
class="h-3 rounded-full transition-all"
|
||||||
|
style={ fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", book.ReadingProgress.Percentage.Float64*100) }
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
<!-- Progress Stats Grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs uppercase tracking-wide text-content-muted">Progress</p>
|
<p style="color: var(--text-secondary)">Progress</p>
|
||||||
<p class="text-xl font-bold text-brand">{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100) }%</p>
|
<p class="text-2xl font-bold" style="color: var(--accent);">
|
||||||
|
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64 * 100) }%
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
|
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs uppercase tracking-wide text-content-muted">Page</p>
|
<p style="color: var(--text-secondary)">Page</p>
|
||||||
<p class="text-content">{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p>
|
<p>{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if book.ReadingProgress.LastReadAt.Valid {
|
if book.ReadingProgress.LastReadAt.Valid {
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs uppercase tracking-wide text-content-muted">Last Read</p>
|
<p style="color: var(--text-secondary)">Last Read</p>
|
||||||
<p class="text-content">{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p>
|
<p>{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if book.ReadingProgress.LastSyncSource.Valid {
|
if book.ReadingProgress.LastSyncSource.Valid {
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs uppercase tracking-wide text-content-muted">Source</p>
|
<p style="color: var(--text-secondary)">Source</p>
|
||||||
<p class="capitalize text-content">{ book.ReadingProgress.LastSyncSource.String }</p>
|
<p class="capitalize">{ book.ReadingProgress.LastSyncSource.String }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<!-- Metadata Grid -->
|
||||||
<!-- Metadata grid -->
|
<div
|
||||||
<div class="card p-5">
|
class="card p-6 rounded-lg border mb-6"
|
||||||
<h3 class="mb-4 text-sm font-semibold text-content">Metadata</h3>
|
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||||
<dl class="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2 lg:grid-cols-3">
|
>
|
||||||
|
<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 -->
|
||||||
if book.Publisher.Valid && book.Publisher.String != "" {
|
if book.Publisher.Valid && book.Publisher.String != "" {
|
||||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Publisher</dt><dd class="text-content">{ book.Publisher.String }</dd></div>
|
<div>
|
||||||
|
<p style="color: var(--text-secondary)">Publisher</p>
|
||||||
|
<p>{ book.Publisher.String }</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
if book.DatePublished.Valid {
|
if book.DatePublished.Valid {
|
||||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Published</dt><dd class="text-content">{ book.DatePublished.Time.Format("01-02-2006") }</dd></div>
|
<div>
|
||||||
|
<p style="color: var(--text-secondary)">Published</p>
|
||||||
|
<p>{ book.DatePublished.Time.Format("01-02-2006") }</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
if book.Isbn.Valid && book.Isbn.String != "" {
|
if book.Isbn.Valid && book.Isbn.String != "" {
|
||||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">ISBN</dt><dd class="text-content">{ book.Isbn.String }</dd></div>
|
<div>
|
||||||
|
<p style="color: var(--text-secondary)">Isbn</p>
|
||||||
|
<p>{ book.Isbn.String }</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
if book.Language.Valid && book.Language.String != "" {
|
if book.Language.Valid && book.Language.String != "" {
|
||||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Language</dt><dd class="capitalize text-content">{ book.Language.String }</dd></div>
|
<div>
|
||||||
|
<p style="color: var(--text-secondary)">Language</p>
|
||||||
|
<p class="capitalize">{ book.Language.String }</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
if book.Edition.Valid && book.Edition.String != "" {
|
if book.Edition.Valid && book.Edition.String != "" {
|
||||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Edition</dt><dd class="text-content">{ book.Edition.String }</dd></div>
|
<div>
|
||||||
|
<p style="color: var(--text-secondary)">Edition</p>
|
||||||
|
<p>{ book.Edition.String }</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
if book.PageCount.Valid && book.PageCount.Int32 > 0 {
|
if book.PageCount.Valid && book.PageCount.Int32 > 0 {
|
||||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Pages</dt><dd class="text-content">{ book.PageCount.Int32 }</dd></div>
|
<div>
|
||||||
|
<p style="color: var(--text-secondary)">Pages</p>
|
||||||
|
<p>{ book.PageCount.Int32 }</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
if book.Genre.Valid && book.Genre.String != "" {
|
if book.Genre.Valid && book.Genre.String != "" {
|
||||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Genre</dt><dd class="text-content">{ book.Genre.String }</dd></div>
|
<div>
|
||||||
|
<p style="color: var(--text-secondary)">Genre</p>
|
||||||
|
<p>{ book.Genre.String }</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
<!-- Series Information (if not shown in badge) -->
|
||||||
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
|
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
|
||||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Series Count</dt><dd class="text-content">{ book.SeriesCount.Int32 } items</dd></div>
|
<div>
|
||||||
|
<p style="color: var(--text-secondary)">Series Count</p>
|
||||||
|
<p>{ book.SeriesCount.Int32 } items</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
if book.Volume.Valid && book.Volume.Int32 > 0 {
|
if book.Volume.Valid && book.Volume.Int32 > 0 {
|
||||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Volume</dt><dd class="text-content">Vol. { book.Volume.Int32 }</dd></div>
|
<div>
|
||||||
|
<p style="color: var(--text-secondary)">Volume</p>
|
||||||
|
<p>Vol. { book.Volume.Int32 }</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
if book.Imprint.Valid && book.Imprint.String != "" {
|
if book.Imprint.Valid && book.Imprint.String != "" {
|
||||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Imprint</dt><dd class="text-content">{ book.Imprint.String }</dd></div>
|
<div>
|
||||||
|
<p style="color: var(--text-secondary)">Imprint</p>
|
||||||
|
<p>{ book.Imprint.String }</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {
|
if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {
|
||||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Copyright Year</dt><dd class="text-content">{ book.CopyrightYear.Int32 }</dd></div>
|
<div>
|
||||||
|
<p style="color: var(--text-secondary)">Copyright Year</p>
|
||||||
|
<p>{ book.CopyrightYear.Int32 }</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
<!-- Comic-Specific Fields -->
|
||||||
if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" {
|
if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" {
|
||||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Manga Type</dt><dd class="capitalize text-content">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</dd></div>
|
<div>
|
||||||
|
<p style="color: var(--text-secondary)">Manga Type</p>
|
||||||
|
<p class="capitalize">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
if book.ScanInformation.Valid && book.ScanInformation.String != "" {
|
if book.ScanInformation.Valid && book.ScanInformation.String != "" {
|
||||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Scan Info</dt><dd class="text-sm text-content-muted">{ book.ScanInformation.String }</dd></div>
|
<div>
|
||||||
|
<p 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 != "" {
|
if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" {
|
||||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Alternate Series</dt><dd class="text-content">{ altSeries }</dd></div>
|
<div>
|
||||||
|
<p style="color: var(--text-secondary)">Alternate Series</p>
|
||||||
|
<p>{ altSeries }</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
if len(book.Contributors) > 0 {
|
if len(book.Contributors) > 0 {
|
||||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Contributors</dt><dd class="text-content">{ strings.Join(book.Contributors, ", ") }</dd></div>
|
<div>
|
||||||
|
<p style="color: var(--text-secondary)">Contributors</p>
|
||||||
|
<p>{ strings.Join(book.Contributors, ", ") }</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Format</dt><dd class="text-content">{ book.MimeType.String }</dd></div>
|
<!-- Technical Info -->
|
||||||
|
<div>
|
||||||
|
<p style="color: var(--text-secondary)">Format</p>
|
||||||
|
<p>{ book.MimeType.String }</p>
|
||||||
|
</div>
|
||||||
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
|
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
|
||||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">File Size</dt><dd class="text-content">{ formatFileSize(book.FileSize.Int64) }</dd></div>
|
<div>
|
||||||
|
<p style="color: var(--text-secondary)">File Size</p>
|
||||||
|
<p>{ formatFileSize(book.FileSize.Int64) }</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</dl>
|
</div>
|
||||||
|
<!-- External IDs Section -->
|
||||||
<!-- External links -->
|
|
||||||
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
|
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
|
||||||
<div class="mt-5 border-t border-line pt-4">
|
<div class="mt-4 pt-4 border-t" style="border-color: var(--border);">
|
||||||
<h4 class="mb-2 text-xs font-semibold uppercase tracking-wide text-content-muted">External Links</h4>
|
<h4 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">External Links</h4>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-3">
|
||||||
<a href={ getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("book", "h-3 w-3") Goodreads</a>
|
if book.GoodreadsID.Valid && book.GoodreadsID.String != "" {
|
||||||
<a href={ getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("book-open", "h-3 w-3") Open Library</a>
|
<a
|
||||||
<a href={ getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("search", "h-3 w-3") Google Books</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>
|
||||||
|
} 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>
|
||||||
|
}
|
||||||
|
if book.OpenlibraryID.Valid && book.OpenlibraryID.String != "" {
|
||||||
|
<a
|
||||||
|
href={ getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author) }
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-sm hover:underline flex items-center gap-1"
|
||||||
|
style="color: var(--accent);"
|
||||||
|
>
|
||||||
|
📖 Open Library
|
||||||
|
</a>
|
||||||
|
} else {
|
||||||
|
<a
|
||||||
|
href={ getExternalURL("openlibrary", "", book.Isbn, book.Title, book.Author) }
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-sm hover:underline flex items-center gap-1"
|
||||||
|
style="color: var(--accent);"
|
||||||
|
>
|
||||||
|
📖 Open Library
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
if book.GoogleBooksID.Valid && book.GoogleBooksID.String != "" {
|
||||||
|
<a
|
||||||
|
href={ getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author) }
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-sm hover:underline flex items-center gap-1"
|
||||||
|
style="color: var(--accent);"
|
||||||
|
>
|
||||||
|
🔍 Google Books
|
||||||
|
</a>
|
||||||
|
} else {
|
||||||
|
<a
|
||||||
|
href={ getExternalURL("googlebooks", "", book.Isbn, book.Title, book.Author) }
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-sm hover:underline flex items-center gap-1"
|
||||||
|
style="color: var(--accent);"
|
||||||
|
>
|
||||||
|
🔍 Google Books
|
||||||
|
</a>
|
||||||
|
}
|
||||||
if book.Asin.Valid && book.Asin.String != "" {
|
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="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("external", "h-3 w-3") Amazon</a>
|
<a
|
||||||
|
href={ getExternalURL("amazon", book.Asin.String, book.Isbn, book.Title, book.Author) }
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-sm hover:underline flex items-center gap-1"
|
||||||
|
style="color: var(--accent);"
|
||||||
|
>
|
||||||
|
🛒 Amazon
|
||||||
|
</a>
|
||||||
} else if book.Isbn.Valid && book.Isbn.String != "" {
|
} else if book.Isbn.Valid && book.Isbn.String != "" {
|
||||||
<a href={ getExternalURL("amazon", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("external", "h-3 w-3") Amazon</a>
|
<a
|
||||||
|
href={ getExternalURL("amazon", "", book.Isbn, book.Title, book.Author) }
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-sm hover:underline flex items-center gap-1"
|
||||||
|
style="color: var(--accent);"
|
||||||
|
>
|
||||||
|
🛒 Amazon
|
||||||
|
</a>
|
||||||
}
|
}
|
||||||
if book.WebUrl.Valid && book.WebUrl.String != "" {
|
if book.WebUrl.Valid && book.WebUrl.String != "" {
|
||||||
<a href={ book.WebUrl.String } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("external", "h-3 w-3") { getDomainName(book.WebUrl.String) }</a>
|
<a
|
||||||
}
|
href={ book.WebUrl.String }
|
||||||
</div>
|
target="_blank"
|
||||||
</div>
|
rel="noopener noreferrer"
|
||||||
}
|
class="text-sm hover:underline flex items-center gap-1"
|
||||||
</div>
|
style="color: var(--accent);"
|
||||||
|
>
|
||||||
<!-- Collections -->
|
🔗 { getDomainName(book.WebUrl.String) }
|
||||||
if len(book.Collections) > 0 {
|
|
||||||
<div class="card p-5">
|
|
||||||
<h3 class="mb-4 text-sm font-semibold text-content">Collections</h3>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
for _, col := range book.Collections {
|
|
||||||
<a href={ "/collections/" + uuidToString(col.ID) } class="flex items-center gap-2 rounded-lg border border-line bg-surface px-3 py-1.5 text-sm transition-colors hover:bg-surface-hover">
|
|
||||||
<span style={ "color: " + col.Color.String }>{ col.Icon.String }</span>
|
|
||||||
<span class="text-content">{ col.Name }</span>
|
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Collections Section -->
|
||||||
|
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);"
|
||||||
|
>
|
||||||
|
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Collections</h3>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
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;"
|
||||||
|
>
|
||||||
|
<span style="color: { col.Color.String };">{ col.Icon.String }</span>
|
||||||
|
<span style="color: var(--text-primary);">{ col.Name }</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- Modals -->
|
||||||
@ProgressSyncModal(user, book)
|
@ProgressSyncModal(user, book)
|
||||||
@NotesHighlightsModal(book)
|
@NotesHighlightsModal(book)
|
||||||
@MetadataEditorModal(book)
|
@MetadataEditorModal(book)
|
||||||
|
|||||||
+213
-169
@@ -9,104 +9,102 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
|||||||
<div
|
<div
|
||||||
id="progress-sync-modal"
|
id="progress-sync-modal"
|
||||||
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
|
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
|
||||||
style="background-color: var(--surface-overlay);"
|
style="background-color: rgba(0, 0, 0, 0.7);"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="card p-6 w-full max-w-4xl mx-4 my-8"
|
class="card rounded-lg p-6 w-full max-w-4xl mx-4 my-8"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold tracking-tight text-content">Sync Progress</h2>
|
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Sync Progress</h2>
|
||||||
<p class="text-sm text-content-muted">{ book.Title }</p>
|
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title }</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="hideProgressSyncModal()"
|
@click="hideProgressSyncModal()"
|
||||||
class="icon-btn"
|
class="p-2 hover:opacity-80 rounded-lg"
|
||||||
|
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||||
>
|
>
|
||||||
@Icon("close", "h-5 w-5")
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
if book.ActiveConflict != nil {
|
if book.ActiveConflict != nil {
|
||||||
<div
|
<div
|
||||||
class="mb-6 p-4 rounded-lg border border-warning bg-warning/10"
|
class="mb-6 p-4 rounded-lg border"
|
||||||
|
style="background-color: #f59e0b20; border-color: #f59e0b;"
|
||||||
>
|
>
|
||||||
<p class="font-semibold mb-2 flex items-center gap-2 text-content">
|
<p class="font-semibold mb-2" style="color: var(--text-primary);">
|
||||||
@Icon("alert", "h-4 w-4 text-warning")
|
⚠️ Conflict Detected
|
||||||
Conflict Detected
|
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-content-muted">
|
<p class="text-sm" style="color: var(--text-secondary);">
|
||||||
Progress differs between devices. Choose which version to keep.
|
Progress differs between devices. Review the options below and manually resolve via the Conflicts page.
|
||||||
</p>
|
</p>
|
||||||
|
<a
|
||||||
|
href="/conflicts"
|
||||||
|
class="inline-block mt-3 px-4 py-2 rounded-lg text-sm font-semibold"
|
||||||
|
style="background-color: #f59e0b; color: white; text-decoration: none;"
|
||||||
|
>
|
||||||
|
Go to Conflicts Page →
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
for source, data := range book.ActiveConflict.ConflictData {
|
for source, data := range book.ActiveConflict.ConflictData {
|
||||||
<div
|
<div
|
||||||
class="card p-4"
|
class="card p-4 rounded-lg border"
|
||||||
|
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between items-start mb-3">
|
<div class="flex justify-between items-start mb-3">
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span
|
||||||
class="inline-block px-2 py-1 rounded text-xs font-semibold capitalize mb-2 bg-brand text-white"
|
class="inline-block px-2 py-1 rounded text-xs font-semibold capitalize mb-2"
|
||||||
|
style="background-color: var(--accent); color: white;"
|
||||||
>
|
>
|
||||||
{ data.Source }
|
{ source }
|
||||||
</span>
|
</span>
|
||||||
<p class="text-xs text-content-muted">
|
<p class="text-xs" style="color: var(--text-secondary);">
|
||||||
{ FormatInTimezone(data.Timestamp, user.Timezone) }
|
{ FormatInTimezone(data.Timestamp, user.Timezone) }
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
if data.Data["percentage"] != nil {
|
<div class="text-3xl font-bold" style="color: var(--accent);">
|
||||||
<div class="text-3xl font-bold text-brand">
|
{ fmt.Sprintf("%.1f", data.Data["percentage"].(float64)) }%
|
||||||
{ fmt.Sprintf("%.1f", data.Data["percentage"].(float64) * 100) }%
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
if data.Data["page"] != nil {
|
</div>
|
||||||
<div class="text-sm text-content-muted">
|
if data.Data["current_page"] != nil {
|
||||||
if data.Data["total_pages"] != nil {
|
<div class="text-sm" style="color: var(--text-secondary);">
|
||||||
Page: { fmt.Sprintf("%.0f", data.Data["page"].(float64)) } / { fmt.Sprintf("%.0f", data.Data["total_pages"].(float64)) }
|
Page: { fmt.Sprintf("%.0f", data.Data["current_page"].(float64)) } / { fmt.Sprintf("%.0f", data.Data["total_pages"].(float64)) }
|
||||||
} else {
|
|
||||||
Page: { fmt.Sprintf("%.0f", data.Data["page"].(float64)) }
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if data.Data["epubcfi"] != nil {
|
if data.Data["epubcfi"] != nil {
|
||||||
<div class="text-xs mt-1 text-content-muted">
|
<div class="text-xs mt-1" style="color: var(--text-secondary);">
|
||||||
CFI: { data.Data["epubcfi"].(string) }
|
CFI: { data.Data["epubcfi"].(string) }
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="mt-3">
|
|
||||||
<button
|
|
||||||
@click={"resolveConflict('" + book.ActiveConflict.ID + "', '" + source + "')"}
|
|
||||||
class="btn btn-primary text-sm"
|
|
||||||
>
|
|
||||||
Keep This
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} else if book.ReadingProgress != nil {
|
} else if book.ReadingProgress != nil {
|
||||||
<div class="mb-4 text-content-muted">
|
<div class="mb-4" style="color: var(--text-secondary);">
|
||||||
<p>No conflicts detected. Current progress from <strong>{ book.ReadingProgress.LastSyncSource.String }</strong>:</p>
|
<p>No conflicts detected. Current progress from <strong>{ book.ReadingProgress.LastSyncSource.String }</strong>:</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="card p-6 text-center"
|
class="card p-6 rounded-lg border text-center"
|
||||||
|
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||||
>
|
>
|
||||||
<div class="text-5xl font-bold mb-4 text-brand">
|
<div class="text-5xl font-bold mb-4" style="color: var(--accent);">
|
||||||
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64 * 100) }%
|
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64 * 100) }%
|
||||||
</div>
|
</div>
|
||||||
<p class="capitalize text-lg mb-2 text-content">
|
<p class="capitalize text-lg mb-2" style="color: var(--text-primary);">
|
||||||
{ book.ReadingProgress.LastSyncSource.String }
|
{ book.ReadingProgress.LastSyncSource.String }
|
||||||
</p>
|
</p>
|
||||||
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
|
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
|
||||||
<p class="text-content-muted">
|
<p style="color: var(--text-secondary);">
|
||||||
Page { book.ReadingProgress.CurrentPage.Int32 } of { book.ReadingProgress.TotalPages.Int32 }
|
Page { book.ReadingProgress.CurrentPage.Int32 } of { book.ReadingProgress.TotalPages.Int32 }
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
if book.ReadingProgress.LastReadAt.Valid {
|
if book.ReadingProgress.LastReadAt.Valid {
|
||||||
<p class="text-sm mt-2 text-content-muted">
|
<p class="text-sm mt-2" style="color: var(--text-secondary);">
|
||||||
Last read: { FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }
|
Last read: { FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
@@ -115,7 +113,8 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
|||||||
<div class="flex justify-end space-x-3 mt-6">
|
<div class="flex justify-end space-x-3 mt-6">
|
||||||
<button
|
<button
|
||||||
@click="hideProgressSyncModal()"
|
@click="hideProgressSyncModal()"
|
||||||
class="btn btn-secondary"
|
class="px-4 py-2 rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
@@ -128,23 +127,23 @@ templ NotesHighlightsModal(book handlers.MediaDetail) {
|
|||||||
<div
|
<div
|
||||||
id="notes-modal"
|
id="notes-modal"
|
||||||
class="hidden fixed inset-0 z-50 flex items-center justify-center"
|
class="hidden fixed inset-0 z-50 flex items-center justify-center"
|
||||||
style="background-color: var(--surface-overlay);"
|
style="background-color: rgba(0, 0, 0, 0.7);"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="card p-8 w-full max-w-2xl mx-4 text-center"
|
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);"
|
||||||
>
|
>
|
||||||
<div class="mb-4 flex justify-center">
|
<div class="text-6xl mb-4">📝</div>
|
||||||
@Icon("edit", "h-12 w-12 text-content-muted")
|
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Notes & Highlights</h2>
|
||||||
</div>
|
<p class="mb-2" style="color: var(--text-secondary);">
|
||||||
<h2 class="text-2xl font-bold tracking-tight text-content mb-2">Notes & Highlights</h2>
|
|
||||||
<p class="mb-2 text-content-muted">
|
|
||||||
This book has <strong>{ book.NotesCount }</strong> notes and <strong>{ book.HighlightsCount }</strong> highlights.
|
This book has <strong>{ book.NotesCount }</strong> notes and <strong>{ book.HighlightsCount }</strong> highlights.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-6 text-content-muted">Feature coming soon!</p>
|
<p class="mb-6" style="color: var(--text-secondary);">Feature coming soon!</p>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
@click="hideNotesModal()"
|
@click="hideNotesModal()"
|
||||||
class="btn btn-primary"
|
class="px-6 py-2 rounded-lg font-semibold"
|
||||||
|
style="background-color: var(--accent); color: white;"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
@@ -157,24 +156,27 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
<div
|
<div
|
||||||
id="metadata-editor-modal"
|
id="metadata-editor-modal"
|
||||||
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
|
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
|
||||||
style="background-color: var(--surface-overlay);"
|
style="background-color: rgba(0, 0, 0, 0.7);"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="card w-full max-w-5xl mx-4 my-8 flex flex-col max-h-[90vh]"
|
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);"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between items-center p-6 border-b border-line flex-shrink-0">
|
<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 tracking-tight text-content">Edit Metadata</h2>
|
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Edit Metadata</h2>
|
||||||
<button
|
<button
|
||||||
@click="hideMetadataEditor()"
|
@click="hideMetadataEditor()"
|
||||||
class="icon-btn"
|
class="p-2 hover:opacity-80 rounded-lg"
|
||||||
|
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||||
>
|
>
|
||||||
@Icon("close", "h-5 w-5")
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col md:flex-row gap-8 p-6 flex-1 min-h-0 overflow-y-auto">
|
<div class="flex flex-col md:flex-row gap-8 p-6 flex-1 min-h-0 overflow-y-auto">
|
||||||
<!-- Cover Section (Left) -->
|
<!-- Cover Section (Left) -->
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<div class="w-64 h-96 rounded-lg overflow-hidden shadow-lg mb-3 cursor-pointer relative group bg-surface"
|
<div class="w-64 h-96 rounded-lg overflow-hidden shadow-lg mb-3 cursor-pointer relative group"
|
||||||
|
style="background-color: var(--bg-primary);"
|
||||||
@click="document.getElementById('cover-upload-input').click()"
|
@click="document.getElementById('cover-upload-input').click()"
|
||||||
>
|
>
|
||||||
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
||||||
@@ -199,29 +201,29 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
<div class="w-64 space-y-2">
|
<div class="w-64 space-y-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary w-full text-sm"
|
class="w-full px-3 py-2 text-sm rounded-lg font-semibold"
|
||||||
|
style="background-color: var(--accent); color: white;"
|
||||||
@click="generateCover()"
|
@click="generateCover()"
|
||||||
x-show="coverGenerating"
|
x-show="coverGenerating"
|
||||||
x-cloak
|
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
Generating...
|
Generating...
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary w-full text-sm"
|
class="w-full px-3 py-2 text-sm rounded-lg font-semibold"
|
||||||
|
style="background-color: var(--accent); color: white;"
|
||||||
@click="generateCover()"
|
@click="generateCover()"
|
||||||
x-show="!coverGenerating"
|
x-show="!coverGenerating"
|
||||||
x-cloak
|
|
||||||
>
|
>
|
||||||
Generate Cover
|
Generate Cover
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-secondary w-full text-sm"
|
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);"
|
||||||
@click="removeCover()"
|
@click="removeCover()"
|
||||||
x-show="hasExistingCover || newCoverPreview"
|
x-show="hasExistingCover || newCoverPreview"
|
||||||
x-cloak
|
|
||||||
>
|
>
|
||||||
Remove Cover
|
Remove Cover
|
||||||
</button>
|
</button>
|
||||||
@@ -230,44 +232,50 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
<!-- Accordion Fields (Right) -->
|
<!-- Accordion Fields (Right) -->
|
||||||
<div class="flex-1 min-w-0 space-y-2">
|
<div class="flex-1 min-w-0 space-y-2">
|
||||||
<!-- Basic Info -->
|
<!-- Basic Info -->
|
||||||
<div class="border rounded-lg border-line">
|
<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 text-content bg-surface"
|
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
||||||
|
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||||
@click="toggleSection('basic')">
|
@click="toggleSection('basic')">
|
||||||
<span class="font-semibold">Basic Info</span>
|
<span class="font-semibold">Basic Info</span>
|
||||||
<span x-text="openSections.basic ? '▾' : '▸'">▸</span>
|
<span x-text="openSections.basic ? '▾' : '▸'">▸</span>
|
||||||
</button>
|
</button>
|
||||||
<div x-show="openSections.basic" x-cloak x-transition class="p-4 space-y-3 bg-surface">
|
<div x-show="openSections.basic" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Title</label>
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Title</label>
|
||||||
<input type="text" name="title" value={ book.Title }
|
<input type="text" name="title" value={ book.Title }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Author</label>
|
<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) }
|
<input type="text" name="author" value={ textToString(book.Author) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Description</label>
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Description</label>
|
||||||
<textarea name="description" rows="3"
|
<textarea name="description" rows="3"
|
||||||
class="input text-sm"
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);"
|
||||||
>{ textToString(book.Description) }</textarea>
|
>{ textToString(book.Description) }</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Summary</label>
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Summary</label>
|
||||||
<textarea name="summary" rows="2"
|
<textarea name="summary" rows="2"
|
||||||
class="input text-sm"
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);"
|
||||||
>{ textToString(book.Summary) }</textarea>
|
>{ textToString(book.Summary) }</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Tags</label>
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Tags</label>
|
||||||
<div class="flex flex-wrap gap-1.5 mb-2">
|
<div class="flex flex-wrap gap-1.5 mb-2">
|
||||||
<template x-for="(tag, idx) in editorTags" :key="idx">
|
<template x-for="(tag, idx) in editorTags" :key="idx">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-brand text-white"
|
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
style="background-color: var(--accent); color: white;"
|
||||||
>
|
>
|
||||||
<span x-text="tag"></span>
|
<span x-text="tag"></span>
|
||||||
<button type="button" class="hover:opacity-70 leading-none" @click="removeEditorTag(idx)">@Icon("close", "h-3 w-3")</button>
|
<button type="button" class="hover:opacity-70 leading-none" @click="removeEditorTag(idx)">✕</button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,12 +289,13 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
@keydown="onTagKeydown($event)"
|
@keydown="onTagKeydown($event)"
|
||||||
@blur="hideEditorTagDropdown()"
|
@blur="hideEditorTagDropdown()"
|
||||||
placeholder="Add tag..."
|
placeholder="Add tag..."
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
<div
|
<div
|
||||||
x-show="showTagDropdown"
|
x-show="showTagDropdown"
|
||||||
x-cloak
|
|
||||||
x-transition
|
x-transition
|
||||||
class="mt-1 w-full rounded-lg shadow-lg border border-line bg-surface max-h-48 overflow-y-auto"
|
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);"
|
||||||
>
|
>
|
||||||
<template x-for="sug in tagSuggestions" :key="sug.value">
|
<template x-for="sug in tagSuggestions" :key="sug.value">
|
||||||
<button
|
<button
|
||||||
@@ -297,244 +306,279 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
@click="selectEditorTagSuggestion(sug.value)"
|
@click="selectEditorTagSuggestion(sug.value)"
|
||||||
>
|
>
|
||||||
<span x-text="sug.value"></span>
|
<span x-text="sug.value"></span>
|
||||||
<span class="text-xs text-content-muted" x-text="sug.count + ' books'"></span>
|
<span class="text-xs" style="color: var(--text-secondary);" x-text="sug.count + ' books'"></span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Community Rating (0-10)</label>
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Community Rating (0-10)</label>
|
||||||
<input type="number" name="community_rating" min="0" max="10" step="0.1"
|
<input type="number" name="community_rating" min="0" max="10" step="0.1"
|
||||||
value={ fmt.Sprintf("%.1f", book.CommunityRating.Float64) }
|
value={ fmt.Sprintf("%.1f", book.CommunityRating.Float64) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Publication -->
|
<!-- Publication -->
|
||||||
<div class="border rounded-lg border-line">
|
<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 text-content bg-surface"
|
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
||||||
|
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||||
@click="toggleSection('publication')">
|
@click="toggleSection('publication')">
|
||||||
<span class="font-semibold">Publication</span>
|
<span class="font-semibold">Publication</span>
|
||||||
<span x-text="openSections.publication ? '▾' : '▸'">▸</span>
|
<span x-text="openSections.publication ? '▾' : '▸'">▸</span>
|
||||||
</button>
|
</button>
|
||||||
<div x-show="openSections.publication" x-cloak x-transition class="p-4 space-y-3 bg-surface">
|
<div x-show="openSections.publication" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Publisher</label>
|
<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) }
|
<input type="text" name="publisher" value={ textToString(book.Publisher) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Date Published</label>
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Date Published</label>
|
||||||
<input type="date" name="date_published"
|
<input type="date" name="date_published"
|
||||||
value={ formatDateForInput(book.DatePublished) }
|
value={ formatDateForInput(book.DatePublished) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Edition</label>
|
<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) }
|
<input type="text" name="edition" value={ textToString(book.Edition) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Language</label>
|
<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) }
|
<input type="text" name="language" value={ textToString(book.Language) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Genre</label>
|
<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) }
|
<input type="text" name="genre" value={ textToString(book.Genre) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Copyright Year</label>
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Copyright Year</label>
|
||||||
<input type="number" name="copyright_year"
|
<input type="number" name="copyright_year"
|
||||||
value={ fmt.Sprintf("%d", book.CopyrightYear.Int32) }
|
value={ fmt.Sprintf("%d", book.CopyrightYear.Int32) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Series -->
|
<!-- Series -->
|
||||||
<div class="border rounded-lg border-line">
|
<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 text-content bg-surface"
|
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
||||||
|
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||||
@click="toggleSection('series')">
|
@click="toggleSection('series')">
|
||||||
<span class="font-semibold">Series</span>
|
<span class="font-semibold">Series</span>
|
||||||
<span x-text="openSections.series ? '▾' : '▸'">▸</span>
|
<span x-text="openSections.series ? '▾' : '▸'">▸</span>
|
||||||
</button>
|
</button>
|
||||||
<div x-show="openSections.series" x-cloak x-transition class="p-4 space-y-3 bg-surface">
|
<div x-show="openSections.series" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Series</label>
|
<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) }
|
<input type="text" name="series" value={ textToString(book.Series) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Number</label>
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Number</label>
|
||||||
<input type="number" name="series_number"
|
<input type="number" name="series_number"
|
||||||
value={ fmt.Sprintf("%d", book.SeriesNumber.Int32) }
|
value={ fmt.Sprintf("%d", book.SeriesNumber.Int32) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Count</label>
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Count</label>
|
||||||
<input type="number" name="series_count"
|
<input type="number" name="series_count"
|
||||||
value={ fmt.Sprintf("%d", book.SeriesCount.Int32) }
|
value={ fmt.Sprintf("%d", book.SeriesCount.Int32) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Volume</label>
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Volume</label>
|
||||||
<input type="number" name="volume"
|
<input type="number" name="volume"
|
||||||
value={ fmt.Sprintf("%d", book.Volume.Int32) }
|
value={ fmt.Sprintf("%d", book.Volume.Int32) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Identifiers -->
|
<!-- Identifiers -->
|
||||||
<div class="border rounded-lg border-line">
|
<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 text-content bg-surface"
|
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
||||||
|
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||||
@click="toggleSection('identifiers')">
|
@click="toggleSection('identifiers')">
|
||||||
<span class="font-semibold">Identifiers</span>
|
<span class="font-semibold">Identifiers</span>
|
||||||
<span x-text="openSections.identifiers ? '▾' : '▸'">▸</span>
|
<span x-text="openSections.identifiers ? '▾' : '▸'">▸</span>
|
||||||
</button>
|
</button>
|
||||||
<div x-show="openSections.identifiers" x-cloak x-transition class="p-4 space-y-3 bg-surface">
|
<div x-show="openSections.identifiers" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">ISBN</label>
|
<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) }
|
<input type="text" name="isbn" value={ textToString(book.Isbn) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">ASIN</label>
|
<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) }
|
<input type="text" name="asin" value={ textToString(book.Asin) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Goodreads ID</label>
|
<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) }
|
<input type="text" name="goodreads_id" value={ textToString(book.GoodreadsID) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">OpenLibrary ID</label>
|
<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) }
|
<input type="text" name="openlibrary_id" value={ textToString(book.OpenlibraryID) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Google Books ID</label>
|
<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) }
|
<input type="text" name="google_books_id" value={ textToString(book.GoogleBooksID) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Web URL</label>
|
<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) }
|
<input type="url" name="web_url" value={ textToString(book.WebUrl) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Comic/Manga -->
|
<!-- Comic/Manga -->
|
||||||
<div class="border rounded-lg border-line">
|
<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 text-content bg-surface"
|
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
||||||
|
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||||
@click="toggleSection('comic')">
|
@click="toggleSection('comic')">
|
||||||
<span class="font-semibold">Comic/Manga</span>
|
<span class="font-semibold">Comic/Manga</span>
|
||||||
<span x-text="openSections.comic ? '▾' : '▸'">▸</span>
|
<span x-text="openSections.comic ? '▾' : '▸'">▸</span>
|
||||||
</button>
|
</button>
|
||||||
<div x-show="openSections.comic" x-cloak x-transition class="p-4 space-y-3 bg-surface">
|
<div x-show="openSections.comic" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
||||||
<div>
|
<div>
|
||||||
<label for="manga_type" class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Manga Type</label>
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Manga Type</label>
|
||||||
<select id="manga_type" name="manga_type"
|
<select name="manga_type"
|
||||||
class="input text-sm">
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
<option value="unknown" selected={ textToString(book.MangaType) == "unknown" }>Unknown</option>
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);">
|
||||||
<option value="no" selected={ textToString(book.MangaType) == "no" }>No</option>
|
<option value="unknown" if textToString(book.MangaType) == "unknown" { selected } >Unknown</option>
|
||||||
<option value="yes" selected={ textToString(book.MangaType) == "yes" }>Yes</option>
|
<option value="no" if textToString(book.MangaType) == "no" { selected }>No</option>
|
||||||
<option value="yes_and_right_to_left" selected={ textToString(book.MangaType) == "yes_and_right_to_left" }>Yes (Right to Left)</option>
|
<option value="yes" if textToString(book.MangaType) == "yes" { selected }>Yes</option>
|
||||||
|
<option value="yes_and_right_to_left" if textToString(book.MangaType) == "yes_and_right_to_left" { selected }>Yes (Right to Left)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="reading_direction" class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Reading Direction</label>
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Reading Direction</label>
|
||||||
<select id="reading_direction" name="reading_direction"
|
<select name="reading_direction"
|
||||||
class="input text-sm">
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
<option value="auto" selected={ textToString(book.ReadingDirection) == "auto" }>Auto</option>
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);">
|
||||||
<option value="ltr" selected={ textToString(book.ReadingDirection) == "ltr" }>Left to Right</option>
|
<option value="auto" if textToString(book.ReadingDirection) == "auto" { selected }>Auto</option>
|
||||||
<option value="rtl" selected={ textToString(book.ReadingDirection) == "rtl" }>Right to Left</option>
|
<option value="ltr" if textToString(book.ReadingDirection) == "ltr" { selected }>Left to Right</option>
|
||||||
<option value="vertical" selected={ textToString(book.ReadingDirection) == "vertical" }>Vertical</option>
|
<option value="rtl" if textToString(book.ReadingDirection) == "rtl" { selected }>Right to Left</option>
|
||||||
|
<option value="vertical" if textToString(book.ReadingDirection) == "vertical" { selected }>Vertical</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Age Rating</label>
|
<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) }
|
<input type="text" name="age_rating" value={ textToString(book.AgeRating) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Story Arc</label>
|
<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) }
|
<input type="text" name="story_arc" value={ textToString(book.StoryArc) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Imprint</label>
|
<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) }
|
<input type="text" name="imprint" value={ textToString(book.Imprint) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Scan Information</label>
|
<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) }
|
<input type="text" name="scan_information" value={ textToString(book.ScanInformation) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Metadata Notes</label>
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Metadata Notes</label>
|
||||||
<textarea name="metadata_notes" rows="2"
|
<textarea name="metadata_notes" rows="2"
|
||||||
class="input text-sm"
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);"
|
||||||
>{ textToString(book.MetadataNotes) }</textarea>
|
>{ textToString(book.MetadataNotes) }</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input type="checkbox" name="is_black_and_white" id="is_black_and_white"
|
<input type="checkbox" name="is_black_and_white" id="is_black_and_white"
|
||||||
if book.IsBlackAndWhite.Bool { checked }
|
if book.IsBlackAndWhite.Bool { checked }
|
||||||
class="rounded" />
|
class="rounded" />
|
||||||
<label for="is_black_and_white" class="text-sm text-content-muted">Black & White</label>
|
<label for="is_black_and_white" class="text-sm" style="color: var(--text-secondary);">Black & White</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Technical -->
|
<!-- Technical -->
|
||||||
<div class="border rounded-lg border-line">
|
<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 text-content bg-surface"
|
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
||||||
|
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||||
@click="toggleSection('technical')">
|
@click="toggleSection('technical')">
|
||||||
<span class="font-semibold">Technical</span>
|
<span class="font-semibold">Technical</span>
|
||||||
<span x-text="openSections.technical ? '▾' : '▸'">▸</span>
|
<span x-text="openSections.technical ? '▾' : '▸'">▸</span>
|
||||||
</button>
|
</button>
|
||||||
<div x-show="openSections.technical" x-cloak x-transition class="p-4 space-y-3 bg-surface">
|
<div x-show="openSections.technical" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Page Count</label>
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Page Count</label>
|
||||||
<input type="number" name="page_count"
|
<input type="number" name="page_count"
|
||||||
value={ fmt.Sprintf("%d", book.PageCount.Int32) }
|
value={ fmt.Sprintf("%d", book.PageCount.Int32) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Contributors (comma-separated)</label>
|
<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) }
|
<input type="text" name="contributors" value={ stringSliceToString(book.Contributors) }
|
||||||
class="input text-sm" />
|
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Format</label>
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Format</label>
|
||||||
<input type="text" value={ textToString(book.MimeType) } readonly
|
<input type="text" value={ textToString(book.MimeType) } readonly
|
||||||
class="input text-sm opacity-60" />
|
class="w-full px-3 py-2 rounded-lg border text-sm opacity-60"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">File Size</label>
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">File Size</label>
|
||||||
<input type="text" value={ fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024) } readonly
|
<input type="text" value={ fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024) } readonly
|
||||||
class="input text-sm opacity-60" />
|
class="w-full px-3 py-2 rounded-lg border text-sm opacity-60"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3 p-6 border-t border-line flex-shrink-0">
|
<div class="flex justify-end space-x-3 p-6 border-t flex-shrink-0" style="border-color: var(--border);">
|
||||||
<button
|
<button
|
||||||
@click="hideMetadataEditor()"
|
@click="hideMetadataEditor()"
|
||||||
class="btn btn-secondary"
|
class="px-4 py-2 rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="saveMetadata()"
|
@click="saveMetadata()"
|
||||||
class="btn btn-primary"
|
class="px-6 py-2 rounded-lg font-semibold"
|
||||||
|
style="background-color: var(--accent); color: white;"
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+587
-661
File diff suppressed because it is too large
Load Diff
@@ -8,12 +8,10 @@ templ BooksGrid(books []handlers.BookInfo, limit int, offset int, count int, cur
|
|||||||
@BookCard(book)
|
@BookCard(book)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
<div class="col-span-full flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-16 text-center">
|
<!-- Empty state -->
|
||||||
<div class="mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted">
|
<div class="col-span-full text-center py-12" style="color: var(--text-secondary);">
|
||||||
@Icon("book", "h-7 w-7")
|
<p class="text-lg mb-2">📚 No books found</p>
|
||||||
</div>
|
<p class="text-sm">Try adjusting your filters or add some books to your library.</p>
|
||||||
<p class="text-base font-semibold text-content">No books found</p>
|
|
||||||
<p class="mt-1 text-sm text-content-muted">Try adjusting your filters or add some books to your library.</p>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
// templ: version: v0.3.1001
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
@@ -39,15 +39,7 @@ func BooksGrid(books []handlers.BookInfo, limit int, offset int, count int, curr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"col-span-full flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-16 text-center\"><div class=\"mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!-- Empty state --> <div class=\"col-span-full text-center py-12\" style=\"color: var(--text-secondary);\"><p class=\"text-lg mb-2\">📚 No books found</p><p class=\"text-sm\">Try adjusting your filters or add some books to your library.</p></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("book", "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, 2, "</div><p class=\"text-base font-semibold text-content\">No books found</p><p class=\"mt-1 text-sm text-content-muted\">Try adjusting your filters or add some books to your library.</p></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+287
-169
@@ -3,7 +3,6 @@ package templates
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"fmt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
templ BookShelf(
|
templ BookShelf(
|
||||||
@@ -12,10 +11,10 @@ templ BookShelf(
|
|||||||
currentLibraryID string,
|
currentLibraryID string,
|
||||||
errorMessage string,
|
errorMessage string,
|
||||||
savedFilters []database.SavedFilters,
|
savedFilters []database.SavedFilters,
|
||||||
books []handlers.BookInfo,
|
books []handlers.BookInfo, // NEW
|
||||||
limit int,
|
limit int, // NEW
|
||||||
offset int,
|
offset int, // NEW
|
||||||
count int,
|
count int, // NEW
|
||||||
) {
|
) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -26,273 +25,369 @@ templ BookShelf(
|
|||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
</head>
|
</head>
|
||||||
<body class="theme-{ user.Theme }" x-data="bookshelf" x-init="initBookshelf()">
|
<body
|
||||||
|
class="theme-{ user.Theme }"
|
||||||
|
x-data="bookshelf"
|
||||||
|
x-init="initBookshelf()"
|
||||||
|
>
|
||||||
@Header(user, "/bookshelf")
|
@Header(user, "/bookshelf")
|
||||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-6">
|
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<form id="filter-form" hx-get="/api/media-items/search" hx-target="#books-grid" class="space-y-3">
|
<!-- Filter Bar -->
|
||||||
<!-- Toolbar -->
|
<div
|
||||||
<div class="card p-3">
|
class="mb-6 card p-4 rounded-lg border"
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||||
<!-- Search (prominent) -->
|
>
|
||||||
<div class="relative min-w-[14rem] flex-1">
|
<form id="filter-form" hx-get="/api/media-items/search" hx-target="#books-grid">
|
||||||
<div class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-content-muted">
|
<div class="flex flex-wrap gap-4 items-center">
|
||||||
@Icon("search", "h-4 w-4")
|
<!-- Library Selector -->
|
||||||
</div>
|
<div class="flex-1 min-w-[200px]">
|
||||||
<input
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
name="q"
|
Library
|
||||||
type="text"
|
</label>
|
||||||
class="input pl-9"
|
<select
|
||||||
placeholder="Search all fields…"
|
id="library-select"
|
||||||
/>
|
name="library_id"
|
||||||
</div>
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
<!-- Library -->
|
>
|
||||||
<select name="library_id" class="input w-auto">
|
|
||||||
if len(libraries) == 0 {
|
if len(libraries) == 0 {
|
||||||
<option value="">No libraries available</option>
|
<option value="">No libraries available</option>
|
||||||
} else {
|
} else {
|
||||||
if currentLibraryID == "" {
|
if currentLibraryID == "" {
|
||||||
<option value="" selected>All Books ({ TotalMediaCount(libraries) })</option>
|
<option value="" selected>All Books</option>
|
||||||
} else {
|
} else {
|
||||||
<option value="">All Books ({ TotalMediaCount(libraries) })</option>
|
<option value="">All Books</option>
|
||||||
}
|
}
|
||||||
for _, lib := range libraries {
|
for _, lib := range libraries {
|
||||||
if lib.ID == currentLibraryID {
|
if lib.ID == currentLibraryID {
|
||||||
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
|
<option value={ lib.ID } selected>{ lib.Name }</option>
|
||||||
} else {
|
} else {
|
||||||
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
|
<option value={ lib.ID }>{ lib.Name }</option>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- Sort -->
|
|
||||||
<select
|
|
||||||
name="sort"
|
|
||||||
class="input w-auto"
|
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Filters toggle -->
|
|
||||||
<button type="button" @click="filtersOpen = !filtersOpen" class="btn btn-secondary">
|
|
||||||
@Icon("filter", "h-4 w-4")
|
|
||||||
Filters
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-1">
|
|
||||||
<button type="button" @click="showSaveFilterModal()" class="btn btn-ghost" title="Save filter">
|
|
||||||
@Icon("save", "h-4 w-4")
|
|
||||||
</button>
|
|
||||||
<div class="relative">
|
|
||||||
<button type="button" @click="toggleFiltersDropdown()" data-load-filter-btn class="btn btn-ghost" title="Load filter">
|
|
||||||
@Icon("folder", "h-4 w-4")
|
|
||||||
</button>
|
|
||||||
<!-- Saved filters dropdown -->
|
|
||||||
<div
|
|
||||||
x-show="showFiltersDropdown"
|
|
||||||
x-cloak
|
|
||||||
@click.outside="showFiltersDropdown = false"
|
|
||||||
x-transition:enter="transition ease-out duration-150"
|
|
||||||
x-transition:enter-start="opacity-0 scale-95"
|
|
||||||
x-transition:enter-end="opacity-100 scale-100"
|
|
||||||
x-transition:leave="transition ease-in duration-100"
|
|
||||||
x-transition:leave-start="opacity-100 scale-100"
|
|
||||||
x-transition:leave-end="opacity-0 scale-95"
|
|
||||||
class="absolute right-0 top-full mt-2 w-80 rounded-xl border border-line bg-surface-raised p-3 shadow-pop"
|
|
||||||
style="display: none;"
|
|
||||||
>
|
|
||||||
<p class="pb-2 text-xs font-semibold uppercase tracking-wide text-content-muted">Saved Filters</p>
|
|
||||||
<div class="space-y-1" id="saved-filters-list">
|
|
||||||
for _, filter := range savedFilters {
|
|
||||||
<div class="flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover" data-filter-id={ uuidToString(filter.ID) }>
|
|
||||||
<button data-action="load-filter" @click="loadFilter($event)" class="flex-1 truncate text-left text-sm text-content">{ filter.Name }</button>
|
|
||||||
<button data-action="delete-filter" @click="deleteFilter($event)" class="icon-btn h-7 w-7" title="Delete filter">
|
|
||||||
@Icon("trash", "h-4 w-4")
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
<!-- Search Input -->
|
||||||
if len(savedFilters) == 0 {
|
<div class="flex-1 min-w-[150px]">
|
||||||
<p class="py-3 text-center text-sm text-content-muted">No saved filters yet</p>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" @click="clearFilters()" class="btn btn-ghost" title="Clear filters">
|
|
||||||
@Icon("close", "h-4 w-4")
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
@Icon("search", "h-4 w-4")
|
|
||||||
Search
|
Search
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
<!-- Author Filter with Autocomplete -->
|
||||||
</div>
|
<div class="flex-1 min-w-[150px]">
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
<!-- Collapsible filters panel -->
|
Author
|
||||||
<div x-show="filtersOpen" x-cloak x-transition class="card p-4">
|
</label>
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<!-- Author -->
|
|
||||||
<div>
|
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Author</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="author_filter"
|
name="author_filter"
|
||||||
class="input"
|
|
||||||
placeholder="Filter by author"
|
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);"
|
||||||
list="author-datalist"
|
list="author-datalist"
|
||||||
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
|
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
|
||||||
/>
|
/>
|
||||||
<datalist id="author-datalist"></datalist>
|
<datalist id="author-datalist"></datalist>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tags -->
|
<!-- Tags Filter with Autocomplete -->
|
||||||
<div class="relative">
|
<div class="flex-1 min-w-[150px] relative">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Tags</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Tags</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="tags_filter"
|
name="tags_filter"
|
||||||
class="input"
|
|
||||||
placeholder="Filter by tags"
|
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);"
|
||||||
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
|
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
|
||||||
@keydown="onTagFilterKeydown($event)"
|
@keydown="onTagFilterKeydown($event)"
|
||||||
@blur="hideTagDropdown()"
|
@blur="hideTagDropdown()"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
x-show="showTagDropdown"
|
x-show="showTagDropdown"
|
||||||
x-cloak
|
|
||||||
x-transition
|
x-transition
|
||||||
class="absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-line bg-surface-raised shadow-pop"
|
class="absolute z-50 mt-1 w-full rounded-lg shadow-lg border max-h-48 overflow-y-auto"
|
||||||
style="display: none;"
|
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||||
>
|
>
|
||||||
<template x-for="sug in tagSuggestions" :key="sug.value">
|
<template x-for="sug in tagSuggestions" :key="sug.value">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center justify-between px-3 py-2 text-sm transition-colors hover:bg-surface-hover"
|
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center"
|
||||||
:class="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'bg-surface-hover' : ''"
|
:class="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''"
|
||||||
|
:style="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'"
|
||||||
@click="selectTagSuggestion(sug.value)"
|
@click="selectTagSuggestion(sug.value)"
|
||||||
>
|
>
|
||||||
<span class="text-content" x-text="sug.value"></span>
|
<span x-text="sug.value"></span>
|
||||||
<span class="text-xs text-content-muted" x-text="sug.count + ' books'"></span>
|
<span class="text-xs" style="color: var(--text-secondary);" x-text="sug.count + ' books'"></span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Series -->
|
// <!-- Genre Filter with Autocomplete -->
|
||||||
<div>
|
// <div class="flex-1 min-w-[150px]">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Series</label>
|
// <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
|
// Genre
|
||||||
|
// </label>
|
||||||
|
// <input
|
||||||
|
// type="text"
|
||||||
|
// name="genre_filter"
|
||||||
|
// placeholder="Filter by genre"
|
||||||
|
// class="w-full px-3 py-2 border rounded-lg"
|
||||||
|
// style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
|
// list="genre-datalist"
|
||||||
|
// @input.debounce.300ms="if($el.value.length >= 2) fetchGenreValues($el)"
|
||||||
|
// />
|
||||||
|
// <datalist id="genre-datalist"></datalist>
|
||||||
|
// </div>
|
||||||
|
<!-- Series Filter with Autocomplete (NEW) -->
|
||||||
|
<div class="flex-1 min-w-[150px]">
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
|
Series
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="series_filter"
|
name="series_filter"
|
||||||
class="input"
|
|
||||||
placeholder="Filter by series"
|
placeholder="Filter by series"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
list="series-datalist"
|
list="series-datalist"
|
||||||
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
|
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
|
||||||
/>
|
/>
|
||||||
<datalist id="series-datalist"></datalist>
|
<datalist id="series-datalist"></datalist>
|
||||||
</div>
|
</div>
|
||||||
<!-- Language -->
|
<!-- Language Filter with Autocomplete (NEW) -->
|
||||||
<div>
|
<div class="flex-1 min-w-[150px]">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Language</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
|
Language
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="language_filter"
|
name="language_filter"
|
||||||
class="input"
|
|
||||||
placeholder="Filter by language"
|
placeholder="Filter by language"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
list="language-datalist"
|
list="language-datalist"
|
||||||
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
|
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
|
||||||
/>
|
/>
|
||||||
<datalist id="language-datalist"></datalist>
|
<datalist id="language-datalist"></datalist>
|
||||||
</div>
|
</div>
|
||||||
<!-- Year range -->
|
<!-- Year Range -->
|
||||||
<div>
|
<div class="flex-1 min-w-[200px]">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Year Range</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
|
Year Range
|
||||||
|
</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input type="number" name="year_min" class="input" placeholder="From"/>
|
<input
|
||||||
<input type="number" name="year_max" class="input" placeholder="To"/>
|
type="number"
|
||||||
|
name="year_min"
|
||||||
|
placeholder="From"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="year_max"
|
||||||
|
placeholder="To"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Cover filter -->
|
<!-- Has Cover Filter - TriState Button -->
|
||||||
<div>
|
<div class="flex-1 min-w-[150px]">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Cover</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
|
Cover Filter
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="button"
|
type="button"
|
||||||
id="has-cover-tristate"
|
id="has-cover-tristate"
|
||||||
class="tristate-btn input cursor-pointer flex items-center justify-center gap-2 font-medium"
|
class="tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm"
|
||||||
:class="{ 'state-null': hasCoverState === null, 'state-true': hasCoverState === true, 'state-false': hasCoverState === false }"
|
:class="{
|
||||||
:value="hasCoverState === null ? 'Cover: Any' : hasCoverState === true ? 'Has Cover' : 'No Cover'"
|
'state-null': hasCoverState === null,
|
||||||
|
'state-true': hasCoverState === true,
|
||||||
|
'state-false': hasCoverState === false
|
||||||
|
}"
|
||||||
|
:value="hasCoverState === null ? '○ Cover: Any' : hasCoverState === true ? '✓ Has Cover' : '✗ No Cover'"
|
||||||
@click="cycleHasCover()"
|
@click="cycleHasCover()"
|
||||||
/>
|
/>
|
||||||
|
<!-- Hidden input for HTMX form submission - conditionally rendered -->
|
||||||
<template x-if="hasCoverState !== null">
|
<template x-if="hasCoverState !== null">
|
||||||
<input type="hidden" name="has_cover" :value="hasCoverState ? 'true' : 'false'"/>
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="has_cover"
|
||||||
|
:value="hasCoverState ? 'true' : 'false'"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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>
|
</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>
|
</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>
|
</form>
|
||||||
|
</div>
|
||||||
<!-- Books grid -->
|
<!-- Books Grid -->
|
||||||
<div id="books-grid" class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
|
||||||
@BooksGrid(books, limit, offset, count, currentLibraryID)
|
@BooksGrid(books, limit, offset, count, currentLibraryID)
|
||||||
|
<!-- Error Message -->
|
||||||
if errorMessage != "" {
|
if errorMessage != "" {
|
||||||
<div class="col-span-full mt-2 rounded-xl border border-danger/40 bg-danger/10 p-4">
|
<div class="mt-6 p-4 rounded-lg border bg-red-500/10 border-red-500">
|
||||||
<p class="text-sm text-danger">{ errorMessage }</p>
|
<p style="color: var(--text-primary)">{ errorMessage }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<div id="pagination" class="mt-8 flex items-center justify-center gap-3">
|
<div id="pagination" class="mt-6 flex justify-center gap-2">
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-secondary disabled:opacity-40"
|
class="px-4 py-2 rounded-lg border disabled:opacity-50"
|
||||||
hx-get={ "/api/media-items/search?library_id=" + currentLibraryID + "&limit=" + fmt.Sprintf("%d", limit) + "&offset=" + fmt.Sprintf("%d", offset-limit) }
|
style="border-color: var(--border); color: var(--text-primary);"
|
||||||
|
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }"
|
||||||
hx-target="#books-grid"
|
hx-target="#books-grid"
|
||||||
hx-include="#filter-form"
|
hx-include="#filter-form"
|
||||||
disabled?={ offset <= 0 }
|
disabled?={ offset <= 0 }
|
||||||
>
|
>
|
||||||
@Icon("chevron-left", "h-4 w-4")
|
← Previous
|
||||||
Previous
|
|
||||||
</button>
|
</button>
|
||||||
<span class="text-sm text-content-muted">Page { offset/limit + 1 }</span>
|
<span class="px-4 py-2" style="color: var(--text-secondary);">
|
||||||
|
Page { offset / limit + 1 }
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
class="px-4 py-2 rounded-lg border disabled:opacity-50"
|
||||||
class="btn btn-secondary disabled:opacity-40"
|
style="border-color: var(--border); color: var(--text-primary);"
|
||||||
hx-get={ "/api/media-items/search?library_id=" + currentLibraryID + "&limit=" + fmt.Sprintf("%d", limit) + "&offset=" + fmt.Sprintf("%d", offset+limit) }
|
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset + limit }"
|
||||||
hx-target="#books-grid"
|
hx-target="#books-grid"
|
||||||
hx-include="#filter-form"
|
hx-include="#filter-form"
|
||||||
disabled?={ offset+limit >= count }
|
disabled?={ offset+limit >= count }
|
||||||
>
|
>
|
||||||
Next
|
Next →
|
||||||
@Icon("chevron-right", "h-4 w-4")
|
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Save Filter Modal -->
|
<!-- Save Filter Modal -->
|
||||||
<div
|
<div
|
||||||
x-show="showSaveModal"
|
x-show="showSaveModal"
|
||||||
x-cloak
|
|
||||||
@click.self="showSaveModal = false"
|
@click.self="showSaveModal = false"
|
||||||
x-transition
|
x-transition
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
style="background-color: var(--surface-overlay); display: none;"
|
style="background-color: rgba(0, 0, 0, 0.7); display: none;"
|
||||||
>
|
>
|
||||||
<div class="card w-full max-w-md p-6" @click.stop>
|
<div
|
||||||
<div class="mb-4 flex items-center justify-between">
|
@click.stop
|
||||||
<h2 class="text-lg font-bold text-content">Save Filter</h2>
|
class="card rounded-lg p-6 w-full max-w-md mx-4"
|
||||||
<button @click="showSaveModal = false" class="icon-btn" aria-label="Close">
|
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||||
@Icon("close", "h-5 w-5")
|
>
|
||||||
</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
hx-post="/api/saved-filters"
|
hx-post="/api/saved-filters"
|
||||||
@@ -302,15 +397,38 @@ templ BookShelf(
|
|||||||
@htmx:afterRequest="if(event.detail.xhr.status < 400) { afterFilterSave() }"
|
@htmx:afterRequest="if(event.detail.xhr.status < 400) { afterFilterSave() }"
|
||||||
>
|
>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Filter Name</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
<input type="text" name="filter_name" class="input" placeholder="My Custom Filter" required/>
|
Filter Name
|
||||||
<div id="filter-save-error" class="mt-2 hidden rounded-lg bg-danger/10 p-3 text-sm text-danger"></div>
|
</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>
|
||||||
<input type="hidden" name="resource_type" value="media-items"/>
|
<input type="hidden" name="resource_type" value="media-items"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button type="button" @click="showSaveModal = false" class="btn btn-secondary">Cancel</button>
|
<button
|
||||||
<button type="submit" class="btn btn-primary">Save</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>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Hidden pagination state -->
|
||||||
<input type="hidden" name="limit" value="50"/>
|
<input type="hidden" name="limit" value="50"/>
|
||||||
<input type="hidden" name="offset" value="0"/>
|
<input type="hidden" name="offset" value="0"/>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+92
-243
File diff suppressed because one or more lines are too long
@@ -16,33 +16,32 @@ templ BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
</head>
|
</head>
|
||||||
<body class="theme-{ user.Theme }">
|
<body class="theme-{ user.Theme }">
|
||||||
@Header(user, backUrl)
|
@Header(user, backUrl)
|
||||||
<div class="sticky top-16 z-30 border-b border-line bg-surface/95 backdrop-blur">
|
<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 sm:px-6 lg:px-8 py-2.5">
|
<div class="w-full px-4 py-3 flex items-center gap-4">
|
||||||
<a href={ backUrl } class="inline-flex items-center gap-1.5 text-sm font-medium text-content-muted transition-colors hover:text-content">
|
<a href={ backUrl } class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||||
@Icon("arrow-left", "h-4 w-4")
|
← { backLabel }
|
||||||
{ backLabel }
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
<div class="w-full px-4 py-8">
|
||||||
<div class="mb-6 flex flex-wrap items-center gap-3">
|
<div class="flex items-center gap-4 mb-6">
|
||||||
<span class="chip">{ badgeIcon } { badgeLabel }</span>
|
<span class="px-3 py-1 rounded-full text-sm font-semibold" style="background-color: var(--accent); color: white;">
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-content">{ title }</h1>
|
{ badgeIcon } { badgeLabel }
|
||||||
<span class="text-sm text-content-muted">{ fmt.Sprintf("%d", len(books)) } books</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
if len(books) > 0 {
|
if len(books) > 0 {
|
||||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
|
||||||
for _, book := range books {
|
for _, book := range books {
|
||||||
@BookCard(book)
|
@BookCard(book)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<div class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-20 text-center">
|
<div class="text-center py-16" style="color: var(--text-secondary)">
|
||||||
<div class="mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted">
|
<div class="text-6xl mb-4">{ emptyIcon }</div>
|
||||||
@Icon("book", "h-7 w-7")
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Books Found</h3>
|
||||||
</div>
|
<p>{ emptyMessage }</p>
|
||||||
<h3 class="text-lg font-semibold text-content">No Books Found</h3>
|
|
||||||
<p class="mt-1 text-sm text-content-muted">{ emptyMessage }</p>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
// templ: version: v0.3.1001
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
@@ -55,7 +55,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"sticky top-16 z-30 border-b border-line bg-surface/95 backdrop-blur\"><div class=\"w-full px-4 sm:px-6 lg:px-8 py-2.5\"><a href=\"")
|
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=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -68,31 +68,27 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"inline-flex items-center gap-1.5 text-sm font-medium text-content-muted transition-colors hover:text-content\">")
|
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;\">← ")
|
||||||
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 23, Col: 17}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 22, Col: 21}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</a></div></div><div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6 flex flex-wrap items-center gap-3\"><span class=\"chip\">")
|
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;\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 29, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 29, Col: 17}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -105,33 +101,33 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 29, Col: 50}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 29, Col: 32}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span><h1 class=\"text-2xl font-bold tracking-tight text-content\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 30, Col: 71}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 78}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</h1><span class=\"text-sm text-content-muted\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</h1><span class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books)))
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books)))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 32, Col: 95}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -142,7 +138,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(books) > 0 {
|
if len(books) > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6\">")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -157,27 +153,32 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-20 text-center\"><div class=\"mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted\">")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("book", "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, 13, "</div><h3 class=\"text-lg font-semibold text-content\">No Books Found</h3><p class=\"mt-1 text-sm text-content-muted\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 45, Col: 63}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 42, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Books Found</h3><p>")
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
_, 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, 14, "</p></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
templ CollectionModal(collection CollectionData) {
|
templ CollectionModal(collection CollectionData) {
|
||||||
<div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center" style="background-color: var(--surface-overlay);">
|
<div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
|
||||||
<div class="card p-6 w-full max-w-md mx-4">
|
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
if collection.ID != "" {
|
if collection.ID != "" {
|
||||||
<h2 class="text-xl font-bold text-content">Edit Collection</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit Collection</h2>
|
||||||
} else {
|
} else {
|
||||||
<h2 class="text-xl font-bold text-content">Create Collection</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Collection</h2>
|
||||||
}
|
}
|
||||||
<button type="button" @click="closeCollectionModal()" class="icon-btn">
|
<button type="button" @click="closeCollectionModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||||
@Icon("close", "h-5 w-5")
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
if collection.ID != "" {
|
if collection.ID != "" {
|
||||||
<form
|
<form
|
||||||
@@ -20,32 +18,35 @@ templ CollectionModal(collection CollectionData) {
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={ collection.ID }/>
|
<input type="hidden" name="id" value={ collection.ID }/>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Name</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
value={ collection.Name }
|
value={ collection.Name }
|
||||||
required
|
required
|
||||||
class="input"
|
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"
|
placeholder="My Reading List"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Description</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
name="description"
|
name="description"
|
||||||
class="input"
|
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"
|
placeholder="Optional description"
|
||||||
rows="3"
|
rows="3"
|
||||||
>{ collection.Description }</textarea>
|
>{ collection.Description }</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Icon</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Icon</label>
|
||||||
<!-- Search/Text Input -->
|
<!-- Search/Text Input -->
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="icon-search"
|
id="icon-search"
|
||||||
class="input mb-2"
|
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..."
|
placeholder="🔍 Search or type emoji..."
|
||||||
maxlength="4"
|
maxlength="4"
|
||||||
oninput="filterIcons(this.value)"
|
oninput="filterIcons(this.value)"
|
||||||
@@ -56,13 +57,14 @@ templ CollectionModal(collection CollectionData) {
|
|||||||
<!-- Emoji Grid -->
|
<!-- Emoji Grid -->
|
||||||
<div
|
<div
|
||||||
id="icon-grid"
|
id="icon-grid"
|
||||||
class="grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border border-line bg-surface"
|
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 -->
|
<!-- Populated dynamically by JavaScript -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Color</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button type="button" @click="selectColor('blue')" class="w-8 h-8 rounded-full color-option bg-blue-500"></button>
|
<button type="button" @click="selectColor('blue')" 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('red')" class="w-8 h-8 rounded-full color-option bg-red-500"></button>
|
||||||
@@ -73,8 +75,8 @@ templ CollectionModal(collection CollectionData) {
|
|||||||
<input type="hidden" name="color" id="collection-color" value={ collection.Color }/>
|
<input type="hidden" name="color" id="collection-color" value={ collection.Color }/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" @click="closeCollectionModal()" class="btn btn-secondary">Cancel</button>
|
<button type="button" @click="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary">Update Collection</button>
|
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Update Collection</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
} else {
|
} else {
|
||||||
@@ -83,31 +85,34 @@ templ CollectionModal(collection CollectionData) {
|
|||||||
hx-redirect="/collections"
|
hx-redirect="/collections"
|
||||||
>
|
>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Name</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
required
|
required
|
||||||
class="input"
|
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"
|
placeholder="My Reading List"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Description</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
name="description"
|
name="description"
|
||||||
class="input"
|
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"
|
placeholder="Optional description"
|
||||||
rows="3"
|
rows="3"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Icon</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Icon</label>
|
||||||
<!-- Search/Text Input -->
|
<!-- Search/Text Input -->
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="icon-search"
|
id="icon-search"
|
||||||
class="input mb-2"
|
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..."
|
placeholder="🔍 Search or type emoji..."
|
||||||
maxlength="4"
|
maxlength="4"
|
||||||
oninput="filterIcons(this.value)"
|
oninput="filterIcons(this.value)"
|
||||||
@@ -118,13 +123,14 @@ templ CollectionModal(collection CollectionData) {
|
|||||||
<!-- Emoji Grid -->
|
<!-- Emoji Grid -->
|
||||||
<div
|
<div
|
||||||
id="icon-grid"
|
id="icon-grid"
|
||||||
class="grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border border-line bg-surface"
|
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 -->
|
<!-- Populated dynamically by JavaScript -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Color</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button type="button" @click="selectColor('blue')" class="w-8 h-8 rounded-full color-option bg-blue-500"></button>
|
<button type="button" @click="selectColor('blue')" 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('red')" class="w-8 h-8 rounded-full color-option bg-red-500"></button>
|
||||||
@@ -135,8 +141,8 @@ templ CollectionModal(collection CollectionData) {
|
|||||||
<input type="hidden" name="color" id="collection-color" value="blue"/>
|
<input type="hidden" name="color" id="collection-color" value="blue"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" @click="closeCollectionModal()" class="btn btn-secondary">Cancel</button>
|
<button type="button" @click="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary">Create Collection</button>
|
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Create Collection</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
// templ: version: v0.3.1001
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
@@ -29,74 +29,66 @@ func CollectionModal(collection CollectionData) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"collections\" class=\"fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-6\">")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if collection.ID != "" {
|
if collection.ID != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<h2 class=\"text-xl font-bold text-content\">Edit Collection</h2>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Edit Collection</h2>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h2 class=\"text-xl font-bold text-content\">Create Collection</h2>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Create Collection</h2>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<button type=\"button\" @click=\"closeCollectionModal()\" class=\"icon-btn\">")
|
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>")
|
||||||
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, 5, "</button></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if collection.ID != "" {
|
if collection.ID != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<form hx-put=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<form hx-put=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("/api/collections/" + collection.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 18, Col: 49}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 16, Col: 49}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" hx-redirect=\"/collections\"><input type=\"hidden\" name=\"id\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" hx-redirect=\"/collections\"><input type=\"hidden\" name=\"id\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(collection.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 21, Col: 57}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 19, Col: 57}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">Name</label> <input type=\"text\" name=\"name\" value=\"")
|
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=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 27, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 25, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\">")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -109,56 +101,56 @@ func CollectionModal(collection CollectionData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">Icon</label><!-- Search/Text Input --><input type=\"text\" id=\"icon-search\" class=\"input mb-2\" 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, 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=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 55, Col: 83}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 56, Col: 83}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"><!-- Emoji Grid --><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border border-line bg-surface\"><!-- Populated dynamically by JavaScript --></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">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, 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=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Color)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 73, Col: 86}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 75, Col: 86}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"></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>")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">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 text-content-muted mb-2\">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 text-content-muted mb-2\">Icon</label><!-- Search/Text Input --><input type=\"text\" id=\"icon-search\" class=\"input mb-2\" 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, 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=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 117, Col: 83}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 122, Col: 83}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"><!-- Emoji Grid --><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border border-line bg-surface\"><!-- Populated dynamically by JavaScript --></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">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 btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,41 +14,39 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
@Header(user, "/collections")
|
@Header(user, "/collections")
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<button @click="backToCollection()" class="btn btn-secondary mb-4">
|
<button @click="backToCollection()" class="btn-secondary px-4 py-2 rounded-lg mb-4">
|
||||||
@Icon("arrow-left", "h-4 w-4")
|
← Back to Collection
|
||||||
Back to Collection
|
|
||||||
</button>
|
</button>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
|
<div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-content">{ collection.Name }</h1>
|
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||||||
<p class="text-content-muted">Auto-Assign Rules</p>
|
<p style="color: var(--text-secondary)">Auto-Assign Rules</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h2 class="text-2xl font-bold tracking-tight text-content mb-4">Existing Rules</h2>
|
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Existing Rules</h2>
|
||||||
<div id="rules-container" class="space-y-4">
|
<div id="rules-container" class="space-y-4">
|
||||||
<div id="no-rules" class="card p-6 text-center">
|
<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="mb-2 flex justify-center">
|
<div class="text-4xl mb-2">📋</div>
|
||||||
@Icon("list", "h-10 w-10 text-content-muted")
|
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">No Rules Yet</h3>
|
||||||
</div>
|
<p style="color: var(--text-secondary)">Create auto-assign rules to automatically add books to this collection</p>
|
||||||
<h3 class="text-lg font-semibold mb-2 text-content">No Rules Yet</h3>
|
|
||||||
<p class="text-content-muted">Create auto-assign rules to automatically add books to this collection</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h2 class="text-2xl font-bold tracking-tight text-content mb-4">Create New Rule</h2>
|
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Create New Rule</h2>
|
||||||
<form id="rule-form" @submit="handleCreateRule($event)">
|
<form id="rule-form" @submit="handleCreateRule($event)">
|
||||||
<input type="hidden" id="collection-id" value="{ collection.ID }"/>
|
<input type="hidden" id="collection-id" value="{ collection.ID }"/>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Field</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Field</label>
|
||||||
<select
|
<select
|
||||||
id="rule-field"
|
id="rule-field"
|
||||||
required
|
required
|
||||||
class="input"
|
class="w-full px-4 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
>
|
>
|
||||||
<option value="">Select field...</option>
|
<option value="">Select field...</option>
|
||||||
<option value="genre">Genre</option>
|
<option value="genre">Genre</option>
|
||||||
@@ -61,11 +59,12 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Operator</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Operator</label>
|
||||||
<select
|
<select
|
||||||
id="rule-operator"
|
id="rule-operator"
|
||||||
required
|
required
|
||||||
class="input"
|
class="w-full px-4 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
>
|
>
|
||||||
<option value="">Select operator...</option>
|
<option value="">Select operator...</option>
|
||||||
<option value="equals">equals</option>
|
<option value="equals">equals</option>
|
||||||
@@ -80,12 +79,13 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Value</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Value</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="rule-value"
|
id="rule-value"
|
||||||
required
|
required
|
||||||
class="input"
|
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="e.g., Science Fiction"
|
placeholder="e.g., Science Fiction"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,16 +97,16 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
checked
|
checked
|
||||||
class="w-5 h-5 rounded"
|
class="w-5 h-5 rounded"
|
||||||
/>
|
/>
|
||||||
<span class="text-content">Enable Rule</span>
|
<span style="color: var(--text-primary)">Enable Rule</span>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs mt-1 text-content-muted">Uncheck to disable without deleting</p>
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Uncheck to disable without deleting</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Priority</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Priority</label>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<label class="flex items-center space-x-2 cursor-pointer">
|
<label class="flex items-center space-x-2 cursor-pointer">
|
||||||
<input type="radio" name="priority" value="1" class="w-4 h-4"/>
|
<input type="radio" name="priority" value="1" class="w-4 h-4"/>
|
||||||
<span class="text-content">High</span>
|
<span style="color: var(--text-primary)">High</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center space-x-2 cursor-pointer">
|
<label class="flex items-center space-x-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -116,7 +116,7 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
checked
|
checked
|
||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
/>
|
/>
|
||||||
<span class="text-content">Medium</span>
|
<span style="color: var(--text-primary)">Medium</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center space-x-2 cursor-pointer">
|
<label class="flex items-center space-x-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -125,38 +125,36 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
value="3"
|
value="3"
|
||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
/>
|
/>
|
||||||
<span class="text-content">Low</span>
|
<span style="color: var(--text-primary)">Low</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" @click="testRule()" class="btn btn-secondary">
|
<button type="button" @click="testRule()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||||
@Icon("play", "h-4 w-4")
|
🧪 Test Rule
|
||||||
Test Rule
|
|
||||||
</button>
|
</button>
|
||||||
<button type="button" @click="clearForm()" class="btn btn-secondary">
|
<button type="button" @click="clearForm()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
|
||||||
@Icon("plus", "h-4 w-4")
|
➕ Add Rule
|
||||||
Add Rule
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div id="test-results" class="hidden card p-6 mb-6">
|
<div id="test-results" class="hidden card p-6 rounded-lg border mb-6" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||||
<h3 class="font-semibold mb-3 text-content">Rule Test Results</h3>
|
<h3 class="font-semibold mb-3" style="color: var(--text-primary)">Rule Test Results</h3>
|
||||||
<p class="text-sm mb-2 text-content-muted">Books that would be added by this rule:</p>
|
<p class="text-sm mb-2" style="color: var(--text-secondary)">Books that would be added by this rule:</p>
|
||||||
<div id="test-results-list" class="max-h-64 overflow-y-auto space-y-2"></div>
|
<div id="test-results-list" class="max-h-64 overflow-y-auto space-y-2"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-6">
|
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||||
<h2 class="text-xl font-bold mb-4 text-content">Rule Examples</h2>
|
<h2 class="text-xl font-bold mb-4" style="color: var(--text-primary)">Rule Examples</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold bg-brand text-surface">1</div>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-content">Add all Science Fiction books</p>
|
<p class="font-medium" style="color: var(--text-primary)">Add all Science Fiction books</p>
|
||||||
<code class="block mt-1 text-sm text-content-muted bg-surface-raised px-2 py-1 rounded">
|
<code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;">
|
||||||
Field: genre
|
Field: genre
|
||||||
<br/>
|
<br/>
|
||||||
Operator: equals
|
Operator: equals
|
||||||
@@ -166,10 +164,10 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold bg-brand text-surface">2</div>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-content">Add books from a specific series</p>
|
<p class="font-medium" style="color: var(--text-primary)">Add books from a specific series</p>
|
||||||
<code class="block mt-1 text-sm text-content-muted bg-surface-raised px-2 py-1 rounded">
|
<code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;">
|
||||||
Field: series
|
Field: series
|
||||||
<br/>
|
<br/>
|
||||||
Operator: starts with
|
Operator: starts with
|
||||||
@@ -179,10 +177,10 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold bg-brand text-surface">3</div>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-content">Add books published in a year range</p>
|
<p class="font-medium" style="color: var(--text-primary)">Add books published in a year range</p>
|
||||||
<code class="block mt-1 text-sm text-content-muted bg-surface-raised px-2 py-1 rounded">
|
<code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;">
|
||||||
Field: copyright_year
|
Field: copyright_year
|
||||||
<br/>
|
<br/>
|
||||||
Operator: greater than
|
Operator: greater than
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+247
-102
@@ -14,56 +14,82 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
|||||||
</head>
|
</head>
|
||||||
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
|
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
|
||||||
@Header(user, "/collections")
|
@Header(user, "/collections")
|
||||||
|
<!-- Modal Container -->
|
||||||
<div id="modal-container"></div>
|
<div id="modal-container"></div>
|
||||||
|
<!-- Actual container page -->
|
||||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="mb-8 flex flex-wrap items-end justify-between gap-4">
|
<div class="mb-8 flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1 flex items-center gap-2">
|
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">My Collections</h1>
|
||||||
@Icon("folder", "h-6 w-6 text-brand")
|
<p style="color: var(--text-secondary)">Organize your books into custom collections</p>
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-content">My Collections</h1>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-content-muted">Organize your books into custom collections</p>
|
<div class="flex gap-3">
|
||||||
</div>
|
<button
|
||||||
<div class="flex gap-2">
|
hx-get="/collections/restore-modal"
|
||||||
<button hx-get="/collections/restore-modal" hx-target="#modal-container" hx-swap="innerHTML" class="btn btn-secondary">
|
hx-target="#modal-container"
|
||||||
@Icon("refresh", "h-4 w-4")
|
hx-swap="innerHTML"
|
||||||
Restore System
|
class="btn-secondary px-4 py-2 rounded-lg"
|
||||||
|
>
|
||||||
|
🔄 Restore System
|
||||||
</button>
|
</button>
|
||||||
<button hx-get="/collections/create-modal" hx-target="#modal-container" hx-swap="innerHTML" class="btn btn-primary">
|
<button
|
||||||
@Icon("plus", "h-4 w-4")
|
hx-get="/collections/create-modal"
|
||||||
New Collection
|
hx-target="#modal-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="btn-primary px-4 py-2 rounded-lg"
|
||||||
|
>
|
||||||
|
➕ New Collection
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="collections-list" class="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
|
<div id="collections-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
if len(collections) == 0 {
|
if len(collections) == 0 {
|
||||||
<div class="col-span-full flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-20 text-center">
|
<div class="text-center py-16 col-span-full" style="color: var(--text-secondary)">
|
||||||
<div class="mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted">
|
<div class="text-6xl mb-4">📚</div>
|
||||||
@Icon("folder", "h-7 w-7")
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3>
|
||||||
</div>
|
<p class="mb-4">Create collections to organize your books</p>
|
||||||
<h3 class="text-lg font-semibold text-content">No Collections Yet</h3>
|
<button
|
||||||
<p class="mb-4 mt-1 text-sm text-content-muted">Create collections to organize your books.</p>
|
hx-get="/collections/create-modal"
|
||||||
<button hx-get="/collections/create-modal" hx-target="#modal-container" hx-swap="innerHTML" class="btn btn-primary">
|
hx-target="#modal-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="btn-primary px-4 py-2 rounded-lg"
|
||||||
|
>
|
||||||
Create Your First Collection
|
Create Your First Collection
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
for _, col := range collections {
|
for _, col := range collections {
|
||||||
<div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block">
|
<div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block">
|
||||||
<div class="card cursor-pointer p-5" style={ "border-left: 4px solid " + col.Color } data-color={ col.Color }>
|
<div
|
||||||
<div class="mb-3 flex items-start justify-between">
|
class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
|
||||||
<div class="text-2xl">{ col.Icon }</div>
|
style="background-color: var(--bg-secondary);"
|
||||||
<div class="flex gap-1">
|
data-color={ col.Color }
|
||||||
<button hx-get={ "/collections/" + col.ID + "/edit-modal" } hx-target="#modal-container" hx-swap="innerHTML" class="icon-btn h-8 w-8" title="Edit">
|
>
|
||||||
@Icon("edit", "h-4 w-4")
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<div class="text-3xl">{ col.Icon }</div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<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);"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
</button>
|
</button>
|
||||||
<button hx-delete={ "/api/collections/" + col.ID + "" } hx-redirect="/collections" hx-confirm="Are you sure you want to delete this collection?" class="icon-btn h-8 w-8 hover:text-danger" title="Delete">
|
<button
|
||||||
@Icon("trash", "h-4 w-4")
|
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);"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="mb-1 text-base font-semibold text-content">{ col.Name }</h3>
|
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ col.Name }</h3>
|
||||||
<p class="text-sm text-content-muted">{ col.Description }</p>
|
<p class="text-sm mb-4" style="color: var(--text-secondary)">{ col.Description }</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -89,124 +115,243 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
@LibrarySwitcher(libData, libraryID)
|
@LibrarySwitcher(libData, libraryID)
|
||||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/collections" class="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-content-muted transition-colors hover:text-content">
|
<a href="/collections" class="btn-secondary px-4 py-2 rounded-lg mb-4 inline-block">
|
||||||
@Icon("arrow-left", "h-4 w-4")
|
← Back to Collections
|
||||||
Back to Collections
|
|
||||||
</a>
|
</a>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl text-2xl" style={ "background-color: " + collection.Color + "20; color: " + collection.Color }>{ collection.Icon }</div>
|
<div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-content">{ collection.Name }</h1>
|
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||||||
<p class="text-sm text-content-muted">{ collection.Description }</p>
|
<p style="color: var(--text-secondary)">{ collection.Description }</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
<div class="mb-6 flex justify-between items-center">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-4">
|
||||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-content-muted">Books in this Collection</h2>
|
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Books in this Collection</h2>
|
||||||
<span id="selected-count" class="hidden badge bg-brand/15 text-brand">0 selected</span>
|
<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>
|
||||||
<div class="flex flex-1 justify-end gap-2">
|
<div class="flex gap-3">
|
||||||
<div class="relative w-full max-w-xs">
|
<div class="flex-1 max-w-md">
|
||||||
<div class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-content-muted">
|
<input
|
||||||
@Icon("search", "h-4 w-4")
|
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>
|
</div>
|
||||||
<input type="text" id="collection-search" placeholder="Search within collection…" onkeyup="filterCollectionBooks()" class="input pl-9"/>
|
<button
|
||||||
</div>
|
id="bulk-remove-btn"
|
||||||
<button id="bulk-remove-btn" disabled class="btn btn-danger disabled:opacity-40 disabled:cursor-not-allowed">
|
disabled
|
||||||
@Icon("trash", "h-4 w-4")
|
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
Remove Selected
|
>
|
||||||
|
🗑️ Remove Selected
|
||||||
</button>
|
</button>
|
||||||
<button @click="$store.bookPicker.open()" class="btn btn-primary">
|
<button
|
||||||
@Icon("plus", "h-4 w-4")
|
@click="$store.bookPicker.open()"
|
||||||
Add Books
|
class="btn-primary px-4 py-2 rounded-lg"
|
||||||
|
>
|
||||||
|
➕ Add Books
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="books-container" class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
if len(books) == 0 {
|
if len(books) == 0 {
|
||||||
<div id="empty-state" class="col-span-full rounded-2xl border border-dashed border-line py-16 text-center text-sm text-content-muted">No books in this collection yet.</div>
|
<div id="empty-state" class="col-span-full text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
|
||||||
}
|
}
|
||||||
for _, book := range books {
|
for _, book := range books {
|
||||||
<div class="card flex gap-4 p-4">
|
<a href={ "/media/" + book.MediaItemID }>
|
||||||
<div class="flex flex-shrink-0 items-center pt-1">
|
<div
|
||||||
<input type="checkbox" onchange="toggleBookForRemoval('{ book.MediaItemID }')" class="h-5 w-5 rounded border-line"/>
|
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>
|
||||||
<a href={ "/media/" + book.MediaItemID } class="flex min-w-0 flex-1 gap-3">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex-shrink-0 w-12 sm:w-16">
|
<h3
|
||||||
if book.CoverImagePath != "" {
|
class="font-semibold text-lg mb-1 line-clamp-2"
|
||||||
<img src={ book.CoverImagePath } alt="Cover" class="aspect-[3/4] w-full rounded object-cover shadow-card" onerror="this.src='/static/placeholder-book.svg'"/>
|
style="color: var(--text-primary)"
|
||||||
} else {
|
>
|
||||||
<img src="/static/placeholder-book.svg" alt="Cover" class="aspect-[3/4] w-full rounded object-cover shadow-card"/>
|
{ book.Title }
|
||||||
}
|
</h3>
|
||||||
</div>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<h3 class="line-clamp-2 text-sm font-semibold text-content">{ book.Title }</h3>
|
|
||||||
if book.Author != "" {
|
if book.Author != "" {
|
||||||
<p class="mt-0.5 line-clamp-1 text-xs text-content-muted">by { book.Author }</p>
|
<p
|
||||||
|
class="text-sm line-clamp-1"
|
||||||
|
style="color: var(--text-secondary)"
|
||||||
|
>
|
||||||
|
by { book.Author }
|
||||||
|
</p>
|
||||||
}
|
}
|
||||||
<button @click={ "removeBook('" + book.MediaItemID + "')" } class="mt-2 inline-flex items-center gap-1 text-xs text-content-muted transition-colors hover:text-danger">
|
</div>
|
||||||
@Icon("trash", "h-3 w-3")
|
<div class="flex-shrink-0 w-16 sm:w-20">
|
||||||
Remove
|
if book.CoverImagePath != "" {
|
||||||
|
<img
|
||||||
|
src={ book.CoverImagePath }
|
||||||
|
alt="Cover"
|
||||||
|
class="w-full aspect-[3/4] object-cover rounded 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"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Book Picker Modal -->
|
||||||
<!-- Book picker modal -->
|
<div
|
||||||
<div x-data="bookPicker" @keyup.escape.window="$store.bookPicker.close()" class="fixed inset-0 z-50 flex items-center justify-center" style="display: none;">
|
x-data="bookPicker"
|
||||||
|
@keyup.escape.window="$store.bookPicker.close()"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
style="display: none;"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
@click.stop
|
@click.stop
|
||||||
x-show="$store.bookPicker.isOpen"
|
x-show="$store.bookPicker.isOpen"
|
||||||
x-cloak
|
|
||||||
x-transition:enter="transition ease-out duration-200"
|
x-transition:enter="transition ease-out duration-200"
|
||||||
x-transition:enter-start="opacity-0 scale-95"
|
x-transition:enter-start="opacity-0 scale-95"
|
||||||
x-transition:enter-end="opacity-100 scale-100"
|
x-transition:enter-end="opacity-100 scale-100"
|
||||||
x-transition:leave="transition ease-in duration-150"
|
x-transition:leave="transition ease-in duration-150"
|
||||||
x-transition:leave-start="opacity-100 scale-100"
|
x-transition:leave-start="opacity-100 scale-100"
|
||||||
x-transition:leave-end="opacity-0 scale-95"
|
x-transition:leave-end="opacity-0 scale-95"
|
||||||
class="card my-8 w-full max-w-6xl overflow-hidden"
|
class="card rounded-lg w-full max-w-6xl mx-4 my-8"
|
||||||
style="display: none;"
|
style="background-color: var(--bg-secondary); border-color: var(--border); display: none;"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between border-b border-line p-5">
|
<div
|
||||||
<h2 class="text-lg font-bold text-content">Add Books to Collection</h2>
|
class="flex justify-between items-center p-6 border-b"
|
||||||
<button @click="$store.bookPicker.close()" class="icon-btn" aria-label="Close">
|
style="border-color: var(--border);"
|
||||||
@Icon("close", "h-5 w-5")
|
>
|
||||||
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-3 border-b border-line p-4">
|
|
||||||
<div class="min-w-[12rem] flex-1">
|
|
||||||
<input type="text" name="search" placeholder="Search books…" class="input" 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>
|
||||||
<div class="min-w-[10rem] flex-1">
|
|
||||||
<input type="text" name="author_filter" placeholder="Author" class="input" 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="min-w-[10rem] flex-1">
|
|
||||||
<input type="text" name="genre_filter" placeholder="Genre" class="input" 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>
|
|
||||||
<button @click="$store.bookPicker.clearFilters()" class="btn btn-ghost">
|
|
||||||
@Icon("close", "h-4 w-4")
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
<form id="filter-form" class="hidden">
|
<form id="filter-form" class="hidden">
|
||||||
<input type="hidden" name="limit" value="50"/>
|
<input type="hidden" name="limit" value="50"/>
|
||||||
<input type="hidden" name="offset" value="0"/>
|
<input type="hidden" name="offset" value="0"/>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div id="book-picker-grid" class="grid max-h-96 grid-cols-2 gap-4 overflow-y-auto p-4 md:grid-cols-4 lg:grid-cols-6"></div>
|
<div
|
||||||
<div class="flex items-center justify-between border-t border-line p-4">
|
id="book-picker-grid"
|
||||||
<div class="text-sm text-content-muted"><span x-text="$store.bookPicker.selectedCount"></span> books selected</div>
|
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">
|
<div class="flex gap-2">
|
||||||
<button @click="$store.bookPicker.close()" class="btn btn-secondary">Cancel</button>
|
<button
|
||||||
<button @click="$store.bookPicker.submit()" class="btn btn-primary">Add Selected Books</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
<div id="collection-data" data-id={ collection.ID } data-library-id={ libraryID } style="display: none;"></div>
|
<div
|
||||||
|
id="collection-data"
|
||||||
|
data-id={ collection.ID }
|
||||||
|
data-library-id={ libraryID }
|
||||||
|
style="display: none;"
|
||||||
|
></div>
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-296
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
// templ: version: v0.3.1001
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
@@ -39,175 +39,114 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div id=\"modal-container\"></div><div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8 flex flex-wrap items-end justify-between gap-4\"><div><div class=\"mb-1 flex items-center gap-2\">")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("folder", "h-6 w-6 text-brand").Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h1 class=\"text-2xl font-bold tracking-tight text-content\">My Collections</h1></div><p class=\"text-sm text-content-muted\">Organize your books into custom collections</p></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, "Restore System</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, "New Collection</button></div></div><div id=\"collections-list\" class=\"grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(collections) == 0 {
|
if len(collections) == 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"col-span-full flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-20 text-center\"><div class=\"mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted\">")
|
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>")
|
||||||
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-lg font-semibold text-content\">No Collections Yet</h3><p class=\"mb-4 mt-1 text-sm text-content-muted\">Create collections to organize your books.</p><button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-primary\">Create Your First Collection</button></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, col := range collections {
|
for _, col := range collections {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div @click=\"navigateToCollection($el)\" data-href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div @click=\"navigateToCollection($el)\" data-href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("/collections/" + col.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 52, Col: 82}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 62, Col: 82}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" class=\"block\"><div class=\"card cursor-pointer p-5\" style=\"")
|
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=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("border-left: 4px solid " + col.Color)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(col.Color)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 53, Col: 89}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 66, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" data-color=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"><div class=\"flex justify-between items-start mb-4\"><div class=\"text-3xl\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 53, Col: 114}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 69, Col: 41}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"><div class=\"mb-3 flex items-start justify-between\"><div class=\"text-2xl\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"flex space-x-2\"><button hx-get=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs("/collections/" + col.ID + "/edit-modal")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 55, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 72, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><div class=\"flex gap-1\"><button hx-get=\"")
|
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=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs("/api/collections/" + col.ID + "")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 57, Col: 67}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 81, Col: 56}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"icon-btn h-8 w-8\" title=\"Edit\">")
|
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)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("edit", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</button> <button hx-delete=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 60, Col: 63}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 91, Col: 92}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" hx-redirect=\"/collections\" hx-confirm=\"Are you sure you want to delete this collection?\" class=\"icon-btn h-8 w-8 hover:text-danger\" title=\"Delete\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</h3><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">")
|
||||||
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=\"mb-1 text-base font-semibold text-content\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 65, Col: 72}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 92, Col: 86}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</h3><p class=\"text-sm text-content-muted\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var9 string
|
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 66, Col: 63}
|
|
||||||
}
|
|
||||||
_, 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></div></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -215,7 +154,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -239,25 +178,25 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var10 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var10 == nil {
|
if templ_7745c5c3_Var9 == nil {
|
||||||
templ_7745c5c3_Var10 = templ.NopComponent
|
templ_7745c5c3_Var9 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
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>")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 83, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 109, Col: 27}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 }\">")
|
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 }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -269,286 +208,165 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6\"><a href=\"/collections\" class=\"mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-content-muted transition-colors hover:text-content\">")
|
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 }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = Icon("arrow-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
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}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "Back to Collections</a><div class=\"flex items-center gap-4\"><div class=\"flex h-12 w-12 items-center justify-center rounded-xl text-2xl\" style=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div><div><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + collection.Color + "20; color: " + collection.Color)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 97, Col: 164}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 124, Col: 90}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</h1><p style=\"color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 97, Col: 184}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 125, Col: 71}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div><h1 class=\"text-2xl font-bold tracking-tight text-content\">")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var14 string
|
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 99, Col: 83}
|
|
||||||
}
|
|
||||||
_, 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, 27, "</h1><p class=\"text-sm text-content-muted\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var15 string
|
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 100, Col: 69}
|
|
||||||
}
|
|
||||||
_, 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, 28, "</p></div></div></div><div class=\"mb-6 flex flex-wrap items-center justify-between gap-3\"><div class=\"flex items-center gap-3\"><h2 class=\"text-sm font-semibold uppercase tracking-wide text-content-muted\">Books in this Collection</h2><span id=\"selected-count\" class=\"hidden badge bg-brand/15 text-brand\">0 selected</span></div><div class=\"flex flex-1 justify-end gap-2\"><div class=\"relative w-full max-w-xs\"><div class=\"pointer-events-none absolute inset-y-0 left-3 flex items-center text-content-muted\">")
|
|
||||||
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, 29, "</div><input type=\"text\" id=\"collection-search\" placeholder=\"Search within collection…\" onkeyup=\"filterCollectionBooks()\" class=\"input pl-9\"></div><button id=\"bulk-remove-btn\" disabled class=\"btn btn-danger disabled:opacity-40 disabled:cursor-not-allowed\">")
|
|
||||||
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, "Remove Selected</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, 31, "Add Books</button></div></div><div id=\"books-container\" class=\"grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(books) == 0 {
|
if len(books) == 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div id=\"empty-state\" class=\"col-span-full rounded-2xl border border-dashed border-line py-16 text-center text-sm text-content-muted\">No books in this collection yet.</div>")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, book := range books {
|
for _, book := range books {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"card flex gap-4 p-4\"><div class=\"flex flex-shrink-0 items-center pt-1\"><input type=\"checkbox\" onchange=\"toggleBookForRemoval('{ book.MediaItemID }')\" class=\"h-5 w-5 rounded border-line\"></div><a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 templ.SafeURL
|
var templ_7745c5c3_Var14 templ.SafeURL
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 135, Col: 45}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 167, Col: 44}
|
||||||
|
}
|
||||||
|
_, 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, 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)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 185, Col: 23}
|
||||||
|
}
|
||||||
|
_, 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, 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 ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var16 string
|
||||||
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 192, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" class=\"flex min-w-0 flex-1 gap-3\"><div class=\"flex-shrink-0 w-12 sm:w-16\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</p>")
|
||||||
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if book.CoverImagePath != "" {
|
if book.CoverImagePath != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<img src=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<img src=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(book.CoverImagePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 138, Col: 40}
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" alt=\"Cover\" class=\"aspect-[3/4] w-full rounded object-cover shadow-card\" onerror=\"this.src='/static/placeholder-book.svg'\">")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<img src=\"/static/placeholder-book.svg\" alt=\"Cover\" class=\"aspect-[3/4] w-full rounded object-cover shadow-card\">")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div><div class=\"min-w-0 flex-1\"><h3 class=\"line-clamp-2 text-sm font-semibold text-content\">")
|
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=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(collection.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 144, Col: 81}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 352, Col: 26}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</h3>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" data-library-id=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if book.Author != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<p class=\"mt-0.5 line-clamp-1 text-xs text-content-muted\">by ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(libraryID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 146, Col: 84}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 353, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" style=\"display: none;\"></div></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<button @click=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var20 string
|
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("removeBook('" + book.MediaItemID + "')")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 148, Col: 66}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" class=\"mt-2 inline-flex items-center gap-1 text-xs text-content-muted transition-colors hover:text-danger\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("trash", "h-3 w-3").Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "Remove</button></div></a></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</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-cloak 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 my-8 w-full max-w-6xl overflow-hidden\" style=\"display: none;\"><div class=\"flex items-center justify-between border-b border-line p-5\"><h2 class=\"text-lg font-bold text-content\">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, 46, "</button></div><div class=\"flex flex-wrap items-center gap-3 border-b border-line p-4\"><div class=\"min-w-[12rem] flex-1\"><input type=\"text\" name=\"search\" placeholder=\"Search books…\" class=\"input\" hx-get=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var21 string
|
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/filtered?show_checkbox=true&collection_id=" + collection.ID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 182, Col: 170}
|
|
||||||
}
|
|
||||||
_, 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, 47, "\" hx-target=\"#book-picker-grid\" hx-trigger=\"keyup changed delay:300ms\" hx-include=\"#book-picker-filters\"></div><div class=\"min-w-[10rem] flex-1\"><input type=\"text\" name=\"author_filter\" placeholder=\"Author\" class=\"input\" hx-get=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var22 string
|
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/filtered?show_checkbox=true&collection_id=" + collection.ID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 185, Col: 168}
|
|
||||||
}
|
|
||||||
_, 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, 48, "\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><div class=\"min-w-[10rem] flex-1\"><input type=\"text\" name=\"genre_filter\" placeholder=\"Genre\" class=\"input\" hx-get=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var23 string
|
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/filtered?show_checkbox=true&collection_id=" + collection.ID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 188, Col: 166}
|
|
||||||
}
|
|
||||||
_, 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, 49, "\" 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, 50, "Clear</button><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 max-h-96 grid-cols-2 gap-4 overflow-y-auto p-4 md:grid-cols-4 lg:grid-cols-6\"></div><div class=\"flex items-center justify-between border-t border-line p-4\"><div class=\"text-sm text-content-muted\"><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></body><div id=\"collection-data\" data-id=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var24 string
|
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 210, Col: 51}
|
|
||||||
}
|
|
||||||
_, 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, 51, "\" data-library-id=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var25 string
|
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 210, Col: 81}
|
|
||||||
}
|
|
||||||
_, 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, 52, "\" style=\"display: none;\"></div></html>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-69
@@ -18,85 +18,81 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
|||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-content">Sync Conflicts</h1>
|
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Sync Conflicts</h1>
|
||||||
<p class="text-content-muted">Resolve conflicts when reading progress differs across devices</p>
|
<p style="color: var(--text-secondary)">Resolve conflicts when reading progress differs across devices</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-3">
|
<div class="flex space-x-3">
|
||||||
<select
|
<select
|
||||||
id="status-filter"
|
id="status-filter"
|
||||||
onchange="filterConflicts()"
|
onchange="filterConflicts()"
|
||||||
class="input w-auto"
|
class="px-4 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
|
||||||
>
|
>
|
||||||
<option value="unresolved">Unresolved Only</option>
|
<option value="unresolved">Unresolved Only</option>
|
||||||
<option value="all">All Conflicts</option>
|
<option value="all">All Conflicts</option>
|
||||||
<option value="resolved">Resolved Only</option>
|
<option value="resolved">Resolved Only</option>
|
||||||
</select>
|
</select>
|
||||||
<button @click="dismissAllResolved()" class="btn btn-secondary">
|
<button @click="dismissAllResolved()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||||
Dismiss All Resolved
|
Dismiss All Resolved
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="stats-bar" class="mt-4 flex space-x-6 text-sm">
|
<div id="stats-bar" class="mt-4 flex space-x-6 text-sm">
|
||||||
<span class="text-content-muted">Total: <strong class="text-content">{ total }</strong></span>
|
<span style="color: var(--text-secondary)">Total: <strong style="color: var(--text-primary)">{ total }</strong></span>
|
||||||
<span class="text-content-muted">Unresolved: <strong class="text-warning">{ unresolved }</strong></span>
|
<span style="color: var(--text-secondary)">Unresolved: <strong style="color: #f59e0b;">{ unresolved }</strong></span>
|
||||||
<span class="text-content-muted">Resolved: <strong class="text-success">{ total - unresolved }</strong></span>
|
<span style="color: var(--text-secondary)">Resolved: <strong style="color: #10b981;">{ total - unresolved }</strong></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="bulk-actions" class="mt-4 hidden">
|
<div id="bulk-actions" class="mt-4 hidden">
|
||||||
<div class="card p-4 flex items-center justify-between">
|
<div class="flex items-center justify-between p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<span class="text-sm font-semibold text-content">
|
<span class="text-sm font-semibold" style="color: var(--text-primary);">
|
||||||
<span id="selected-count">0</span> conflicts selected
|
<span id="selected-count">0</span> conflicts selected
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
id="bulk-strategy"
|
id="bulk-strategy"
|
||||||
class="input w-auto text-sm"
|
class="px-3 py-2 border rounded-lg text-sm"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
>
|
>
|
||||||
<option value="most_recent">Most Recent</option>
|
<option value="most_recent">Most Recent</option>
|
||||||
<option value="highest_progress">Highest Progress</option>
|
<option value="highest_progress">Highest Progress</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<button @click="bulkResolve()" class="btn btn-primary text-sm">
|
<button @click="bulkResolve()" class="btn-primary px-4 py-2 rounded-lg text-sm">
|
||||||
@Icon("check", "h-4 w-4")
|
✅ Resolve Selected
|
||||||
Resolve Selected
|
|
||||||
</button>
|
</button>
|
||||||
<button @click="bulkDismiss()" class="btn btn-danger text-sm">
|
<button @click="bulkDismiss()" class="btn-danger px-4 py-2 rounded-lg text-sm">
|
||||||
@Icon("x-circle", "h-4 w-4")
|
❌ Dismiss Selected
|
||||||
Dismiss Selected
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="loading" class="hidden text-center py-16 text-content-muted">
|
<div id="loading" class="hidden text-center py-16" style="color: var(--text-secondary)">
|
||||||
<div class="loading-spinner mx-auto mb-4"></div>
|
<div class="loading-spinner mx-auto mb-4"></div>
|
||||||
<p>Loading conflicts...</p>
|
<p>Loading conflicts...</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="empty-state" class="hidden text-center py-16 text-content-muted">
|
<div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)">
|
||||||
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-brand/15 text-success">
|
<div class="text-6xl mb-4">✅</div>
|
||||||
@Icon("check-circle", "h-7 w-7")
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
|
||||||
</div>
|
|
||||||
<h3 class="text-xl font-semibold mb-2 text-content">No Conflicts</h3>
|
|
||||||
<p>Your devices are in sync! No conflicts to resolve.</p>
|
<p>Your devices are in sync! No conflicts to resolve.</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="conflicts-list" class="space-y-6">
|
<div id="conflicts-list" class="space-y-6">
|
||||||
<div id="select-all-container" class="mb-4 hidden">
|
<div id="select-all-container" class="mb-4 hidden">
|
||||||
<label class="flex items-center space-x-2 cursor-pointer">
|
<label class="flex items-center space-x-2 cursor-pointer">
|
||||||
<input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" onchange="toggleAllConflicts()"/>
|
<input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" onchange="toggleAllConflicts()"/>
|
||||||
<span class="text-sm font-semibold text-content">Select All</span>
|
<span class="text-sm font-semibold" style="color: var(--text-primary);">Select All</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
if len(conflicts) == 0 {
|
if len(conflicts) == 0 {
|
||||||
<div class="text-center py-16 text-content-muted">
|
<div class="text-center py-16" style="color: var(--text-secondary)">
|
||||||
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-brand/15 text-success">
|
<div class="text-6xl mb-4">✅</div>
|
||||||
@Icon("check-circle", "h-7 w-7")
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
|
||||||
</div>
|
|
||||||
<h3 class="text-xl font-semibold mb-2 text-content">No Conflicts</h3>
|
|
||||||
<p>Your devices are in sync! No conflicts to resolve.</p>
|
<p>Your devices are in sync! No conflicts to resolve.</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
for _, conflict := range conflicts {
|
for _, conflict := range conflicts {
|
||||||
<div class="conflict-item card p-6 border-l-4 border-l-warning" data-conflict-id="{ conflict.ID }">
|
<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="flex justify-between items-start mb-4">
|
||||||
<div class="flex items-start space-x-4">
|
<div class="flex items-start space-x-4">
|
||||||
<input
|
<input
|
||||||
@@ -106,15 +102,15 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
|||||||
onchange="updateBulkActions()"
|
onchange="updateBulkActions()"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-semibold text-content">{ conflict.MediaItemTitle }</h3>
|
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3>
|
||||||
<p class="text-sm text-content-muted">Type: { conflict.ConflictType }</p>
|
<p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button @click="showResolveModal('{ conflict.ID }')" class="btn btn-primary">
|
<button @click="showResolveModal('{ conflict.ID }')" class="btn-primary px-4 py-2 rounded-lg">
|
||||||
Resolve
|
Resolve
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-content-muted">
|
<div class="text-sm" style="color: var(--text-secondary)">
|
||||||
Created: { FormatInTimezone(conflict.CreatedAt, user.Timezone) }
|
Created: { FormatInTimezone(conflict.CreatedAt, user.Timezone) }
|
||||||
if conflict.ResolvedBy != "" {
|
if conflict.ResolvedBy != "" {
|
||||||
| Resolved by: { conflict.ResolvedBy }
|
| Resolved by: { conflict.ResolvedBy }
|
||||||
@@ -123,90 +119,94 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style="background-color: var(--surface-overlay);">
|
<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 p-6 w-full max-w-4xl mx-4 my-8">
|
<div class="card rounded-lg p-6 w-full max-w-4xl mx-4 my-8" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold tracking-tight text-content">Resolve Conflict</h2>
|
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Resolve Conflict</h2>
|
||||||
<p id="conflict-book-title" class="text-sm text-content-muted">Book Title</p>
|
<p class="text-sm" id="conflict-book-title" style="color: var(--text-secondary);">Book Title</p>
|
||||||
</div>
|
</div>
|
||||||
<button @click="hideResolveModal()" class="icon-btn" aria-label="Close">
|
<button @click="hideResolveModal()" class="p-2 hover:opacity-80 rounded-lg" style="color: var(--text-primary); background-color: var(--bg-primary);">
|
||||||
@Icon("close", "h-5 w-5")
|
X
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="conflict-resolution-form" @submit="handleResolveSubmit($event)">
|
<form id="conflict-resolution-form" @submit="handleResolveSubmit($event)">
|
||||||
<input type="hidden" id="conflict-id"/>
|
<input type="hidden" id="conflict-id"/>
|
||||||
<div id="conflict-details" class="mb-6"></div>
|
<div id="conflict-details" class="mb-6"></div>
|
||||||
<div class="card p-4 mb-6 bg-surface">
|
<div class="card p-4 rounded-lg border mb-6" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||||
<h3 class="font-semibold mb-4 text-content">Resolution Options</h3>
|
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Resolution Options</h3>
|
||||||
<div class="space-y-3 mb-6">
|
<div class="space-y-3 mb-6">
|
||||||
<label class="flex items-center p-3 border border-line rounded-lg cursor-pointer hover:bg-surface-hover">
|
<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/>
|
<input type="radio" name="winner" value="koreader" class="mr-3" required/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="font-semibold text-content">Keep KOReader Progress</span>
|
<span class="font-semibold" style="color: var(--text-primary);">Keep KOReader Progress</span>
|
||||||
<p class="text-xs text-content-muted">Use progress from your KOReader device</p>
|
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your KOReader device</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center p-3 border border-line rounded-lg cursor-pointer hover:bg-surface-hover">
|
<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"/>
|
<input type="radio" name="winner" value="kobo" class="mr-3"/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="font-semibold text-content">Keep Kobo Progress</span>
|
<span class="font-semibold" style="color: var(--text-primary);">Keep Kobo Progress</span>
|
||||||
<p class="text-xs text-content-muted">Use progress from your Kobo device</p>
|
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your Kobo device</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center p-3 border border-line rounded-lg cursor-pointer hover:bg-surface-hover">
|
<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"/>
|
<input type="radio" name="winner" value="web" class="mr-3"/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="font-semibold text-content">Keep Web Progress</span>
|
<span class="font-semibold" style="color: var(--text-primary);">Keep Web Progress</span>
|
||||||
<p class="text-xs text-content-muted">Use progress from the web interface</p>
|
<p class="text-xs" style="color: var(--text-secondary);">Use progress from the web interface</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center p-3 border border-line rounded-lg cursor-pointer hover:bg-surface-hover">
|
<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"/>
|
<input type="radio" name="winner" value="manual" class="mr-3"/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="font-semibold text-content">Manual Override</span>
|
<span class="font-semibold" style="color: var(--text-primary);">Manual Override</span>
|
||||||
<p class="text-xs text-content-muted">Specify a custom progress value</p>
|
<p class="text-xs" style="color: var(--text-secondary);">Specify a custom progress value</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="manual-override" class="hidden mb-4 p-4 border border-line rounded-lg bg-surface-raised">
|
<div id="manual-override" class="hidden mb-4 p-4 border rounded-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<h4 class="font-semibold mb-3 text-content">Manual Progress</h4>
|
<h4 class="font-semibold mb-3" style="color: var(--text-primary);">Manual Progress</h4>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Percentage (0-100)</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Percentage (0-100)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="manual-percentage"
|
id="manual-percentage"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
class="input"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Page Number</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Page Number</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="manual-page"
|
id="manual-page"
|
||||||
min="1"
|
min="1"
|
||||||
class="input"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">EPUB CFI</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">EPUB CFI</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="manual-epubcfi"
|
id="manual-epubcfi"
|
||||||
class="input"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Chapter</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Chapter</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="manual-chapter"
|
id="manual-chapter"
|
||||||
min="1"
|
min="1"
|
||||||
class="input"
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,23 +214,24 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="flex items-center space-x-3 cursor-pointer">
|
<label class="flex items-center space-x-3 cursor-pointer">
|
||||||
<input type="checkbox" id="apply-to-all" class="w-5 h-5 rounded"/>
|
<input type="checkbox" id="apply-to-all" class="w-5 h-5 rounded"/>
|
||||||
<span class="text-content">Apply this resolution to all future conflicts</span>
|
<span style="color: var(--text-primary);">Apply this resolution to all future conflicts</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Resolution Reason (optional)</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Resolution Reason (optional)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="resolution-reason"
|
id="resolution-reason"
|
||||||
class="input"
|
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="e.g., Device was offline, using most recent progress"
|
placeholder="e.g., Device was offline, using most recent progress"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" @click="hideResolveModal()" class="btn btn-secondary">Cancel</button>
|
<button type="button" @click="hideResolveModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
|
||||||
<button type="button" @click="deleteConflict()" class="btn btn-danger">Dismiss</button>
|
<button type="button" @click="deleteConflict()" class="btn-danger px-4 py-2 rounded-lg">Dismiss</button>
|
||||||
<button type="submit" class="btn btn-primary">Resolve Conflict</button>
|
<button type="submit" class="btn-primary px-6 py-2 rounded-lg">Resolve Conflict</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -13,51 +13,55 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
|
|||||||
<body class="theme-{ user.Theme }">
|
<body class="theme-{ user.Theme }">
|
||||||
@Header(user, "/custom-section")
|
@Header(user, "/custom-section")
|
||||||
<main class="max-w-4xl mx-auto px-4 py-8">
|
<main class="max-w-4xl mx-auto px-4 py-8">
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-content mb-2">Create Custom Section</h1>
|
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Create Custom Section</h1>
|
||||||
<p class="mb-6 text-content-muted">Build a custom dashboard section by defining filter rules or manually selecting books.</p>
|
<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">
|
<form id="custom-section-form" class="space-y-6">
|
||||||
<!-- Section Details -->
|
<!-- Section Details -->
|
||||||
<div class="p-4 rounded-lg bg-surface-raised">
|
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
||||||
<h2 class="text-xl font-semibold mb-4 text-content">Section Details</h2>
|
<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 class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Name *</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="section-name"
|
id="section-name"
|
||||||
name="name"
|
name="name"
|
||||||
required
|
required
|
||||||
class="input"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Icon (emoji)</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Icon (emoji)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="section-icon"
|
id="section-icon"
|
||||||
name="icon"
|
name="icon"
|
||||||
maxlength="4"
|
maxlength="4"
|
||||||
class="input"
|
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="📚"
|
placeholder="📚"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Description</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="section-description"
|
id="section-description"
|
||||||
name="description"
|
name="description"
|
||||||
rows="2"
|
rows="2"
|
||||||
class="input"
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Library *</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Library *</label>
|
||||||
<select
|
<select
|
||||||
id="section-library"
|
id="section-library"
|
||||||
name="library_id"
|
name="library_id"
|
||||||
required
|
required
|
||||||
class="input"
|
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>
|
<option value="">Select a library...</option>
|
||||||
for _, lib := range libraries {
|
for _, lib := range libraries {
|
||||||
@@ -67,28 +71,29 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Filter Rules -->
|
<!-- Filter Rules -->
|
||||||
<div class="p-4 rounded-lg bg-surface-raised">
|
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="text-xl font-semibold text-content">Filter Rules</h2>
|
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Filter Rules</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="add-rule-btn"
|
id="add-rule-btn"
|
||||||
class="btn btn-primary text-sm"
|
class="px-3 py-1 rounded-lg text-sm font-medium"
|
||||||
|
style="background-color: var(--accent);"
|
||||||
>
|
>
|
||||||
@Icon("plus", "h-4 w-4")
|
+ Add Rule
|
||||||
Add Rule
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm mb-4 text-content-muted">
|
<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.
|
Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.
|
||||||
</p>
|
</p>
|
||||||
<div id="rules-container" class="space-y-3"></div>
|
<div id="rules-container" class="space-y-3"></div>
|
||||||
<div class="mt-4 flex items-center gap-2">
|
<div class="mt-4 flex items-center gap-2">
|
||||||
<label class="text-sm font-medium text-content-muted">Match:</label>
|
<label class="text-sm font-medium" style="color: var(--text-secondary)">Match:</label>
|
||||||
<select
|
<select
|
||||||
id="match-type"
|
id="match-type"
|
||||||
name="match_type"
|
name="match_type"
|
||||||
class="input w-auto"
|
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="all">ALL rules (AND)</option>
|
||||||
<option value="any">ANY rule (OR)</option>
|
<option value="any">ANY rule (OR)</option>
|
||||||
@@ -96,64 +101,68 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Manual Book Selection -->
|
<!-- Manual Book Selection -->
|
||||||
<div class="p-4 rounded-lg bg-surface-raised">
|
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
||||||
<h2 class="text-xl font-semibold mb-4 text-content">Manual Book Selection</h2>
|
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Manual Book Selection</h2>
|
||||||
<p class="text-sm mb-4 text-content-muted">
|
<p class="text-sm mb-4" style="color: var(--text-secondary)">
|
||||||
Add specific books to this section. Use the search to find and select multiple books.
|
Add specific books to this section. Use the search to find and select multiple books.
|
||||||
</p>
|
</p>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Search Books</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Search Books</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="book-search"
|
id="book-search"
|
||||||
name="book_search"
|
name="book_search"
|
||||||
class="input flex-1"
|
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..."
|
placeholder="Search by title or author..."
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="search-books-btn"
|
id="search-books-btn"
|
||||||
class="btn btn-primary"
|
class="px-4 py-2 rounded-lg font-medium"
|
||||||
|
style="background-color: var(--accent);"
|
||||||
>
|
>
|
||||||
@Icon("search", "h-4 w-4")
|
|
||||||
Search
|
Search
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="search-results"
|
id="search-results"
|
||||||
class="hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto bg-surface"
|
class="hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto"
|
||||||
|
style="background-color: var(--bg-primary);"
|
||||||
></div>
|
></div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Selected Books</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Selected Books</label>
|
||||||
<div
|
<div
|
||||||
id="selected-books"
|
id="selected-books"
|
||||||
class="min-h-[60px] p-3 rounded-lg border-2 border-dashed border-line bg-surface"
|
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 text-content-muted">No books selected</p>
|
<p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Live Preview -->
|
<!-- Live Preview -->
|
||||||
<div class="p-4 rounded-lg bg-surface-raised">
|
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="text-xl font-semibold text-content">Live Preview</h2>
|
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Live Preview</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="preview-btn"
|
id="preview-btn"
|
||||||
class="btn btn-primary"
|
class="px-4 py-2 rounded-lg font-medium"
|
||||||
|
style="background-color: var(--accent);"
|
||||||
>
|
>
|
||||||
@Icon("refresh", "h-4 w-4")
|
|
||||||
Refresh Preview
|
Refresh Preview
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="preview-container"
|
id="preview-container"
|
||||||
class="p-4 rounded-lg bg-surface min-h-[200px]"
|
class="p-4 rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); min-height: 200px;"
|
||||||
>
|
>
|
||||||
<p class="text-center text-content-muted">
|
<p class="text-center" style="color: var(--text-secondary);">
|
||||||
Add filter rules or select books to see a preview of your custom section.
|
Add filter rules or select books to see a preview of your custom section.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,16 +172,17 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="cancel-btn"
|
id="cancel-btn"
|
||||||
class="btn btn-secondary"
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
id="save-section-btn"
|
id="save-section-btn"
|
||||||
class="btn btn-primary"
|
class="px-6 py-2 rounded-lg font-medium text-white hover:opacity-90"
|
||||||
|
style="background-color: var(--accent);"
|
||||||
>
|
>
|
||||||
@Icon("save", "h-4 w-4")
|
|
||||||
Save Section
|
Save Section
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
// templ: version: v0.3.1001
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
@@ -37,7 +37,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"max-w-4xl mx-auto px-4 py-8\"><h1 class=\"text-2xl font-bold tracking-tight text-content mb-2\">Create Custom Section</h1><p class=\"mb-6 text-content-muted\">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 bg-surface-raised\"><h2 class=\"text-xl font-semibold mb-4 text-content\">Section Details</h2><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">Name *</label> <input type=\"text\" id=\"section-name\" name=\"name\" required class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">Icon (emoji)</label> <input type=\"text\" id=\"section-icon\" name=\"icon\" maxlength=\"4\" class=\"input\" placeholder=\"📚\"></div></div><div class=\"mt-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">Description</label> <textarea id=\"section-description\" name=\"description\" rows=\"2\" class=\"input\"></textarea></div><div class=\"mt-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">Library *</label> <select id=\"section-library\" name=\"library_id\" required class=\"input\"><option value=\"\">Select a library...</option> ")
|
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> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -47,11 +47,11 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 64, Col: 31}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 68, Col: 31}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 64, Col: 44}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 68, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -73,39 +73,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</select></div></div><!-- Filter Rules --><div class=\"p-4 rounded-lg bg-surface-raised\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-xl font-semibold text-content\">Filter Rules</h2><button type=\"button\" id=\"add-rule-btn\" class=\"btn btn-primary text-sm\">")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "Add Rule</button></div><p class=\"text-sm mb-4 text-content-muted\">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 text-content-muted\">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><!-- Manual Book Selection --><div class=\"p-4 rounded-lg bg-surface-raised\"><h2 class=\"text-xl font-semibold mb-4 text-content\">Manual Book Selection</h2><p class=\"text-sm mb-4 text-content-muted\">Add specific books to this section. Use the search to find and select multiple books.</p><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">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, 8, "Search</button></div></div><div id=\"search-results\" class=\"hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto bg-surface\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">Selected Books</label><div id=\"selected-books\" class=\"min-h-[60px] p-3 rounded-lg border-2 border-dashed border-line bg-surface\"><p class=\"text-sm text-center text-content-muted\">No books selected</p></div></div></div><!-- Live Preview --><div class=\"p-4 rounded-lg bg-surface-raised\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-xl font-semibold text-content\">Live Preview</h2><button type=\"button\" id=\"preview-btn\" class=\"btn btn-primary\">")
|
|
||||||
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, 9, "Refresh Preview</button></div><div id=\"preview-container\" class=\"p-4 rounded-lg bg-surface min-h-[200px]\"><p class=\"text-center text-content-muted\">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=\"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, 10, "Save Section</button></div></form></main>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -113,7 +81,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-82
@@ -18,23 +18,13 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
|
|||||||
<body x-data="dashboard" x-init="initDashboard()" class="theme-{ user.Theme }">
|
<body x-data="dashboard" x-init="initDashboard()" class="theme-{ user.Theme }">
|
||||||
@Header(user, "/dashboard")
|
@Header(user, "/dashboard")
|
||||||
@LibrarySwitcher(libData, currentLibraryID, DashboardActions())
|
@LibrarySwitcher(libData, currentLibraryID, DashboardActions())
|
||||||
<main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
<!-- Collections Container -->
|
||||||
if len(sections) == 0 {
|
<main id="collections-container" class="w-full px-4 py-8">
|
||||||
<div class="card mx-auto max-w-md p-10 text-center">
|
|
||||||
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-brand/15 text-brand">
|
|
||||||
@Icon("book-open", "h-7 w-7")
|
|
||||||
</div>
|
|
||||||
<h2 class="text-lg font-semibold text-content">Your library is empty</h2>
|
|
||||||
<p class="mt-1 text-sm text-content-muted">Once books are added to your libraries, collections will appear here.</p>
|
|
||||||
</div>
|
|
||||||
} else {
|
|
||||||
<div class="space-y-10">
|
|
||||||
for _, section := range sections {
|
for _, section := range sections {
|
||||||
@CollectionCarousel(section)
|
@CollectionCarousel(section)
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</main>
|
</main>
|
||||||
|
<!-- Dashboard Settings Modal -->
|
||||||
@DashboardSettingsModal(allSections, hiddenCollections, itemsPerSection)
|
@DashboardSettingsModal(allSections, hiddenCollections, itemsPerSection)
|
||||||
@ErrorToast(errorMessage)
|
@ErrorToast(errorMessage)
|
||||||
</body>
|
</body>
|
||||||
@@ -42,92 +32,115 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ CollectionCarousel(section handlers.SectionData) {
|
templ CollectionCarousel(section handlers.SectionData) {
|
||||||
<section
|
<div
|
||||||
class="dashboard-collection"
|
class="dashboard-collection mb-8"
|
||||||
data-collection-id={ section.ID }
|
data-collection-id={ section.ID }
|
||||||
data-is-system={ section.IsSystem }
|
data-is-system={ section.IsSystem }
|
||||||
>
|
>
|
||||||
<div class="mb-4 flex items-end justify-between gap-4">
|
<!-- Collection Header -->
|
||||||
<div class="min-w-0">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="flex items-center gap-2 text-lg font-bold tracking-tight text-content">
|
<div class="flex items-center gap-3">
|
||||||
if section.Icon != "" {
|
<span class="text-2xl">{ section.Icon }</span>
|
||||||
<span class="text-base">{ section.Icon }</span>
|
<div>
|
||||||
}
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">{ section.Title }</h2>
|
||||||
{ section.Title }
|
|
||||||
</h2>
|
|
||||||
if section.Description != "" {
|
if section.Description != "" {
|
||||||
<p class="mt-0.5 text-sm text-content-muted">{ section.Description }</p>
|
<p class="text-sm" style="color: var(--text-secondary)">{ section.Description }</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
if section.ViewAllURL != "" {
|
if section.ViewAllURL != "" {
|
||||||
<a href={ section.ViewAllURL } class="flex items-center gap-1 text-sm font-medium text-content-muted transition-colors hover:text-brand">
|
<a
|
||||||
View All
|
href={ section.ViewAllURL }
|
||||||
@Icon("arrow-right", "h-4 w-4")
|
class="text-sm font-medium hover:underline transition-colors"
|
||||||
|
style="color: var(--accent);"
|
||||||
|
>
|
||||||
|
View All →
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Carousel -->
|
||||||
<div class="carousel-container relative group">
|
<div class="carousel-container relative group">
|
||||||
<button
|
<button
|
||||||
class="carousel-nav-left absolute left-0 top-0 z-10 flex h-full w-12 items-center justify-start bg-gradient-to-r from-surface to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100 focus-visible:opacity-100"
|
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-action="scroll-carousel"
|
||||||
data-collection-id={ section.ID }
|
data-collection-id={ section.ID }
|
||||||
data-direction="-1"
|
data-direction="-1"
|
||||||
aria-label="Scroll left"
|
aria-label="Scroll left"
|
||||||
>
|
>
|
||||||
@Icon("chevron-left", "h-6 w-6 text-content")
|
<span class="text-3xl pl-2" style="color: var(--text-primary);">‹</span>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
id={ "carousel-track-" + section.ID }
|
id={ "carousel-track-" + section.ID }
|
||||||
class="carousel-track flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory px-6 pb-2"
|
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;"
|
style="scrollbar-width: none; -ms-overflow-style: none;"
|
||||||
>
|
>
|
||||||
for _, item := range section.Items {
|
for _, item := range section.Items {
|
||||||
@BookCard(item)
|
@BookCard(item)
|
||||||
}
|
}
|
||||||
if len(section.Items) == 0 {
|
if len(section.Items) == 0 {
|
||||||
<div class="w-full py-10 text-center text-sm text-content-muted">
|
<div class="text-center py-8 w-full" style="color: var(--text-secondary);">
|
||||||
No items in this collection
|
<p>No items in this collection</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="carousel-nav-right absolute right-0 top-0 z-10 flex h-full w-12 items-center justify-end bg-gradient-to-l from-surface to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100 focus-visible:opacity-100"
|
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-action="scroll-carousel"
|
||||||
data-collection-id={ section.ID }
|
data-collection-id={ section.ID }
|
||||||
data-direction="1"
|
data-direction="1"
|
||||||
aria-label="Scroll right"
|
aria-label="Scroll right"
|
||||||
>
|
>
|
||||||
@Icon("chevron-right", "h-6 w-6 text-content")
|
<span class="text-3xl pr-2" style="color: var(--text-primary);">›</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
// BookCard is the single, canonical cover-forward card used across the
|
|
||||||
// dashboard, bookshelf, series and collections. The metadata block sits on a
|
|
||||||
// real surface so cards always read as cohesive objects (previously the text
|
|
||||||
// floated with no background when wood-paneling was off).
|
|
||||||
templ BookCard(item handlers.BookInfo) {
|
templ BookCard(item handlers.BookInfo) {
|
||||||
<a href={ "/media/" + item.MediaItemID } title={ item.Title } class="block w-36 flex-shrink-0 snap-start">
|
<a href={ "/media/" + item.MediaItemID } title={ item.Title }>
|
||||||
<div class="book-card" tabindex="0" role="button" aria-label={ "View " + item.Title }>
|
<div
|
||||||
<div class="aspect-[2/3] overflow-hidden bg-surface-hover">
|
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"
|
||||||
|
>
|
||||||
if item.CoverImagePath != "" {
|
if item.CoverImagePath != "" {
|
||||||
<img
|
<img
|
||||||
src={ item.CoverImagePath }
|
src={ item.CoverImagePath }
|
||||||
alt={ item.Title }
|
alt={ item.Title }
|
||||||
class="h-full w-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onerror="this.src='/static/placeholder-book.svg'"
|
onerror="this.src='/static/placeholder-book.svg'"
|
||||||
/>
|
/>
|
||||||
} else {
|
} else {
|
||||||
<img src="/static/placeholder-book.svg" alt={ item.Title } class="h-full w-full object-cover"/>
|
<img
|
||||||
|
src="/static/placeholder-book.svg"
|
||||||
|
alt={ item.Title }
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="book-card-meta">
|
<div class="book-card-text px-2 py-1 bg-[color-mix(in_srgb,var(--wood-border)_40%,transparent)]">
|
||||||
<h3 class="line-clamp-2 text-sm font-semibold leading-snug text-content" title={ item.Title }>{ item.Title }</h3>
|
<h3 class="font-semibold text-base line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
|
||||||
|
{ item.Title }
|
||||||
|
</h3>
|
||||||
if item.Author != "" {
|
if item.Author != "" {
|
||||||
<p class="mt-0.5 line-clamp-1 text-xs text-content-muted" title={ item.Author }>{ item.Author }</p>
|
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)" title={ item.Author }>
|
||||||
|
{ item.Author }
|
||||||
|
</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,69 +150,82 @@ templ BookCard(item handlers.BookInfo) {
|
|||||||
templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) {
|
templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) {
|
||||||
<div
|
<div
|
||||||
id="dashboard-settings-modal"
|
id="dashboard-settings-modal"
|
||||||
class="hidden fixed inset-0 z-50 flex items-center justify-center p-4"
|
class="hidden fixed inset-0 z-50 flex items-center justify-center"
|
||||||
style="background-color: var(--surface-overlay);"
|
style="background-color: rgba(0, 0, 0, 0.7);"
|
||||||
>
|
>
|
||||||
<div class="card w-full max-w-2xl p-6">
|
<div
|
||||||
<div class="mb-6 flex items-center justify-between">
|
class="rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl"
|
||||||
<div>
|
style="background-color: var(--bg-secondary);"
|
||||||
<h2 class="text-lg font-bold text-content">Customize Dashboard</h2>
|
>
|
||||||
<p class="mt-0.5 text-sm text-content-muted">Drag to reorder, toggle to show or hide.</p>
|
<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="icon-btn" aria-label="Close">
|
<button
|
||||||
@Icon("close", "h-5 w-5")
|
data-action="close-dashboard-settings"
|
||||||
|
class="p-2 hover:bg-gray-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-sm mb-4" style="color: var(--text-secondary);">
|
||||||
<div id="collection-list" class="mb-6 space-y-2">
|
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 {
|
for _, section := range sections {
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between gap-3 rounded-xl border border-line bg-surface px-3 py-2.5 cursor-move select-none"
|
class="collection-item flex items-center justify-between p-3 rounded border
|
||||||
|
cursor-move select-none"
|
||||||
data-collection-id={ section.ID }
|
data-collection-id={ section.ID }
|
||||||
data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
|
data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
|
||||||
draggable="true"
|
draggable="true"
|
||||||
|
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3">
|
||||||
@Icon("grip", "h-4 w-4 text-content-muted")
|
<span class="text-xl" style="color: var(--text-secondary);">☰</span>
|
||||||
if section.Icon != "" {
|
<span class="text-xl">{ section.Icon }</span>
|
||||||
<span class="text-base">{ section.Icon }</span>
|
<div>
|
||||||
}
|
<span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span>
|
||||||
<div class="min-w-0">
|
|
||||||
<span class="block truncate text-sm font-medium text-content">{ section.Title }</span>
|
|
||||||
</div>
|
|
||||||
if section.IsSystem {
|
if section.IsSystem {
|
||||||
<span class="badge bg-brand/15 text-brand">System</span>
|
<span class="text-xs ml-2 px-2 py-1 rounded" style="background-color: var(--accent);">System</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
if section.IsSystem {
|
if section.IsSystem {
|
||||||
<button
|
<button
|
||||||
data-action="restore-system-collection"
|
data-action="restore-system-collection"
|
||||||
data-collection-name={ section.ID }
|
data-collection-name={ section.ID }
|
||||||
class="btn btn-ghost px-2 py-1 text-xs"
|
class="text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity"
|
||||||
title="Restore"
|
style="border-color: var(--border); color: var(--text-secondary);"
|
||||||
|
title="Restore { section.Title } to defaults"
|
||||||
>
|
>
|
||||||
Restore
|
Restore
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<label class="relative inline-flex cursor-pointer items-center">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="peer sr-only"
|
class="sr-only peer"
|
||||||
if !ContainsString(hiddenCollections, section.ID) {
|
if !ContainsString(hiddenCollections, section.ID) {
|
||||||
checked
|
checked
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div class="h-6 w-11 rounded-full bg-surface-hover transition-colors after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-content-muted after:transition-all peer-checked:bg-brand peer-checked:after:translate-x-full peer-checked:after:bg-white peer-focus-visible:ring-2 peer-focus-visible:ring-brand/50"></div>
|
<div
|
||||||
|
class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
|
||||||
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Items Per Section Slider -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="mb-2 block text-sm font-medium text-content-muted">
|
<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 text-content">{ itemsPerSection }</span>
|
Items per Collection: <span id="items-count-display" class="font-bold">{ itemsPerSection }</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -207,15 +233,26 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
|
|||||||
max="50"
|
max="50"
|
||||||
step="5"
|
step="5"
|
||||||
value={ itemsPerSection }
|
value={ itemsPerSection }
|
||||||
class="h-2 w-full cursor-pointer appearance-none rounded-full bg-surface-hover"
|
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
|
||||||
data-input-action="update-items-count"
|
data-input-action="update-items-count"
|
||||||
target="items-count-display"
|
target="items-count-display"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<button data-action="close-dashboard-settings" class="btn btn-secondary">Cancel</button>
|
<button
|
||||||
<button data-action="save-dashboard-settings" class="btn btn-primary">Save Changes</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+109
-188
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
// templ: version: v0.3.1001
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
@@ -46,25 +46,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main id=\"collections-container\" class=\"w-full px-4 sm:px-6 lg:px-8 py-8\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!-- Collections Container --><main id=\"collections-container\" class=\"w-full px-4 py-8\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if len(sections) == 0 {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"card mx-auto max-w-md p-10 text-center\"><div class=\"mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-brand/15 text-brand\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("book-open", "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, 4, "</div><h2 class=\"text-lg font-semibold text-content\">Your library is empty</h2><p class=\"mt-1 text-sm text-content-muted\">Once books are added to your libraries, collections will appear here.</p></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"space-y-10\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -74,12 +56,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</main><!-- Dashboard Settings Modal -->")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</main>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -91,7 +68,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -120,153 +97,131 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
templ_7745c5c3_Var2 = templ.NopComponent
|
templ_7745c5c3_Var2 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<section class=\"dashboard-collection\" data-collection-id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"dashboard-collection mb-8\" data-collection-id=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 47, Col: 33}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 37, Col: 33}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" data-is-system=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" data-is-system=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(section.IsSystem)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 48, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 38, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"><div class=\"mb-4 flex items-end justify-between gap-4\"><div class=\"min-w-0\"><h2 class=\"flex items-center gap-2 text-lg font-bold tracking-tight text-content\">")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if section.Icon != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<span class=\"text-base\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 54, Col: 44}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 43, Col: 41}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 56, Col: 20}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 45, Col: 85}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</h2>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</h2>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if section.Description != "" {
|
if section.Description != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p class=\"mt-0.5 text-sm text-content-muted\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 59, Col: 71}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 47, Col: 83}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if section.ViewAllURL != "" {
|
if section.ViewAllURL != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 templ.SafeURL
|
var templ_7745c5c3_Var8 templ.SafeURL
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 63, Col: 32}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 53, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"flex items-center gap-1 text-sm font-medium text-content-muted transition-colors hover:text-brand\">View All")
|
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>")
|
||||||
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, 20, "</a>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div><div class=\"carousel-container relative group\"><button class=\"carousel-nav-left absolute left-0 top-0 z-10 flex h-full w-12 items-center justify-start bg-gradient-to-r from-surface to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100 focus-visible:opacity-100\" data-action=\"scroll-carousel\" data-collection-id=\"")
|
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=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 74, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 69, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-direction=\"-1\" aria-label=\"Scroll left\">")
|
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=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("chevron-left", "h-6 w-6 text-content").Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</button><div id=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs("carousel-track-" + section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 81, Col: 39}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 76, Col: 39}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" 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;\">")
|
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;\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -277,33 +232,25 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(section.Items) == 0 {
|
if len(section.Items) == 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"w-full py-10 text-center text-sm text-content-muted\">No items in this collection</div>")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><button class=\"carousel-nav-right absolute right-0 top-0 z-10 flex h-full w-12 items-center justify-end bg-gradient-to-l from-surface to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100 focus-visible:opacity-100\" data-action=\"scroll-carousel\" data-collection-id=\"")
|
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=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 97, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 97, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" data-direction=\"1\" aria-label=\"Scroll right\">")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("chevron-right", "h-6 w-6 text-content").Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</button></div></section>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -311,10 +258,6 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// BookCard is the single, canonical cover-forward card used across the
|
|
||||||
// dashboard, bookshelf, series and collections. The metadata block sits on a
|
|
||||||
// real surface so cards always read as cohesive objects (previously the text
|
|
||||||
// floated with no background when wood-paneling was off).
|
|
||||||
func BookCard(item handlers.BookInfo) templ.Component {
|
func BookCard(item handlers.BookInfo) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
@@ -336,162 +279,162 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
templ_7745c5c3_Var12 = templ.NopComponent
|
templ_7745c5c3_Var12 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 templ.SafeURL
|
var templ_7745c5c3_Var13 templ.SafeURL
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 112, Col: 39}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 39}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" title=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" title=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 112, Col: 60}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"block w-36 flex-shrink-0 snap-start\"><div class=\"book-card\" tabindex=\"0\" role=\"button\" aria-label=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"><div class=\"book-card flex-shrink-0 w-36 rounded-lg overflow-hidden snap-start cursor-pointer transition-transform duration-200 hover:scale-105\" tabindex=\"0\" role=\"button\" aria-label=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("View " + item.Title)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs("View " + item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 113, Col: 85}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 114, Col: 36}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\"><div class=\"aspect-[2/3] overflow-hidden bg-surface-hover\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\"><div class=\"aspect-[2/3] overflow-hidden shadow-lg bg-gradient-to-br from-gray-700 to-gray-900\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if item.CoverImagePath != "" {
|
if item.CoverImagePath != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<img src=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<img src=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(item.CoverImagePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 117, Col: 31}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 122, Col: 31}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" alt=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" alt=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 118, Col: 22}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 123, Col: 22}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" class=\"h-full w-full object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<img src=\"/static/placeholder-book.svg\" alt=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<img src=\"/static/placeholder-book.svg\" alt=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var18 string
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 124, Col: 61}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 22}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" class=\"h-full w-full object-cover\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"w-full h-full object-cover\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div><div class=\"book-card-meta\"><h3 class=\"line-clamp-2 text-sm font-semibold leading-snug text-content\" title=\"")
|
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 templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 128, Col: 95}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 137, Col: 106}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 128, Col: 110}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 138, Col: 17}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</h3>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</h3>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if item.Author != "" {
|
if item.Author != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<p class=\"mt-0.5 line-clamp-1 text-xs text-content-muted\" title=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<p class=\"text-sm line-clamp-1\" style=\"color: var(--text-secondary)\" title=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 130, Col: 82}
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 130, Col: 98}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 142, Col: 19}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div></div></a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div></div></a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -520,160 +463,138 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
templ_7745c5c3_Var23 = templ.NopComponent
|
templ_7745c5c3_Var23 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div id=\"dashboard-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 w-full max-w-2xl p-6\"><div class=\"mb-6 flex items-center justify-between\"><div><h2 class=\"text-lg font-bold text-content\">Customize Dashboard</h2><p class=\"mt-0.5 text-sm text-content-muted\">Drag to reorder, toggle to show or hide.</p></div><button data-action=\"close-dashboard-settings\" class=\"icon-btn\" aria-label=\"Close\">")
|
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\">")
|
||||||
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, 46, "</button></div><div id=\"collection-list\" class=\"mb-6 space-y-2\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, section := range sections {
|
for _, section := range sections {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<div class=\"flex items-center justify-between gap-3 rounded-xl border border-line bg-surface px-3 py-2.5 cursor-move select-none\" data-collection-id=\"")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var24 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 158, Col: 37}
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" data-is-system=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" data-is-system=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var25 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", section.IsSystem))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 159, Col: 58}
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" draggable=\"true\"><div class=\"flex items-center gap-3 min-w-0\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" draggable=\"true\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><div class=\"flex items-center gap-3\"><span class=\"text-xl\" style=\"color: var(--text-secondary);\">☰</span> <span class=\"text-xl\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("grip", "h-4 w-4 text-content-muted").Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if section.Icon != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span class=\"text-base\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var26 string
|
var templ_7745c5c3_Var26 string
|
||||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 165, Col: 46}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 185, Col: 43}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</span><div><span class=\"font-medium\" style=\"color: var(--text-primary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div class=\"min-w-0\"><span class=\"block truncate text-sm font-medium text-content\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var27 string
|
var templ_7745c5c3_Var27 string
|
||||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 168, Col: 85}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 187, Col: 85}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</span></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if section.IsSystem {
|
if section.IsSystem {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span class=\"badge bg-brand/15 text-brand\">System</span>")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</div><div class=\"flex items-center gap-3\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div></div><div class=\"flex items-center gap-3\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if section.IsSystem {
|
if section.IsSystem {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<button data-action=\"restore-system-collection\" data-collection-name=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<button data-action=\"restore-system-collection\" data-collection-name=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var28 string
|
var templ_7745c5c3_Var28 string
|
||||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 178, Col: 42}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 197, Col: 42}
|
||||||
}
|
}
|
||||||
_, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" class=\"btn btn-ghost px-2 py-1 text-xs\" title=\"Restore\">Restore</button> ")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<label class=\"relative inline-flex cursor-pointer items-center\"><input type=\"checkbox\" class=\"peer sr-only\"")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if !ContainsString(hiddenCollections, section.ID) {
|
if !ContainsString(hiddenCollections, section.ID) {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " checked")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " checked")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "><div class=\"h-6 w-11 rounded-full bg-surface-hover transition-colors after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-content-muted after:transition-all peer-checked:bg-brand peer-checked:after:translate-x-full peer-checked:after:bg-white peer-focus-visible:ring-2 peer-focus-visible:ring-brand/50\"></div></label></div></div>")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</div><div class=\"mb-6\"><label class=\"mb-2 block text-sm font-medium text-content-muted\">Items per Collection: <span id=\"items-count-display\" class=\"font-bold text-content\">")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var29 string
|
var templ_7745c5c3_Var29 string
|
||||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 202, Col: 106}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 228, Col: 93}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span></label> <input type=\"range\" min=\"10\" max=\"50\" step=\"5\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span></label> <input type=\"range\" min=\"10\" max=\"50\" step=\"5\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var30 string
|
var templ_7745c5c3_Var30 string
|
||||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 209, Col: 28}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 235, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" class=\"h-2 w-full cursor-pointer appearance-none rounded-full bg-surface-hover\" 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>")
|
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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+125
-133
@@ -19,12 +19,11 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-content">Device Management</h1>
|
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Device Management</h1>
|
||||||
<p class="text-content-muted">Manage your reading devices and sync settings</p>
|
<p style="color: var(--text-secondary)">Manage your reading devices and sync settings</p>
|
||||||
</div>
|
</div>
|
||||||
<button @click="showAddDeviceModal()" class="btn btn-primary">
|
<button @click="showAddDeviceModal()" class="btn-primary px-4 py-2 rounded-lg">
|
||||||
@Icon("plus", "h-4 w-4")
|
➕ Add New Device
|
||||||
Add New Device
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,13 +31,11 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
<div id="devices-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div id="devices-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
if len(devices) == 0 {
|
if len(devices) == 0 {
|
||||||
<div id="empty-state" class="text-center py-16 col-span-full text-content-muted">
|
<div id="empty-state" class="text-center py-16 col-span-full" style="color: var(--text-secondary)">
|
||||||
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-brand/15 text-brand">
|
<div class="text-6xl mb-4">📱</div>
|
||||||
@Icon("device", "h-7 w-7")
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Devices Yet</h3>
|
||||||
</div>
|
|
||||||
<h3 class="text-xl font-semibold mb-2 text-content">No Devices Yet</h3>
|
|
||||||
<p class="mb-4">Add your reading devices to enable cross-device sync</p>
|
<p class="mb-4">Add your reading devices to enable cross-device sync</p>
|
||||||
<button @click="showAddDeviceModal()" class="btn btn-primary">
|
<button @click="showAddDeviceModal()" class="btn-primary px-4 py-2 rounded-lg">
|
||||||
Add Your First Device
|
Add Your First Device
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +44,7 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
if len(devices) > 0 {
|
if len(devices) > 0 {
|
||||||
<div id="devices-grid" class="grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div id="devices-grid" class="grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
for _, device := range devices {
|
for _, device := range devices {
|
||||||
<div class="card p-6">
|
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<div class="flex items-start justify-between mb-4">
|
<div class="flex items-start justify-between mb-4">
|
||||||
<div class="text-4xl">
|
<div class="text-4xl">
|
||||||
if device.DeviceType == "koreader" {
|
if device.DeviceType == "koreader" {
|
||||||
@@ -61,110 +58,103 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<button @click={ "showShelfMappings('" + device.ID.String() + "')" } class="icon-btn" aria-label="Shelf mappings">
|
<button @click="showShelfMappings('{ device.ID }')" class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
|
||||||
@Icon("library", "h-4 w-4")
|
📚
|
||||||
</button>
|
</button>
|
||||||
<button @click={ "showDeviceSettings('" + device.ID.String() + "')" } class="icon-btn" aria-label="Device settings">
|
<button @click="showDeviceSettings('{ device.ID }')" class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
|
||||||
@Icon("settings", "h-4 w-4")
|
⚙️
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-semibold mb-1 text-content">{ device.DeviceName }</h3>
|
<h3 class="text-lg font-semibold mb-1" style="color: var(--text-primary)">{ device.DeviceName }</h3>
|
||||||
<p class="text-sm mb-4 text-content-muted">{ device.DeviceType }</p>
|
<p class="text-sm mb-4" style="color: var(--text-secondary)">{ device.DeviceType }</p>
|
||||||
<div class="space-y-2 text-sm mb-4">
|
<div class="space-y-2 text-sm mb-4">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-content-muted">Sync Status</span>
|
<span style="color: var(--text-secondary)">Sync Status</span>
|
||||||
if device.SyncEnabled {
|
if device.SyncEnabled {
|
||||||
<span class="inline-flex items-center gap-1 text-brand">
|
<span style="color: var(--accent)">✓ Enabled</span>
|
||||||
@Icon("check", "h-3.5 w-3.5")
|
|
||||||
Enabled
|
|
||||||
</span>
|
|
||||||
} else {
|
} else {
|
||||||
<span class="inline-flex items-center gap-1 text-content-muted">
|
<span style="color: var(--text-secondary)">✗ Disabled</span>
|
||||||
@Icon("close", "h-3.5 w-3.5")
|
|
||||||
Disabled
|
|
||||||
</span>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-content-muted">Last Sync</span>
|
<span style="color: var(--text-secondary)">Last Sync</span>
|
||||||
if device.LastSync != nil {
|
if device.LastSync != nil {
|
||||||
<span class="text-content">{ FormatInTimezone(*device.LastSync, user.Timezone) }</span>
|
<span style="color: var(--text-primary)">{ FormatInTimezone(*device.LastSync, user.Timezone) }</span>
|
||||||
} else {
|
} else {
|
||||||
<span class="text-content">Never</span>
|
<span style="color: var(--text-primary)">Never</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-content-muted">Last Seen</span>
|
<span style="color: var(--text-secondary)">Last Seen</span>
|
||||||
if device.LastSeen != nil {
|
if device.LastSeen != nil {
|
||||||
<span class="text-content">{ FormatInTimezone(*device.LastSeen, user.Timezone) }</span>
|
<span style="color: var(--text-primary)">{ FormatInTimezone(*device.LastSeen, user.Timezone) }</span>
|
||||||
} else {
|
} else {
|
||||||
<span class="text-content">Never</span>
|
<span style="color: var(--text-primary)">Never</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- NEW: Sync URL & Token Management -->
|
<!-- NEW: Sync URL & Token Management -->
|
||||||
<div class="border-t border-line pt-4">
|
<div class="border-t pt-4" style="border-color: var(--border);">
|
||||||
<p class="text-xs font-semibold mb-2 text-content-muted">DEVICE SYNC CONFIGURATION</p>
|
<p class="text-xs font-semibold mb-2" style="color: var(--text-secondary)">DEVICE SYNC CONFIGURATION</p>
|
||||||
if device.DeviceType == "kobo" {
|
if device.DeviceType == "kobo" {
|
||||||
<!-- Kobo: Copy Full Sync URL -->
|
<!-- Kobo: Copy Full Sync URL -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Kobo Sync URL</label>
|
<label class="block text-xs mb-1" style="color: var(--text-secondary)">Kobo Sync URL</label>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="sync-url-{ device.ID }"
|
id="sync-url-{ device.ID }"
|
||||||
readonly
|
readonly
|
||||||
value={ baseURL + "/api/sync/kobo/" + device.AuthToken }
|
value={ baseURL + "/api/sync/kobo/" + device.AuthToken }
|
||||||
class="input flex-1 text-xs"
|
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);"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@click="copyToClipboard(document.getElementById('sync-url-{ device.ID }').value, 'Kobo sync URL')"
|
@click="copyToClipboard(document.getElementById('sync-url-{ device.ID }').value, 'Kobo sync URL')"
|
||||||
class="btn btn-primary text-xs"
|
class="px-3 py-2 text-xs rounded hover:opacity-80"
|
||||||
|
style="background-color: var(--accent); color: white;"
|
||||||
>
|
>
|
||||||
@Icon("copy", "h-3.5 w-3.5")
|
📋 Copy
|
||||||
Copy
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs mt-1 text-content-muted">Paste this URL into Kobo's <code class="px-1 py-0.5 rounded bg-surface">api_endpoint</code> setting</p>
|
<p class="text-xs mt-1" style="color: var(--text-secondary);">Paste this URL into Kobo's <code class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary);">api_endpoint</code> setting</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if device.DeviceType == "koreader" {
|
if device.DeviceType == "koreader" {
|
||||||
<!-- KOReader: Copy Auth Token -->
|
<!-- KOReader: Copy Auth Token -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Auth Token (for plugin)</label>
|
<label class="block text-xs mb-1" style="color: var(--text-secondary)">Auth Token (for plugin)</label>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="auth-token-{ device.ID }"
|
id="auth-token-{ device.ID }"
|
||||||
readonly
|
readonly
|
||||||
value={ device.AuthToken }
|
value={ device.AuthToken }
|
||||||
class="input flex-1 text-xs font-mono"
|
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;"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@click="copyToClipboard(document.getElementById('auth-token-{ device.ID }').value, 'Auth token')"
|
@click="copyToClipboard(document.getElementById('auth-token-{ device.ID }').value, 'Auth token')"
|
||||||
class="btn btn-primary text-xs"
|
class="px-3 py-2 text-xs rounded hover:opacity-80"
|
||||||
|
style="background-color: var(--accent); color: white;"
|
||||||
>
|
>
|
||||||
@Icon("copy", "h-3.5 w-3.5")
|
📋 Copy
|
||||||
Copy
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs mt-1 text-content-muted">Enter this token in the KOReader plugin settings</p>
|
<p class="text-xs mt-1" style="color: var(--text-secondary);">Enter this token in the KOReader plugin settings</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Regenerate Token Button -->
|
<!-- Regenerate Token Button -->
|
||||||
<button
|
<button
|
||||||
hx-put={ "/api/devices/" + device.ID.String() + "/regenerate-token" }
|
hx-put={ "/api/devices/" + device.ID.String() + "/regenerate-token" }
|
||||||
hx-confirm="⚠️ Old token will immediately stop working. Regenerate anyway?"
|
hx-confirm="⚠️ Old token will immediately stop working. Regenerate anyway?"
|
||||||
class="btn btn-secondary w-full text-xs"
|
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);"
|
||||||
>
|
>
|
||||||
@Icon("refresh", "h-3.5 w-3.5")
|
🔄 Regenerate Token
|
||||||
Regenerate Token
|
|
||||||
</button>
|
</button>
|
||||||
<p class="text-xs mt-1 inline-flex items-center gap-1 text-content-muted">
|
<p class="text-xs mt-1" style="color: var(--text-secondary);">⚠️ Old token will immediately stop working</p>
|
||||||
@Icon("alert", "h-3 w-3")
|
|
||||||
Old token will immediately stop working
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -174,21 +164,21 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
<!-- Pending Registrations -->
|
<!-- Pending Registrations -->
|
||||||
if len(pendingRegistrations) > 0 {
|
if len(pendingRegistrations) > 0 {
|
||||||
<div id="pending-section" class="mt-12">
|
<div id="pending-section" class="mt-12">
|
||||||
<h2 class="text-2xl font-bold tracking-tight mb-4 text-content">Pending Device Registrations</h2>
|
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Pending Device Registrations</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
for _, reg := range pendingRegistrations {
|
for _, reg := range pendingRegistrations {
|
||||||
<div class="card p-4 flex items-center justify-between">
|
<div class="card p-4 rounded-lg border flex items-center justify-between" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold text-content">{ reg.DeviceName }</h3>
|
<h3 class="font-semibold" style="color: var(--text-primary)">{ reg.DeviceName }</h3>
|
||||||
<p class="text-sm text-content-muted">
|
<p class="text-sm" style="color: var(--text-secondary)">
|
||||||
{ reg.DeviceType } - Expires in { reg.ExpiresAt }
|
{ reg.DeviceType } - Expires in { reg.ExpiresAt }
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<button @click={ "approveDevice('" + reg.RegistrationID + "')" } class="btn btn-primary text-sm">
|
<button @click="approveDevice('{ reg.RegistrationID }')" class="btn-primary px-4 py-2 rounded-lg text-sm">
|
||||||
Approve
|
Approve
|
||||||
</button>
|
</button>
|
||||||
<button @click={ "rejectDevice('" + reg.RegistrationID + "')" } class="btn btn-danger text-sm">
|
<button @click="rejectDevice('{ reg.RegistrationID }')" class="btn-danger px-4 py-2 rounded-lg text-sm">
|
||||||
Reject
|
Reject
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,8 +190,8 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
<!-- Sync Queue Section -->
|
<!-- Sync Queue Section -->
|
||||||
<div id="sync-queue-section" class="mt-12 hidden">
|
<div id="sync-queue-section" class="mt-12 hidden">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="text-2xl font-bold tracking-tight text-content">Sync Queue</h2>
|
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Sync Queue</h2>
|
||||||
<button @click="clearSyncQueue()" class="btn btn-secondary">
|
<button @click="clearSyncQueue()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||||
Clear Queue
|
Clear Queue
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,31 +199,31 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Add Device Modal -->
|
<!-- Add Device Modal -->
|
||||||
<div id="add-device-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: var(--surface-overlay);">
|
<div id="add-device-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
||||||
<div class="card p-6 w-full max-w-md mx-4">
|
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-xl font-bold text-content">Add New Device</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add New Device</h2>
|
||||||
<button @click="hideAddDeviceModal()" class="icon-btn" aria-label="Close">
|
<button @click="hideAddDeviceModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||||
@Icon("close", "h-5 w-5")
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<form id="add-device-form" @submit="handleAddDevice($event)">
|
<form id="add-device-form" @submit="handleAddDevice($event)">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Device Name</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="device-name"
|
id="device-name"
|
||||||
required
|
required
|
||||||
class="input"
|
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"
|
placeholder="My Kindle Paperwhite"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Device Type</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Type</label>
|
||||||
<select
|
<select
|
||||||
id="device-type"
|
id="device-type"
|
||||||
required
|
required
|
||||||
class="input"
|
class="w-full px-4 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
>
|
>
|
||||||
<option value="">Select device type...</option>
|
<option value="">Select device type...</option>
|
||||||
<option value="koreader">KOReader (Kindle, Kobo, PocketBook)</option>
|
<option value="koreader">KOReader (Kindle, Kobo, PocketBook)</option>
|
||||||
@@ -243,21 +233,22 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Device Identifier</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Identifier</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="device-identifier"
|
id="device-identifier"
|
||||||
required
|
required
|
||||||
class="input"
|
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="Hardware ID or Serial Number"
|
placeholder="Hardware ID or Serial Number"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs mt-1 text-content-muted">Enter your device's unique identifier</p>
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Enter your device's unique identifier</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" @click="hideAddDeviceModal()" class="btn btn-secondary">
|
<button type="button" @click="hideAddDeviceModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
|
||||||
Register Device
|
Register Device
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -265,24 +256,23 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Device Settings Modal -->
|
<!-- Device Settings Modal -->
|
||||||
<div id="device-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: var(--surface-overlay);">
|
<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 p-6 w-full max-w-md mx-4">
|
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-xl font-bold text-content">Device Settings</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Device Settings</h2>
|
||||||
<button @click="hideDeviceSettingsModal()" class="icon-btn" aria-label="Close">
|
<button @click="hideDeviceSettingsModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||||
@Icon("close", "h-5 w-5")
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<form id="device-settings-form" @submit="handleSaveDeviceSettings($event)">
|
<form id="device-settings-form" @submit="handleSaveDeviceSettings($event)">
|
||||||
<input type="hidden" id="settings-device-id"/>
|
<input type="hidden" id="settings-device-id"/>
|
||||||
<input type="hidden" id="settings-device-type"/>
|
<input type="hidden" id="settings-device-type"/>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Device Name</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="settings-device-name"
|
id="settings-device-name"
|
||||||
required
|
required
|
||||||
class="input"
|
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>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@@ -293,9 +283,9 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
checked
|
checked
|
||||||
class="w-5 h-5 rounded"
|
class="w-5 h-5 rounded"
|
||||||
/>
|
/>
|
||||||
<span class="text-content">Enable Sync</span>
|
<span style="color: var(--text-primary)">Enable Sync</span>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs mt-1 ml-8 text-content-muted">Allow this device to sync reading progress</p>
|
<p class="text-xs mt-1 ml-8" style="color: var(--text-secondary)">Allow this device to sync reading progress</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="flex items-center space-x-3 cursor-pointer">
|
<label class="flex items-center space-x-3 cursor-pointer">
|
||||||
@@ -305,15 +295,16 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
checked
|
checked
|
||||||
class="w-5 h-5 rounded"
|
class="w-5 h-5 rounded"
|
||||||
/>
|
/>
|
||||||
<span class="text-content">Auto Sync</span>
|
<span style="color: var(--text-primary)">Auto Sync</span>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs mt-1 ml-8 text-content-muted">Automatically sync changes</p>
|
<p class="text-xs mt-1 ml-8" style="color: var(--text-secondary)">Automatically sync changes</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Sync Frequency</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Sync Frequency</label>
|
||||||
<select
|
<select
|
||||||
id="settings-sync-frequency"
|
id="settings-sync-frequency"
|
||||||
class="input"
|
class="w-full px-4 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
>
|
>
|
||||||
<option value="1">Every 1 minute</option>
|
<option value="1">Every 1 minute</option>
|
||||||
<option value="5" selected>Every 5 minutes</option>
|
<option value="5" selected>Every 5 minutes</option>
|
||||||
@@ -322,14 +313,15 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
<option value="60">Every hour</option>
|
<option value="60">Every hour</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-line pt-6 mb-6">
|
<div class="border-t pt-6 mb-6" style="border-color: var(--border);">
|
||||||
<h3 class="text-lg font-semibold mb-4 text-content">Collection View Settings</h3>
|
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Collection View Settings</h3>
|
||||||
<p class="text-sm mb-4 text-content-muted">Configure how collections are displayed on this device</p>
|
<p class="text-sm mb-4" style="color: var(--text-secondary)">Configure how collections are displayed on this device</p>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Default View Mode</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Default View Mode</label>
|
||||||
<select
|
<select
|
||||||
id="settings-view-mode"
|
id="settings-view-mode"
|
||||||
class="input"
|
class="w-full px-4 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
>
|
>
|
||||||
<option value="grid">Grid View</option>
|
<option value="grid">Grid View</option>
|
||||||
<option value="list">List View</option>
|
<option value="list">List View</option>
|
||||||
@@ -337,10 +329,11 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Sort Collections By</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Sort Collections By</label>
|
||||||
<select
|
<select
|
||||||
id="settings-sort-order"
|
id="settings-sort-order"
|
||||||
class="input"
|
class="w-full px-4 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
>
|
>
|
||||||
<option value="name">Collection Name</option>
|
<option value="name">Collection Name</option>
|
||||||
<option value="created">Date Created</option>
|
<option value="created">Date Created</option>
|
||||||
@@ -349,10 +342,11 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Items Per Page</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Items Per Page</label>
|
||||||
<select
|
<select
|
||||||
id="settings-items-per-page"
|
id="settings-items-per-page"
|
||||||
class="input"
|
class="w-full px-4 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
>
|
>
|
||||||
<option value="12">12 items</option>
|
<option value="12">12 items</option>
|
||||||
<option value="24" selected>24 items</option>
|
<option value="24" selected>24 items</option>
|
||||||
@@ -368,9 +362,9 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
checked
|
checked
|
||||||
class="w-5 h-5 rounded"
|
class="w-5 h-5 rounded"
|
||||||
/>
|
/>
|
||||||
<span class="text-content">Show Cover Images</span>
|
<span style="color: var(--text-primary)">Show Cover Images</span>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs mt-1 ml-8 text-content-muted">Display book covers in collection views</p>
|
<p class="text-xs mt-1 ml-8" style="color: var(--text-secondary)">Display book covers in collection views</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="flex items-center space-x-3 cursor-pointer">
|
<label class="flex items-center space-x-3 cursor-pointer">
|
||||||
@@ -379,19 +373,19 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
id="settings-show-progress"
|
id="settings-show-progress"
|
||||||
class="w-5 h-5 rounded"
|
class="w-5 h-5 rounded"
|
||||||
/>
|
/>
|
||||||
<span class="text-content">Show Reading Progress</span>
|
<span style="color: var(--text-primary)">Show Reading Progress</span>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs mt-1 ml-8 text-content-muted">Display progress indicators for books</p>
|
<p class="text-xs mt-1 ml-8" style="color: var(--text-secondary)">Display progress indicators for books</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" @click="hideDeviceSettingsModal()" class="btn btn-secondary">
|
<button type="button" @click="hideDeviceSettingsModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="button" @click="handleRevokeDevice()" class="btn btn-danger">
|
<button type="button" @click="handleRevokeDevice()" class="btn-danger px-4 py-2 rounded-lg">
|
||||||
Revoke Device
|
Revoke Device
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
|
||||||
Save Settings
|
Save Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -399,68 +393,66 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Device Shelf Mappings Modal -->
|
<!-- Device Shelf Mappings Modal -->
|
||||||
<div id="shelf-mappings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: var(--surface-overlay);">
|
<div id="shelf-mappings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
||||||
<div class="card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
<div class="card rounded-lg p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-xl font-bold text-content">Collection to Shelf Mappings</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Collection to Shelf Mappings</h2>
|
||||||
<button @click="hideShelfMappingsModal()" class="icon-btn" aria-label="Close">
|
<button @click="hideShelfMappingsModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||||
@Icon("close", "h-5 w-5")
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="mappings-device-id"/>
|
<input type="hidden" id="mappings-device-id"/>
|
||||||
<div id="shelf-mappings-container" class="space-y-4 mb-6">
|
<div id="shelf-mappings-container" class="space-y-4 mb-6">
|
||||||
<p class="text-content-muted">Loading mappings...</p>
|
<p style="color: var(--text-secondary)">Loading mappings...</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" @click="hideShelfMappingsModal()" class="btn btn-secondary">
|
<button type="button" @click="hideShelfMappingsModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
<button type="button" @click="showAddMappingModal()" class="btn btn-primary">
|
<button type="button" @click="showAddMappingModal()" class="btn-primary px-4 py-2 rounded-lg">
|
||||||
@Icon("plus", "h-4 w-4")
|
➕ Add Mapping
|
||||||
Add Mapping
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Add/Edit Shelf Mapping Modal -->
|
<!-- 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: var(--surface-overlay);">
|
<div id="add-mapping-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
||||||
<div class="card p-6 w-full max-w-md mx-4">
|
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-xl font-bold text-content">Add Collection Mapping</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Collection Mapping</h2>
|
||||||
<button @click="hideAddMappingModal()" class="icon-btn" aria-label="Close">
|
<button @click="hideAddMappingModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||||
@Icon("close", "h-5 w-5")
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<form id="mapping-form" @submit="handleSaveMapping($event)">
|
<form id="mapping-form" @submit="handleSaveMapping($event)">
|
||||||
<input type="hidden" id="mapping-device-id"/>
|
<input type="hidden" id="mapping-device-id"/>
|
||||||
<input type="hidden" id="mapping-id"/>
|
<input type="hidden" id="mapping-id"/>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Collection</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Collection</label>
|
||||||
<select
|
<select
|
||||||
id="mapping-collection"
|
id="mapping-collection"
|
||||||
required
|
required
|
||||||
class="input"
|
class="w-full px-4 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
>
|
>
|
||||||
<option value="">Select collection...</option>
|
<option value="">Select collection...</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Device Shelf Name</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Shelf Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="mapping-shelf-name"
|
id="mapping-shelf-name"
|
||||||
required
|
required
|
||||||
class="input"
|
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="e.g., Sci-Fi"
|
placeholder="e.g., Sci-Fi"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs mt-1 text-content-muted">Name of the shelf on this device</p>
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Name of the shelf on this device</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Sync Direction</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Sync Direction</label>
|
||||||
<select
|
<select
|
||||||
id="mapping-sync-direction"
|
id="mapping-sync-direction"
|
||||||
required
|
required
|
||||||
class="input"
|
class="w-full px-4 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
>
|
>
|
||||||
<option value="bidirectional">Bidirectional (sync both ways)</option>
|
<option value="bidirectional">Bidirectional (sync both ways)</option>
|
||||||
<option value="book_to_device">Bookhoard → Device</option>
|
<option value="book_to_device">Bookhoard → Device</option>
|
||||||
@@ -469,10 +461,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" @click="hideAddMappingModal()" class="btn btn-secondary">
|
<button type="button" @click="hideAddMappingModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
|
||||||
Save Mapping
|
Save Mapping
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+109
-281
File diff suppressed because one or more lines are too long
+19
-17
@@ -12,25 +12,27 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
|
|||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="stylesheet" href="/static/highlight-dark.min.css"/>
|
<link rel="stylesheet" href="/static/highlight-dark.min.css"/>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="docs" x-init="initializeSearch(); highlightCurrentPage(); initializeCodeCopyButtons()" class={ "theme-" + user.Theme + " page-docs bg-surface text-content font-sans antialiased" }>
|
<body x-data="docs" x-init="initializeSearch(); highlightCurrentPage(); initializeCodeCopyButtons()" class={ "theme-" + user.Theme + " page-docs bg-background-primary text-text-primary font-sans antialiased" }>
|
||||||
@Header(user, currentPath)
|
@Header(user, currentPath)
|
||||||
<!-- Mobile Menu Button -->
|
<!-- Mobile Menu Button -->
|
||||||
<button
|
<button
|
||||||
class="icon-btn lg:hidden fixed top-4 left-4 z-50"
|
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()"
|
onclick="toggleSidebar()"
|
||||||
aria-label="Toggle menu"
|
aria-label="Toggle menu"
|
||||||
>
|
>
|
||||||
@Icon("menu", "h-6 w-6")
|
<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>
|
</button>
|
||||||
<!-- Search Results Overlay -->
|
<!-- 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>
|
<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 -->
|
<!-- Sidebar -->
|
||||||
<div class="sidebar fixed left-0 top-0 bottom-0 w-72 bg-surface-raised border-r border-line overflow-y-auto mt-16 lg:translate-x-0 transition-transform duration-300 ease-in-out">
|
<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 -->
|
<!-- Search -->
|
||||||
<div class="p-4 border-b border-line">
|
<div class="p-4 border-b border-border">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="search-input input text-sm"
|
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..."
|
placeholder="Search documentation..."
|
||||||
id="docs-search"
|
id="docs-search"
|
||||||
/>
|
/>
|
||||||
@@ -39,7 +41,7 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
|
|||||||
for _, section := range nav.Sections {
|
for _, section := range nav.Sections {
|
||||||
<div class="nav-section mb-6">
|
<div class="nav-section mb-6">
|
||||||
<div
|
<div
|
||||||
class="nav-section-title font-semibold text-xs uppercase tracking-wide text-content-muted px-4 py-3 cursor-pointer select-none flex items-center justify-between"
|
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)"
|
@click="toggleSection($el)"
|
||||||
>
|
>
|
||||||
<span>{ section.Title }</span>
|
<span>{ section.Title }</span>
|
||||||
@@ -50,7 +52,7 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
|
|||||||
if section.Collapsed {
|
if section.Collapsed {
|
||||||
<div class="nav-items hidden" data-collapsed="true">
|
<div class="nav-items hidden" data-collapsed="true">
|
||||||
for _, item := range section.Items {
|
for _, item := range section.Items {
|
||||||
<a href={ item.URL } class="nav-item block py-2 px-4 pl-8 text-content-muted hover:text-brand text-sm transition-colors no-underline">
|
<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 }
|
<span class="mr-2">{ item.Icon }</span>{ item.Title }
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
@@ -58,7 +60,7 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
|
|||||||
} else {
|
} else {
|
||||||
<div class="nav-items" data-collapsed="false">
|
<div class="nav-items" data-collapsed="false">
|
||||||
for _, item := range section.Items {
|
for _, item := range section.Items {
|
||||||
<a href={ item.URL } class="nav-item block py-2 px-4 pl-8 text-content-muted hover:text-brand text-sm transition-colors no-underline">
|
<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 }
|
<span class="mr-2">{ item.Icon }</span>{ item.Title }
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
@@ -71,28 +73,28 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
|
|||||||
<main class="main-content lg:ml-72 p-8 max-w-4xl mx-auto">
|
<main class="main-content lg:ml-72 p-8 max-w-4xl mx-auto">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
if len(doc.Breadcrumb) > 0 {
|
if len(doc.Breadcrumb) > 0 {
|
||||||
<nav class="breadcrumb flex gap-2 text-sm text-content-muted mb-8" aria-label="Breadcrumb">
|
<nav class="breadcrumb flex gap-2 text-sm text-text-secondary mb-8" aria-label="Breadcrumb">
|
||||||
for i, crumb := range doc.Breadcrumb {
|
for i, crumb := range doc.Breadcrumb {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
<span class="text-content-muted/50">›</span>
|
<span class="text-text-secondary/50">›</span>
|
||||||
}
|
}
|
||||||
<a href={ crumb.URL } class="text-brand hover:underline no-underline">{ crumb.Title }</a>
|
<a href={ crumb.URL } class="text-accent hover:underline no-underline">{ crumb.Title }</a>
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
}
|
}
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<h1 class="text-3xl font-bold tracking-tight mb-8 text-content">{ doc.Title }</h1>
|
<h1 class="text-4xl font-bold mb-8 text-text-primary">{ doc.Title }</h1>
|
||||||
<!-- Table of Contents -->
|
<!-- Table of Contents -->
|
||||||
if len(doc.TOC) > 0 {
|
if len(doc.TOC) > 0 {
|
||||||
<details class="toc bg-surface-raised p-4 rounded-lg mb-8 border border-line">
|
<details class="toc bg-background-secondary p-4 rounded-lg mb-8 border border-border">
|
||||||
<summary class="cursor-pointer hover:opacity-80 transition-opacity">
|
<summary class="cursor-pointer hover:opacity-80 transition-opacity">
|
||||||
<strong class="text-content font-semibold">On this page</strong>
|
<strong class="text-text-primary font-semibold">On this page</strong>
|
||||||
<span class="ml-2 text-content-muted text-xs">▼</span>
|
<span class="ml-2 text-text-secondary text-xs">▼</span>
|
||||||
</summary>
|
</summary>
|
||||||
for _, item := range doc.TOC {
|
for _, item := range doc.TOC {
|
||||||
<a
|
<a
|
||||||
href={ "#" + item.Anchor }
|
href={ "#" + item.Anchor }
|
||||||
class="toc-item block py-1 text-content-muted hover:text-brand text-sm no-underline"
|
class="toc-item block py-1 text-text-secondary hover:text-accent text-sm no-underline"
|
||||||
style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) }
|
style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) }
|
||||||
>
|
>
|
||||||
{ item.Title }
|
{ item.Title }
|
||||||
|
|||||||
+53
-61
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
// templ: version: v0.3.1001
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
@@ -48,7 +48,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme + " page-docs bg-surface text-content font-sans antialiased"}
|
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme + " page-docs bg-background-primary text-text-primary font-sans antialiased"}
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
@@ -58,11 +58,11 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -74,136 +74,133 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<!-- Mobile Menu Button --><button class=\"icon-btn lg:hidden fixed top-4 left-4 z-50\" onclick=\"toggleSidebar()\" aria-label=\"Toggle menu\">")
|
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 -->")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("menu", "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, 6, "</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-surface-raised border-r border-line overflow-y-auto mt-16 lg:translate-x-0 transition-transform duration-300 ease-in-out\"><!-- Search --><div class=\"p-4 border-b border-line\"><input type=\"text\" class=\"search-input input text-sm\" placeholder=\"Search documentation...\" id=\"docs-search\"></div><!-- Navigation Sections -->")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, section := range nav.Sections {
|
for _, section := range nav.Sections {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"nav-section mb-6\"><div class=\"nav-section-title font-semibold text-xs uppercase tracking-wide text-content-muted 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, 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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 45, Col: 28}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 47, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span> <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, 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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if section.Collapsed {
|
if section.Collapsed {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"nav-items hidden\" data-collapsed=\"true\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"nav-items hidden\" data-collapsed=\"true\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, item := range section.Items {
|
for _, item := range section.Items {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 templ.SafeURL
|
var templ_7745c5c3_Var6 templ.SafeURL
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 53, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 55, Col: 27}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" class=\"nav-item block py-2 px-4 pl-8 text-content-muted hover:text-brand text-sm transition-colors no-underline\"><span class=\"mr-2\">")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 54, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 40}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 54, Col: 61}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 61}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"nav-items\" data-collapsed=\"false\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"nav-items\" data-collapsed=\"false\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, item := range section.Items {
|
for _, item := range section.Items {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 templ.SafeURL
|
var templ_7745c5c3_Var9 templ.SafeURL
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 61, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 63, Col: 27}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"nav-item block py-2 px-4 pl-8 text-content-muted hover:text-brand text-sm transition-colors no-underline\"><span class=\"mr-2\">")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 62, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 64, Col: 40}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 62, Col: 61}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 64, Col: 61}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</a>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -213,136 +210,131 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div>")
|
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 -->")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div><!-- Main Content --><main class=\"main-content lg:ml-72 p-8 max-w-4xl mx-auto\"><!-- Breadcrumb -->")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(doc.Breadcrumb) > 0 {
|
if len(doc.Breadcrumb) > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<nav class=\"breadcrumb flex gap-2 text-sm text-content-muted mb-8\" aria-label=\"Breadcrumb\">")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for i, crumb := range doc.Breadcrumb {
|
for i, crumb := range doc.Breadcrumb {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<span class=\"text-content-muted/50\">›</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"text-text-secondary/50\">›</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " <a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " <a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 templ.SafeURL
|
var templ_7745c5c3_Var12 templ.SafeURL
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 79, Col: 26}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 81, Col: 26}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" class=\"text-brand hover:underline no-underline\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" class=\"text-accent hover:underline no-underline\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 79, Col: 90}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 81, Col: 91}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</nav>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</nav>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<!-- Title --><h1 class=\"text-3xl font-bold tracking-tight mb-8 text-content\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<!-- Title --><h1 class=\"text-4xl font-bold mb-8 text-text-primary\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 84, Col: 79}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 86, Col: 69}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</h1><!-- Table of Contents -->")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</h1><!-- Table of Contents -->")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(doc.TOC) > 0 {
|
if len(doc.TOC) > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<details class=\"toc bg-surface-raised p-4 rounded-lg mb-8 border border-line\"><summary class=\"cursor-pointer hover:opacity-80 transition-opacity\"><strong class=\"text-content font-semibold\">On this page</strong> <span class=\"ml-2 text-content-muted text-xs\">▼</span></summary> ")
|
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> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, item := range doc.TOC {
|
for _, item := range doc.TOC {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var15 templ.SafeURL
|
var templ_7745c5c3_Var15 templ.SafeURL
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 94, Col: 32}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 96, Col: 32}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" class=\"toc-item block py-1 text-content-muted hover:text-brand text-sm no-underline\" style=\"")
|
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=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level))
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 96, Col: 66}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 98, Col: 66}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 98, Col: 20}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 100, Col: 20}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</details>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</details>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<!-- Content --><div class=\"prose prose-invert max-w-none\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<!-- Content --><div class=\"prose prose-invert max-w-none\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -350,7 +342,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div></main></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div></main></body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
// templ: version: v0.3.1001
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|||||||
@@ -3,23 +3,26 @@ package templates
|
|||||||
// FilterItem renders a single saved filter item for the list
|
// FilterItem renders a single saved filter item for the list
|
||||||
templ FilterItem(id string, name string) {
|
templ FilterItem(id string, name string) {
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between p-2 rounded bg-surface hover:bg-surface-hover"
|
class="flex items-center justify-between p-2 rounded hover:opacity-80"
|
||||||
|
style="background-color: var(--bg-primary);"
|
||||||
data-filter-id={ id }
|
data-filter-id={ id }
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
data-action="load-filter"
|
data-action="load-filter"
|
||||||
@click="loadFilter($event)"
|
@click="loadFilter($event)"
|
||||||
class="flex-1 text-left px-2 py-1 rounded text-content"
|
class="flex-1 text-left px-2 py-1 rounded"
|
||||||
|
style="color: var(--text-primary);"
|
||||||
>
|
>
|
||||||
{ name }
|
{ name }
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
data-action="delete-filter"
|
data-action="delete-filter"
|
||||||
@click="deleteFilter($event)"
|
@click="deleteFilter($event)"
|
||||||
class="icon-btn"
|
class="p-1 hover:opacity-70 rounded"
|
||||||
|
style="color: var(--text-secondary);"
|
||||||
title="Delete filter"
|
title="Delete filter"
|
||||||
>
|
>
|
||||||
@Icon("trash", "h-4 w-4")
|
🗑
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
// templ: version: v0.3.1001
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
@@ -30,41 +30,33 @@ func FilterItem(id string, name string) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"flex items-center justify-between p-2 rounded bg-surface hover:bg-surface-hover\" data-filter-id=\"")
|
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=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(id)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(id)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 7, Col: 21}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 8, Col: 21}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"><button data-action=\"load-filter\" @click=\"loadFilter($event)\" class=\"flex-1 text-left px-2 py-1 rounded text-content\">")
|
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);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(name)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 14, Col: 9}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 16, Col: 9}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</button> <button data-action=\"delete-filter\" @click=\"deleteFilter($event)\" class=\"icon-btn\" title=\"Delete filter\">")
|
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>")
|
||||||
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user